Compare commits

...

280 Commits

Author SHA1 Message Date
mAi
7e66da8def mAi: #92 - t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump
Two related editor polish fixes.

(A) Autosave-refresh focus preservation
  paintVariables() replaces every input via innerHTML, blowing away
  the focused-input reference and dropping the cursor mid-edit. Fix:
  capture the active variable input's data-var key + selectionStart/
  End/Direction before the repaint, restore on the new element after
  (by data-var lookup + setSelectionRange). Cursor stays put across
  autosave, rename, and reset cycles. Works for <input> and
  <textarea> via the shared selectionRange contract.

(B) Click variable in preview → jump to sidebar input
  Go renderer wraps every substituted placeholder value in the HTML
  preview with <span class="draft-var" data-var="key">…</span>.
  Implemented via a valueWrapperFn plumbed through
  substituteInDocumentXML → substituteInTextNodes /
  substituteAcrossRuns → replacePlaceholders. RenderHTML passes
  htmlPreviewWrapper which marks values with three PUA sentinels
  (U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
  span pair inside docXMLToHTML. Missing-marker text is wrapped too
  so a clicked [KEIN WERT: foo] jumps to the empty field.

  Render() (.docx export) passes nil for wrap → output is byte-
  identical to pre-261. New test
  TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
  carries draft-var/data-var markup or PUA sentinels.

  Client wireDraftVars() adds .draft-var--has-input only to spans
  whose key resolves to a sidebar input — derived variables (e.g.
  today.iso) stay non-clickable. Click handler:
    scrollIntoView(smooth, center) → focus + select after 50ms →
    1.2s lime flash on the row.
  Keyboard accessible (Enter / Space) with role=button + aria-label.

CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.

Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
2026-05-25 15:12:10 +02:00
mAi
ef21e43375 Merge: t-paliad-260 — submission-draft mobile layout (m/paliad#91) 2026-05-25 14:59:53 +02:00
mAi
4cb99fb627 mAi: #91 - t-paliad-260 — submission-draft mobile layout: drop sticky on sidebar at ≤900px
Approach A: stack vertically. At single-column widths the variable
editor was sticky + max-height: calc(100vh - 2rem), so it stayed
pinned at the top of the viewport while the user scrolled down to
read the preview, visually overlaying the preview pane.

Add a media-query override that switches the sidebar to position:
static, max-height: none, overflow-y: visible at the same ≤900px
breakpoint where the grid already collapses to one column. The
sidebar now reflows above the preview, takes its natural height,
and scrolls away as the user moves down — no overlay, no
horizontal scroll. Desktop (≥901px) layout unchanged: sidebar
keeps its sticky behavior side-by-side with the preview.

Verified at 375 / 414 / 768 / 1280 px in Playwright on the
populated editor body — same renderer serves both URL shapes
(/submissions/draft/{id} and
/projects/{id}/submissions/{code}/draft/{id}).
2026-05-25 14:58:21 +02:00
mAi
452ccdf127 Merge: t-paliad-258 — Deadline form Auto/Custom rule field + canonical rule-label display (m/paliad#89) 2026-05-25 14:56:18 +02:00
mAi
045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00
mAi
e6b61b4d2e Merge: t-paliad-259 — universal _skeleton.docx fallback for submission preview/generate (m/paliad#90) 2026-05-25 14:45:50 +02:00
mAi
940df95418 fix(submissions): t-paliad-259 — universal _skeleton.docx for fallback chain
Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.

Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:

  per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm

The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.

Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
  .docx generator mirroring gen-demo-submission-template but with a
  code-agnostic body (no Klageerwiderung "I./II./III." structure, a
  single [Schriftsatztext] block the lawyer replaces). One run per
  placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
  fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
  semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
  between fetchSubmissionTemplateBytes (per-firm code) and
  fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
  the final fallback for resilience if mWorkRepo is unreachable.

The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.

Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
2026-05-25 14:44:58 +02:00
mAi
538c2d2da9 Merge: t-paliad-257 — Verfahrensablauf user-perspective column axis (Unsere Seite / Gericht / Gegnerseite) (m/paliad#88) 2026-05-25 14:34:38 +02:00
mAi
a9a9adbd2a mAi: #88 - Verfahrensablauf: column axis reframed to user-perspective
Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.

Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.

Changes:

- frontend/src/client/views/verfahrensablauf-core.ts:
  • ColumnsRow fields proactive/reactive → ours/opponent.
  • renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
    labels — no more variant-by-side label keys.
  • bucketDeadlinesIntoColumns routes the user's party into `ours`
    when opts.side ∈ {"defendant"}; default (null) keeps the legacy
    "we are claimant" fallback so claimant-on-left layout survives.

- verfahrensablauf-core.test.ts: rewritten expectations on the new
  ours/opponent fields. Added two new tests pinning the WE-on-left
  semantics and the side+appellant interaction (side=defendant +
  appellant=claimant → "both" collapses into opponent).

- fristenrechner.ts: wires currentPerspective into renderColumnsBody
  as `side` so the columns honour the chip-strip perspective.
  Without this, a defendant-perspective user would see claimant
  filings under the "Unsere Seite" header — the old code didn't
  need the wire-up because the labels weren't perspective-aware.

- i18n.ts: replaces deadlines.col.proactive(.defendant) +
  deadlines.col.reactive(.claimant) with deadlines.col.ours +
  deadlines.col.opponent ("Unsere Seite"/"Client Side",
  "Gegnerseite"/"Opponent Side"). Court key unchanged.

- i18n-keys.ts: regenerated key union.

- global.css: .fr-col-proactive/.fr-col-reactive renamed to
  .fr-col-ours/.fr-col-opponent.

Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
  has no project context, and /tools/fristenrechner already does this
  via applyOurSidePredefine().

Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
2026-05-25 14:32:57 +02:00
mAi
f24a90b722 Merge: t-paliad-252 — Approval withdraw warning modal + edit-instead path (m/paliad#83) 2026-05-25 14:26:20 +02:00
mAi
55bfe439f2 Merge: t-paliad-256 — test-data reset + Example Projects seed exercising chain codes (m/paliad#87) 2026-05-25 14:26:03 +02:00
mAi
0ac26fe0ee chore(seed): t-paliad-256 — wipe + seed Example Projects exercising chain code
Re-runnable Go script under scripts/seed-example-projects/ that wipes
every paliad.projects row (FK CASCADE handles dependents) and seeds 18
realistic patent-litigation projects across 3 example clients:

  SIEMENS  — UPC + LG cases incl. CCR (Widerklage) on EP3456789
  BAYER    — EPA Einspruch + BPatG Nichtigkeit on EP2222333
  BEISPL   — sparse DPMA demo on DE10987654

Every node carries the chain-code-driving fields (reference on client,
opponent_code on litigation, patent_number on patent, proceeding_type_id
on case), producing codes like SIEMENS.HUAW.789.INF.CFI and
SIEMENS.HUAW.789.CCR.CFI via services.BuildProjectCode.

One transaction wraps both wipe and seed; -dry-run rolls back so the
script can be sanity-checked before commit. Reference tables
(proceeding_types, deadline_rules, event_types, gerichte, checklists
templates, firms) are untouched.

Ran live against youpc Postgres 2026-05-25: 12 rows wiped, 18 seeded.
2026-05-25 14:25:16 +02:00
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
50cd80a4a6 Merge: t-paliad-255 — kill /events horizontal scroll on mobile (m/paliad#86) 2026-05-25 14:10:07 +02:00
mAi
716f6d7ece fix(events): t-paliad-255 — kill /events horizontal scroll on mobile
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.

The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.

Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.

Approach: A — let the trigger respect its container at every width.

Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
2026-05-25 14:08:44 +02:00
mAi
1bf62c78e3 Merge: t-paliad-251 — Deadline form overhaul (m/paliad#82) 2026-05-25 14:05:06 +02:00
mAi
9a774ba3ad Merge: t-paliad-254 — Sidebar scroll position persists across nav (m/paliad#85) 2026-05-25 14:04:39 +02:00
mAi
8caaf6a631 mAi: #82 - deadline form overhaul: type-modal filter chips, type→rule autofill, Auto mode, Standardtitel
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.

Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
  (UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
  any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
  the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.

Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
  client-side: given a chosen event_type X, candidate rules are
  those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
  proceeding_type_id, (2) jurisdiction match on the rule's
  proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
  by court (jurisdiction grouping with optgroup), alphabetical.
  Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
  payload — no new endpoint.

Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
  surfaces whenever the Rule was derived from the chosen Type.
  Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
  "Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
  existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
  fill only replaces its own previous suggestion, never a manual
  pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
  auto-derived from the type — the "vorgegeben durch Regel" copy
  reads backwards in that case; show picker + Auto badge instead.

Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
  and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
    head =
      1. event_type label (if exactly one Typ chip is set)
      2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
      3. proceeding type name from project (create form only)
      4. fallback: t("deadlines.field.title.default_fallback")
    suffix = " — <project.reference>" when ref is set and not
             already in head.
  Examples:
    Klageerwiderung — C-UPC-0042       (type known)
    RoP.023 — Klageerwiderung — REF    (rule known, no type)
    UPC — Verletzungsverfahren — REF   (only proceeding type)
    Neue Frist — REF                   (fallback)
- Click REPLACES current title; no destructive confirmation
  because the user invoked it explicitly. Focus moves into the
  title input afterwards so the user can fine-tune.

Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
  schema, no service, no migration.

Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
  Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
  + show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
  badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
  .form-hint--auto, .form-hint-badge, .form-field-label-row,
  .btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
2026-05-25 14:03:04 +02:00
mAi
228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00
mAi
cdd3747c2b Merge: t-paliad-250 — Browse-a-proceeding side+appellant selectors + 'appealable decision' trigger label (m/paliad#81) 2026-05-25 14:00:02 +02:00
mAi
02255c4234 mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:

A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
   (Kläger/Beklagter/Beide) and an appellant selector. The side selector
   swaps which column labels which user-side; the appellant selector
   collapses party='both' rules into the appellant's column (no mirror)
   so role-swap proceedings (Appeal, etc.) stop showing every row
   twice in the timeline. Both selectors are URL-driven (?side= +
   ?appellant=) and re-render without a backend round-trip.

   The appellant row hides itself for proceedings without an appellant
   axis (first-instance Inf/Rev/Opp) via a small allowlist.

B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
   / "Appealable Decision" instead of falling back to the proceeding
   name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
   trigger_event_label_{de,en} column on paliad.proceeding_types (mig
   121); the frontend prefers it over the proceedingName fallback that
   fires when no rule has IsRootEvent=true. No new deadline rules, no
   slug changes (hard rule from the issue).

C. Parameter contract for the column projection is unified in
   bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
   helper extracted from renderColumnsBody so the routing behaviour
   stays unit-testable without a DOM. Tests cover the default mirror,
   appellant-collapse for both sides, side-swap of column ownership,
   the combined case, and row alignment by dueDate.

Verification

- go build ./...                        clean
- go test ./...                         all green
- bun run build (frontend)              clean
- bun test (frontend/src)               110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
  carries the curated trigger label pair.

Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
2026-05-25 13:57:38 +02:00
mAi
206f2917ea Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84) 2026-05-25 13:55:14 +02:00
mAi
5df87f4129 fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.

The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.

Fix:

- `internal/services/submission_draft_service.go` — add
  `RenderProjectSubmission(ctx, userID, projectID, submissionCode,
  templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
  no-saved-draft path. Returns the merged bytes plus the resolved
  SubmissionVarsResult (rule, project, user, lang) so the handler can
  derive filename + audit metadata without a second DB round-trip.

- `internal/handlers/submissions.go` — rewrite
  `handleGenerateProjectSubmission` to resolve the template via
  `resolveSubmissionTemplate` (per-firm slug → HL Patents Style
  fallback, same as the editor draft) and run the new service method.
  Visibility / rule-not-found semantics route through
  `SubmissionVarsService` errors so the gate behavior matches every
  other project endpoint. Removed `loadPublishedRuleByCode` and
  `errRuleNotFound` — both were only used by the old handler.

- `scripts/gen-demo-submission-template/main.go` + the regenerated
  `de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
  exercise the bare `{{today}}` alias too. The demo template covers
  every one of the 48 keys SubmissionVarsService can resolve (firm 2,
  today 4, user 3, project 18, parties 6, rule 8, deadline 7).

The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.

Build + vet + tests clean (go test ./internal/...; bun run build).
2026-05-25 13:51:45 +02:00
mAi
898348a64a Merge: t-paliad-245 — Daten Exportieren demoted into Verwaltung tab (m/paliad#76) 2026-05-25 13:34:53 +02:00
mAi
1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00
mAi
db8335253b Merge: t-paliad-244 — Team View mailto: link for non-admin members (m/paliad#75) 2026-05-25 13:31:52 +02:00
mAi
5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00
mAi
0059e3f15b Merge: t-paliad-243 — project-optional Schriftsätze drafts + /submissions/new picker 2026-05-23 02:20:26 +02:00
mAi
a911a2d0ee feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:

- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
  and rewrites the four RLS policies to gate purely on user_id when
  project_id IS NULL, otherwise on paliad.can_see_project. Down
  refuses to run if project-less rows exist (safer than silent
  data corruption).

- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
  layer skips project/parties/deadline lookups when nil and exposes
  DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
  ListAllForUser LEFT JOINs paliad.projects so project-less drafts
  surface in the global index next to project-scoped ones.

- New HTTP surface:
    GET  /submissions/new                 (picker page)
    GET  /submissions/draft/{draft_id}    (editor for any draft)
    GET  /api/submissions/catalog         (catalog without project)
    POST /api/submission-drafts           (project-less or attached)
    GET/PATCH/DELETE /api/submission-drafts/{draft_id}
    POST /api/submission-drafts/{draft_id}/export
  Existing /api/projects/{id}/submissions/... routes remain bit-
  identical so the project-scoped flow keeps working unchanged.

- Frontend: /submissions/new lists the full cross-proceeding catalog
  grouped by proceeding, filterable by text + chip. Each row offers
  "Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
  with autocomplete over visible projects). /submissions index gains
  a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
  the picker. The editor renders a banner + "Projekt zuweisen"
  action when project_id is null; assigning persists project_id and
  redirects to the project-scoped URL.

Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
2026-05-23 02:19:55 +02:00
mAi
b26f04ffe0 Merge: t-paliad-242 — Schriftsätze tab full catalog grouped by proceeding 2026-05-23 01:56:44 +02:00
mAi
8e195cb497 feat(submissions): t-paliad-242 — Schriftsätze tab shows full catalog grouped by proceeding
Per m's 2026-05-23 ask: from any project, surface every available
template/generator instead of just the project's own proceeding.

Backend (GET /api/projects/{id}/submissions):
- drop the proceeding_type_id filter; JOIN deadline_rules with
  proceeding_types to return every active+published filing rule
  across every active proceeding
- response gains proceeding_code, proceeding_name, proceeding_name_en
  per row plus project_proceeding_code at the top so the frontend
  can pin the project's own group
- has_template now reflects "per-submission .docx wired in
  submissionTemplateRegistry"; the editor still falls back to the
  universal HL Patents Style for everything else (t-paliad-238)
- can_see_project gate unchanged; rules are static reference data
- sorted by (proceeding_code, submission_code)

Frontend:
- client/submissions.ts renders a grouped table: project's own
  proceeding pinned to the top with a lime border + "(dieses
  Projekt)" suffix, every other proceeding alphabetised below
- "Generieren" + "Bearbeiten" buttons stay on every row (editor
  handles missing variables via [KEIN WERT: …])
- "universell"/"universal" badge surfaces for rules without a
  per-submission template — informational, not blocking
- soften the no_proceeding hint so the catalog still renders below
- entity-table-group-header CSS, including --own modifier and a
  read-only override so group rows don't pretend to be clickable

Verified: 103 filing rules across 19 proceedings surface (de.inf.lg,
upc.inf.cfi, epa.opp.opd, etc.). go build + go vet + go test
./internal/... + bun run build clean.
2026-05-23 01:55:32 +02:00
mAi
1f7de99493 Merge: t-paliad-241 — demo Klageerwiderung template + placeholder wiring 2026-05-23 01:33:22 +02:00
mAi
0adcc2c826 Merge: t-paliad-240 — Schriftsätze sidebar + global drafts index 2026-05-23 01:30:32 +02:00
mAi
2c7ac6423f feat(submissions): t-paliad-241 — demo Klageerwiderung template wired
Authored a per-submission-code .docx template for `de.inf.lg.erwidg`
exercising every placeholder SubmissionVarsService resolves (45 keys
across firm/today/user/project/parties/rule/deadline namespaces), so
the Submissions draft editor has variables to substitute and the
sidebar/preview feature can be demonstrated end-to-end.

Pieces:

- `scripts/gen-demo-submission-template/` — one-shot Go authoring tool
  that emits a minimal but Word-compatible .docx zip with a fake
  Klageerwiderung skeleton in German. Each placeholder lives in its own
  <w:r> run so the renderer's pass-1 (format-preserving) substitution
  catches it without falling into the cross-run merge path. Output is
  byte-reproducible (fixed mtime).

- `internal/handlers/files.go` — added `submissionTemplateRegistry`
  (submission_code → fileRegistry slug) plus
  `fetchSubmissionTemplateBytes` helper that reuses the Gitea proxy
  cache infra. Registered one entry for `de.inf.lg.erwidg`. The file
  itself was uploaded to mWorkRepo at
  `6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx`
  (mWorkRepo commit 9633524).

- `internal/handlers/submission_drafts.go` —
  `resolveSubmissionTemplate` now tries the per-code lookup first;
  falls back to the universal HL Patents Style for any code that
  doesn't have a per-firm template registered, matching the cronus
  design fallback chain §8.

The existing HL Patents Style .dotm is untouched (still the universal
fallback and still the source for the format-only /generate path).
Future per-submission templates register one fileRegistry entry +
one submissionTemplateRegistry row.
2026-05-23 01:30:24 +02:00
mAi
436c1b41bb feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index
Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.

Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.

Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
2026-05-23 01:29:56 +02:00
mAi
2c5f85b802 Merge: t-paliad-238 Slice A — dedicated Submissions draft editor + merge engine 2026-05-23 00:06:50 +02:00
mAi
d3aade5aac feat(submissions): t-paliad-238 Slice A — dedicated draft editor page
Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.

Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).

Resurrected from git history per the design's "no rewrite" plan:
  SubmissionVarsService    ← commit 1765d5e (Slice 2 with patent_number_upc)
  SubmissionRenderer       ← commit 8ea3509 (in-house merge engine — the
                             lukasjarosch/go-docx library refuses sibling
                             placeholders in one run, which patent submissions
                             use routinely)
  ConvertDotmToDocx        ← existing format-only convert (kept; reused as
                             pre-pass so .dotm inputs strip macros before
                             merge)

New code:
  paliad.submission_drafts  migration 119 (idempotent — DROP POLICY IF EXISTS
                            + CREATE; CREATE OR REPLACE for the shared trigger
                            function). Applied to live DB.
  SubmissionDraftService    CRUD + autosave-friendly Update + Export/RenderPreview
                            entry points
  RenderHTML method         new on the renderer; walks the same merged
                            document.xml as Render but emits HTML for the
                            preview pane (Q-E3 server-side pick)
  7 API handlers            list / create / get / patch / delete / preview / export
  2 page routes             /draft and /draft/{draft_id}
  submission-draft.tsx      stand-alone editor page (header / sidebar /
                            preview / export button); served via
                            dist/submission-draft.html
  submission-draft.ts       client bundle — autosave (500ms debounce),
                            draft switcher, rename, delete, export with
                            blob download

Tab integration: existing /projects/{id}/#tab-submissions rows get
[Bearbeiten] alongside the existing [Generieren] one-click format-only
path — additive, no removal.

Slice A template: universal HL Patents Style .dotm (same path
t-paliad-230 uses). resolveSubmissionTemplate carries the
submission_code parameter so Slice B's TemplateRegistry wiring (per-
code .docx fallback chain) is a one-function swap.

Audit trail: paliad.system_audit_log row per export
(event_type='submission.exported') + paliad.project_events row
(event_type='submission_exported', timeline_kind='custom_milestone')
so the export surfaces on the project's Verlauf / SmartTimeline. No
paliad.documents write (Q-E2 inventor pick, head-ratified).

Tests: TestRender_* / TestPlaceholderRegex_* / TestRenderHTML_* +
TestLegalSourcePretty / TestOurSide* / TestPatentNumberUPC — all
green. go build / go vet / go test ./internal/... / bun run build all
clean.

Migration slot taken: 119.
2026-05-23 00:06:08 +02:00
mAi
17d2fff661 Merge: t-paliad-239 — Add Checklist button on project Checklists tab 2026-05-22 23:56:50 +02:00
mAi
c6a5416611 feat(projects): t-paliad-239 — add Checklist button on project Checklists tab
The project-detail Checklists tab now exposes an "Add Checklist"
button that opens a template picker modal. Picking a template POSTs
to /api/checklists/{slug}/instances with the current project_id and
the template title as the instance name; the table refreshes and a
transient success banner confirms the add. Reuses the catalog cache
across the tab renderer and modal so the second open doesn't refetch.

Closes the UX cul-de-sac in the previous empty-state copy that told
users to leave the page and create instances on the Vorlagen-Seite.
2026-05-22 23:56:14 +02:00
mAi
d590be4bb7 Merge: t-paliad-237 — anchor lookup traverses linked proceedings 2026-05-22 23:43:59 +02:00
mAi
f7374a67cd docs(submissions): t-paliad-238 design — dedicated Submissions/Schriftsätze page
Adds docs/design-submission-page-2026-05-22.md (~600 lines) — a
dedicated submission-draft page at /projects/{id}/submissions/{code}/draft
with sidebar variable editor + read-only HTML preview + .docx export.

Reuses (resurrects from git) the deleted Slice 1 backend that t-paliad-230
ripped out — SubmissionVarsService (3677c81 + 1765d5e), in-house
SubmissionRenderer (8ea3509), TemplateRegistry (3677c81). All four
compile against today's services with zero API drift.

New schema: paliad.submission_drafts keyed on
(project_id, submission_code, user_id, name) with RLS via can_see_project.

Three slices: A = schema + page + variables-only export against universal
.docx; B = per-submission_code templates with fallback-chain registry;
C = toggleable passages.

Four material picks escalated to head in §11 (template authoring effort,
paliad.documents row, server-vs-client preview, inter-user draft
visibility). All other open questions defaulted to inventor (R)
recommendations from task brief.

No code. Read-only design phase per inventor → coder gate.
2026-05-22 23:43:51 +02:00
mAi
3ff1b23238 fix(timeline): t-paliad-237 — anchor lookup must traverse linked proceedings
On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.

The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.

Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).

Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
2026-05-22 23:43:15 +02:00
mAi
a88269c7c1 Merge: hotfix checklist detail page authored slugs 2026-05-22 23:32:12 +02:00
mAi
3d85ce5444 hotfix(checklists): serve detail page for authored slugs (u-a-…)
m's 2026-05-22 report: user-authored checklists appear in the overview
list but clicking through to /checklists/u-a-<id> 404s.

Root cause: handleChecklistDetailPage only consulted
checklists.Find(slug), which is the STATIC compile-time catalog.
Authored checklists (t-paliad-225) live in the DB and never appear
there, so every authored slug fell into the http.NotFound branch even
though /api/checklists returned them in the overview.

Fix: when the static lookup misses AND a DB-backed catalog is wired,
ask checklistCatalog.Find(ctx, uid, slug). The catalog enforces
visibility — slugs the caller can't see still return 404 (via
ErrNotVisible), so this doesn't open a leak. The static path is
unchanged.
2026-05-22 23:32:12 +02:00
mAi
903225b593 Merge: tesla — dashboard widget size clamp on read + write 2026-05-22 15:53:59 +02:00
mAi
4cd2f05d33 fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode
Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.

Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
  pass places hidden widgets after the visible pass — collision-aware
  + cursor-aware so the hidden tray stacks below the active layout
  without ever displacing a visible widget. applyLayout() passes
  includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
  their stored coordinates so un-hiding restores them in place).

Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
  catalog Min/Max + grid bounds on load. Stale rows with W below MinW
  (or above MaxW, or X+W overflowing the grid) heal on the next
  /api/me/dashboard-layout GET and the cleaned spec is persisted
  back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
  exists to recover users who got into a bad state under the old
  rules.

Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
  behaviour (hidden skipped by default, two-pass ordering, multi-
  hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
  SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
  W=0 sentinel, negative X) plus a round-trip Validate guarantee.
2026-05-22 15:53:19 +02:00
mAi
b4f5af7f70 Merge: t-paliad-236 — demote Projekt archivieren into Edit modal 2026-05-22 15:52:35 +02:00
mAi
83b00d13fe feat(projects): demote "Projekt archivieren" into Edit modal danger zone (t-paliad-236)
Archive is a rare, deliberate action — it doesn't deserve real estate next
to navigation / common actions on the project view. Move it from the
prominent entity-detail-footer button into the bottom of the Edit Project
modal as a tertiary btn-link-danger inside a visually separated
.modal-danger-zone (top-bordered section, right-aligned).

The visibility wiring (project-delete-wrap, admin-gated in renderHeader)
and click handler (project-delete-btn → delete-modal confirm flow) keep
the same DOM ids, so the existing confirm-modal and POST behaviour are
unchanged. Backend (/api/projects/{id} status=archived) untouched.
2026-05-22 15:51:34 +02:00
mAi
34372ca4c8 Merge: project detail trio — partner-units scan + submissions 404 + client_number nil-coercion 2026-05-22 15:48:47 +02:00
mAi
65308651dd fix(projects): three project-detail page hotfixes
m hit a cluster of three bugs on /projects/{id}/submissions:

1. 500 on /api/projects/{id}/partner-units — DerivationService.AttachedUnit
   scanned derive_unit_roles (text[]) into a plain []string. sqlx returns
   []uint8 for array columns without an adapter. Swap to pq.StringArray
   (same shape as the other array-scanned types in the codebase).

2. 404 on /projects/{id}/submissions — every other project-tab path
   (history, deadlines, team, checklists, …) is registered in handlers.go
   routing all to handleProjectsDetailPage so deep links work, but the
   submissions tab added in t-paliad-230 never got the matching route.
   Result: m navigates to the share-able URL and gets the 404 chrome.
   Add the missing route entry.

3. Create / update project rejected by projekte_client_number_check —
   the CHECK is `client_number IS NULL OR matches '^[0-9]{6}$'`, but the
   form sends empty string "" for an unset field. The Create path passed
   `*input.ClientNumber` raw; the Update path's appendSetSkippable did
   the same. Both now route through a new nullableTrimmed helper that
   coerces empty/whitespace to nil → SQL NULL → constraint accepts.
   matter_number gets the same treatment for symmetry.

Verified the SQL by EXPLAIN against the live DB on the today-filter
hotfix (becf4f0). These three fixes only change Go-side type / nil-
coercion, so no SQL-syntax exposure.
2026-05-22 15:48:47 +02:00
mAi
d088de95eb Merge: hotfix today filter SQL syntax 2026-05-22 15:29:15 +02:00
mAi
becf4f0ce3 hotfix(deadlines): use date(completed_at) not ::date — sqlx named-param bug
Production down (Termin/Fristen list returning 503) since 13:28 UTC on
my own 6c40823 deploy:

  ERROR service: prepare list deadlines:
  pq: syntax error at or near ":" at position 24:68 (42601)

Root cause: sqlx's named-parameter parser scans for `:identifier`
tokens and rewrites them into positional placeholders. The Postgres
cast operator `::` is two consecutive `:`. So `completed_at::date`
gets read as `completed_at:` + `:date` placeholder; the parser strips
the cast and leaves a bare `:` next to `date`, which Postgres rejects.

Same query rewritten with the function form `date(completed_at)`
avoids the lexer collision. Verified against the live DB via EXPLAIN
before pushing.

Lesson for future: tests in internal/services/ don't exercise the SQL
path because there's no DB fixture in the service-layer unit tests.
EXPLAIN-against-live or a real DB integration test is the only way to
catch this kind of SQL regression. Filing as architecture follow-up.
2026-05-22 15:29:15 +02:00
mAi
924dbd9768 Merge branch 'mai/knuth/coder-paliadin-chat' 2026-05-22 15:19:32 +02:00
mAi
6c40823038 Merge: Heute filter retains done-today deadlines 2026-05-22 15:18:59 +02:00
mAi
007ebc2794 fix(deadlines): "Heute" filter retains deadlines completed today
m's 2026-05-22 observation: checking off a deadline on the Heute view
made it disappear immediately — no sense of progress, no record that
the day's work is getting done.

Root cause: filterDeadlines was filtering on `status = 'pending'` even
for the Heute bucket. The bucket should be a date-scoped view of the
day's deadlines, not a pending-only queue.

Fix: include items where `due_date = today` AND either still pending
OR completed today (`completed_at::date = :today`). Items completed on
earlier dates still drop out — the bucket stays "today's work" rather
than "everything that was ever due today".

Frontend already renders completed deadlines as strikethrough/green
via `frist-urgency-done` (see frontend/src/client/events.ts:254), so
no client change needed.

Dashboard counter (`SELECT … COUNT(*) FILTER WHERE status='pending'`)
intentionally unchanged — keeps the lime card a "remaining to do"
indicator, like an unread-mail badge.
2026-05-22 15:18:59 +02:00
mAi
cdd27d674e feat(paliadin): stream + honest late-recovery (t-paliad-235)
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.

Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:

1. Switch the aichat backend to /chat/turn/stream
   - new AichatPaliadinService.RunTurnStream relays incremental chunks
   - SSE parser handles default `data:` frames (chunk/meta/done/error)
     and named `event: heartbeat` frames per the upstream contract
   - no more 130 s hard ceiling — stream stays open as long as data or
     heartbeats flow; silenceTimeout (90 s) catches a true upstream
     stall instead

2. Proof-of-life thinking events
   - handler emits `event: thinking` every 5 s while the upstream is
     silent (synthesised locally) AND relays aichat's `heartbeat`
     events as thinking pings
   - frontend renders a lime-dot pulse + monospace counter inside the
     assistant bubble — the user can SEE the chat is still working

3. Honest disconnect copy + real late-recovery
   - new dispatching endpoint GET /api/paliadin/turns/{id}/recover
   - aichat backend: asks aichat via GET /chat/conversations and
     /chat/conversations/{id}/turns whether the turn actually finished
   - legacy backend: falls through to the local row read (janitor)
   - frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
     while the recovery polls; on confirmed "lost" swaps to
     "Antwort konnte nicht zugestellt werden — bitte erneut stellen"
   - migration 118 adds aichat_conversation_id to paliadin_turns so
     the recovery has a fast path when the done frame arrived before
     the drop

Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.

13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.

go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
2026-05-22 15:17:24 +02:00
mAi
28de2e56d0 Merge: t-paliad-233 — print views default portrait + landscape opt-ins 2026-05-21 22:03:18 +02:00
mAi
af073f87da fix(print): default to portrait, opt-in landscape for wide surfaces (t-paliad-233)
The smart-timeline-chart block in global.css declared @page { size: A4
landscape } inside @media print. @page rules are global even when nested
in selectors, so this leaked landscape onto every printed surface in
paliad — not just the chart.

Switch to named-page strategy:

- Default @page { size: A4 portrait; margin: 1.5cm 1.2cm }
- @page paliad-landscape { size: A4 landscape; margin: 1.5cm }
- @media print: body.<surface> { page: paliad-landscape } opts surfaces
  that need width into landscape via per-page body classes

Landscape opt-ins:
- body.page-kostenrechner — wide fee-tier tables
- body.page-projects-chart — horizontal Smart Timeline chart
- body.events-view-calendar — /events Kalender tab (month grid)
- body.views-shape-active-calendar / -timeline — Custom Views shapes
- body.verfahrensablauf-view-timeline — horizontal procedure timeline

Body classes:
- kostenrechner.tsx, projects-chart.tsx, verfahrensablauf.tsx now set
  page-<slug> on body
- verfahrensablauf.ts toggles verfahrensablauf-view-(timeline|columns)
  in initViewToggle
- views.ts toggles views-shape-active-<shape> in setActiveShape (mirrors
  the existing events.ts events-view-* pattern)

General print polish in the universal block (the catch-all at the bottom
of global.css):
- Hide .fab / .fab-button / .edit-mode-handle / .paliadin-widget /
  [data-print-hide] in print
- thead { display: table-header-group } so headers repeat across pages
- tr/th/td page-break-inside: avoid so rows don't split mid-cell
- h1-h6 page-break-after: avoid, orphans/widows: 3 for p/h*/li
- print-color-adjust: exact on brand-coloured headers + status pills
- a[href^="http"]::after content: " (" attr(href) ")" prints external
  URLs after their link text (opt-out via data-print-url="hide")
- body font-size: 11pt for print readability

Verified via Playwright on static dist build that:
- Default surfaces (dashboard, projects, fristenrechner, agenda, admin)
  match no page: rule → portrait
- kostenrechner, projects-chart match the landscape rule
- verfahrensablauf-view-columns → portrait, -view-timeline → landscape
- views-shape-active-list/-cards → portrait, -calendar/-timeline →
  landscape
- /events default (events-view-cards) → portrait, calendar toggle →
  landscape

go build ./... + go test ./internal/... + bun test (99 pass) + bun
run build all clean.
2026-05-21 22:01:46 +02:00
mAi
f22e918048 Merge: hotfix compose env — SUPABASE_SERVICE_ROLE_KEY 2026-05-21 21:50:43 +02:00
mAi
79d98cfeb8 hotfix(compose): declare SUPABASE_SERVICE_ROLE_KEY in web env block
m reported "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY
fehlt am Server)" when trying to add a user account on /admin/team.

Root cause: the value was provisioned in Dokploy's compose env block
(I confirmed it via compose.one API), but docker-compose.yml's
`environment:` section never declared the variable. Docker compose
only forwards env vars that are listed in `environment:` — Dokploy's
project-level env is just a source of `${…}` interpolation, not an
automatic injection.

Fix: add `- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}`
alongside the other Supabase keys. The `:-` default keeps the compose
parseable on deployments that haven't provisioned the key (those still
get the existing /admin/team 503 fallback log line).

After the auto-deploy, cmd/server/main.go:139 will log
"supabase admin API configured — /admin/team Add-User path active"
instead of "SUPABASE_SERVICE_ROLE_KEY not set".
2026-05-21 21:50:43 +02:00
mAi
19d95d6f5b Merge: hotfix paliadin /chat/turn user_id 2026-05-21 21:21:37 +02:00
mAi
17d149c09e hotfix(paliadin): ship user_id on /chat/turn (aichat tenant-DB requirement)
m reported "ai chat seems not to be wired anymore" + the frontend
showed "Verbindung verloren. Antwort wird nachgereicht…".

Root cause: aichat on mRiver added a tenant-DB layer that demands
`user_id` on every /chat/turn request:

  {"error":{"code":"bad_request",
            "message":"user_id is required when a tenant DB is
                       configured","retryable":false}}

aichat itself is healthy (/chat/health 200, paliadin session ok:true,
last successful turn was ~2.6h ago). The paliad side built and shipped
an aichatTurnRequest without user_id, so every turn since the tenant-DB
flip 400s; paliad's SSE relay receives no upstream data and closes
empty, producing the user-visible "Verbindung verloren".

Fix: add UserID to aichatTurnRequest (json: user_id, mandatory now),
populate from req.UserID.String() at the call site. The userID was
already in scope (used for JWT mint + username lookup); the struct just
wasn't shipping it.

Regression test in TestRunTurn_HappyPath_ViaCallHTTP asserts
captured.UserID == request UUID so a future struct edit that drops the
field fails CI instead of production.
2026-05-21 21:21:32 +02:00
mAi
7c7030c5bf Merge: t-paliad-232 — Verfahrenstyp picker + Schriftsätze CTA 2026-05-21 15:45:59 +02:00
mAi
da8389b6e3 feat(projects): t-paliad-232 Verfahrenstyp picker + Schriftsätze CTA
Two-part fix from m's 2026-05-21 finding that the Schriftsätze tab
told users "Bitte zuerst einen Verfahrenstyp setzen" while the
project form had no field to set it. The `proceeding_type_id`
column was already on `paliad.projects` and accepted by the API.

  Part 1 — Verfahrenstyp picker on the case-fields block

    * frontend/src/components/ProjectFormFields.tsx — new optional
      <select id="project-proceeding-type-id"> rendered between
      Aktenzeichen and Mandantenrolle inside the type=case block.
      First option is "(nicht gesetzt)" / "(unset)".
    * frontend/src/client/project-form.ts — shared
      loadProceedingTypes() + populateProceedingTypeSelect()
      helpers. Options sorted by `code` (de.* → dpma.* → epa.* →
      upc.*). readPayload sends `proceeding_type_id` only when the
      user picked a value; prefillForm restores the saved id via
      dataset.preselect to survive the async populate race.
    * frontend/src/client/projects-new.ts — kicks off populate on
      DOMContentLoaded.
    * frontend/src/client/projects-detail.ts — edit-modal preload
      now awaits populate; the local loadProceedingTypes duplicate
      (used by the counterclaim modal) is replaced by the shared
      helper so both surfaces hit the same cache.

  Part 2 — Actionable empty-state on the Schriftsätze tab

    * frontend/src/projects-detail.tsx — the static <p> empty-state
      becomes a div with a "Projekt bearbeiten" button.
    * frontend/src/client/projects-detail.ts — openEditModal now
      accepts an optional focusFieldID; the new
      #project-submissions-edit-cta click handler calls it with
      "project-proceeding-type-id" so the picker is scrolled into
      view and focused right after the modal opens.

  i18n: new keys projects.field.proceeding_type{,.unset,.hint} and
  projects.detail.submissions.empty.no_proceeding.cta; reworded
  no_proceeding copy to match the new "edit the project" CTA.

  Backend already validates via validateProceedingTypeCategory
  (mig 087/088 fristenrechner-category guard). Added
  TestProjectService_CaseProceedingTypePicker exercising both the
  happy and reject paths through a `case`-typed Create.

Manual test path: open any case project → Edit → the Verfahrenstyp
picker shows below Aktenzeichen → save → the Schriftsätze tab now
lists the submission codes. Clicking the empty-state CTA jumps
straight to the picker.
2026-05-21 15:45:19 +02:00
mAi
7967839f78 Merge: t-paliad-230 — submission generator format-only convert (.dotm → .docx) 2026-05-21 15:26:31 +02:00
mAi
d86cac0b53 feat(submissions): t-paliad-230 format-only .dotm→.docx convert
m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.

Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:

* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
  format converter that strips word/vbaProject.bin, word/vbaData.xml,
  word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
  [Content_Types].xml (demotes the macro/template main type to plain
  docx, drops the .bin Default Extension and the macro Overrides), and
  rewrites word/_rels/document.xml.rels to drop the vbaProject +
  keyMapCustomizations relationships. Idempotent on a plain .docx.
  archive/zip + regex stdlib only — no new third-party dependencies.

* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
  /generate fetches the cached HL Patents Style .dotm (via a new
  fetchHLPatentsStyleBytes accessor on files.go that shares the same
  cache as /files/{slug}), converts, writes one paliad.system_audit_log
  row (event_type='submission.generated', metadata={submission_code,
  rule_name, filename}), and streams the .docx as an attachment. GET
  /api/projects/{id}/submissions still lists filing rules but
  has_template is unconditionally true (one universal template).

* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
  .docx, with Umlauts ASCII-folded and slashes → underscores.

Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.

Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):

  unzip -tq /tmp/hl-patents-style.converted.docx   → No errors
  python3 -c "import zipfile, xml.etree.ElementTree as ET; \
              z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
              [ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
  uv run --with python-docx python3 -c "import docx; \
              d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
              print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
              → 236 paragraphs, 168 styles, 1 section

All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.

go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
2026-05-21 15:23:24 +02:00
mAi
69f45893a3 Merge: t-paliad-231 — mailto: team selection on project Team tab 2026-05-21 15:18:42 +02:00
mAi
9f339747e5 feat(team): mailto: selection on project-detail Team tab (t-paliad-231)
Non-admins can now select team members directly on the project detail
Team tab and open a mailto: link in their local mail client with every
selected member queued in the To: line. No server call, no audit row —
the existing /admin/team server-SMTP broadcast (t-paliad-147) stays
admin-only and untouched.

Behaviour:
- Checkbox column on every team-body row (direct + ancestor-inherited).
  Rows for users without an email render a disabled checkbox so the
  column geometry stays uniform.
- Tri-state master checkbox in the header row toggles every visible,
  email-bearing row.
- Single "Mail an Auswahl" button above the table, disabled while the
  selection is empty. When one or more rows are selected the label
  picks up "(N)" and the title attribute spells out the count.
- Click composes mailto:a@x,b@y via the existing buildMailtoHref
  helper from broadcast.ts (RFC 6068 comma join + encodeURIComponent
  per address) and sets window.location.href. Pure client side.
- Selection is pruned to currently-rendered, email-bearing user_ids
  on every renderTeam call so removed members or members who lose
  their email drop out automatically.
2026-05-21 15:17:52 +02:00
mAi
7c3c84454d Merge: t-paliad-229 — changelog catch-up May 2026 2026-05-21 15:03:44 +02:00
mAi
61210943d9 content(changelog): drop submission-generator entry per task scope
t-paliad-229 hard rule: "Don't write release notes for things still
in design phase (submission generator, etc.)". Klageerwiderung
shipped end-to-end via t-paliad-215 Slice 1, but m flagged the whole
submission generator as too early for the public changelog — only
one template, more to follow. Removing the 2026-05-19 entry; the 9
other entries remain unchanged.
2026-05-21 15:01:54 +02:00
mAi
74783e7a89 content(changelog): t-paliad-229 — catch up changelog for May 2026
Adds 10 user-visible entries covering everything shipped since the
2026-04-30 entries. Newest first, voice and length match the
established pattern.

- 2026-05-21 Configurable dashboard (drag/drop edit mode, resize,
  per-widget options, widget catalog, firm-wide admin default,
  collision-aware placement — bundles t-paliad-219 Slice A+B+C +
  m/paliad#69 + #70)
- 2026-05-20 User-authored checklists (Wizard + explicit sharing +
  admin firm-wide promotion + template versioning — t-paliad-225
  Slice A+B+C)
- 2026-05-20 Approvals: suggest changes (third inbox action,
  counter-proposal modal, Verlauf integration — t-paliad-216 +
  t-paliad-217)
- 2026-05-20 Client role + auto-derived project codes (t-paliad-222 =
  m/paliad#47 + #50)
- 2026-05-19 Submissions: Klageerwiderung als Word-Datei (Schriftsätze
  tab + first .docx template — t-paliad-215)
- 2026-05-19 Personal data export (xlsx/csv/json on /settings +
  per-project subtree export — t-paliad-214)
- 2026-05-15 Custom Views (Meine Sichten + list/cards/calendar/timeline
  + exports — t-paliad-144 + t-paliad-177 + t-paliad-211)
- 2026-05-07 Projects page redesign (tree + chips + pin + search + Cards
  view — t-paliad-149 PR 1+2)
- 2026-05-06 Four-eyes approvals (dual-control on deadline/appointment
  CRUD + admin policies UI — t-paliad-138 + t-paliad-154)
- 2026-05-05 Fristenrechner v3 (Pathway A/B + decision tree + concept
  layer + DE/EPA/DPMA expansion — t-paliad-131 / 133 / 134 / 136)

go build ./... + go test ./internal/changelog/... clean.
2026-05-21 15:00:53 +02:00
mAi
062afb6cc5 Merge: hotfix project tree ltree-on-text outage 2026-05-21 14:52:56 +02:00
mAi
47b869dddf hotfix(projects): drop ltree operators on text path — production outage
Production-down: project tree returned the
"Projektverwaltung zurzeit nicht verfügbar" message because every
PopulateProjectCodes call raised:

  ERROR service: populate project codes: bulk fetch:
  pq: operator does not exist: text @> text at position 13:38 (42883)

Root cause: paliad.projects.path is stored as TEXT (dot-separated
UUIDs), not as the ltree extension type. The rest of the codebase
treats it accordingly — can_see_project uses
string_to_array(path, '.')::uuid[]; export_service.go uses LIKE
patterns; export_service.go even spells it out:
"Subtree-aware queries via paliad.projects.path (ltree as text)."

The new project-code helper (t-paliad-222 / m/paliad#50) was the only
caller using ltree operators (@>, nlevel) against this text column.
Postgres correctly rejected text @> text — no such operator exists.

Fix: rewrite both queries (BuildProjectCode + PopulateProjectCodes) to
walk ancestors via string_to_array(path, '.')::uuid[], consistent with
the existing visibility predicate. Ordering uses array_position
instead of nlevel. Query shape validated against the live DB.

Pure-function tests (assemble + segment) untouched and passing. The
gap that let this ship: no integration test exercises the actual SQL
— it only tests the pure assembler. Filing a follow-up issue for a
real-DB regression test.
2026-05-21 14:52:50 +02:00
mAi
c4c4fa267f Merge: fix dashboard deadline link query preservation 2026-05-21 14:23:07 +02:00
mAi
d555d5f679 fix(dashboard): preserve query string on /deadlines → /events redirect
m's 2026-05-21 14:20 report: dashboard "Diese Woche" card linked to
/deadlines?status=this_week but the 301 to /events?type=deadline dropped
the query string, landing on the default Pending filter instead of the
This-Week bucket.

Two-part fix:

1. handleDeadlinesListRedirect now appends r.URL.RawQuery to the
   target so any filter (status, project_id, event_type, …) survives
   the redirect. Regression test pins all three shapes (no query,
   single param, multi param).

2. Dashboard summary cards point at the canonical
   /events?type=deadline&status=… URL directly — saves the 301 bounce
   and matches the URL the events page itself reads on load.

The five card values (overdue/today/this_week/next_week/later) are all
in STATUS_OPTIONS_DEADLINE in frontend/src/client/events.ts, so the
events page filter chip picks them up natively.
2026-05-21 14:23:04 +02:00
mAi
875d0c149a Merge: m/paliad#70 — collision-aware widget placement (dashboard overlap fix)
Follow-up to m/paliad#69. Mixed-size rows (e.g. 2-col widget next to 1-col)
no longer visually overlap because:

- Grid occupancy map now accounts for each widget's full colspan footprint,
  not just its origin cell.
- Drop-target hit detection excludes cells covered by another widget's
  colspan.
- Resize-grow shifts conflicting siblings to the next free cell (m's
  recommended behaviour per the issue body).

Tesla stays persistent on mai/tesla/dashboard-overlap for follow-up
dashboard tweaks per m's continuity ask.
2026-05-21 10:49:45 +02:00
mAi
92d0340d74 fix(dashboard): t-paliad-228 — collision-aware widget placement (m/paliad#70)
After m/paliad#69's edit-mode overhaul, widgets visually overlapped on
mixed-size rows: a 12-col + 6-col swap, an auto-flow widget landing on
an explicit blocker, or a resize-grow into a sibling all produced
layouts that ignored colspan footprints when computing occupancy.

Extracts placement math from dashboard.ts into a pure ./dashboard-grid
module and adds an occupancy bitmap. Every visible widget is placed
once; explicit-position collisions are resolved by searching downward
from the requested row for the first w×h block that fits, preferring
the requested column. Resize-grow + drag-drop swap now reliably
produce no-overlap layouts because the placer cleans up after them.

x+w > GRID_COLUMNS is clamped in the placer instead of rendered as an
overflow — matches the validator's hard rule on the wire.

Adds 14 dashboard-grid.test.ts regressions covering the mixed-width
swap, resize-grow shifting siblings, multi-row widgets, and the
overflow clamp. Pure tests — no DOM.
2026-05-21 10:48:10 +02:00
mAi
f8c6206afe Merge: m/paliad#69 — dashboard edit-mode overhaul (drag/drop + resize + per-widget options)
Three regressions / gaps on newton's just-shipped Slice B+C addressed.

- **Drag/drop reorder**: rebuilt on a single proper 12-col grid (newton's
  implementation had per-row containers which blocked cross-row drops + the
  swap heuristic only handled adjacent same-size cells). Drop hit detection
  now works across the entire grid; recalc step uses real grid coordinates;
  any widget moves anywhere, autosaves.
- **Resize**: bottom-right resize handle added (visible only in edit mode).
  Snaps to valid 1x1 / 2x1 / 2x2 grid sizes; sibling widgets reflow on
  resize; autosave via the same PUT /api/user/dashboard path.
- **Per-widget options expansion**: widget catalog entries now carry an
  option schema (limits, position, content/view-type). Settings pane
  renders the right controls dynamically per schema. Deadlines widget
  exposes list / calendar / timeline-strip view picker; activity widget
  full / compact toggle; etc.

No schema migration — option schema rides on the existing user_dashboard_layouts
jsonb. Backward-compat: legacy layouts (without per-widget options) hydrate
with catalog defaults.
2026-05-21 09:56:08 +02:00
mAi
f8245a06a6 fix(dashboard): t-paliad-227 — rebuild edit mode on a single 12-col grid (m/paliad#69)
Three issues from Slice B were entangled in the same root cause:

1. **Drag/drop reorder only swapped the first two same-size widgets.**
   Widgets lived in two parents (.container + .dashboard-columns); the
   old applyLayout used parent.appendChild per widget which physically
   moved every .container widget to the END of .container — past the
   .dashboard-columns row, edit-footer, and save-toast. Only the two
   columns inside .dashboard-columns swapped visibly because they
   shared a parent. Cross-row drags appeared to silently no-op.

2. **No resize affordance** — the design's per-widget sizing existed
   only on paper.

3. **Per-widget options were thin** — count + horizon dropdowns only.

This change rebuilds the whole layout primitive on a single 12-column
CSS grid:

Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
  against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
  HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
  addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
  calendar), inline-agenda (timeline, list), recent-activity (full,
  compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
  tracking row-max height so taller previous neighbours never overlap.

Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
  wrapper; matter-summary converted to a CollapsibleSection so it
  participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
  grid-column / grid-row from computed placements. computePlacements
  trusts explicit positions and auto-flows the rest with the same
  rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
  by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
  derived from live grid metrics, snaps to grid + clamps to schema,
  autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
  (x,y,w,h) so the spec stays self-describing — no mixed
  explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
  numeric inputs alongside preset dropdowns, size (W/H) + position
  (X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
  - upcoming-deadlines / -appointments: list (default) or mini-month
    calendar with item dots.
  - inline-agenda: timeline (default) or flat list.
  - recent-activity: full (default) or compact (one-line per row).

CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
  stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.

Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
  RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
  GridSizeOutsideClamps, NoOverlap (greedy packer regression),
  AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
  values inside [1,CountMax] are valid; presets stay valid too).

Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
2026-05-21 09:54:23 +02:00
mAi
ca71162543 Merge: t-paliad-219 Slice C — catalog expansion + firm-wide admin default (m/paliad#46)
Final slice of the configurable dashboard. Catalog expansion + firm-wide
default propagation.

- mig 117 paliad.firm_dashboard_default — single-row firm-wide factory
  layout, editable by global_admin. New users hydrate from this; existing
  users get 'reset to firm default' option alongside the existing
  'reset to factory'.
- Catalog expansion: pinned-projects widget brought live (C0 pin-machinery
  prerequisite shipped inline); plus 2-3 high-value adds per design
  catalog (recent-deadlines-by-type, my-open-approvals, etc.).
- Frontend: admin '/admin/dashboard-default' page to edit the firm shape;
  user-side 'Reset auf Firmenstandard' link in the dashboard reset flow.

m/paliad#46 fully shipped (Slices A + B + C).
2026-05-20 19:30:20 +02:00
mAi
6b565be830 feat(dashboard): t-paliad-219 Slice C — catalog expansion + firm-wide admin default
Three additions on top of Slice B's edit-mode chrome.

**Catalog expansion (2 new widgets, default-hidden — opt-in via picker):**

- pinned-projects: surfaces a list of the user's pinned matters via the
  pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New
  DashboardService.loadPinnedProjects joins paliad.user_pinned_projects
  to paliad.projects under the standard visibility predicate, preserves
  pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects
  []PinnedProjectRef grows DashboardData; SetPinService wired
  post-construction to mirror the SetApprovalService pattern.

- quick-actions: pure UI affordance with three buttons linking to the
  existing /projects/new, /deadlines/new, /appointments/new routes. No
  backend payload, no settings schema.

Both default-hidden — m's brief asked for "high-value adds"; injecting
new widgets into every user's dashboard unannounced would be loud.
Factory test relaxed: visibility now matches catalog.DefaultVisible
instead of the previous "all-visible" invariant.

**Firm-wide admin default (mig 117 + new service + 4 endpoints):**

- paliad.firm_dashboard_default: single-row table (id smallint PK CHECK
  id=1) with layout_json + updated_by + updated_at. RLS: SELECT
  authenticated, no INSERT/UPDATE policy (writes go through the
  service-role connection behind the adminGate).
- FirmDashboardDefaultService Get/Set/Clear. Validates against the
  catalog on Set so an admin can't seed an invalid layout.
- DashboardLayoutService.SetFirmDefaultService wires in the firm
  source. Both GetOrSeed and ResetToDefault now prefer the firm
  default over the code-resident FactoryDefaultLayout when one is set.
  Nil-safe — empty firm row falls back to the factory layout, transient
  DB errors fall back too (a blip can't strand a user without a
  dashboard).
- HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin-
  gated). POST /api/me/dashboard-layout/promote: admin convenience —
  reads the admin's own current layout and stashes it as the firm
  default (saves the JSON-editor step; admins edit via /dashboard's
  normal editor, then click Promote).

**Frontend (Slice B's edit-mode footer grew an admin button):**

- "Als Firmen-Standard speichern" button in the edit footer; hidden via
  CSS-inline until syncPromoteButtonVisibility unhides for
  global_admin. Confirm() → POST /promote → toast.
- The existing "Auf Standard zurücksetzen" copy stays the same — the
  semantics now "firm default if set, else factory", which is the
  desired surface: users see one canonical "Standard" link.

i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*,
dashboard.edit.promote*). i18n-keys.ts regenerated by build.

m/paliad#46.

go build ./... clean; go vet ./... clean
go test ./internal/... clean (Slice C catalog test + factory-default
   test relaxation; FirmDashboardDefault round-trip tests gated on
   TEST_DATABASE_URL)
Migration 117 dry-run: PASS (other dry-run failures are pre-existing
   local-DB collisions on origin/main; mig 117 itself clean)
bun run build clean: dashboard.html carries new section markup + admin
   button; dashboard.js bundles renderPinnedProjects + promote handler
   + all new i18n keys
2026-05-20 19:15:32 +02:00
mAi
0857c1c078 Merge: t-paliad-219 Slice B — dashboard edit mode (m/paliad#46)
Second slice of the configurable dashboard. Adds the user-facing edit-mode
on top of Slice A's storage + factory render.

- 'Anpassen' toggle button in the dashboard header — off by default.
- Drag handles + x + + buttons appear on widgets when edit mode is on;
  invisible otherwise so the reading-only path stays clean.
- Per-widget settings (counts + horizon dropdowns) per widget catalog.
- 12-col grid drag/drop reorder; mobile fallback to single column with
  drag-by-handle.
- Autosave 400ms debounced via PUT /api/user/dashboard.
- Reset-to-default link to revert layout to the factory shape.

Frontend-only slice. Net 5 files, +1027/-3 LoC (most of it in
client/dashboard.ts + the new CSS block).

Slice C (catalog expansion + admin firm-wide default) remaining.
2026-05-20 19:00:11 +02:00
mAi
4bf0a719b0 feat(dashboard): t-paliad-219 Slice B — edit mode + drag/drop + autosave
Adds the user-facing dashboard customization UI on top of Slice A's
backend (already shipped). Off by default — view-mode DOM and behavior
are byte-identical to the factory render.

Anpassen toggle in the dashboard header flips body.dashboard-editing.
When on, every [data-widget-key] grows a chrome strip with drag handle,
↑/↓ keyboard reorder buttons, hide/show button, and ⚙ gear for widgets
with a settings schema. An edit footer below the activity widget
surfaces "+ Widget hinzufügen" and "Auf Standard zurücksetzen".

Drag-and-drop uses native HTML5 DnD (dragstart / dragover / drop) on
the widget element itself. ↑/↓ buttons are the keyboard + touch
fallback. Hide flips Visible:false in the layout draft; re-showing via
the picker either un-hides in place or appends to the end if the
widget was never added.

Picker modal uses the unified openModal() helper (t-paliad-217). Each
catalog entry shows title + description + active/hidden/absent pill;
tapping an inactive entry mutates the layout and the list re-renders
in place so the user can multi-add.

Gear popover anchors absolutely inside the widget. Per-widget knobs
follow the catalog's WidgetSettingsSchema: count {1,3,5,10,20} for
list widgets, horizon_days {7,14,30,60} for upcoming-deadlines/-appoint-
ments, horizon-only {14,30,60} for inline-agenda, count {1,3,5,10} for
inbox. Selecting a value scheduleSave()s; close on outside-click / Esc.

Autosave: every layout mutation → snapshot rollback target +
400ms-debounced PUT /api/me/dashboard-layout. Success flashes a
"Gespeichert" toast (1.5s); failure rolls back, re-renders, and shows
"Speichern fehlgeschlagen". Reset link → confirm() → POST /api/me/
dashboard-layout/reset, replacing currentLayout with the factory
default returned by the service.

Mobile (≤32rem): toggle becomes full-width tappable, drag handle
hides in favor of ↑/↓ buttons (touch DnD is unreliable), picker uses
the existing modal full-screen breakpoint, toast spans the row.

Frontend-only — Slice A already shipped GET/PUT/POST /api/me/dashboard-
layout, GET /api/dashboard-widget-catalog, and the three-blob shell
hydration (data, layout, catalog). The client reads __PALIAD_DASHBOARD
_CATALOG__ inline; fetch fallback on hydration miss.

i18n: 23 new keys × 2 langs (DE + EN) for the toggle, picker, gear,
toast, and reset confirm. The i18n-keys.ts regenerates on every build.

m/paliad#46.

go build ./... clean
go vet ./... clean
go test ./internal/... clean (24 dashboard-layout/widget-catalog unit tests pass)
go test ./cmd/server/ -run TestBootSmoke: SKIPS without TEST_DATABASE_URL
   (CI's clean test DB runs the boot-smoke gate)
bun run build clean: dashboard.html still carries the three placeholder
   tokens; dashboard.js bundles the edit-mode code + i18n keys
2026-05-20 18:42:41 +02:00
mAi
15ce176ebd Merge: t-paliad-225 Slice C — checklist gallery + versioning (m/paliad#61)
Final slice. Discoverability + versioning on user-authored checklists.

- mig 116 paliad.checklists.version int NOT NULL DEFAULT 1 +
  paliad.checklist_instances.template_version int (snapshot column).
  Version bumps on template UPDATE; instance carries the version it was
  created from.
- 'Geteilte Vorlagen' tab on /tools/checklists surfacing templates the
  user can see via firm/global visibility + checklist_shares. Filter by
  author / tag / visibility level. Popularity sort optional (deferred).
- Outdated-template badge on instance detail when
  instance.template_version < template.version. Click → modal showing
  the diff (template's new sections / items vs the snapshot).
- audit events: checklist_template_versioned emitted on each UPDATE.

t-paliad-225 / m/paliad#61 fully shipped (Slices A + B + C).
2026-05-20 15:51:43 +02:00
mAi
e56cb3b210 feat(checklists): t-paliad-225 Slice C frontend — Geteilte Vorlagen tab + outdated-template badge
m/paliad#61 Slice C frontend pass.

Discovery (Geteilte Vorlagen):
- New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene
  Instanzen". Filters the merged catalog response to authored entries
  not owned by the caller (firm-visible OR globally-promoted OR
  share-recipient). Tab state round-trips via ?tab=gallery.
- Regime filter pills (UPC / DE / EPA / OTHER) operate independently
  from the main Vorlagen tab.
- Cards show regime badge, item count, author line, visibility chip.
- Self-filter relies on /api/me email match — loadMe() fires once on
  page boot and is idempotent.

Versioning UI on /checklists/instances/{id}:
- "Vorlage aktualisiert" badge appears when the instance's
  template_version is known AND lags the live template version (only
  for authored templates; static templates never bump). Shows "v{from}
  → v{to}" delta.
- "Änderungen anzeigen" button opens a diff modal that compares the
  instance's template_snapshot against the live template body.
  Item-level grouping by (section title, item label). Surfaces added /
  removed / changed items with localised section labels. Empty state
  when only metadata changed.

i18n: 13 new keys per language (DE + EN) under
checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other,
and checklisten.instance.{outdated,diff}.*. Total 2666 keys.

Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test
+ TestBootSmoke ./cmd/server/ all green.
2026-05-20 15:50:38 +02:00
mAi
fffddcc71a feat(checklists): t-paliad-225 Slice C backend — template versioning + catalog Version
m/paliad#61 Slice C backend.

Schema (mig 116, idempotent):
- ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1.
  Pre-Slice-C rows default to 1 (the column was added with DEFAULT
  so the UPDATE clause is a no-op safety net).
- ALTER paliad.checklist_instances ADD COLUMN template_version int.
  NULL on existing rows — instance detail page leaves the "outdated"
  badge off when the snapshot version is unknown.

Services:
- ChecklistTemplateService.Update — version bumps on title/body
  changes (the meaningful edits that warrant notifying instance
  owners). Pure metadata tweaks (description/court/reference/deadline)
  update updated_at without bumping. Emits the new 'checklist.versioned'
  audit event with prior_version + new_version metadata.
- ChecklistInstanceService.Create — captures snapshot_version
  alongside the body snapshot.
- ChecklistCatalogService — CatalogEntry grew a Version field
  (1 for static; live column for authored). ListVisible / Find
  populate it.
- Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int.
- /api/checklists/{slug} response now includes version so the
  instance detail page can compare against the snapshot.

Migration verified live via BEGIN..ROLLBACK against paliad.checklists
and paliad.checklist_instances.

Build hygiene: go build/vet/test ./internal/... + TestBootSmoke
./cmd/server/ all green.
2026-05-20 15:50:21 +02:00
mAi
b850eb755c Merge: t-paliad-225 Slice B — checklist sharing + admin promotion (m/paliad#61)
Second slice. Explicit sharing of personal checklists to user / office /
partner_unit / project + global_admin promote-to-firm / demote.

- mig 115 paliad.checklist_shares (FK to user_id / office_key / partner_unit_id
  / project_id; granted_by; granted_at). Partial indexes per share kind.
- Backend: ListShares / GrantShare / RevokeShare on ChecklistService.
  Promote/Demote on AdminChecklistService — flips visibility to/from 'global'
  and emits checklist_promoted_global / checklist_demoted audit events.
- HTTP routes (under /api/checklists/templates/ + /api/checklists/shares/ +
  /api/admin/checklists/ — all literal-prefixed to avoid the route-collision
  class the hotfix 6b63420 just shipped to address).
- Frontend: 'Teilen' modal on a checklist detail page (recipient picker:
  user / office / partner-unit / project); 'Als global markieren' / 'Aus
  global entfernen' admin buttons (global_admin only).
- RLS extended: select policy allows owner + visibility='firm' + visibility='global'
  + rows present in checklist_shares matching caller's ancestry.

Slice C (discoverability gallery + versioning) follows.
2026-05-20 15:39:56 +02:00
mAi
a93277a072 feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page
m/paliad#61 Slice B frontend pass.

Detail page (/checklists/{slug}) gains:
- Provenance line ("Erstellt von <author>") for authored templates,
  populated from the catalog response's owner_display_name.
- Owner action buttons: Bearbeiten (links to
  /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen,
  Löschen. Reveal driven by /api/me email match against the catalog
  response's owner_email.
- global_admin action buttons: "Als Firmen-Vorlage hinterlegen"
  (promote) when visibility != 'global'; "Aus Katalog entfernen"
  (demote) when visibility == 'global'. Reveal driven by /api/me
  global_role.

Share modal:
- Single modal with a kind-picker (Kollege / Office / Dezernat /
  Projekt) and a matching select per kind — sections toggle on the
  active kind.
- Recipient pickers populated from /api/users, /api/partner-units,
  /api/projects (loaded in parallel on open). Office options use the
  canonical 8-key set from internal/offices.
- Existing grants surface in a list under the form with per-row
  Entfernen buttons; Revoke confirms before DELETE.
- Errors surface inline (recipient-required, generic share failure).

i18n: 32 new keys per language (DE+EN) under checklisten.share.*
and checklisten.detail.promote/demote/delete.*. Total 2653 keys.

Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all
green; bun run build clean.
2026-05-20 15:38:43 +02:00
mAi
c3cd51eb85 feat(checklists): t-paliad-225 Slice B backend — explicit sharing + admin promotion
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.

Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
  recipient via xor-check: recipient_kind in {user, office,
  partner_unit, project} with exactly one matching recipient_* column
  populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
  duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
  INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
  no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
  share branch uses inline ltree walk over projects.path because
  can_see_project reads auth.uid() (NULL on service-role connection,
  same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
  set; accepts the matching kind/recipient pair

Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
  required FK target, friendly 409 on partial-unique-index conflict),
  Revoke (owner or global_admin), ListGrants (owner or global_admin;
  enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
  + promoted_at/by + audit), Demote (global_admin → target visibility,
  default 'firm', clears promoted_at/by; rejects demote of non-global
  rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
  include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
  'shared' as an author-set value; 'global' stays admin-only

Endpoints:
- GET    /api/checklists/templates/{slug}/shares  — list grants (owner/admin)
- POST   /api/checklists/templates/{slug}/shares  — grant
- DELETE /api/checklists/shares/{id}              — revoke
- POST   /api/admin/checklists/{slug}/promote     — promote to global
- POST   /api/admin/checklists/{slug}/demote      — demote (body.target default 'firm')

Audit (paliad.system_audit_log):
- checklist.shared      — recipient_kind + recipient_id in metadata
- checklist.unshared    — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted     — target_visibility

Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.

Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
2026-05-20 15:38:30 +02:00
mAi
6b634207c2 Merge: hotfix — disambiguate checklists route conflict (production-down) 2026-05-20 15:34:00 +02:00
mAi
794617cbfd hotfix(checklists): disambiguate /checklists/{slug}/edit → /checklists/templates/{slug}/edit (production-down route conflict)
Go ServeMux refused to register patterns 'GET /checklists/{slug}/edit' (from
dirac's Slice A merge b418705) and 'GET /checklists/instances/{id}' (existing)
because both match '/checklists/instances/edit'. Container crash-looped on
boot since 13:32 UTC; paliad.de returned 404 from Traefik because no app was
listening.

Renaming the new template-edit route to /checklists/templates/{slug}/edit
disambiguates — '/templates/...' is a literal segment so the {slug} is now
strictly under a fixed prefix that can't collide with 'instances'.

Touches:
- internal/handlers/handlers.go:257 — route pattern
- frontend/src/client/checklists.ts:290 — Bearbeiten link
- frontend/src/client/checklists-author.ts:52 — URL parser regex
- frontend/src/checklists-author.tsx — doc comment

go build + bun run build clean.
2026-05-20 15:34:00 +02:00
mAi
b418705775 Merge: t-paliad-225 Slice A — user-authored checklists (m/paliad#61)
First slice of the user-checklist feature. Personal templates + 'Meine Vorlagen'
authoring; private + firm visibility only (explicit sharing to specific
users/offices/units/projects + admin-promotion ship in Slices B + C).

- mig 114 paliad.user_checklists table (owner_id, visibility text, name, sections
  jsonb, created_at). RLS scoped to owner + 'firm' visibility = visible to
  all authenticated users. Verified-via-gap-tolerant-runner.
- ChecklistService — Create/List/Get/Update/Delete + RLS-aware queries.
- HTTP layer — GET/POST /api/checklists, PATCH/DELETE /api/checklists/{id}.
- 'Meine Vorlagen' surface on /tools/checklists with authoring wizard
  (sections + items + visibility radio).

Slice B (share-to-individual + promotion to global) and Slice C (gallery +
versioning) come in follow-up shifts.
2026-05-20 15:24:28 +02:00
mAi
7a1fd81d23 feat(checklists): t-paliad-225 Slice A frontend — Meine Vorlagen + authoring wizard
m/paliad#61 Slice A frontend pass.

Pages:
- /checklists gets a third tab "Meine Vorlagen" between Vorlagen and
  Vorhandene Instanzen — lists owned authored templates with regime
  badge, visibility chip, Bearbeiten / Löschen actions, "Neue Vorlage"
  CTA. Tab state round-trips via ?tab=mine.
- /checklists/new and /checklists/{slug}/edit serve a shared bundle
  (checklists-author.html). Client reads location.pathname to decide
  create vs edit mode; edit mode prefills from /api/checklists/templates/mine.

Wizard:
- Metadata form (title, description, regime UPC/DE/EPA/OTHER, court,
  reference, deadline, language de/en, visibility private/firm).
- Repeating section + item editor — add/remove sections, add/remove
  items per section, label + optional note + optional rule per item.
- Single-language authoring (lang column on paliad.checklists). The
  catalog read layer mirrors the title/description onto both DE and EN
  sides so the existing bilingual frontend renders without a special
  case for authored entries.
- Save POSTs (create) or PATCHes (edit) the template; visibility flip
  on edit goes through its own endpoint so the audit row captures the
  transition.

Merged catalog:
- /api/checklists now returns the merged list (static + DB visible);
  the Summary shape gained origin / visibility / owner_email /
  owner_display_name fields.

i18n: 55 new keys per language (110 total) under
checklisten.tab.mine.*, checklisten.mine.*, checklisten.author.*,
checklisten.detail.* (Bearbeiten/Löschen labels for Slice B). i18n
codegen total: 2621 keys.

Build hygiene: bun run build clean, go build clean, go vet clean,
go test ./internal/... + ./cmd/server/ all green.
2026-05-20 15:24:07 +02:00
mAi
a4e2f3526d feat(checklists): t-paliad-225 Slice A backend — user-authored templates
m/paliad#61 Slice A. Introduces paliad.checklists (mig 114) as the
DB-backed companion to the static Go catalog. ChecklistCatalogService
unifies both sources at read time; ChecklistTemplateService handles
authoring CRUD + visibility toggle (private↔firm; Slice B opens
'shared' and 'global').

Schema (mig 114, idempotent):
- paliad.checklists (uuid, slug UNIQUE, owner_id FK, title/description
  /regime/court/reference/deadline/lang, body jsonb, visibility CHECK
  ('private','shared','firm','global'), promoted_at/_by, timestamps)
- paliad.can_see_checklist(uuid, uuid) STABLE SECURITY DEFINER —
  owner OR firm/global. Slice B extends with the explicit-share branch.
- RLS: select via can_see_checklist; insert owner=self; update/delete
  owner OR global_admin
- ALTER paliad.checklist_instances ADD COLUMN template_snapshot jsonb
  (snapshot semantics so per-Akte instances stay decoupled from
  subsequent template edits)

Services:
- ChecklistCatalogService — ListVisible, Find, SnapshotBody, IsStaticSlug.
  Reapplies visibility application-side (service-role bypasses RLS, per
  visibility.go pattern). Static-slug map computed once at boot for
  collision detection.
- ChecklistTemplateService — Create (auto-generates u-<slug>-<hex> with
  retry), Update (changed_fields[] in audit), SetVisibility, Delete,
  ListOwnedBy, GetBySlug. Owner-or-global_admin gate.
- SystemAuditLogService.WriteChecklistEvent — thin helper writing into
  paliad.system_audit_log with scope='org'.
- ChecklistInstanceService.Create now captures template_snapshot via
  the catalog; GetByID returns it inline so the frontend can render
  the captured body even after the upstream template is mutated.

Endpoints (all owner-gated where mutating):
- GET    /api/checklists                 — merged catalog (static + DB visible)
- GET    /api/checklists/{slug}          — single template; static-first lookup
- GET    /api/checklists/templates/mine  — caller's authored templates
- POST   /api/checklists/templates       — create
- PATCH  /api/checklists/templates/{slug}            — edit
- PATCH  /api/checklists/templates/{slug}/visibility — private↔firm
- DELETE /api/checklists/templates/{slug}            — delete
- GET    /checklists/new, /checklists/{slug}/edit    — author wizard pages

Tests: pure-helper unit tests cover slugifyTitle (umlaut → ae/oe/ue/ss
normalisation + clamp), regime/lang/visibility validation, body-shape
enforcement, static-slug detection, predicate shape, clamp.
2026-05-20 15:24:06 +02:00
mAi
1c8cdd3079 docs(checklists): t-paliad-225 inventor design — user-authored checklists (#61)
918-line design doc covering all three capabilities from m/paliad#61:
authoring, multi-axis sharing, admin-promotion to global.

Load-bearing premise correction: the issue body claims `paliad.checklists`
is an existing table that gets new columns. It is NOT — checklists today
are static Go data in `internal/checklists/templates.go`. Design
introduces `paliad.checklists` from scratch and keeps the static catalog
as a parallel source via a hybrid catalog read layer.

Schema (mig 112): `paliad.checklists` (owner + visibility enum), `paliad.checklist_shares`
(polymorphic recipient: user/office/partner_unit/project),
`paliad.can_see_checklist` predicate, `paliad.checklist_instances.template_snapshot`
column for instance integrity under template edits.

12 decisions ledgered, all defaulted to (R) per task brief (no AskUserQuestion).
Three slices (A foundation, B sharing+promotion, C gallery+backfill).
2026-05-20 15:24:06 +02:00
mAi
82ecbe3b8e Merge: t-paliad-224 — calendar-view alignment (m/paliad#55)
Three calendar implementations consolidated into one. Custom Views' shape-calendar.ts
becomes the canonical renderer; /events Kalender tab and the orphaned
/deadlines/calendar + /appointments/calendar pages now use the same module.

- frontend/src/client/calendar/mount-calendar.ts — new canon module extracted
  from shape-calendar.ts. Month/week/day, URL state via ?cal_view/?cal_date,
  drill-down day view, kind-coded pills.
- /events Kalender tab folded onto mountCalendar(); the old modal popup
  replaced with day-view drill-down (Q2/(R)).
- /deadlines/calendar + /appointments/calendar become 301 redirects to
  /events?type=…&view=calendar (handlers test added to pin the targets).
- .frist-cal-* CSS block dropped (~180 lines). Dead i18n keys removed.

Net: ~700 LOC removed, ~100 added. Zero schema/endpoint changes. Same data-loader
shared across all surfaces. Single PR per Q7(R).
2026-05-20 15:23:50 +02:00
mAi
badbffa6e0 test(handlers): t-paliad-224 — pin /deadlines/calendar + /appointments/calendar redirect targets
Adds TestStandaloneCalendarHandlers_RedirectToEventsKalender to
internal/handlers/redirects_test.go covering both standalone-
calendar handlers. Each must 301 to the canonical Kalender-tab URL
on /events, preserving the bookmark contract called out in the
handler doc comments. Sister of the existing sub-projects redirect
test.
2026-05-20 15:23:28 +02:00
mAi
0f98d2cd39 refactor(calendar): t-paliad-224 — retire standalone calendar pages + prune dead code
Delete the four orphan files behind /deadlines/calendar +
/appointments/calendar:
- frontend/src/{deadlines,appointments}-calendar.tsx
- frontend/src/client/{deadlines,appointments}-calendar.ts
The standalone pages were unreachable from the UI since t-paliad-110
(Sidebar/BottomNav point at /events?type=…); their only role was as
bookmark targets.

Handlers in internal/handlers/{deadlines,appointments}_pages.go now
301-redirect to /events?type=…&view=calendar so bookmarks still
work. Route registrations in handlers.go remain unchanged — the
gate + redirect pair gives us the same URL surface with one canonical
renderer.

build.ts: drop the renderDeadlinesCalendar / renderAppointmentsCalendar
imports + entry-point bundle paths + dist HTML writes.

frontend/src/client/paliadin-context.ts: drop the two route-key
matches for the standalone URLs (the client never sees those
pathnames any more — 301 fires server-side).

Dead CSS pruned in frontend/src/styles/global.css (~180 lines):
- .frist-calendar, .frist-cal-{controls,month-label,grid,cell,…}
  block (lines 7464-7613 pre-refactor)
- @media (max-width: 700px) { .frist-cal-cell { min-height: 64px; } }
- .termin-cal-legend{,-item}
- .frist-cal-popup-time
- .frist-cal-dot.events-cal-dot-appointment

All verified by grep across frontend/ + internal/ to have no
non-calendar consumers before deletion.

Dead i18n keys removed (DE + EN + i18n-keys.ts union type):
- deadlines.kalender.{title,heading,subtitle,list,today,empty}
- appointments.kalender.{title,heading,subtitle,list,empty}
- deadlines.list.calendar, appointments.list.calendar (button labels
  on the deleted standalone routes)
- events.calendar.empty (replaced by cal.day.no_entries inside
  mountCalendar's day view)

Per head decisions §11 Q1 + Q8 (drop standalone pages as 301s; drop
dead i18n now).

Tests: go build ./... clean; go test ./internal/... 9 packages pass;
cd frontend && bun run build clean (2535 i18n keys); bun test
frontend/src/client/{calendar,views}/ all 73/73 pass.
2026-05-20 15:23:28 +02:00
mAi
d0f732d0ec refactor(events): t-paliad-224 — fold Kalender tab into mountCalendar()
The /events Kalender view now mounts the canonical mountCalendar()
module from frontend/src/client/calendar/ — same renderer Custom
Views uses for shape=calendar. Drops the events-page-specific
month-grid + popup code path entirely.

What replaces what
- renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear /
  isoDate / itemDateISO and the calYear/calMonth module state →
  one mountCalendar() handle (lazy, urlState=true).
- events-cal-prev / events-cal-next / events-cal-today buttons →
  toolbar in mountCalendar (includes its own 'Heute' button).
- modal popup on cell click → drill-down to day view (matches
  /views; head decision §11 Q2).
- @media min-height shrink on .frist-cal-cell → views-calendar-*
  responsive surface (CSS unchanged from /views).

Behavioural deltas vs pre-refactor
- /events Kalender now persists view+anchor in ?cal_view + ?cal_date
  (head decision §11 Q3) — refresh / share-link safe.
- Pills are kind-coded (deadline / appointment) rather than urgency-
  coded; matches /views (head decision §11 Q4 — drop subtype dot
  colouring, file as follow-up).
- Empty-month message gone; the per-day no-entries state from the
  day-view replaces it (head decision §11 Q8 — drop dead i18n).

Adapter: toCalendarItem() preserves the pre-refactor bucketing rule
— deadlines bucket on due_date, appointments on start_at, both fall
back to event_date.

events.tsx: 31-line calendar subtree (toolbar + grid + modal +
empty hint) reduces to a single host div. mountCalendar fills it
when the user picks Kalender.
2026-05-20 15:23:28 +02:00
mAi
e83b150eda refactor(calendar): t-paliad-224 — extract mountCalendar() canon module
Lift the month/week/day renderer out of shape-calendar.ts into a new
frontend/src/client/calendar/mount-calendar.ts module so /events
Kalender (next commit) and Custom Views shape=calendar both go
through the same code path.

shape-calendar.ts becomes a thin adapter (ViewRow → CalendarItem +
defaultView=render.calendar.default_view, urlState=true). The
extracted module adds:

- update(items) on the returned handle so /events can re-mount on
  filter changes without rebuilding state.
- destroy() for clean teardown when /events switches shapes.
- A 'Heute' button in the toolbar (cal.today, DE+EN added to i18n.ts
  + i18n-keys.ts).
- Optional opts.urlPrefix for surfaces that may share a URL with
  another calendar.

mountCalendar reads ?cal_view / ?cal_date when opts.urlState=true.
/events will mount with urlState=true after the next commit so the
Kalender tab + day-view drill remain refresh-stable (per §11 Q3 in
the design doc).

Pure-helper test suite (mount-calendar.test.ts) covers isoDate,
startOfDay, startOfWeek, shift, bucketByDate, filterByDay, isToday —
12 assertions, all green. DOM rendering covered by manual smoke (no
jsdom in this repo's bun test setup; see verfahrensablauf-core.test.
ts comment for the convention).
2026-05-20 15:23:28 +02:00
mAi
2320cb765d docs(design): t-paliad-224 — head accepted all 8 (R) defaults
Decisions section §12 filled in per head msg #2087. Status → ACCEPTED.
Coder shift proceeds on same branch per Q7(R): single PR.
2026-05-20 15:23:28 +02:00
mAi
668558380d docs(design): t-paliad-224 — align calendar views (m/paliad#55)
Audit + refactor plan: three calendar implementations live today —
/events tab, standalone /deadlines|appointments/calendar pages, and
Custom Views shape-calendar.ts. Canonicalise on shape-calendar.ts by
extracting a shared mount-calendar.ts module, fold /events into it,
retire the standalone pages as 301 redirects, delete ~180 lines of
duplicated CSS.

Net: ~700 LOC removed, ~100 added, zero schema/endpoint changes.

8 open questions for head in §11; AskUserQuestion is disabled for this
task per role brief, so head answers via mai instruct and decisions
land in §12.
2026-05-20 15:23:28 +02:00
mAi
9dd47a0591 Merge: t-paliad-223 Slice B — Add User on /admin/team (m/paliad#49)
Completes t-paliad-223 (team & admin surface). Slice A (Project Admin role
+ inheritable role-edit) and Slice C (click-to-select) already merged at
111c7c3.

- SupabaseAdminService + AdminCreateUserFull — auth.users create via the
  Supabase Admin API (requires SUPABASE_SERVICE_ROLE_KEY env, provisioned
  on paliad's Dokploy compose by head 2026-05-20). Best-effort rollback
  on paliad.users insert failure: deletes the auth row to keep state
  clean.
- Welcome email with magic link sent on create when 'Send welcome email'
  checkbox is on (default per Q2).
- POST /api/admin/users/full endpoint, gated on global_admin.
- Frontend modal on /admin/team — 'Add user' button alongside the
  existing 'Invite colleague' / 'Onboard existing' actions.
- i18n keys for the new modal and toast feedback.
- Tests: happy path, duplicate-email refusal, paliad.users insert failure
  with best-effort auth rollback.

t-paliad-223 fully shipped.
2026-05-20 15:20:13 +02:00
mAi
3d3a4fa36d feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside
"Onboard existing" and "Invite colleague". Creates both auth.users (via
Supabase Admin API) and paliad.users in one click; new user is visible in
dropdowns immediately and receives a paliad-branded magic-link email.

- internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping.
- internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route).
- internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort).
- internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB).
- internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars).
- internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject.
- internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other).
- internal/handlers/handlers.go: route registered behind adminGate.
- cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active.
- frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on).
- frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur.
- i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN.

Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head.

go build && go test -short ./internal/... + bun run build all green.
2026-05-20 15:19:48 +02:00
mAi
1c021ed515 Merge: t-paliad-222 — project metadata rework (m/paliad#47 Client Role + m/paliad#50 auto-derived project codes)
Two related issues bundled in one PR.

## #47 Client Role
- mig 112 widens projects.our_side CHECK with new sub-roles: Active
  (claimant/applicant/appellant), Reactive (defendant/respondent),
  third_party/other. Drops 'court' + 'both' (semantically odd; backfilled
  to NULL).
- ProjectFormFields.tsx hides the field on type='client', 'litigation',
  'patent'; shows 'Client Role' on type='case' with 7 grouped options.
- Submission template variable bag — ourSideDE / ourSideEN updated for
  the new values. Determinator perspective inference: Active →
  claimant-perspective, Reactive → defendant-perspective.

## #50 Auto-derived project codes
- mig 113 adds paliad.projects.opponent_code text on litigations (vs
  brittle regex on title).
- New Go helper services/project_code.go: BuildProjectCode(ctx, projectID)
  walks the ltree ancestor chain, derives <CLIENT>.<OPPONENT>.<PATENT>.<TYPE>.<COURT>
  (each segment optional). Custom override via projects.reference still wins.
- Project JSON gets an eager 'code' field populated by the service (no
  per-render lookups; one DB round-trip per list page).
- Rendered as a second header badge on /projects/{id} + in the parent-picker
  typeahead so users see the auto code while organising the tree.

Both migrations land cleanly via the new gap-tolerant runner (boltzmann
c85c382). 376-line project_code_test.go covers the segment-derivation
matrix.
2026-05-20 14:56:25 +02:00
mAi
35217fab4f feat(project-picker): show auto-derived project code in parent typeahead
t-paliad-222 follow-up — wire .code into the parent-project picker so
two same-titled projects in different trees can be disambiguated by
their auto-derived dotted code. Search includes the code; the badge
renders only when distinct from the manual reference.

Excel __meta sheet still pending — the JSON code field is populated
by PopulateProjectCodes for every list payload, so the export
generator only needs to add one row in a follow-up shift.
2026-05-20 14:55:55 +02:00
mAi
225204cf1c feat(projects-detail): render auto-derived project code as a second header badge
t-paliad-222 follow-up — wire the .code field populated by
PopulateProjectCodes into the project-detail header. Shows next to
the manual reference when distinct, hidden when they match (avoid
duplication) or when no segments resolved. CSS `.entity-ref-code`
adds bracket-styling so the user knows the value is derived rather
than typed.

Also extends the frontend Project interface with code + opponent_code
to make TypeScript surface the new fields cleanly across consumers.
2026-05-20 14:55:55 +02:00
mAi
ea0715a8c7 feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:55:55 +02:00
mAi
3fdc969902 wip(projects): bump migrations 110→111, 111→112 (euler claimed 110) 2026-05-20 14:55:55 +02:00
mAi
5dea0a703b wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:55:55 +02:00
mAi
cc23e9e537 design(projects): t-paliad-222 — Client Role + auto-derived project codes
Design doc for paired m/paliad#47 (Client Role rework) + m/paliad#50
(auto-derived project codes from the ancestor tree). Two migrations
(110 widen our_side CHECK + backfill court/both → NULL; 111 add
opponent_code on litigations), one new BuildProjectCode helper that
walks the existing ltree path, plus form / submission-template /
Determinator wiring.

9 open design questions surfaced for the head; recommendations
default to the issue-body (R) picks unless a material concern is
flagged in §2.2 / §3.2.

Verified against live data (2026-05-20): all 12 projects have
our_side=NULL, so the backfill is a no-op on production today.
No 'opponent' field exists yet.
2026-05-20 14:55:55 +02:00
mAi
ca770636f7 Merge: m/paliad#58 — UPC CCR Procedure Roadmap (EN label + spawn-as-standalone renderer)
m's 2-part feedback on the UPC Counterclaim-for-Revocation roadmap surface.

1. Backfill missing English label on the trigger event 'Widerklage auf Nichtigkeit'
   → 'Counterclaim for Revocation'. Handled in services/proceeding_mapping.go
   (application layer; no corpus migration needed).
2. Generic 'spawn-as-standalone' renderer for sub-track proceeding types that
   have no native rules but spawn under a parent flag (CCR under
   upc.inf.cfi+with_ccr is the canonical case; the same pattern applies to
   R.46 Amendment etc.). When picked standalone, the timeline now renders the
   spawned rules with a contextual note explaining the normal parent context.

40 new unit tests in proceeding_mapping_test.go pin the renderer's standalone
detection + the EN label coverage.
2026-05-20 14:53:48 +02:00
mAi
ea9823db80 fix(verfahrensablauf): m/paliad#58 — UPC CCR roadmap (EN label + spawn-as-standalone)
m's 2026-05-20 14:08 reports on /tools/verfahrensablauf:

  1. "There seems to be a lacking english term here" — picking
     UPC CCR shows "Trigger event: Widerklage auf Nichtigkeit" on EN.
  2. "Nothing shows in the roadmap" — the timeline is empty because
     upc.ccr.cfi has no native rules (it's an illustrative peer that
     normally runs as a sub-track of upc.inf.cfi with with_ccr).

Root cause for (1): UIResponse.proceedingName was DE-only. When a
proceeding had no root rule the frontend fell back to that field, so
EN users saw the DE label. The DB already has bilingual names; this
was pure plumbing.

Root cause for (2): the upc.ccr.cfi proceeding-type row exists for
the picker (mig 096) but ResolveCounterclaimRouting — the helper
that maps it to upc.inf.cfi with the with_ccr flag — was defined
but never called. Calculate queried rules directly off upc.ccr.cfi
and got an empty list.

Fix:

  * Add ProceedingNameEN, ContextualNote, ContextualNoteEN to
    UIResponse. Frontend triggerEventLabelFor now consults the EN
    name on EN, falling back to DE only if the EN field is empty.
  * New SubTrackRouting registry in proceeding_mapping.go and a
    LookupSubTrackRouting lookup — single source of truth for the
    "this proceeding has no native rules, route to a parent with
    flags + show a contextual note" pattern. Today's only entry is
    upc.ccr.cfi → upc.inf.cfi + with_ccr; the pattern generalises
    to other sub-tracks via data-only additions.
  * Calculate consults the registry at the top: when a hit, the
    proceeding type is re-resolved to the parent for rule lookup, the
    default flags are merged into the user's flag set (user flags win
    on conflict), and the response identity (Code/Name/NameEN) stays
    on the user-picked proceeding so the page header still reads
    "Counterclaim for Revocation". The bilingual note surfaces in
    ContextualNote{,EN}.
  * Frontend renderResults paints a lime-accent banner above the
    timeline body when the response carries a note
    (.timeline-context-note). escHtml already exported from
    views/verfahrensablauf-core — imported here for the banner.

No DB migration: SELECTs against paliad.proceeding_types,
paliad.deadline_rules, and paliad.trigger_events confirm every
active row already has a non-empty name_en / name. The bug was
the API + frontend never reading the EN columns through the
proceedingName fallback path.

Tests: TestSubTrackRoutings pins the registry shape (every entry
has matching key/value, non-empty parent+flags, bilingual notes;
CCR's exact shape is asserted; non-sub-tracks miss). The existing
TestResolveCounterclaimRouting continues to pass because the
helper now consults the registry but the CCR semantics are
unchanged.
2026-05-20 14:53:22 +02:00
mAi
111c7c39e8 Merge: t-paliad-223 — team & admin Slices A + C (m/paliad#48 Project Admin role + #53 click-to-select)
Two slices on the team/admin surface. Slice B (Add User, m/paliad#49) is
parked pending m's go-ahead on the SUPABASE_SERVICE_ROLE_KEY credential.

## Slice A — Project Admin role (#48)
- mig 111 (renumbered from author's 110 to avoid collision with euler's
  project_type_other mig 110 merged immediately prior).
- 'admin' added to project_teams.responsibility CHECK.
- New paliad.effective_project_admin(user_id, project_id) SQL function
  walks the ltree path; sees admin on the row, on any ancestor, or
  global_admin status.
- ChangeResponsibility service method + last-admin-on-tree safeguard.
- Frontend inline-select on the project team panel, gated on
  effective_project_admin for the calling user.

## Slice C — Click-to-select (#53)
- /team gains a checkbox column + selection Set + sticky-footer
  broadcast action.
- Selection survives filter changes; drop-out rows de-select; navigation
  wipes selection.
- Empty-selection action falls back to the filtered set (no regression
  vs. existing broadcast).
- No backend changes; pure frontend.

All builds + tests green.
2026-05-20 14:47:13 +02:00
mAi
25cee32d01 feat(team): t-paliad-223 Slice C — click-to-select on /team with sticky-footer broadcast
#53 — adds an explicit selection layer ON TOP of the existing filter
pills on /team. Frontend-only; no backend changes, no migration.

- frontend/src/team.tsx: master "Alle sichtbaren auswählen" checkbox row above the team-list.
- frontend/src/client/team.ts:
  - Module-scoped selectedUserIDs Set + renderedUserIDs DOM-order snapshot + lastToggledUserID for Shift-click range expansion.
  - renderUserCard gains a per-row checkbox + data-selected attribute mirroring the Set.
  - pruneSelectionToVisible(): every render() drops user_ids that no longer match the filter — invariant "selection ⊆ visible".
  - wireSelectionCheckboxes() + applyRangeSelection() + refreshCardSelectedAttribute(): plain-click toggles one row, Shift-click extends a contiguous range using renderedUserIDs as the order reference.
  - renderSelectionFooter(): fixed-position bar that mounts when selection > 0, hides when empty. Hosts the live "{n} ausgewählt" counter, a "Auswahl aufheben" reset, and an "E-Mail an Auswahl" button that calls openBroadcastModal with selectedRecipients() — reuses the existing modal verbatim.
  - syncMasterCheckbox() + onMasterToggle(): tri-state master checkbox (empty / partial / full) for "select all visible".
- frontend/src/styles/global.css: .team-card[data-selected="true"] highlight, .team-card-select checkbox cell, .team-select-master-row, .team-selection-footer (z-index 150 — above mobile bottom-nav at 100, well below modal overlays at 1000+).
- i18n: +10 keys (team.selection.{count,clear,send,select_all,toggle_card}) × DE + EN.

Design picks honoured: surface=/team not /admin/team (Q1), checkbox column not modifier-key (Q2), sticky footer not always-visible (Q3), drop-out de-selects on filter change (Q4), fallback to filtered set when selection empty preserved by leaving the existing top-bar broadcast button intact (Q5), wipe on navigation since the Set is module-scoped in-memory only (Q6).

bun run build clean (2543 i18n keys, data-i18n scan clean). go build + go test -short ./internal/... unchanged (no backend touched).
2026-05-20 14:46:52 +02:00
mAi
2ed0ef3177 feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an
inheritable role-edit gate via the materialised ltree path.

- migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase.
- services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate.
- services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column).
- services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError.
- handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage.
- handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip.
- frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg.
- i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs).
- tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table.

go build && go test -short ./internal/... && bun run build all clean.
2026-05-20 14:46:36 +02:00
mAi
a5ae2148fa Merge: small UX polish batch — m/paliad#51, #52, #54, #56, #60
5 small cleanups bundled in one batch per m's 'group sensibly' guidance.

- #51 (projects): 'other' added as real project type via CHECK extension;
  synthetic 'Empty' option dropped from the type filter. No NULL-typed rows
  in prod today; backfill is a no-op.
- #52 (approvals): density-picker (Compact/Comfortable) active state uses
  brand accent #c6f41c. Sourced from a CSS variable so future surfaces
  inherit.
- #54 (events): broken 'From Today' appointment filter dropped (frontend
  was sending status=upcoming with no matching backend case). Default for
  appointments is now today's bucket; 'Alle (auch vergangene)' stays as
  the explicit opt-in.
- #56 (deadlines): event type renders before rule; the two are bundled as
  a single 'Verfahrenshandlung' visual block on display (Event Type — Rule).
  Form-level keeps separate inputs but visually grouped.
- #60 (a11y): label htmlFor=trigger-event dropped — the target was a
  <span>, which isn't labelable; the warning surfaced in Chrome Issues tab.
2026-05-20 14:44:01 +02:00
mAi
5a0674a2cf fix(a11y): drop label htmlFor=trigger-event — span isn't labelable
m/paliad#60 (t-paliad-221) — Chrome's Issues tab flagged a label/for
violation on the timeline wizard: <label for="trigger-event"> pointed
at a <span> showing the selected trigger event name. <label for=…>
must target a labelable form control (input/select/textarea/…), never
a span; the browser strips the association and a11y tooling sees a
dangling reference.

Audit found two occurrences — verfahrensablauf.tsx and fristenrechner.tsx
both use the same wizard markup. Switch both captions to plain
<span class="date-label">; the .date-label rule already targets by
class only, so visual styling is unchanged. No other label-for
mismatches surfaced (194 label-fors scanned across frontend/src).
2026-05-20 14:43:42 +02:00
mAi
13bb01ec96 fix(deadlines): event type renders before rule; bundle as Verfahrenshandlung
m/paliad#56 (t-paliad-221) — the deadlines editor read Title → Rule →
Event Type, which inverted the conceptual hierarchy (rule is the
citation under an event type, not its peer). Reorder all three
surfaces so the event-type parent comes first and the rule sits
directly beneath it.

- deadlines-new.tsx: pull the Regel select out of the Due-date row and
  drop it directly under the Typ picker; Due becomes its own row below.
- deadlines-detail.tsx: swap the Typ and Regel <dt>/<dd> rows in the
  detail list.
- approval-edit-modal.ts: remove rule_code from the generic
  DEADLINE_FIELDS list and render it inside a new
  "Verfahrenshandlung (Typ + Regel)" section beneath the event-type
  picker. The shared per-field renderer is extracted so the bundled
  section reuses the same dirty-tracking / pre_image-hint wiring.
- New i18n key approvals.suggest.section.event_type_rule (DE/EN).

Form-level inputs stay independent (some rules attach to multiple
event types and vice versa) — the change is purely about visual
grouping and reading order.
2026-05-20 14:43:42 +02:00
mAi
072b3d0c3d fix(events): drop broken 'From Today' appointment filter; default to today
m/paliad#54 (t-paliad-221) — fix 92780cf added a status=upcoming option
for appointments and made it the default, but DeadlineFilterUpcoming
only narrowed deadlines. The appointment query had no matching case, so
the bucket fell through to the unfiltered path and past events leaked
into "Ab heute" / "From today".

- Drop the 'upcoming' option from STATUS_OPTIONS_APPOINTMENT — confusing
  label that never delivered.
- Default appointments to the 'today' bucket (matches the dashboard
  tile; sane lawyer-relevant view).
- Keep 'Alle (auch vergangene)' as the explicit opt-in at the bottom
  of the list.
- Defensive backend fix: map DeadlineFilterUpcoming to start_at >= today
  in bucketAppointmentWindow so any persisted ?status=upcoming bookmarks
  stop leaking past events.
2026-05-20 14:43:42 +02:00
mAi
e39c4eb62d fix(approvals): density-picker active state uses brand accent
m/paliad#52 (t-paliad-221) — the Compact/Comfortable segmented control
on /approvals was rendering its active pill in plain --color-surface
(white in light mode, midnight-tinted in dark). Switch to the brand
lime so the segmented controls speak the same primary-action language
as the rest of Paliad.

Introduces three semantic tokens (--color-segment-active-bg / fg /
border) so any future surface that adopts .filter-bar-segment
inherits the same accent treatment without a CSS rewrite. The tokens
resolve to --color-accent / --color-accent-dark in both themes,
keeping the midnight foreground WCAG-AA on lime.
2026-05-20 14:43:42 +02:00
mAi
dc5f11ddef feat(projects): add 'other' as a real type; drop synthetic Empty filter
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to
treat unclassified projects as a synthetic "Empty" bucket. Make 'other'
a first-class projects.type value so every row carries a meaningful
label and the filter UI stops needing a NULL/Empty shim.

- mig 110: extend projects.type CHECK to include 'other'; backfill any
  NULL rows defensively (production query confirmed zero, but the
  NOT NULL constraint isn't load-bearing once the IN-list changes).
- Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType
  recognise it; handler doc lists 'other' in the ?type whitelist.
- Frontend: new chip in the projects.tsx type filter, new option in the
  Create-Project form, DE "Sonstiges" / EN "Other" labels for the
  projects.type and projects.chip.type i18n families.

Also drops a stray data-i18n-text attribute on the existing 'project'
chip checkbox (it had no consumer in i18n.ts and the surrounding markup
was nesting a <span> inside an <input>).
2026-05-20 14:43:42 +02:00
mAi
e343b759da Merge: m/paliad#57 — Fristenrechner cleanup (Custom labels + forward-workflow + same-context-twice + Add prefill)
m's 4-part feedback bundled in one PR:

1. Pre-selected project carries through 'Add' on both Pathway A (Save modal) and
   Pathway B (card-calc Add).
2. 'Custom' prefix stripped from all four adhoc proceeding-type chips (DE + EN).
3. 'Ich möchte etwas einreichen' option removed from 'Was ist passiert?' picker
   via HIDDEN_CASCADE_ROOTS; future forward-workflow tool tracked in m/paliad#65.
4. Same-context-asked-twice on Statement-of-Defence picker: pill-click now locks
   context inline (no duplicate 'Which context?' picker on top of the info list).
2026-05-20 14:42:27 +02:00
mAi
7288cf3c9c fix(fristenrechner): m/paliad#57 — cleanup (Custom labels, forward-workflow root, same-context-twice, Add prefill)
Four UX cleanups on /tools/fristenrechner per m's 2026-05-20 14:02–14:04
report:

1. **Pre-fill project on 'Add'** — when Step 1 binds an Akte, both the
   Pathway A "Save to Project" modal and the Pathway B card-calc inline
   'Add' picker now default their <select> to that project. Override
   still allowed; the picker lists all projects. New helper
   `preselectedProjectId()` reads `currentStep1Context` once so both
   surfaces stay in sync.

2. **Drop 'Custom' prefix from UPC/DE/EPA/DPMA adhoc chips** — the
   chip context already reads "oder ad-hoc, ohne Akte"; 'Custom' was
   redundant signaling. Labels become "UPC-Verfahren" /
   "UPC proceeding" (and the three sister jurisdictions).

3. **Remove 'Ich möchte etwas einreichen' from 'Was ist passiert?'** —
   the Fristenrechner is a backward-looking calc ("event happened, what
   spawns?"); the forward-workflow framing ("I want to file X") needs a
   different tool. Filter the `ich-moechte-einreichen` root subtree out
   in `loadEventCategoryTree()` (HIDDEN_CASCADE_ROOTS set) so the picker
   never offers it. DB rows preserved for the future forward-workflow
   tool, tracked in m/paliad#65.

4. **Same-context-asked-twice on Statement-of-Defence picker** —
   when the user clicks a specific rule pill on a concept card, the
   calc panel now renders a locked "Kontext: <proceeding — rule>"
   caption with an "ändern" affordance instead of re-showing the same
   five proceedings as a radio fieldset. When the user clicks the card
   body (no specific pill), the picker is still the primary surface, but
   the card's rule-pill section hides via CSS while expanded
   (`fristen-card-pills-section--rules`) so the same options aren't
   listed twice. Cross-cutting trigger pills (Wiedereinsetzung,
   Weiterbehandlung etc.) stay visible — they're conceptually
   different siblings, not the same proceeding context.
2026-05-20 14:42:14 +02:00
mAi
7f9e2ce7ed Merge: m/paliad#59 — restore click-to-edit on Procedure Roadmap timeline dates
m's priority bug 2026-05-20: 'we cannot change the dates in that anymore!
the timeline dates — they seem to be fix, nothing happens when I click on a date.'

Regression introduced when the verfahrensablauf-core renderer was extracted
as the shared source of truth for both /tools/verfahrensablauf and
/tools/fristenrechner — the delegated click handler that opens the inline
date-edit modal was wired on the Fristenrechner side but never re-attached
on the Verfahrensablauf side. Anchor overrides + editable:true flag were
not threading through.

Fix: thread anchorOverrides + editable:true through CardOpts into the
shared renderer; wire the delegated click handler on
/tools/verfahrensablauf; pin the editable → data-rule-code contract with
5 regression tests so this can't re-break silently.
2026-05-20 14:31:19 +02:00
mAi
bbb8c962a1 fix(verfahrensablauf): m/paliad#59 — restore click-to-edit on timeline dates
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.

Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:

  - openInlineDateEditor(span, onCommit) — swaps the date span for
    a `<input type=date>`, commits on blur/Enter, cancels on Escape,
    fires `onCommit(ruleCode, newValue)` ("" = revert).
  - wireDateEditClicks(container, onCommit) — idempotent delegated
    click + keyboard handler that resolves `.frist-date-edit
    [data-rule-code]` and opens the editor. Survives innerHTML
    rewrites because the listener lives on the container.

verfahrensablauf.ts now:
  - Owns its own anchorOverrides Map (cleared when proceeding-type
    changes — overrides for one proceeding don't apply to another).
  - Forwards overrides in calculateDeadlines() so downstream rules
    re-anchor on the user's date.
  - Passes `editable: true` to renderColumnsBody + renderTimelineBody.
  - Calls wireDateEditClicks() once on #timeline-container in
    DOMContentLoaded.

fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.

Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
2026-05-20 14:31:06 +02:00
mAi
3966394a39 Merge: t-paliad-219 Slice A — configurable dashboard backend + factory-default render
Slice A of the configurable user dashboard. Backend + factory layout served
on /dashboard; edit-mode + drag/drop come in Slice B.

- A1: paliad.user_dashboard_layouts storage (mig 109 — single-row-per-user
  PK, jsonb layout, RLS owner-only) + UserDashboardService CRUD.
- A2: HTTP handlers + service wiring (GET/PUT /api/user/dashboard).
- A3: widened server windows for the baseline widgets (deadlines 7d→60d
  LIMIT 10→40 with client-side filtering; activity similarly) +
  InboxSummary aggregate so the new inbox-approvals widget has data.
- A4: frontend widget dispatch + the 7 v1 widgets (6 baseline + inbox-
  approvals). 8th widget (pinned-projects) lands in Slice C, gated on the
  Slice C0 pin-machinery pre-req per m's Q3 deviation in the design doc.

Mig 109 lands cleanly via boltzmann's gap-tolerant applied-set tracker.
Edit mode (Anpassen toggle + drag/drop + per-widget settings) is Slice B.
2026-05-20 13:56:14 +02:00
mAi
5dacc97a6b feat(dashboard): t-paliad-219 Slice A4 — frontend widget dispatch + inbox-approvals
Wire the configurable dashboard end-to-end on the frontend side. Factory
render only (edit mode is Slice B).

dashboard.tsx:

- Add data-widget-key to every section that participates in the layout
  (deadline-summary, matter-summary, upcoming-deadlines, upcoming-
  appointments, inline-agenda, recent-activity, inbox-approvals).
- New inbox-approvals section markup with summary line, list, empty
  state, and full-inbox link.
- Triple hydration placeholder: data + layout + catalog spliced as
  separate window.__PALIAD_DASHBOARD_* globals.

dashboard_shell.go + dashboard.go:

- Three placeholder splice instead of one. splicePlaceholder() helper
  consolidates the JS-assignment encoding.
- handleDashboardPage pre-fetches the user's saved layout via
  dashboardLayout.GetOrSeed and inlines the WidgetCatalog (code-
  resident — always inlined so the widget picker can boot on knowledge-
  platform-only deploys too).

dashboard.ts client:

- New InboxSummary / InboxEntry / DashboardLayoutSpec / DashboardWidgetRef
  types mirroring the Go shapes.
- settingsFor(key) reads per-widget settings (count, horizon_days) from
  the active layout; defaults fall back to catalog values.
- Existing renderers (Deadlines, Appointments, Activity, Agenda) thread
  count + horizon settings — backend now returns 60d / LIMIT 40 so the
  client narrows per the user's widget config.
- New renderInbox() renders the inbox-approvals widget with summary
  copy ("N offene Freigaben warten auf dich"), top-N entry list, and
  the empty state.
- applyLayout() walks the saved spec and (a) hides widgets whose
  layout entry is visible:false and (b) reorders visible widgets via
  parent.appendChild within their existing parent — preserves the
  .dashboard-columns 2-up grid for deadlines+appointments.
- filterByHorizonDays() filters list items by date relative to today.
- Boot wiring: read __PALIAD_DASHBOARD_LAYOUT__ at mount; if missing,
  best-effort fetch /api/me/dashboard-layout and re-render once data
  has landed. Factory order baked into dashboard.tsx is the fallback
  so a hydration failure never breaks the dashboard.

i18n: 5 new keys per language for the inbox widget. 2528 → 2533.

go build + go vet + go test ./internal/... -short + bun run build all
clean. Triple placeholder verified present in dist/dashboard.html.

Pixel-identical factory render budget: every previously-visible widget
keeps its DOM markup, classes, IDs, and parent. New widget (inbox-
approvals) lands between agenda and activity per the factory layout
ordering in WidgetCatalog. Visible regression on the factory layout is
+1 section (inbox-approvals), expected per m's Q3 pick.
2026-05-20 13:55:56 +02:00
mAi
15bcba5d7c feat(dashboard): t-paliad-219 Slice A3 — widen windows + add InboxSummary
Two changes to DashboardService for the configurable dashboard:

1) Widen upcoming windows from 7d/LIMIT 10 → 60d/LIMIT 40 for both
   loadUpcomingDeadlines and loadUpcomingAppointments. Per design §18
   Note B, the per-widget horizon dropdown (7/14/30/60 days) filters
   client-side from a single payload — server-side widening preserves
   the Q4 "one big payload" pick without forcing per-widget endpoints.
   Existing tests pass: the dashboard CTE bucket math is unchanged and
   the wider rows-list is a superset of what /api/dashboard returned
   before.

2) Add InboxSummary { pending_count, top: []InboxEntry } to DashboardData
   for the new inbox-approvals widget (Q3 expansion). Powered by
   ApprovalService.PendingCountForUser + ListPendingForApprover with
   Limit=InboxTopCap (10). InboxEntry is the minimum needed to render
   a clickable preview line: request id, entity_type/title, project,
   requester, requested_at.

   ApprovalService is wired post-construction via
   DashboardService.SetApprovalService to avoid a circular constructor
   dependency. When unwired (knowledge-platform-only deployments,
   tests), loadInboxSummary is a no-op and the widget renders its
   empty state.

3 new pure-function tests: nil-approvals no-op, SetApprovalService
wiring, InboxTopCap sanity.

go build + go vet + go test ./internal/... -short all clean.
2026-05-20 13:55:56 +02:00
mAi
48f78a713b feat(dashboard): t-paliad-219 Slice A2 — HTTP handlers + service wiring
Four endpoints for the per-user dashboard layout:

- GET  /api/me/dashboard-layout         (auto-seeds factory on first call)
- PUT  /api/me/dashboard-layout         (validates against catalog)
- POST /api/me/dashboard-layout/reset   (overwrites with factory default)
- GET  /api/dashboard-widget-catalog    (catalog metadata for the picker)

Catalog endpoint is DB-independent by design — knowledge-platform-only
deployments (no DATABASE_URL) still surface the widget metadata. The
layout endpoints 503 when the service is unwired, matching the pattern
established by handleListCardLayouts / handleListPinnedProjects.

Wired through services.Services → handlers.dbServices via the
DashboardLayout field. main.go gains a single NewDashboardLayoutService
call next to NewCardLayoutService.

ErrInvalidInput from the service maps to 400; everything else flows
through writeServiceError for the existing 500/503 fallthrough.

go build + go vet + go test ./internal/services/ -short all clean.
2026-05-20 13:55:56 +02:00
mAi
a421bff856 feat(dashboard): t-paliad-219 Slice A1 — user_dashboard_layouts storage + service
Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.

Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (c85c382) lets any
embedded migration apply regardless of authoring order.

What ships:

- paliad.user_dashboard_layouts table: single-row PK on user_id (Q2 pick
  was single layout per user — no named-layout switcher). RLS owner-only,
  mirrors user_card_layouts / user_views patterns.
- DashboardLayoutSpec: { v: 1, widgets: [{ key, visible, settings? }] }.
  Validation is strict on write (catalog membership + per-widget settings
  schema, duplicate-key check, 32-widget cap, version pin). SanitizeForRead
  is forgiving — unknown keys dropped silently per design §10 versioning
  rule.
- DashboardLayoutService: GetOrSeed (auto-seeds factory default on first
  call, idempotent under concurrent first-load via ON CONFLICT DO NOTHING),
  Update (validates + upserts), ResetToDefault.
- WidgetCatalog: 7 v1 widget defs (deadline-summary, matter-summary,
  upcoming-deadlines, upcoming-appointments, inline-agenda, recent-activity,
  inbox-approvals). Per-widget WidgetSettingsSchema with CountOptions +
  HorizonOptions per design §18 Note B. pinned-projects const reserved
  but omitted from KnownWidgetKeys until Slice C lands its widget module.
- 18 pure-function tests pin: factory layout shape, validation failures
  (wrong version / over cap / unknown key / duplicate / bad settings),
  sanitize-on-read (drop unknown / noop on clean / bump version), JSON
  round-trip, catalog completeness, nil-schema behaviour.
- 4 live-DB tests (skipped without TEST_DATABASE_URL): GetOrSeed
  auto-seeds + idempotent, Update round-trips, Update rejects invalid,
  ResetToDefault overwrites.

Migration SQL dry-run live in BEGIN..ROLLBACK against supabase — clean.
go build + go test ./internal/services/ -short both clean.

Slice C0 (pin-machinery) from the design doc is OBSOLETE — paliad
.user_pinned_projects + PinService already exist (pre-dates t-paliad-219).
Slice C in the original plan becomes a single PR adding the
pinned-projects widget module that reads from the existing service.

Design: docs/design-dashboard-configurable-2026-05-20.md §5 + §18.
2026-05-20 13:55:56 +02:00
mAi
0aa81139a3 Merge: t-paliad-212 Slice 2c — MKCALENDAR + Google-degrade
Completes the CalDAV multi-calendar product. Slice 2 (a + b + c) is now
shipped end-to-end.

- mig 108 — user_caldav_config.supports_mkcalendar (tri-state: NULL=unprobed,
  TRUE=show create radio, FALSE=Google-degrade UX) + mkcalendar_probed_at.
  Capability lives on the server-creds row per Q2 — capability is per-server.
- POST /api/caldav-mkcalendar — issues MKCALENDAR + creates matching binding
  in one tx; 501 if probe=false; 409 on name conflict; 5xx upstream.
- caldav_client.go: OPTIONS probe (Allow: header parse) + synthetic
  fallback (MKCALENDAR against /.paliad-probe-<rand>/ then DELETE) for
  legacy SOGo / misconfigured Radicale that don't expose MKCALENDAR in
  Allow. Probe runs once + caches.
- 'Create new calendar' radio in the add-modal — visible only when
  supports_mkcalendar=TRUE. Slugifies display_name → calendar path with
  -N collision retry; gives up after 3 with 'pick a name yourself' error.
- Google-degrade UX (probe=FALSE): create-button hidden, bilingual notice
  surfaced, manual-URL input with PROPFIND-Depth-0 validation on submit,
  NO OAuth bounce.

t-paliad-212 complete: Slice 1 (mig 101 schema + bootstrap binding) +
Slice 2a (sync engine cut-over + mig 107 binding_id) + Slice 2b (write
APIs + picker UI) + Slice 2c (this). Hierarchy scopes
(client/litigation/patent/case) remain parked for Slice 3 per the master
design.
2026-05-20 13:26:45 +02:00
mAi
fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00
mAi
8bac1b4f88 Merge: t-paliad-212 Slice 2b — CalDAV write APIs + picker UI
- POST /api/caldav-bindings — synchronous first push (Q5 pick); 201 with
  {binding, initial_pushed} (or initial_sync_error on push failure after
  create).
- PATCH /api/caldav-bindings/{id} — lazy cleanup on scope-change (Q6).
- DELETE /api/caldav-bindings/{id} — best-effort remote DELETE → binding
  drop; partial failure disables for next-tick retry (202).
- GET /api/caldav-discover — RFC 6764 chain (current-user-principal →
  calendar-home-set → child PROPFIND); 5-min server-side cache invalidated
  on creds change (Q4 pick). VEVENT-only filter. Surfaces
  degrade_reason='google-cuhs-empty' when Google's CUHS isn't enumerable.
- CalDAVService.EnsureLoop — spawns the per-user goroutine when a user
  creates their first binding.
- Picker UI on /einstellungen/caldav: Kalender section under the existing
  CalDAV-server creds form. Add modal is single-step (Q3) — discovery
  dropdown OR custom URL + scope radio (all_visible / personal_only /
  project). Edit modal preserves source, allows scope + enabled changes.
- ~25 new i18n keys under einstellungen.caldav.bindings.* (DE + EN).

Slice 2c (MKCALENDAR + Google-degrade polish) ships separately.
2026-05-20 13:18:20 +02:00
mAi
1fcfab7791 feat(caldav): Slice 2b write APIs + picker UI (t-paliad-212)
User-visible Slice 2 milestone: the /einstellungen/caldav Kalender
section now lets a user pin multiple calendars to Paliad via a
single-step add modal (Q3 of the Slice 2 brief). m greenlit
"all yes / all R" on 2026-05-20, so this lands with: synchronous
first-push on POST (Q5), lazy cleanup on PATCH scope change (Q6),
5-minute server-side cache on /api/caldav-discover (Q4),
calendar_path retained-but-deprecated (Q7).

Backend
- CalDAVService.PushBindingNow — runs one push pass for a single
  binding synchronously; called from POST /api/caldav-bindings so
  the modal closes with events already landed.
- CalDAVService.RemoveBinding — best-effort remote-event DELETE +
  binding row drop (§2.6 of brief). On partial remote failure,
  the binding is disabled instead of dropped and the handler
  surfaces 202 Accepted.
- CalDAVService.EnsureLoop — spawns the per-user sync goroutine
  for users who didn't have one before this request.
- CalDAVService.DiscoverCalendars — walks current-user-principal
  → calendar-home-set → child PROPFIND (RFC 6764 §6 / RFC 6638
  §10). Cached 5 minutes per user; invalidated on SaveConfig /
  DeleteConfig.
- caldav_client.go gains DiscoverCalendars + propfindHrefs +
  listCalendars + supporting multistatus types. VEVENT-only
  filter skips iCloud reminder lists / addr books.

HTTP API
- POST /api/caldav-bindings — create binding + sync first-push;
  201 with binding + initial_pushed count, or 201 with
  initial_sync_error when the push fails after binding creation.
- PATCH /api/caldav-bindings/{id} — partial update.
- DELETE /api/caldav-bindings/{id} — calls RemoveBinding;
  responds 204 (full cleanup) or 202 (partial — binding disabled
  for next-tick retry).
- GET /api/caldav-discover — returns {calendars, calendar_home}
  for the picker.

Frontend
- /einstellungen/caldav Kalender section: list of binding cards
  with enabled toggle / Edit / Remove. "+ Kalender hinzufügen"
  opens the single-step modal.
- Single-step add modal: source picker (discovery dropdown or
  custom URL toggle) + scope radio (all_visible / personal_only
  / project + project picker) + display name. Edit mode reuses
  the modal with the source field hidden.
- 32 new i18n keys under caldav.bindings.* (DE primary, EN
  parallel) covering modal copy, card actions, error messages,
  delete-confirm, scope labels.

Verification
- Live Supabase BEGIN..ROLLBACK: full CRUD flow exercised
  (create → patch display_name → patch scope → second
  all_visible after the first scope-shifts → delete);
  the partial unique index frees correctly when scope moves
  off all_visible, no race or constraint surprise.
- go build ./... + go test ./internal/... + bun run build all
  clean.
2026-05-20 13:18:00 +02:00
mAi
12ed8bb8da Merge: t-paliad-217 — unified modal primitive + suggest-changes rework
m greenlit 6 picks in §0a (2 divergences from inventor recs: Q1 full-edit
loosens t-paliad-138 4-Augen policy beyond date changes; Q4 broadcast.ts
retrofit included in this PR for primitive validation).

Slice A — components/modal.ts primitive + global.css block. Native <dialog>
substrate (browser-owned top-layer, ESC, focus). openModal({title, body,
primary, secondary, size, onClose}) returns a Promise<T|null>. Backdrop
click closes via target check on dialog click event. History pushState on
open + popstate listener for browser back-button close on mobile. Focus
restoration to previously-focused element on close. Mobile full-screen
takeover with max-height excluding the PWA bottom-nav.

Slice B — counter_payload allowlist expansion in approval_service.go.
Renames buildRevertSetClauses → buildEntityFieldSetClauses; separates the
'revert from pre_image' allowlist (defence-in-depth for Reject) from the
'counter from approver' allowlist (wider, for SuggestChanges). New
editable fields for SuggestChanges: deadline {title, description, notes,
rule_code, event_type_ids — junction table writes}; appointment {title,
description, location, appointment_type}.

Slice C — approval-edit-modal full rewrite using openModal. Every field
in the requester's payload becomes editable; read-only context section
shows project / requester / created_at / approval status pill /
event-type chips (where not editable). Vorschlagskommentar prominent.
Submit disabled until form dirty OR note has content (mirrors
ErrSuggestionRequiresChange server-side guard).

Slice D — broadcast.ts retrofit onto the new primitive. Drops bespoke
.modal-broadcast CSS overrides + the per-modal ESC / close / backdrop
handlers. Demonstrates the primitive's generality.

Slice E (i18n + CSS cleanup) folded into Slice A's commit — all new keys
authored once. Legacy .modal-overlay / .modal-card / .modal-content / .modal
CSS retained for the other 7 unmigrated modals (each migrates in a
follow-up PR).

2489 i18n keys; data-i18n attributes clean. No DB migration.
2026-05-20 13:06:23 +02:00
mAi
7654ce6833 feat(modals): t-paliad-217 Slice D — broadcast.ts onto openModal primitive
m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.

Changes:
  - Body is built imperatively (renderBody + wireBody) and handed to
    openModal as the body element. The submit logic reads form state
    from that element on primary-handler invocation.
  - Drops the per-modal ESC + close + backdrop + overlay-stack handlers
    — the primitive owns them.
  - Drops the bespoke .modal-broadcast { width / max-height / padding /
    label / input / textarea } CSS overrides. The primitive's data-size
    handles width; the existing .form-field rules handle inputs; only
    the textarea's code-monospace font is kept as a broadcast-specific
    override (placeholder syntax needs to read as code).
  - Primary action is "Senden (N)" — clicks invoke the existing
    onSubmit logic which POSTs to /api/team/broadcast and on success
    shows the per-recipient report inline then closes via the
    setTimeout(close, 2500) pattern.

The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.

i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
2026-05-20 13:05:59 +02:00
mAi
f3b947e3ad feat(approvals): t-paliad-217 Slice C — approval-edit-modal full rewrite
Rewrite atop the unified openModal() primitive (Slice A). Drops the
per-modal ESC + focus + backdrop + close-button handlers — the
primitive owns them.

New three-section body per design §2:
  1. Editable fields. Every editable column on the entity, per m's Q1
     Reading A lock-in:
       deadline:    title, due_date, original_due_date, warning_date,
                    rule_code, description, notes, event_type_ids
                    (attached via the existing event-types picker).
       appointment: title, start_at, end_at, location, appointment_type,
                    description.
  2. Read-only context. Project title, requester, requested_at, current
     approval status. Renders as a definition-list with muted dt/dd
     pairs so the eye lands on the editable section first.
  3. Vorschlagskommentar (note). Always present, prominent.

Block labels matching /deadlines/new + views editor — reuses the
existing .form-field shapes for typography + spacing parity with the
rest of the app (m's Q6 lock-in).

inbox.ts gains projectTitle / requesterName / requestedAt hydration
from the per-row API response so the context section has data to
render. Falls back gracefully when missing.

Submit-button gate (in the openModal primary handler): refuses when no
field is dirty AND the note is empty. Mirrors the server's
ErrSuggestionRequiresChange.

CSS .approval-suggest-* classes added to global.css alongside the
modal primitive block (committed in Slice A).
2026-05-20 13:05:59 +02:00
mAi
f0b08e9d06 feat(approvals): t-paliad-217 Slice B — counter_payload allowlist expansion
m's t-paliad-217 Q1 lock-in (2026-05-20): the suggest-changes modal lets
the approver edit EVERY field on the underlying deadline / appointment,
not just the date allowlist that triggers approval. Server-side support
for the wider counter shape:

  - buildCounterSetClauses (new) — the counter-allowlist:
      deadline:    title, due_date, original_due_date, warning_date,
                   description, notes, rule_code (event_type_ids handled
                   separately via junction-table rewrite).
      appointment: title, start_at, end_at, description, location,
                   appointment_type.
  - buildRevertSetClauses (existing) stays narrow — Reject only restores
    what pre_image actually contains (defence-in-depth: a hostile UPDATE
    on the request row must not let arbitrary fields be reverted, and
    pre_image is server-written so what's in there is what we trust).
  - rewriteDeadlineEventTypes — junction-table DELETE+INSERT for the
    deadline_event_types m-to-m when counter_payload carries
    event_type_ids. Runs in the same tx as the entity UPDATE.
  - applyEntityUpdate — switched from buildRevertSetClauses to
    buildCounterSetClauses; gained the event_type_ids branch for
    deadlines.
  - SuggestChanges no-op validator — now uses buildCounterSetClauses
    so the wider field set counts as "differs".
  - title is treated as NOT NULL — whitespace-only counter title
    surfaces ErrSuggestionRequiresChange (defence-in-depth against the
    column's own NOT NULL CHECK).

Tests:
  - TestApprovalService_SuggestChanges_TitleOnlyCounter — title diff
    succeeds; entity title updates.
  - TestApprovalService_SuggestChanges_NotesOnlyCounter — notes diff
    succeeds; entity notes column populates.
  - TestApprovalService_SuggestChanges_EmptyTitleRejected — whitespace-
    only title rejected with ErrSuggestionRequiresChange.

No DB migration needed (counter_payload jsonb already accepts arbitrary
shape; the change is in the column-allowlist switch on read).
2026-05-20 13:05:59 +02:00
mAi
760a0de931 feat(modals): t-paliad-217 Slice A + content additions — unified modal primitive
frontend/src/client/components/modal.ts — new openModal() primitive,
native <dialog>-backed. The browser handles top-layer stacking, ESC,
ARIA, and focus trap. We layer on top:
  - browser back-button closes the modal (history.pushState on open +
    popstate listener, matching m's Q5 lock-in)
  - focus restoration to whatever was focused before open (the native
    <dialog> doesn't do this)
  - backdrop click closes
  - close (×) button mandatory in the header, always rendered

CSS (global.css):
  - dialog.modal + .modal__{header,title,close,body,footer} block. Sizes
    sm/md/lg/full via data-size attr.
  - Phone breakpoint (≤32rem): full-screen takeover sitting ABOVE the
    PWA bottom-nav. max-height accounts for --bottom-nav-height (56px)
    and margin-bottom keeps the nav visible.
  - Legacy .modal-overlay / .modal-card / .modal-content / .modal stay
    in place for the ~7 unmigrated modals — the new BEM-style .modal__*
    avoids colliding with the legacy hierarchy. Cleanup is a follow-up
    PR after the last legacy modal flips.

i18n keys + i18n-keys.ts regenerated:
  - modal.close.label (DE/EN)
  - approvals.suggest.section.editable / .context (DE/EN)
  - approvals.suggest.context.{project,requester,requested_at,approval_status} (DE/EN)
  - approvals.suggest.field.{original_due_date,warning_date,rule_code,description} (DE/EN)
  - approvals.suggest.event_type_picker_unavailable (DE/EN)

(Slice C consumes the suggest.section/context/field keys; bundling them
here keeps the i18n.ts diff coherent.)
2026-05-20 13:05:59 +02:00
mAi
bc8dc9d048 Merge: t-paliad-212 Slice 2a — bindings-driven CalDAV sync (backend cut-over)
Sync engine pivots from scalar user_caldav_config.calendar_path to the
binding-driven loop over paliad.user_calendar_bindings. Invisible-but-shippable:
existing users keep working through the bootstrap binding row mig 101 created
for them; new bindings (Slice 2b UI) plug into the same loop.

- mig 107 — paliad.caldav_sync_log.binding_id (nullable FK ON DELETE SET NULL
  so audit history survives binding deletes) + partial index. Idempotent.
- CalendarBindingService — full CRUD + ListEnabled/ListAllEnabled, scope
  validation mirrors the CHECK constraints from mig 101.
- AppointmentTargetService — UpsertAfterPush, StaleForBinding,
  FindByUIDAndBinding. Authoritative source of per-target state going forward.
- CalDAVService rewritten: per-binding inner loop, ForBinding() scope filter
  (all_visible / personal_only / project — hierarchy scopes parked for Slice 3).
- REPORT calendar-multiget in caldav_client.go — collapses N GETs/min to one
  multistatus REPORT (fits inside iCloud/Google rate windows).
- Read-only GET /api/caldav-bindings (write APIs come in Slice 2b).
- caldav_sync_log writes carry binding_id; pre-mig-107 rows stay NULL.

First migration to land via the new gap-tolerant runner (boltzmann c85c382).
2026-05-20 13:05:46 +02:00
mAi
694c7a53ad feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar
calendar_path to the binding-row model introduced in Slice 1
(mig 101). Invisible-but-shippable: existing Phase F users keep
their backfilled all_visible binding, new users hitting the legacy
PUT /api/caldav-config get an auto-created all_visible binding so
the "configure → it just works" UX survives. Slice 2b adds the
picker UI and write APIs on top.

Schema (mig 107)
- paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL
  so audit history survives binding deletes).
- Per-binding index for the read path.
- Idempotent (column-exists DO block) + assertion.

Services
- CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled,
  Get, Create, Update, Delete, SetSyncStatus. Mirrors the table
  CHECK constraints client-side so the API returns useful 400s.
- AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding,
  ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding.
  Replaces SetCalDAVMeta as the authoritative source of per-target
  state; legacy scalar columns still written for back-compat.
- AppointmentService.ForBinding: scope filter implementing
  all_visible, personal_only, project. Hierarchy scopes
  (client/litigation/patent/case) return ErrUnsupportedScope —
  Slice 3 wires them via the existing path-based descendant
  predicate.

Sync engine rewrite
- CalDAVService.Start iterates ListAllEnabled to discover users
  with at least one enabled binding.
- runSyncOnce loops bindings, writes one caldav_sync_log row per
  (user, binding) tick, rolls the worst-case error up onto
  user_caldav_config.last_sync_error so /api/caldav-config still
  shows aggregate status.
- pushBinding pushes the ForBinding() slice + cleans up
  stale-target rows (project unshared, scope PATCHed).
- pullBinding swaps the N×GET pattern for REPORT calendar-multiget
  (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate
  limits) and reconciles via per-target etag comparison.
- Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the
  user's matching bindings using appointmentInBinding() — best
  effort per binding, same 30s timeout as Phase F.
- SaveConfig auto-creates an all_visible binding on first-time
  configure so Phase F "configure → events appear" survives the
  cut-over.

CalDAV client
- New ReportMultiget verb implementing RFC 4791 §7.9
  calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google
  Calendar's per-request cap.

HTTP API
- GET /api/caldav-bindings — read-only list of the authenticated
  user's bindings. Slice 2b adds POST/PATCH/DELETE.

Verification
- BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies
  cleanly + the synthetic two-binding scenario lands the project
  appointment in both bindings while keeping the personal one in
  master only; cascade on appointment-delete drops targets; cascade
  on binding-delete drops targets AND sets sync_log.binding_id NULL.
- go build ./..., go test ./internal/..., bun run build all clean.

Backwards-compat
- paliad.appointments.caldav_uid / caldav_etag still written in
  pushBinding so legacy readers see fresh values. Slice 4 drops
  them after telemetry confirms no path still reads them.
2026-05-20 13:05:27 +02:00
mAi
81cb89f68e Merge: t-paliad-214 Slice 2 — project-subtree Excel export (GET /api/projects/{id}/export)
Generalises Slice 1's writer abstraction to a full project subtree export.

- Backend: ExportService.WriteProject + GET /api/projects/{id}/export?direct_only=0|1
- 24 sheets (16 entity + 8 reference) — projects, project_teams, project_partner_units,
  deadlines, appointments, parties, notes, documents, project_events,
  approval_requests, approval_policies (triple-source attribution: project + ancestor
  + partner-unit default), checklist_instances, partner_units, partner_unit_members,
  users_referenced (restricted), system_audit_log_subset.
- Permission gate: responsibility ∈ {lead, member} or global_admin (m's Q1 pick).
- Cross-subtree FK detection appends warning rows to __meta (m's Q3 pick).
- Filename: paliad-export-project-<slug>-<short-uuid>-<timestamp>.zip with 8-hex
  disambiguator (m's Q5 pick).
- Audit row: scope_root + metadata.root_path (ltree, survives project deletion).
- 403 bilingual DE/EN error copy.
- Frontend: 'Daten exportieren' button on /projects/{id} next to existing tabs.

No new migration. All builds + tests green.
2026-05-20 13:04:14 +02:00
mAi
a6b2979a94 feat(export): t-paliad-214 Slice 2 frontend — Daten exportieren button on /projects/{id}
Adds a Datenexport action button at the end of the project-detail tabs
nav. Hidden by default; revealed when canExportProject() returns true
(global_admin OR direct team responsibility ∈ {lead, member}) — mirror
of the server-side §4 gate. Server re-enforces on the request.

Click handler swaps in a transient <a download> that hits
GET /api/projects/{id}/export — browser handles the download via
Content-Disposition. Same pattern as the personal export in
client/settings.ts.

4 new i18n keys (DE+EN):
  - projects.detail.tab.export (n/a — uses .export.button on the action)
  - projects.detail.export.button = "Daten exportieren" / "Export data"
  - projects.detail.export.tooltip with hint about subtree inclusion

Total i18n keys now 2479.
2026-05-20 13:03:57 +02:00
mAi
8f1f88b517 feat(export): t-paliad-214 Slice 2 backend — project-subtree sync export
Adds GET /api/projects/{id}/export?direct_only=0|1 streaming a
deterministic project-subtree bundle in the same xlsx + JSON + per-sheet
CSV shape as Slice 1's personal export. 16 entity sheets per design §2:
projects + project_teams + project_partner_units + deadlines +
appointments + parties + notes (4-way polymorphism resolved) + documents
(metadata only) + project_events + approval_requests + approval_policies
(triple-source attribution with `source` column for Q4 lock-in) +
checklist_instances + partner_units (attached only) +
partner_unit_members (members of attached units only) + users_referenced
(FK-referenced users only) + system_audit_log_subset. Personal sidecars
explicitly excluded; reference sheets (proceeding_types, event_types,
deadline_rules, courts, …) ship for standalone interpretability.

§4 permission gate enforced server-side:
  - global_admin can export anything, OR
  - direct project_teams membership with responsibility ∈ {lead, member}
  - Observers + Externals + derived-only partner-unit users → 403
    bilingual ("Datenexport ist nur Team-Mitgliedern (Lead / Member)
    vorbehalten / Data export is restricted to project team members").

Cross-subtree FK detection (Q3 lock-in: keep + warn) runs one
lightweight SELECT against projects.counterclaim_of and appends one
warning row to __meta.warnings per outbound reference. Recipients can
choose to keep or strip the FK on re-import.

Filename includes 8-hex-char short-uuid disambiguator (Q5 lock-in):
paliad-export-project-<slug>-<short-uuid>-<ts>.zip — two projects with
identical titles produce different filenames even when archived
together.

Audit row in paliad.system_audit_log (no new migration — already
supports scope='project'): metadata carries root_label + root_path
(ltree) + direct_only flag (Q6 lock-in) so the audit row remains
interpretable after the project is deleted.

__meta sheet + README.txt extended to surface project-scope fields:
scope_root_label, scope_root_path, direct_only.

ExportFilename signature extended to take a rootID; Slice 1 callsite
updated to pass uuid.Nil.

8 new pure-function tests pin: sheet registry shape (24 sheets in
order), triple-source approval_policies SQL tags, direct_only narrows
subtree to root-only, no-personal-sidecars guard, attached-only
partner_units filter, shortUUIDSuffix shape, project-scope meta rows,
short-uuid filename collision avoidance.
2026-05-20 13:03:57 +02:00
mAi
d5c80febb1 Merge: t-paliad-215 Slice 2 (code only) — patent_number_upc helper (UPC patent-number prettifier)
Pure helper + tests, no schema change. Reformats project.patent_number from
'EP 1 234 567 B1' to 'EP 1 234 567 (B1)' for UPC briefs (e.g. SoC / SoD
templates). Mirrors legalSourcePretty's shape: pure function, no schema,
register as {{project.patent_number_upc}} in the variable bag, 8 unit tests.

51 LoC helper + 35 LoC tests (vs the ~40+6 design estimate — small extras
for UPC documentation block and a couple more edge cases).

Slice 2 .docx templates (3 DE-LG + 2 UPC-CFI + 2 family skeletons) are
authored separately in HL/mWorkRepo via the python-docx flow; that side
of Slice 2 follows in a separate commit on the templates repo.
2026-05-20 12:59:56 +02:00
mAi
1765d5e55f feat(submissions): t-paliad-215 Slice 2 — patent_number_upc helper
UPC briefs parenthesise the patent kind code ("EP 1 234 567 (B1)")
where the DE convention runs it inline ("EP 1 234 567 B1"). Slice 2
adds the {{project.patent_number_upc}} placeholder for the new UPC
templates (Q-S2-4 locked at 'all yes' on 2026-05-20).

Pure function alongside legalSourcePretty. Trailing single-letter +
single-digit kind code regex; everything else preserved. Pass-through
on unrecognised shapes — the lawyer's draft never sees a number worse
than the source value.

Wired into addProjectVars so every render exposes both forms
({{project.patent_number}} and {{project.patent_number_upc}}). UPC
templates pull the parenthesised form; DE templates ignore it.

8 test cases (more than the 6 in the brief) covering:
- EP B1 / EP A1 — common case
- DE national with kind code
- No kind code → pass-through
- Whitespace trimming
- Empty input
- WO publication number (no kind-code shape) → pass-through
- Two-digit kind code (B12) → pass-through (intentional — real EP
  kind codes are single-letter + single-digit)

No schema change, no migration, no var-bag namespace additions
beyond the one new placeholder.
2026-05-20 12:59:40 +02:00
mAi
c85c382b1b Merge: t-paliad-218 — gap-tolerant migration runner with applied-set tracker
Replace single-counter golang-migrate tracker with a hand-rolled runner
over embed.FS that tracks applied versions as a set in
paliad.applied_migrations. Fixes the 2026-05-20 production drift where
mig 103 was silently skipped (fermi's mig 104/105 deployed first → counter
jumped past 103 → Slice A schema never installed; recovered manually).

Now: any embedded migration not present in applied_migrations gets
applied on next deploy, regardless of authoring order. Race can't repeat.

- New hand-rolled ApplyMigrations over embed.FS in internal/db/migrate.go.
  - Acquires pg_advisory_lock(hash('paliad.applied_migrations') → int64).
  - Creates paliad.applied_migrations(version, name, applied_at, checksum) if missing.
  - Bootstraps from paliad.paliad_schema_migrations.version=106 when
    applied_migrations is empty (INSERT 1..106 with ON CONFLICT DO NOTHING,
    checksum=NULL — verified by hand for mig 103 which was manually applied).
  - Scans embed.FS for \d+_*.up.sql.
  - Hard-fails on version collisions (≥2 files at same version) and on
    rename mismatch (DB name vs disk name for already-applied version).
  - Applies pending ASC, each in one tx with INSERT + sha256(file_bytes).
- Drops github.com/golang-migrate/migrate/v4 from go.mod.
- Test suite updated: internal/db/migrate_test.go and cmd/server/main_smoke_test.go
  read paliad.applied_migrations. Dirty-flag check removed.

Drift-detection verify is deferred (checksums populated but not verified
in v1). Down-migrations remain on-disk as reference but not callable from
the runner (no v1 use case). Legacy tracker tables drop in a follow-up
mig 108 after burn-in.

Priority merge: unblocks parallel migration work by leibniz (mig 107/108
on t-paliad-212 CalDAV Slice 2) and newton (mig 107 on t-paliad-219
dashboard).
2026-05-20 12:59:35 +02:00
mAi
7a359989a9 feat(db): t-paliad-218 — gap-tolerant migration runner with applied-set tracker
Replaces the golang-migrate single-counter tracker with a hand-rolled
runner over embed.FS that tracks applied state as a set in
paliad.applied_migrations (version PK, name, applied_at, checksum).

Closes the parallel-merge skip-hole the 2026-05-20 mig-103 incident
exposed (m/paliad#44): a migration whose version is missing from
applied_migrations runs on the next deploy regardless of which higher
versions are already applied. Gaps are first-class.

Slice 1 of the design at docs/design-migration-runner-applied-set-2026-05-20.md.
All eight design decisions m-picked = inventor recommendation.

Runner contract:
- Ensure paliad schema → pg_advisory_lock(hash('paliad.applied_migrations'))
  → CREATE TABLE IF NOT EXISTS applied_migrations.
- bootstrapFromLegacyTracker: if applied_migrations is empty and the legacy
  paliad.paliad_schema_migrations row is present and clean, INSERT rows
  1..N for every on-disk version with checksum=NULL via ON CONFLICT DO
  NOTHING. Hard-fail if legacy tracker is dirty (operator must recover).
- scanEmbeddedMigrations: hard-fail on two .up.sql files sharing a version
  prefix — the failure mode the post-mortem exposed.
- checkNameAgreement: hard-fail on rename-after-apply mismatch (disk name
  for an already-applied version != DB name).
- applyOne: SQL body + INSERT(version, name, now(), sha256(file_bytes))
  in one transaction. All-or-nothing per migration.

Checksums populated on apply for future drift detection; rows backfilled
from the legacy tracker carry NULL (we can't fabricate a hash for what
golang-migrate applied historically). Verify-on-deploy intentionally
deferred to a focused follow-up — single if-block flip when m wants it.

Up-only runner. .down.sql files stay in embed.FS as reference; manual
roll-back path is psql + DELETE FROM paliad.applied_migrations WHERE
version=N. Zero call sites for migrate.Down in the codebase today.

Drops github.com/golang-migrate/migrate/v4 from go.mod (no other
importers; verified via grep).

Tests:
- internal/db/migrate_test.go: TestMigrations_DryRun walks pending =
  on_disk \\ applied (read from paliad.applied_migrations, missing-table
  → empty set), runs each in BEGIN/ROLLBACK against the scratch DB.
- cmd/server/main_smoke_test.go: TestBootSmoke asserts the applied set
  equals the on-disk set exactly (not just max-version-match) — catches
  the skip class the post-mortem documented. Dirty-flag check removed
  (rows are committed or absent, not 'dirty').
- All 45 service-test call sites of db.ApplyMigrations work unchanged
  (same signature, same fresh-DB behavior).

Follow-up: mig 108_drop_legacy_trackers (DROP paliad.paliad_schema_migrations
and public.paliad_schema_migrations) after one or two deploys of burn-in
on this slice.
2026-05-20 12:59:16 +02:00
mAi
1a8eee2a10 docs(t-paliad-207): fermi close-out assessment — verdict (A) DONE 2026-05-20 10:46:48 +02:00
mAi
4472faf224 docs(t-paliad-207): close-out assessment — verdict (A) DONE
Read-only audit of the t-paliad-207 surface per paliadin's 2026-05-20
re-engage instruction. Six commits shipped under this task are now
merged. Two larger follow-ups (m/paliad#39 youpc-laws ingest + #41 DE
combined timeline) are filed with concrete scope. Remaining tail is
optional polish, best handled as discrete issues rather than a parked
inventor.
2026-05-20 10:46:48 +02:00
mAi
2504e50f29 Merge: t-paliad-216 — hertz Slice C — Verlauf labels for *_approval_changes_suggested events
Final slice on the suggest-changes feature. Pure i18n addition (4 keys,
DE + EN). The existing convention-based translateEvent() helper picks
up event.title.{slug} / event.description.{slug} keys by event_type, so
the new deadline_approval_changes_suggested + appointment_approval_
changes_suggested events light up the Verlauf timeline (projects-
detail.ts) and the admin audit log without any renderer changes.

t-paliad-216 complete across all 3 slices:
  - Slice A (backend)  → merged 6a20241
  - Slice B (frontend) → merged 741cab4
  - Slice C (Verlauf)  → this merge

Feature is now live: approver opens an editable modal on a pending
update-request, edits the requester's proposed values into a counter-
proposal + writes a Vorschlagskommentar, submits → server in one tx
closes the old row as changes_requested, reverts the entity, spawns a
new pending approval_request authored by the suggesting approver with
counter_payload as its new payload. Original requester now sees the
counter in /inbox and can approve / reject / counter-suggest back.
2026-05-20 10:07:04 +02:00
mAi
d244ff5158 i18n(approvals): t-paliad-216 Slice C — Verlauf labels for changes_suggested
Adds the DE + EN event title + description keys for the two new
*_approval_changes_suggested event_types emitted by SuggestChanges
(Slice A). The existing translateEvent() helper picks up
event.title.{event_type} and event.description.{event_type} keys by
convention — no renderer code changes are needed; this slice is pure i18n.

Surfaces covered:
  - projects-detail.ts Verlauf tab (per-project timeline)
  - admin-audit-log.ts admin audit table
Both call translateEvent() which now resolves the new keys.

No icon system is tied to event_type slugs; no server-side event_types
registry needs the new types pre-registered (the slugs are emitted into
project_events.event_type as free text by the service layer, then
localised at read time).

This is the final slice for t-paliad-216. Slice A (mig 103 + service +
handler + tests), Slice B (frontend modal + /inbox UI + back-link
hydration), Slice C (Verlauf i18n) all on main now.
2026-05-20 10:06:33 +02:00
mAi
741cab4d25 Merge: t-paliad-216 — hertz Slice B — "Suggest changes" frontend modal + /inbox UI
Slice B per docs/design-approval-suggest-changes-2026-05-19.md §3.4-3.6.
Slice A backend (mig 103 + service + handler + tests) already merged at
6a20241; this is the user-facing layer.

- i18n — DE + EN keys for suggest-changes UI: button label
  ("Änderungen vorschlagen" / "Suggest changes"), changes_requested
  status pill ("Abgelehnt mit Vorschlag" / "Declined with changes"),
  modal title + buttons + validation errors, filter chip, back-link.

- shape-list + filter chip — 4th action button alongside
  approve/reject/revoke, only rendered for lifecycle='update' rows
  (the only entity-mutation flow worth counter-proposing on; create
  and delete don't make sense). `changes_requested` filter chip added
  to /inbox.

- approval-edit-modal component — new
  frontend/src/client/components/approval-edit-modal.ts. Renders the
  requester's payload as editable fields per entity_type (deadlines:
  due_date / title / description; appointments: title / start_time /
  end_time / location). Free-text Vorschlagskommentar textarea at the
  bottom. Submit gated until form is dirty OR note has text — mirrors
  the server's ErrSuggestionRequiresChange guard so the user can't
  send a no-op.

- /inbox wiring — clicking the suggest-changes button opens the modal
  (not window.prompt). POST to the new endpoint; on success, refresh
  the bar: old row flips to changes_requested, new pending row
  appears.

- Server-side back-link hydration — extended the ApprovalRequestView
  with a computed next_request_id (reverse lookup on
  previous_request_id) so the changes_requested row can render
  "→ Neuer Vorschlag von {approver}" without an extra round trip.
  Single LEFT JOIN added to the hydrator.

Bun build clean (2473 i18n keys). Go build + tests green.

Slice C (Verlauf integration — *_approval_changes_suggested event
rendering on the project / deadline / appointment timelines) remains.
2026-05-20 10:03:24 +02:00
mAi
0263a0e932 feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
2026-05-20 10:02:36 +02:00
mAi
0fd02bf033 feat(approvals): t-paliad-216 — wire suggest-changes click in /inbox
Suggest-changes branches off into handleSuggestChanges() instead of the
generic POST-with-prompt-note path. It:

  1. Fetches the full request payload + pre_image via GET
     /api/approval-requests/{id} — list payload may be stale.
  2. Opens approval-edit-modal with the entity_type-appropriate fields
     pre-populated.
  3. On submit, POSTs to /api/approval-requests/{id}/suggest-changes
     with {counter_payload, note}.
  4. On success, refreshes the filter bar (the OLD row flips to
     changes_requested, the NEW pending row appears) and the inbox badge.
  5. Stamps data-spawned-request-id on the OLD row's <li> so downstream
     code / tests can locate the counter without a re-query.

The error mapping (mapApprovalError) gains the two new codes
suggestion_requires_change + suggestion_lifecycle_invalid plus the
generic codes already handled. Body reads now go through `body.code`
preferentially with `body.error` fallback — the server uses {code,
message} envelopes.
2026-05-20 10:02:36 +02:00
mAi
dce98e273b feat(approvals): t-paliad-216 — approval edit modal component
New module: frontend/src/client/components/approval-edit-modal.ts.

The approver clicks "Änderungen vorschlagen" on a pending update-lifecycle
row; this modal opens with the requester's original payload pre-populated
in editable date inputs (per entity_type allowlist):
  - deadline:    due_date, original_due_date, warning_date
  - appointment: start_at, end_at

The pre_image value for each field renders as a "Vorher" hint so the
approver sees what's being changed before they commit a counter.

A free-text "Vorschlagskommentar" textarea sits below the inputs. The
submit button stays disabled until the form is dirty OR the note has
non-whitespace content — mirrors the server's ErrSuggestionRequiresChange
no-op guard so the user doesn't bounce off a server-side 400.

API: openApprovalEditModal({entityType, lifecycleEvent, payload,
preImage}) returns Promise<{counterPayload, note} | null>. null = user
cancelled (ESC, overlay click, Cancel button). counterPayload contains
only fields that the user changed; unchanged keys are omitted (the
server's buildRevertSetClauses ignores absent keys cleanly).

Lifecycles other than "update" are guarded with an alert + resolve null —
shape-list.ts hides the button for them, but the modal is defence-in-
depth.
2026-05-20 10:02:36 +02:00
mAi
c1c5532d52 feat(approvals): t-paliad-216 — Slice B shape-list + filter chip
shape-list.ts:
  - Pending-row action group extends to four buttons. suggest_changes is
    only rendered for lifecycle='update' rows (the backend rejects other
    lifecycles with ErrSuggestionLifecycleInvalid).
  - ApprovalAction union widened to "approve" | "reject" | "revoke" |
    "suggest_changes". Disabled-reason logic shared with approve/reject
    (viewer_can_approve gate).
  - Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
    via the existing approval-pill--historic style — no new colour token.
  - ApprovalDetail picks up counter_payload + next_request_id. When a
    row is changes_requested AND a next_request_id is present, render a
    back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
    row (server-side hydrated via correlated subquery on
    previous_request_id, indexed by mig 103's partial index).

filter-bar/axes.ts:
  - APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
    the /inbox filter cluster alongside pending/approved/rejected/revoked.
2026-05-20 10:02:36 +02:00
mAi
ee837815e1 i18n(approvals): t-paliad-216 — keys for suggest-changes UI
Adds DE + EN strings for the fourth approval action:
  - approvals.action.suggest_changes              — button label
  - approvals.status.changes_requested            — pill / row label
  - approvals.suggest.{modal_title,intro,note_label,note_placeholder,
                      submit,cancel,submit_disabled_hint,
                      next_request_link,unsupported_lifecycle}
  - approvals.error.{suggestion_requires_change,suggestion_lifecycle_invalid}
  - approvals.disabled.suggest_lifecycle
  - views.bar.approval_status.changes_requested   — filter chip

i18n-keys.ts is regenerated by frontend/build.ts (2473 keys now).
2026-05-20 10:02:36 +02:00
mAi
e035512e70 Merge: add Madrid to firm office list (mig 106) 2026-05-20 09:52:32 +02:00
mAi
6401a8198d feat(offices): add Madrid as a firm office (mig 106)
m's ask 2026-05-20 09:42. Eighth HLC office alongside Munich,
Düsseldorf, Hamburg, Amsterdam, London, Paris, Milan.

- `internal/offices/offices.go` — append Madrid to All[] (display
  order: end of list, after Milan). Doc comment refreshed to point at
  the actual current CHECK constraints (users mig 002 + partner_units
  mig 018/024/027), not the obsolete akten reference from before
  projects-v2.
- `internal/offices/offices_test.go` — add `madrid` to the valid-keys
  table.
- mig 106 — extend the two CHECK constraints on users.office and
  partner_units.office. Idempotent (DROP IF EXISTS), audit_reason
  set_config at top, dry-run validated against the live youpc paliad
  schema (BEGIN; ALTER...; ROLLBACK).

Frontend picks up Madrid automatically via GET /api/offices.

Admin UI for managing firm office list is a separate longer-term
issue — m's "for now, just add Madrid already" path.
2026-05-20 09:52:28 +02:00
mAi
6a202411f6 Merge: t-paliad-216 — hertz Slice A — "Suggest changes" approval action (backend)
Design doc + Slice A backend per docs/design-approval-suggest-changes-2026-05-19.md.
m greenlit design 2026-05-20 09:35; hertz delivered Slice A 09:48.

- Design doc (2 commits) — full 8-Q decision table in §0a, implementation
  sketch §3, slice plan §4. Two material divergences from inventor recs
  captured: Q4 counter_payload jsonb (real counter-proposal, not just a
  note) + Q6/Q7 counter-as-new-pending-request model.

- mig 103 — extend approval_requests.status CHECK to allow
  'changes_requested' + add counter_payload jsonb + previous_request_id
  uuid FK with partial index. Non-blocking (metadata-only).

- Service — SuggestChanges(ctx, requestID, callerID, counterPayload,
  note) single tx: lock pending → guard no-op → close old row as
  changes_requested → applyRevert (entity from pre_image) → deadlock-
  check new (requester=caller, role) → INSERT new pending row →
  re-apply counter to entity → emit *_approval_changes_suggested +
  *_approval_requested events. 3-layer self-approval guard intact.

- Handler — POST /api/approval-requests/{id}/suggest-changes with
  full error mapping (400 suggestion_requires_change /
  invalid_counter_payload, 403 self_approval_blocked / not_authorized,
  404, 409 request_not_pending / no_qualified_approver).

- Tests — 8 service tests (real-DB) covering happy path, no-op guard,
  self-approval block, request-not-pending, deadlock on new row,
  counter_payload validation. 2 handler tests for error mapping.

Migration dry-run validated against live youpc paliad schema
(BEGIN; ALTER...; ROLLBACK). Build + tests green.

Slice B (frontend modal + 4th button + status pill + i18n) and
Slice C (Verlauf integration) remain.
2026-05-20 09:50:34 +02:00
mAi
d924ab9743 test(approvals): t-paliad-216 SuggestChanges service + handler error mapping
Service-level (real DB, gated on TEST_DATABASE_URL like the rest of the
approval suite):
  - HappyPath: OLD row → changes_requested; NEW row pending with
    previous_request_id back-pointer; entity reflects counter payload;
    two project_events emitted (changes_suggested + requested).
  - NoOpRejected: identical counter + empty note → ErrSuggestionRequiresChange.
  - NoteOnlyAccepted: identical counter + non-empty note succeeds; entity
    keeps the original counter values.
  - SelfApprovalBlocked: original requester cannot suggest on their own row.
  - RequestNotPending: already-decided row rejects suggest-changes.
  - LifecycleInvalid: create-lifecycle pending → ErrSuggestionLifecycleInvalid.
  - OriginalRequesterCanApproveCounter: m's Q6 model — after the approver
    suggests changes, the ORIGINAL REQUESTER (now no longer the new row's
    requested_by) can approve the counter themselves provided their
    profession qualifies.
  - CounterApproverCannotSelfApprove: 4-Augen still holds — the suggesting
    approver cannot approve their own counter (ErrSelfApproval on the new row).

Handler-level (pure-Go, no DB):
  - SuggestionRequiresChange400: error code mapping.
  - SuggestionLifecycleInvalid400: error code mapping.
2026-05-20 09:50:07 +02:00
mAi
fb2896c836 feat(approvals): t-paliad-216 POST /api/approval-requests/{id}/suggest-changes
Wires the HTTP handler for the new action. Body shape:

    {"counter_payload": { ...allowlist fields... }, "note": "..."}

Returns 200 {"status": "ok", "new_request_id": "<uuid>"} on success.

Error mapping (via mapApprovalError):
    400 suggestion_requires_change   — ErrSuggestionRequiresChange
    400 suggestion_lifecycle_invalid — ErrSuggestionLifecycleInvalid
    403 self_approval_blocked        — ErrSelfApproval
    403 not_authorized               — ErrNotApprover
    404                              — not visible / not found (service)
    409 request_not_pending          — ErrRequestNotPending
    409 no_qualified_approver        — ErrNoQualifiedApprover

Route registered alongside the existing approve / reject / revoke trio
in handlers.go.
2026-05-20 09:50:07 +02:00
mAi
705e1a2e79 feat(approvals): t-paliad-216 SuggestChanges service method
ApprovalService.SuggestChanges is the fourth approval action — in one
transaction:

  1. Validates the OLD pending row (caller satisfies canApprove,
     lifecycle in update/complete only, counter differs from old.payload
     OR note is non-empty).
  2. Closes the OLD row as 'changes_requested' with decision_note +
     counter_payload + decided_by + decided_at + decision_kind.
  3. Reverts the entity from old.pre_image (reuses applyRevert — same
     code path Reject runs).
  4. Runs the deadlock check for the NEW row (excluding the suggesting
     caller; original requester is no longer excluded).
  5. Re-applies the counter_payload to the entity row (via
     applyEntityUpdate, mirroring the write-then-approve write).
  6. INSERTs a NEW pending approval_requests row authored by the caller
     with previous_request_id pointing back at the OLD row.
  7. Marks the entity pending + pending_request_id → new row.
  8. Emits two project_events: *_approval_changes_suggested + a fresh
     *_approval_requested for the new row.

4-Augen still holds: the suggesting caller is the new row's
requested_by, so self-approval on the new row is blocked by the standard
3-layer guard. The ORIGINAL requester is no longer the requested_by of
the new row — if their profession satisfies the required_role they can
now approve the counter themselves.

Adds:
  - const RequestStatusChangesRequested = "changes_requested"
  - var  ErrSuggestionRequiresChange   = "suggestion requires counter diff or note"
  - var  ErrSuggestionLifecycleInvalid = "suggest is only valid for update/complete"
  - models.ApprovalRequest.CounterPayload + PreviousRequestID
  - Per-row read paths (getRequestForUpdate, approvalRequestViewColumns)
    populate the new columns.
2026-05-20 09:50:07 +02:00
mAi
d8acbd613c feat(approvals): t-paliad-216 mig 103 — suggest-changes schema
Adds the schema scaffolding for the fourth approval action (alongside
Approve / Reject / Revoke):

  1. Extends approval_requests.status CHECK to include 'changes_requested'.
  2. Adds counter_payload jsonb — the approver's edited values on a
     changes_requested row (the basis of the new row's payload).
  3. Adds previous_request_id uuid FK — back-pointer from a SuggestChanges-
     spawned row to its source. Partial index on the FK supports chain
     traversal.

Non-blocking: extending a CHECK constraint is metadata-only on Postgres;
adding NULLable columns + a NULLable FK is metadata-only. Safe for live
deploy.

Dry-run validated against the live youpc paliad schema via BEGIN/ROLLBACK
(migration tracker at 102 pre-apply; schema unchanged post-rollback).
2026-05-20 09:50:07 +02:00
mAi
c01f3f2db8 docs(approvals): t-paliad-216 — fold m's decisions, rewrite §3 implementation
§0a captures m's locked picks across all 8 questions. Two divergences from
inventor recommendations reshape the model:

- Q4: hybrid — approver edits the proposed values (counter-payload) AND/OR
  leaves free-text in decision_note. Adds counter_payload jsonb column.
- Q6/Q7: the counter is treated as a NEW pending approval_request authored
  by the approver, not an "edit and resubmit" CTA on the requester side.
  Original requester sees the old row as changes_requested ("Abgelehnt mit
  Vorschlag") and the new row as pending — they can approve it themselves
  if eligible (they're no longer the requested_by). 4-Augen still holds.

§3 implementation sketch rewritten: SuggestChanges atomically closes the
old row, applyRevert's the entity, spawns a new pending row with
counter_payload as payload + previous_request_id linking back, re-applies
the counter via write-then-approve, emits both *_approval_changes_suggested
and *_approval_requested events. Migration 103 adds the CHECK value plus
counter_payload jsonb + previous_request_id FK + index. Slice plan trimmed
to backend / frontend-modal / Verlauf-integration.
2026-05-20 09:50:07 +02:00
mAi
2fa47278ce docs(approvals): t-paliad-216 — design doc for "Suggest changes" action
Inventor draft of the fourth approval action alongside approve / reject /
revoke. Open questions in §2 will be resolved via AskUserQuestion before
any coder work. Recommendations folded in inline.

Verified live state before designing: status enum already carries an
unused 'superseded' value; entity approval_status is approved/pending/legacy
only; decision_note exists as free text; the existing decide() kernel
handles approve / reject / revoke with a single switch.
2026-05-20 09:50:07 +02:00
mAi
6c7e9ef44d Merge: t-paliad-207 — fermi's parked verfahrensablauf followups (mig 104 + mig 105 + notes toggle)
Three parked commits from fermi's 2026-05-18 interactive session, never
engaged at the time; m greenlit 2026-05-20 09:43:

- mig 104 (was 101): strip rule-cite brackets from Einspruch names + flip
  CCR priority informational → optional. m's 18:01 + 18:08 corrections.
- mig 105 (was 102): track-aware sequence reshuffle for upc.inf.cfi
  (infringement → revocation → amendment within tied-date groups). m's
  18:08 ask about Replik-before-Erwiderung-NichtigkeitsWK ordering.
- Notes toggle UI: per-rule notes default to compact ⓘ hover hint;
  "Hinweise anzeigen" switch in the toggle bar expands them inline.
  Shared localStorage between verfahrensablauf and fristenrechner.
  m's 18:21 ask.

Renumbered from 101/102 because leibniz CalDAV claimed mig 101 and
archimedes system_audit_log claimed mig 102 between fermi's parked
session and now. Mig 103 reserved for hertz suggest-changes Slice A
(in flight). Both go and frontend builds green.
2026-05-20 09:47:36 +02:00
mAi
17cd5b3b0c feat(t-paliad-207): notes toggle — compact ⓘ hover by default, expand inline when "Hinweise anzeigen" is checked
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.

**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on  → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
        aria-label=… title=…>ⓘ</span>` inside the meta line (browser
        title for hover, aria-label for screen readers, tabindex for
        keyboard accessibility)

Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.

**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.

**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.

i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
2026-05-20 09:47:14 +02:00
mAi
d127c768f7 feat(t-paliad-207): mig 105 — track-aware sequence reshuffle for upc.inf.cfi (infringement → revocation → amendment)
m's ask 2026-05-18 18:08: 'the infringement parts (like Replik) should
show above the part for the revocation (Erwiderung Nichtigkeitswider-
klage)'. Three tracks (infringement / revocation / amendment) coexist
on upc.inf.cfi once with_ccr / with_amend are set. They share tied
calendar dates because R.29/R.30/R.32 all key off the SoD or its
descendants. Current sequence_orders (post-mig 100) interleave them
arbitrarily; user sees Erwiderung-zur-CCR before Replik even though
Replik is the infringement-side response to the same triggering event.

**Re-sequencing** keeps the existing soc=0, prelim=5, sod=10 head and
the interim=40 / oral=50 / decision=60 / cost_app=70 / appeal_spawn=80
tail untouched. The 10 reshuffled rules move into a track-aware
arrangement:

  10-19 infringement: sod=10, reply=12, rejoin=14
  20-29 revocation:   ccr=20, def_to_ccr=22, reply_def_ccr=24, rejoin_reply_ccr=26
  30-39 amendment:    app_to_amend=30, def_to_amend=32, reply_def_amd=34, rejoin_amd=36

Tied-date ordering after the reshuffle:
  D+3mo: sod(10), ccr(20)                            — SoD then its CCR
  D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
  D+7mo: reply_def_ccr(24), def_to_amend(32)         — rev → amd
  D+8mo: rejoin_reply_ccr(26), reply_def_amd(34)     — rev → amd

**Two-phase swap** — every reshuffled rule first parks at sequence
1000+number, then jumps to its final value. Prevents transient
sequence-collisions if Postgres evaluates UPDATEs in parallel within
the same statement. Each UPDATE is keyed by submission_code AND the
SOURCE sequence_order, so re-apply is a no-op.

audit_reason set_config at top per mig 099 hotfix pattern.

Renumbered from mig 102 → mig 105 to avoid collision with archimedes
system_audit_log mig 102 (merged between fermi's parked session and
now); follows mig 104 (Einspruch name + CCR priority).
2026-05-20 09:47:14 +02:00
mAi
dab06e068f fix(t-paliad-207): mig 104 — strip rule cite from Einspruch names + flip CCR priority informational→optional
Two corrections to mig 100's merged-state:

1. **CCR priority informational → optional**. m's correction
   2026-05-18 18:01. The fermi amend (e8d658a) flipping this didn't
   land — paliadin merged the pre-amend c10f8cf. The Nichtigkeits-
   widerklage is a substantive defensive choice, rendered unchecked
   in the save modal so user opts in if they want to track it.

2. **Strip rule-cite brackets from Einspruch names**. m's
   correction 2026-05-18 18:08. Every other rule name in the corpus
   carries the act-name without a parenthetical rule cite — the two
   Einspruch rules were outliers:
     upc.inf.cfi.prelim  'Einspruch (R. 19 VerfO)'             → 'Einspruch'
     upc.rev.cfi.prelim  'Einspruch (R. 19 i.V.m. R. 46 VerfO)' → 'Einspruch'
   plus EN equivalents. The legal_source / rule_code columns already
   carry the citation in the meta line, so the name stays clean.

Idempotent: priority UPDATE guarded on 'informational'; name UPDATEs
guarded on the current parenthetical-bearing values. audit_reason
set_config at top per mig 099 hotfix pattern.

Renumbered from mig 101 → mig 104 to avoid collision with leibniz
CalDAV mig 101 + archimedes system_audit_log mig 102 (both merged
between fermi's parked session and now); mig 103 reserved for hertz.
2026-05-20 09:47:14 +02:00
mAi
defa516e4f Merge: t-paliad-215 — copernicus submission generator Slice 1 (in-house .docx render engine + template registry + Schriftsätze tab + Klageerwiderung end-to-end) 2026-05-19 13:43:11 +02:00
mAi
6ff26e8a6e feat(submissions): t-paliad-215 Slice 1 — Schriftsätze tab on project detail
New "Schriftsätze" tab on /projects/{id}, lazy-loaded by the
existing tab switcher (same pattern as the Checklisten tab — only
hits the API when the user actually opens it). Lists the project's
filing rules in a 4-column table: name (with submission_code under
it), party, legal basis, action button.

Action column shows [Generieren] for rules with a resolvable
template and "Keine Vorlage" / "No template" for rules without one.
The generate button fetches the .docx via XHR, parses the
Content-Disposition filename, creates an object URL, and triggers
the browser download via a hidden <a download>. Disabled
mid-flight to prevent double-submits.

The table opts into the `.entity-table--readonly` modifier — rows
themselves don't navigate; only the inline button does (avoids the
"clickable row that isn't" UX lie called out in the project
CLAUDE.md frontend conventions).

11 new i18n keys per language. New CSS block for the submission-row
typography (name + dim-grey code stacked vertically, right-aligned
action cell, italic no-template hint).
2026-05-19 13:42:51 +02:00
mAi
2c94420a4b feat(submissions): t-paliad-215 Slice 1 — HTTP layer + wiring
Two endpoints under /api/projects/{id}/:

  GET /submissions
       Lists the project's filing-type rules (event_type='filing',
       lifecycle_state='published') for the project's proceeding,
       each annotated with has_template via the registry's cheap
       SHA-only probe. Powers the SubmissionsPanel.

  GET /submissions/{code}/generate
       Renders the .docx and streams it back as an attachment with
       Content-Disposition: attachment; filename="…". Writes three
       audit records: paliad.system_audit_log (event_type=
       'submission.generated'), paliad.project_events (event_type=
       'submission_generated', surfaces in Verlauf / SmartTimeline),
       and paliad.documents (doc_type='generated_submission',
       file_path NULL — bytes are regenerable from inputs per m's
       Q3 pick, no server-side binary). All three writes use a 10s
       background context so the user still gets the download if
       audit insertion races a slow DB.

File naming follows §7 of the design:
  {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx with locale-
  aware rule.name and slash→underscore sanitisation on
  case_number. Empty case_number falls back to an 8-hex-char id from
  the project UUID.

Visibility: ProjectService.GetByID gates every request; 404 (not
403) on no-access to avoid project enumeration. No profession floor
— matches every other write surface in paliad.

Wired into handlers.Services + dbServices + cmd/server/main.go.
Singletons constructed once at boot; no per-request allocation. No
migration needed — paliad.documents has no CHECK on doc_type, so
'generated_submission' is purely additive.
2026-05-19 13:42:51 +02:00
mAi
3677c81fbe feat(submissions): t-paliad-215 Slice 1 — template registry + variable bag
TemplateRegistry (services/submission_templates.go) walks the
m-locked Q4 fallback chain — templates/{FIRM_NAME}/{code}.docx →
templates/_base/{code}.docx → templates/_base/{family}.docx →
templates/_base/_skeleton.docx — against the Gitea repo
HL/mWorkRepo. SHA-cache + 5-min refresh check, identical pattern to
internal/handlers/files.go's HL Patents Style proxy. Distinguishes
"no template" (chain fallthrough) from "Gitea down" so the handler
can render different UI for each.

SubmissionVarsService (services/submission_vars.go) assembles the
~30-placeholder bag from project + parties + rule + next-deadline +
user + firm + today. Locale-aware long-date forms (DE + EN) and a
legal_source pretty-printer that rewrites DE.ZPO.276.1 → "§ 276 Abs.
1 ZPO" / "Section 276(1) ZPO" for the prefixes the 254-rule corpus
uses today. Unknown prefixes pass through unchanged.

Visibility inherits from ProjectService.GetByID
(paliad.can_see_project) — unauthorised callers get the same
ErrNotVisible that every project surface returns.
2026-05-19 13:42:51 +02:00
mAi
8ea3509b98 feat(submissions): t-paliad-215 Slice 1 — in-house .docx render engine
Pure-Go {{path.dot.notation}} placeholder engine + unit tests
(t-paliad-215, design docs/design-submission-generator-2026-05-19.md
§6). Chosen over github.com/lukasjarosch/go-docx because that library
treats sibling placeholders inside one <w:t> run as nested and
refuses to replace them — patent submissions routinely carry multiple
placeholders per paragraph (party blocks especially), so the library
is a non-starter.

Two-pass strategy preserves run-level formatting on the common path:

 1. Pass 1: regex replace inside each <w:t>…</w:t> independently —
    no format loss for the 99% case where placeholders are intact.
 2. Pass 2: paragraph-level merge for paragraphs that still contain
    orphan "{{" or "}}" markers (Word fragmented the placeholder
    across runs).

Missing placeholders render [KEIN WERT: <key>] / [NO VALUE: <key>]
markers so the lawyer sees the gap in Word rather than getting a 400.

Tests cover: single-run, multi-per-run (the go-docx failure mode),
cross-run merge, missing-marker (DE+EN), XML escaping of special
chars, non-document zip entries preserved, placeholder regex
grammar.
2026-05-19 13:42:51 +02:00
mAi
5ff637ab70 Merge: t-paliad-215 — copernicus submission-generator design doc + decisions 2026-05-19 13:21:00 +02:00
mAi
265f240151 docs(submission-generator): t-paliad-215 inventor design
DESIGN READY FOR REVIEW — copernicus inventor pass on the submission
generator (t-paliad-215). 5 questions answered with m's picks captured
in §2; awaiting head's go/no-go on coder shift.

Locked decisions:
- Scope: template-render to .docx (no LLM in v1)
- Template registry: Gitea (mWorkRepo proxy, same pattern as
  HL Patents Style)
- Output: direct download, no server-side binary persistence
- Mapping: fallback chain (firm → base/code → base/family → skeleton)
- Slice 1: one template end-to-end on one project
  (de.inf.lg.erwidg / Klageerwiderung)

No code, no migrations, no schema additions. Read-only design phase
per inventor SKILL.md.
2026-05-19 13:20:59 +02:00
mAi
1039680878 Merge: patentstyle info page 2026-05-19 13:17:07 +02:00
mAi
773654523e feat(patentstyle): real info page (replaces placeholder)
Replaces the one-sentence "endpoint" stub with a proper landing: features list, update flow explainer, fresh-install download link, contact line. Renders the served version live from version.json. Paliad palette (midnight/lime). This is what the HL Patents Style ribbon's Info dialog now links to on OK.
2026-05-19 13:17:07 +02:00
mAi
f7585376df Merge: t-paliad-214 fix — xlsx docProps Created/Modified + complete pane XML (resolves Excel 'repairs required' + wrong Modified date) 2026-05-19 13:06:08 +02:00
mAi
f9ff7b93e8 fix(export): xlsx docProps + pane XML — Excel "repairs required" + wrong Modified date
m hit two bugs opening the Slice 1 export in Excel / Windows:

1. **Excel showed a "Repairs required" prompt** on open. Root cause:
   the SetPanes call passed only `{Freeze: true, YSplit: 1}` — the
   obvious-but-wrong shape. The resulting <pane> XML missed the
   `topLeftCell` and `activePane` attributes that Excel requires for
   a frozen-row pane (excelize's parser is permissive on re-read but
   Excel is strict). Fix: complete the Panes struct (TopLeftCell="A2",
   ActivePane="bottomLeft", Selection on bottomLeft) and surface
   SetPanes errors instead of `_ =`-ignoring them.

2. **Windows Explorer / Excel's File→Info showed Modified=2006-09-16
   ("xuri")** — excelize's hardcoded first-commit defaults. Root cause:
   buildXLSX never called SetDocProps so the canned defaults leaked.
   Fix: SetDocProps({Created, Modified} = meta.GeneratedAt;
   Creator = "Paliad (<firm>)"; Title/Description scoped per export).

3. **Bonus**: the outer-zip entry mtimes were stamped 2000-01-01 (the
   deterministic constant) so extracted files showed a Y2K Modified
   date in Explorer. Now stamped meta.GeneratedAt, which preserves
   determinism within an export (same row state + same GeneratedAt →
   same bytes, the actual m's-Q6 contract).

Also: set the active sheet to __meta (index 0) after sheet creation so
a future code path that adds/removes sheets can't leave an out-of-range
active-sheet index that would trip a separate "repairs required" path.

Regression tests in dump_export_test.go pin all three fixes by re-opening
the generated xlsx via excelize.OpenReader and asserting:
- docProps Created/Modified == meta.GeneratedAt (RFC 3339 UTC)
- docProps Creator contains "Paliad"
- xlsx bytes never contain "2006-09-16T00:00:00Z" or "<dc:creator>xuri</dc:creator>"
- sheet2/sheet3 raw XML carries topLeftCell + activePane + state=frozen
- outer-zip entries' Modified is within ±2s of GeneratedAt
- developer hatch: DUMP_EXPORT=1 writes /tmp/paliad-export-debug.{zip,xlsx}
  for opening in real Excel.
2026-05-19 13:05:54 +02:00
mAi
86d20ed6d4 Merge: spaced filename on /patentstyle/ download 2026-05-19 13:05:28 +02:00
mAi
1639b3919a feat(handlers): serve /patentstyle/HL-Patents-Style.dotm as "HL Patents Style.dotm" via Content-Disposition
URL keeps the dashed name for cleanliness; the on-disk filename PA users land in their Downloads folder has the canonical spaces.
2026-05-19 13:05:28 +02:00
mAi
bf31935767 Merge: t-paliad-214 — archimedes Excel-export Slice 1 (mig 102 system_audit_log + personal /api/me/export + xlsx/json/csv writer + Datenexport tab on /settings) 2026-05-19 12:52:25 +02:00
mAi
aee177a303 feat(export): t-paliad-214 Slice 1 frontend — Datenexport tab on /settings
Adds a 4th tab "Datenexport" to /settings (after Profil /
Benachrichtigungen / CalDAV) with a single-button card that triggers
GET /api/me/export. Browser handles the download via
Content-Disposition: attachment.

i18n: 12 new keys under einstellungen.export.* (DE primary, EN
secondary) — subtitle, bullets per format, scope notice, audit
notice, button label, post-click hint.

The tab is loaded lazily (idempotent loadExportTab) like every other
settings tab, and the runExport handler swaps in a transient <a download>
to use the browser's normal download pipeline.
2026-05-19 12:51:52 +02:00
mAi
28c7215458 feat(export): t-paliad-214 Slice 1 backend — personal sync export endpoint + xlsx/json/csv writer
Adds GET /api/me/export streaming a deterministic .zip bundle of the
caller's RLS-visible projection (per design §2.3): projects, deadlines,
appointments, parties, notes, documents (metadata), audit events,
approval requests, checklist instances + personal sidecars (me row,
caldav config without ciphertext, views, pins, card layouts, paliadin
turns) + reference data (proceeding_types, event_types, deadline_rules,
courts, countries, holidays …) + restricted users_referenced sheet.

Bundle shape: paliad-export.xlsx + paliad-export.json + per-sheet
CSVs (UTF-8 BOM, RFC 4180) + README.txt + __meta.json. Outer zip is
byte-deterministic — sorted file list, fixed Modified time on every
entry, sorted JSON keys. Two runs at same row-state → identical bytes.

ExportService.WritePersonal owns the SQL recipe + column discovery
+ PII deny-regex (?i)secret|token|password|api[_-]?key|private[_-]?key
+ per-sheet DropColumns belt-and-braces (e.g. user_caldav_config
.password_encrypted explicitly dropped on top of the regex). Audit row
written to paliad.system_audit_log before the run, patched with
row_counts + file_size_bytes after.

Migration 102 creates paliad.system_audit_log (generic event_type +
actor_id/email + scope + scope_root + metadata jsonb). Idempotent
CREATE TABLE IF NOT EXISTS + indexes; RLS enabled with self-read +
admin-read policies. AuditService.ListEntries gains a 6th UNION branch
so the new table surfaces on /admin/audit-log.

excelize/v2 added to go.mod for xlsx generation.

Pure-function tests pin formatCellValue value-coercion, PII regex,
CSV quoting + BOM + umlaut survival, JSON shape, meta key order
stability, filename slugify, and byte-determinism of the bundle
assembly.

Design: docs/design-paliad-data-export-2026-05-19.md §7 Slice 1.
2026-05-19 12:51:52 +02:00
mAi
9aebe5780b Merge: t-paliad-212 — leibniz CalDAV Slice 1 (mig 101 user_calendar_bindings + appointment_caldav_targets + backfill, RLS, idempotent) 2026-05-19 12:45:50 +02:00
mAi
8a43aed100 feat(caldav): mig 101 — multi-calendar binding schema + backfill (t-paliad-212 Slice 1)
Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:

- paliad.user_calendar_bindings — N bindings per user with scope_kind
  ∈ {all_visible, personal_only, project, client, litigation, patent,
  case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
  Partial unique indexes enforce one binding per (user, scope_kind,
  scope_id) for hierarchical scopes and one per (user, scope_kind)
  for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
  carrying caldav_uid + caldav_etag. UID stays canonical per
  appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
  row, one target row per appointment already pushed. Maps target to
  the creator's binding, matching today's Phase F semantics where the
  creator's goroutine owns the etag.

Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).

Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.

Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
2026-05-19 12:44:27 +02:00
mAi
52b3feb9d2 Merge: t-paliad-213 — mendel test-strategy Slice 1 (Make targets, migration dry-run gate, boot smoke, /healthz) 2026-05-19 12:41:33 +02:00
mAi
586ba29b86 feat(test): migration dry-run gate + boot smoke (Slice 1)
Slice 1 of docs/design-paliad-test-strategy-2026-05-19.md — the test
infrastructure that would have caught mig 098 (digit-regex) and mig 099
(missing audit_reason) before the deploy hit prod.

Three new files + one route addition:

- Makefile: `make verify-migrations` (alias `verify-mig`) runs the
  per-migration dry-run + boot smoke against TEST_DATABASE_URL. Fails
  fast with a clear error if TEST_DATABASE_URL is unset so CI can't
  silently pass a missing env var. `make test` and `make test-go`
  cover the rest of the short / full Go suites.

- internal/db/migrate_test.go (TestMigrations_DryRun): walks every
  pending *.up.sql in numeric order, applies each inside its own
  BEGIN..ROLLBACK transaction, fails on the first SQL error with the
  file name + Postgres error. "Pending" = greater than the scratch
  DB's current tracker version, so fresh-DB CI runs verify everything
  while developer scratch DBs only re-verify the new pending migration.
  Always non-destructive — the rollback runs even on success.

- cmd/server/main_smoke_test.go (TestBootSmoke): boots the apply path
  end-to-end, asserts (a) db.ApplyMigrations returns nil, (b) the
  tracker advanced to the highest *.up.sql version on disk with
  dirty=false, (c) GET /healthz on the registered mux returns 200.
  The dry-run catches per-migration syntax errors; this catches the
  apply+bind path the container actually runs.

- internal/handlers/handlers.go: adds a GET /healthz public route — a
  no-auth, no-DB liveness probe. Used by the boot smoke; also safe
  for any future orchestrator or uptime check.

Both live-DB tests gate on TEST_DATABASE_URL and skip cleanly without
it, matching the rest of paliad's live-DB test pattern.

Verification: go build ./... clean, go vet ./... clean,
go test -short ./internal/... ./cmd/... clean (all packages pass,
live-DB tests skip), bun run build clean (2436 i18n keys unchanged).

Per CLAUDE.md inventor → coder gate, NOT self-merged.
2026-05-19 12:41:15 +02:00
mAi
0b57ec5257 Merge: t-paliad-214 — archimedes Excel-export decisions addendum (9 Qs answered) 2026-05-19 12:37:08 +02:00
mAi
2007ad39bb docs(export): §12 addendum — m's decisions on the 9 §11 questions
t-paliad-214. m walked all 9 questions live; deviated on Q2 (project-scope
floor = any team member, not associate), Q3 (retention 90d, not 7d), Q5
(paliadin_turns hard-excluded from org scope, not opt-in). Other 6
matched inventor picks. Net slice-plan deltas captured in §12.
2026-05-19 12:36:49 +02:00
mAi
b7c4de9ac9 Merge: t-paliad-212 — leibniz CalDAV decisions addendum (6 Qs answered) 2026-05-19 10:43:37 +02:00
mAi
8e0e4c9dcc docs(caldav): fold m's decisions on the 6 open Qs into the design (t-paliad-212)
Addendum after §10 captures m's picks (2026-05-19, via AskUserQuestion):
§8.1 bidirectional default: YES; §8.2 personal_only: KEEP first-class;
§8.3 MKCALENDAR: Slice 2 with Google-degrade; §8.4 soft caps: NONE in
v1 (add later if telemetry warrants); §8.5 admin view: don't ship;
§8.6 approval-flow remote-edit gap: separate task under t-138.

Net effect: drops the 20-warn/80-block UI guards from §6 and the
`read_only` flag from §3; Slice 2 gains MKCALENDAR + binding-count
telemetry; §8.6 fix filed separately so multi-cal slices stay clean.
2026-05-19 10:43:20 +02:00
mAi
023f32d4f2 Merge: t-paliad-213 — mendel test-strategy decisions addendum (all 6 Qs answered, picks match inventor recs) 2026-05-19 10:31:03 +02:00
mAi
621fe35d79 docs(test-strategy): fold m's §10 decisions addendum
m's 2026-05-19 picks via AskUserQuestion interview:
- Q1 budget: 60–90s gate, 3–4min full (inventor's call — m deferred)
- Q2 CI: Gitea Actions, gate tier only
- Q3 test DB: YouPC for devs + ephemeral docker for CI
- Q4 coverage: critical-path only, no % gate
- Q5 floor: Slices 1+4+5 before new feature work
- Q6 ownership: head decides + rotate per profile

All six matched inventor's recommendation. Slice 1 (migration
dry-run + boot smoke) starts first; Slices 4+5 in parallel after.
2026-05-19 10:30:25 +02:00
mAi
139c4a6406 Merge: t-paliad-214 — archimedes Excel data-export design doc 2026-05-19 10:12:24 +02:00
mAi
6e8e2e7653 Merge: t-paliad-213 — mendel test-strategy design doc 2026-05-19 10:11:26 +02:00
mAi
de20356cec docs(export): inventor design for scoped Excel data export (org / project-subtree / personal)
t-paliad-214. Covers scope definitions, format choices (xlsx + JSON + CSV
in one zip, deterministic, schema_version 1), authorization model
(global_admin / project-team-with-associate-floor / authenticated-self),
trigger model (sync personal+project, async org), storage on
PALIAD_EXPORT_DIR with 7-day retention, PII/GDPR posture, 3-slice plan,
and 9 open questions for m. No code touches — design only.
2026-05-19 10:10:59 +02:00
mAi
8414aa4c14 docs(test-strategy): inventor design for production-grade test pyramid
t-paliad-213 — six-layer pyramid (migration dry-run, Go/frontend unit,
frontend DOM, service live-DB, handler integration, Playwright E2E),
audit of current coverage (323 test funcs, 24 untested services, 53
untested handlers, 4/90 frontend modules), eight-slice tracer-bullet
roll-out, six open questions for m.

Read-only design phase per CLAUDE.md inventor gate — no test files,
make targets or CI configs touched. Awaiting m go/no-go on §5 slice
plan + §6 open questions before any coder shift.
2026-05-19 10:10:23 +02:00
mAi
1e1c84b0f6 Merge: t-paliad-212 — leibniz CalDAV multi-calendar design doc 2026-05-19 10:07:40 +02:00
mAi
e1b91a9481 docs(caldav): design for multi-calendar binding model (t-paliad-212)
Inventor design for letting users connect Paliad's CalDAV sync to N
external calendars per user, with scope filters (master / personal /
per-project / per-client / per-litigation / per-patent / per-case)
rather than today's single-target push. Splits credentials (per user,
unchanged) from bindings (new join table). Adds a per-target join for
push state so the same Appointment can live in multiple calendars at
once. Includes per-provider limit research (iCloud 100, Google ~100,
Fastmail no cap, Nextcloud 30 default), a 4-slice rollout plan, and 6
open questions for m. READ-ONLY design — no schema or code changes.
2026-05-19 10:06:58 +02:00
mAi
92780cf726 fix(events): default Termine filter to 'upcoming' so past events don't show by default
m's call 2026-05-19: opening /events with type=appointment was
defaulting status='all' which surfaces every past appointment in
the corpus. The default should hide past events; 'Alle (auch
vergangene)' is opt-in for the one user who actually wants the
historical view.

Replaces the default with the existing DeadlineFilterUpcoming bucket
(already implemented backend-side at internal/services/deadline_service.go:132
as 'today + future'). New status option 'upcoming' at the top of the
appointment list; existing 'all' moves to the bottom with a clearer
label that calls out 'incl. past'.

Deadlines unaffected — they still default to 'pending'.

i18n keys added in both DE + EN slots (events.filter.status.upcoming
'Ab heute' / 'From today'; .all reframed as 'Alle (auch vergangene)'
/ 'All (incl. past)').
2026-05-19 09:56:05 +02:00
mAi
a0082d2b0d fix(index): drop Downloads section from anon landing — the dotm card was the only visible affordance for unauth visitors
m's call 2026-05-19: the /files/hl-patents-style.dotm link on the
anonymous frontpage shouldn't tempt visitors to try downloading. The
/files/{filename} route IS already auth-gated (302 to /login on
anon click), and the macro-update endpoint at /patentstyle/* stays
public for the in-Word update logic per m's note ('with knowledge
of the direct source link it needs to be available').

Authenticated users never see this page anyway — handleRootPage 302s
them to /dashboard. So removing the section costs them nothing and
removes the obvious affordance for anon visitors. ICON_DOWNLOAD
const dropped along with it.

The Downloads page itself (/downloads + Sidebar nav entry) stays —
that's auth-gated and works for logged-in users.

Leftover surface: /patentstyle/HL-Patents-Style.dotm is still anon-
downloadable (necessary for the Word macro's auto-update poll).
That's m's stated requirement — flagged as the known leak path for
anyone who knows the URL.
2026-05-19 09:05:36 +02:00
mAi
c921925c68 Merge: hlpat /patentstyle/ endpoint 2026-05-18 21:00:46 +02:00
mAi
22cfdb909f feat(handlers): serve /patentstyle/ for HL Patents Style auto-update
Hosts the manifest + .dotm that the Word ribbon's Check-for-Updates button polls. paliad.msbls.de is the primary endpoint; hihlc.msbls.de mirrors it (hihlc/main b871ded). Files live in frontend/public/patentstyle/, copied into dist/ by the frontend build. Cache-Control: no-cache via noCacheAssets so version.json never serves stale after a release.
2026-05-18 21:00:46 +02:00
mAi
4ddcd28d26 Merge: t-paliad-207 — mig 100 (upc.inf.cfi.ccr informational rule, makes CCR filing visible on timeline when with_ccr is set) 2026-05-18 17:46:55 +02:00
mAi
c10f8cff70 feat(t-paliad-207): mig 100 — make CCR filing visible in calc output when with_ccr is set
m's observation 2026-05-18 (interactive session): toggling "Mit Nichtig-
keitswiderklage" surfaces the response rules (def_to_ccr, reply, rejoin,
…) but the triggering event itself — the act of filing the CCR — is
invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement of
Defence with the same 3-month deadline, so the corpus author (mig 028)
skipped it. UX problem: users see consequences without the cause.

**New rule** `upc.inf.cfi.ccr`:
- parent: `upc.inf.cfi.soc` (root anchor, same as SoD)
- duration: 3 months (same as SoD — no separate deadline)
- party: defendant
- legal_source: `UPC.RoP.25.1`
- condition_expr: `{"flag":"with_ccr"}`
- priority: **`informational`** — renders as a notice card, no save
  action, no duplicate write into paliad.deadlines (the SoD's row
  already covers the calendar date).

**Sequence reshuffle** — inserting at sequence_order=11 pushes
def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
SoD → CCR → def_to_ccr → app_to_amend (cause before effect).

**Idempotency** — INSERT uses NOT EXISTS keyed on
(proceeding_type_id, submission_code, lifecycle_state='published');
UPDATEs are guarded by the source sequence_order so re-apply is a
no-op. audit_reason set via set_config('paliad.audit_reason', ...,
true) at the top per the mig 099 hotfix pattern.

Migration counter re-checked against origin/main + ls
internal/db/migrations/ | tail before picking 100 — per the friction
note from msg 2016.

Build hygiene: go build/vet clean; bun run build clean (no i18n
changes). Down.sql restores both sequence values + DELETEs the new
row. Branch: mai/fermi/interactive-session.
2026-05-18 17:46:08 +02:00
mAi
5ae1e5ad01 Merge: t-paliad-211 — Custom Views polish (calendar week/day + click-drill + aligned grid, timeline zoom + lane-label clamping, filter-bar transfer) 2026-05-18 17:45:44 +02:00
mAi
06c826a818 feat(t-paliad-211): mount filter-bar on Custom Views runner
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.

Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.

Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.

Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
2026-05-18 17:45:30 +02:00
mAi
8020cb2ddb feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.

shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.

i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
a5b94739b4 feat(t-paliad-211): calendar shape adds week + day views and aligned grid
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.

Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.

Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.

Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.

i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
283c9e8f67 fix(mig 099): add missing audit_reason wrapper
Mig 099 (drop_with_po_flag) crash-looped paliad.de prod immediately
after deploy: the mig 079 trigger on paliad.deadline_rules raises
EXCEPTION 'audit reason required' on UPDATE when paliad.audit_reason
is unset. Original file (fermi, t-paliad-207) only had the UPDATE,
no set_config wrapper.

Patch: prepend the standard 'SELECT set_config(paliad.audit_reason,
...)' at the top so the trigger sees the reason. Same shape as every
other migration that mutates deadline_rules.

Manual recovery already applied via head MCP — UPDATE'd the 2 rows
with audit_reason set, marked tracker version=99 dirty=false,
force-restarted the container which booted clean. This commit aligns
the in-repo file with the recovered prod state. Idempotent: the
WHERE clause matches only rows that still carry with_po, so re-apply
is a no-op.
2026-05-18 17:33:01 +02:00
mAi
dece61107b Merge: t-paliad-207 — fermi's polish session (jurisdiction prefix + trigger-event label + flag rows + youpc rule links + DE sub-group headers + R.19 Einspruch as always-available; mig 099 NULLs with_po flag on RoP.019.1 rows) 2026-05-18 17:29:43 +02:00
mAi
8bf1626997 fix(mig): renumber drop_with_po_flag 098 → 099 (number collision with submission_codes_prefix_and_rename) 2026-05-18 17:29:21 +02:00
mAi
7f49851abf fix(t-paliad-207): drop with_po flag — R.19 Einspruch is always available, not flag-gated (mig 098)
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.

**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).

**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).

Branch: mai/fermi/interactive-session @ third commit on top of Path A.
2026-05-18 17:29:14 +02:00
mAi
518b2d9617 feat(t-paliad-207): DE proceeding picker — sub-group headers + parallel labels (Path A)
m's 2026-05-18 ask: the 5 DE proceeding tiles followed three different
labelling conventions ("Verletzungsklage (LG)" / "Berufung OLG" /
"Nichtigkeitsverfahren" — instance in brackets vs not vs not even
present). Path A reshapes both the picker and the labels so a user
scanning "Deutsche Gerichte" sees the type→instance hierarchy at a
glance and every tile reads <court> (<procedural role>) in parallel.

**Picker structure (verfahrensablauf.tsx + fristenrechner.tsx):**
Inside the existing `<.proceeding-group data-forum="de">` block, the
single flat row of 5 tiles is now two sub-groups with mixed-case h5
headings — Verletzungsverfahren over LG/OLG/BGH, Nichtigkeitsverfahren
over BPatG/BGH. DE_TYPES split into DE_INF_TYPES (3) + DE_NULL_TYPES (2)
in both page shells.

**Labels (i18n.ts, DE + EN parallel):**
| Code           | Old DE                       | New DE                |
|---             |---                           |---                    |
| de.inf.lg      | Verletzungsklage (LG)        | LG (1. Instanz)       |
| de.inf.olg     | Berufung OLG                 | OLG (Berufung)        |
| de.inf.bgh     | Revision/NZB BGH             | BGH (Revision / NZB)  |
| de.null.bpatg  | Nichtigkeitsverfahren        | BPatG (1. Instanz)    |
| de.null.bgh    | Berufung BGH (Nichtigk.)     | BGH (Berufung)        |

Two new i18n keys carry the sub-group headings:
- deadlines.de.group.inf  — "Verletzungsverfahren" / "Infringement proceedings"
- deadlines.de.group.null — "Nichtigkeitsverfahren" / "Nullity proceedings"

**CSS (global.css):**
New `.proceeding-subgroup` + `.proceeding-subgroup-heading` rules,
co-located with `.proceeding-group h4`. Sub-heading sits one tier below
the h4 (mixed-case, no upper-tracking) so the two-level hierarchy reads
at a glance.

**What this does NOT do** — the "one long sequence" combined-timeline
behaviour (m's same ask, larger scope: spawn rules + de-duplication +
multi-instance UI) is filed as m/paliad#41 and stays a separate
delivery. Per-instance tiles keep their meaning either way.

Build hygiene: go build/vet clean; bun run build clean (2419 keys, +2).
2026-05-18 17:29:14 +02:00
mAi
4131d2e2a6 feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 17:29:14 +02:00
mAi
d507db22a7 fix(mig 098): exempt orphan rules from §6.2 NULL-check (proceeding_type_id IS NULL)
Recovery during the prod outage uncovered a second mig 098 bug: §6.2
assertion '0 NULL submission_code on active+published rows' counted
the 77 orphan rules (proceeding_type_id IS NULL, cross-cutting
Wiedereinsetzung / Schriftsatznachreichung pattern) and rejected the
migration. Patch: gate the NULL count on `proceeding_type_id IS NOT
NULL` so orphans pass through. Migration already applied to prod via
manual recovery with the same patched assertion; this commit aligns
the in-repo file with the deployed state.
2026-05-18 17:28:19 +02:00
mAi
a0a3ec32a3 fix(mig 098): relax submission_code shape regex to allow digits in suffix
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1
assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects
EPA rule codes that carry the statutory rule number in the suffix —
e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`,
`epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`.
Migration's UPDATE step succeeds against these rows; the transactional
assertion blows them up; rollback leaves the migration tracker dirty
at version 98 and the container refuses to start.

Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the
SQL assertion (mig 098 §6.1) and the matching Go shape regex
(submission_codes_shape_test.go). Same change in both spots so the
runtime sanity test stays aligned with the SQL invariant.

Manual recovery already applied: forced
`paliad.paliad_schema_migrations.version` back to 97 with `dirty=false`
so the next deploy retries mig 098 from scratch against the patched
file. No data state changed (mig 098 ran inside a transaction and
fully rolled back — snapshot table, prefix UPDATE, and column rename
all reverted).

go build ./... clean. TestProceedingCodeShapeRegexStandalone green.
2026-05-18 16:52:38 +02:00
mAi
f9d32a90e7 Merge: t-paliad-207 — fermi's polish (jurisdiction prefix, trigger-event label, flag rows, youpc rule links, R.19 Einspruch label) 2026-05-18 16:37:54 +02:00
mAi
a18b825bee feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 15:58:26 +02:00
mAi
7d275cac6b Merge: t-paliad-210 — mig 097 legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through) 2026-05-18 15:54:47 +02:00
mAi
21727bf1ca feat(db): mig 097 — legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through)
t-paliad-210 / paliadin-head msg 2002 + 2006. Applies huygens's HIGH/MED
proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
(commit 391be09) plus m's FLAG walk-through:

  § 1  Easy wins                — 6 rows (rule_code only).
  § 2  HIGH/MED proceeding-typed — 15 rows.
  § 3  HIGH/MED orphans         — 47 rows.
  § 4  FLAG-A dedup (clean only) — 1 canonical fill + 3 archives
                                  (Wiedereinsetzung §123-PatG twin,
                                  Berufungsschrift, Berufungsbegründung).
                                  Mängelbeseitigung 6× and Beginn-
                                  Hauptsache 2× DEFERRED pending m's call
                                  on distinct-context rule_codes[].
  § 5  FLAG-B court-scheduled    — 26 rows. RoP.111 / RoP.118 / § 285 ZPO
                                  / § 300 ZPO / § 47 PatG etc.
  § 6  FLAG-C/D rubber-stamp     — 5 rows. RoP.52 / RoP.235.1 / § 273 ZPO.
  § 7  FLAG-E service triggers   — 6 rows. § 317 ZPO / § 99 / 47 / 79 PatG
                                  / R. 111 EPÜ.
  § 8  FLAG-F combined-pleading  — 5 rows via rule_codes[] multi-cite.
  § 9  FLAG-G/H/I + RoP.271.b    — 13 rows. Patentänderung INF/REV split,
                                  H sub-paragraphs, RoP.069 by analogy,
                                  + RoP.271.b secondary cite on 5 UPC
                                  initial submissions.
  § 10 R.19 label rename         — defensive backstop for fermi's prod
                                  write (t-paliad-207 consolidated).
  § 11 RoP.49.1 → RoP.049.1      — padding normalization on rev.defence.

FLAG-J 3 rows (d124c95b / 002c2ba7 / 902cc5d5) left NULL for m's
/admin/rules pickup. 11 rows total stay NULL post-mig (3 FLAG-J + 8
deferred dedup).

Snapshot table paliad.deadline_rules_pre_097 preserves pre-mig state
including the distinct rule_codes[] on the deferred Mängelbeseitigung +
Beginn-Hauptsache sets.

Dry-run on supabase produced expected counts:
  null_count=11, old_outlier=0, new_padded=2

Idempotent: re-applying matches no rows. Audit-trail through mig 079
trigger via set_config(paliad.audit_reason, ..., true).
2026-05-18 15:39:03 +02:00
mAi
d126913185 Merge: t-paliad-209 — workstream B (submission_code rename + prefix + Rechtsgrundlage column) 2026-05-18 15:11:52 +02:00
mAi
ea29165d2f feat(t-paliad-209): rename Code → Submission Code + add Rechtsgrundlage column
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).

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

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

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

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

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

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

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

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

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

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

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

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

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
mAi
edcf41d203 Merge: t-paliad-208 — legal-citation backfill proposal (huygens, doc only) 2026-05-18 14:57:25 +02:00
mAi
391be09b1e docs(t-paliad-208): legal-citation backfill proposal for 130 deadline_rules
Researcher draft for Workstream A — per-rule proposals for rule_code +
legal_source on the 130 active+published deadline_rules with rule_code IS
NULL. Grouped by proceeding (53 PT rows) and orphan-bucket (77 rows with
proceeding_type_id IS NULL).

~75 HIGH/MED proposals, ~47 FLAG entries pending m's call (court-set
event-markers, combined-pleading rows, ambiguous orphans, RoP
sub-paragraph spot-checks). Profiles the field convention from the 83
already-populated rows. READ-ONLY phase: no DB writes, no migration yet
— mig 097 follows once m signs off.

Side-fix candidate: normalize the one outlier RoP.49.1 -> RoP.049.1 on
rev.defence as part of mig 097.
2026-05-18 14:56:42 +02:00
mAi
d76b8a6c64 Merge: small UX — deadline-done confirm modal + cascade ändern i18n 2026-05-18 14:26:19 +02:00
mAi
061780dea5 fix(frontend): two small UX issues — deadline-done confirm + i18n the cascade "ändern"
1. /deadlines list ticking the complete-checkbox now goes through
   window.confirm() before firing PATCH /api/deadlines/{id}/complete.
   The deadline title is interpolated into the prompt so the user sees
   what they're closing. Matches the existing window.confirm() pattern
   used in projects-detail / admin-team / approvals-withdraw etc. —
   no custom modal layer.

2. The cascade row "ändern" button in the deadline calculator stayed
   in German on the EN side. data-i18n="deadlines.row.edit" was set
   correctly but applyTranslations() only runs at page init and on
   lang-toggle; the cascade re-renders on every state change without
   re-hydrating, so the static "ändern" fallback in the HTML stuck.
   Render the label via t() directly in the template — same pattern
   the rest of the cascade uses, no hydration dependency.

Both i18n keys land on both DE and EN sides (deadlines.complete.confirm
+ existing deadlines.row.edit). bun run build clean, 2414 keys.
2026-05-18 14:26:13 +02:00
mAi
b07702a095 Merge: t-paliad-206 — proceeding-code rename to lowercase dot-form (mig 096 + Go sweep + frontend sweep + taxonomy spec) 2026-05-18 12:14:38 +02:00
mAi
aa9e47fda9 feat(t-paliad-206): switch frontend to lowercase dot-form proceeding codes
Sweep of frontend/src/* for the proceeding-code rename landed by
mig 096. Same scope as the Go sweep — comments + literal string
codes substituted, plus the visible additions:

- fristenrechner.tsx / verfahrensablauf.tsx UPC_TYPES gain
  upc.ccr.cfi as a fourth UPC option ("Widerklage auf Nichtigkeit");
  it surfaces in the picker and renders the determinator routing
  notice from proceeding_mapping.ResolveCounterclaimRouting.
- i18n.ts deadlines.* keys renamed to mirror the new codes exactly
  (`deadlines.upc.inf.cfi`, …). DE + EN sides in sync.
- frontend/src/client/fristenrechner.ts fristenrechnerCodeToCascadeSegment
  rekeyed to new codes; upc.ccr.cfi shares the upc-inf kebab segment
  because the event_categories slug taxonomy is not renamed and ccr
  resolves to inf-rules anyway.
- client/views/verfahrensablauf-core.ts court-picker conditions
  rewritten against the new codes.

Bun build clean (i18n-keys.ts regenerated from the canonical map).
2026-05-18 12:13:39 +02:00
mAi
216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00
mAi
cce0ada3ce feat(t-paliad-206): mig 096 — rename proceeding_types.code to lowercase dot-form
19 active fristenrechner codes renamed from UPPER_SNAKE to the
lowercase three-position dot-separated taxonomy ratified by m on
2026-05-18 (see docs/design-proceeding-code-taxonomy-2026-05-18.md).
IDs are stable; only the `code` STRING changes.

Adds upc.ccr.cfi as an illustrative peer of upc.inf.cfi
(is_active=true, no rules — Go code routes cascade hits back to
inf.cfi with with_ccr=true).

Also updates the soft `proceeding_type_code` references on
paliad.event_category_concepts so the soft-join through
proceeding_types.code keeps resolving, refreshes the
deadline_search materialized view, and installs the
paliad_proceeding_code_shape CHECK constraint enforcing
`^[a-z]+\\.[a-z]+\\.[a-z]+$` on every active row.

Idempotent: every UPDATE is guarded on the OLD code; INSERT uses
WHERE NOT EXISTS; CHECK is dropped-then-recreated by name. Backup
snapshot lives in paliad.proceeding_types_pre_096. Dry-run on the
live youpc DB (BEGIN; … ROLLBACK) confirmed 20 active rows on the
new shape, 0 old codes left, 1 active upc.ccr.cfi.
2026-05-18 12:13:13 +02:00
mAi
e857829ac2 docs(t-paliad-206): proceeding-code taxonomy spec — lowercase dot-separated
Captures m's 2026-05-18 ratification of the new fristenrechner
proceeding-code convention `<jurisdiction>.<X>.<Y>` and the 5
sub-decisions: ccr.cfi is an illustrative peer that routes back to
inf.cfi with with_ccr; damages-appeal stays bundled into
upc.apl.merits; NZB at BGH is a flag, not a separate proceeding;
DPMA appeals stay generic with source differentiation at rule level.

This document is the source of truth for mig 096 (lands next) and the
post-mig proceeding_mapping.go.
2026-05-18 12:13:02 +02:00
mAi
1d535a2175 Merge: t-paliad-205 — mig 095 fristen gap-fill (4 new rules + 4 patches per t-203 decisions) 2026-05-18 11:47:23 +02:00
mAi
af30c06d9b feat(t-paliad-205): mig 095 — ingest t-paliad-203 fristen gap-fill deltas
Codifies curie's 4 new rules + 4 patches from
docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions).

NEW (4):
  inf.prelim         UPC_INF  parent=inf.soc      1mo  RoP.019.1  flag=with_po
  rev.prelim         UPC_REV  parent=rev.app      1mo  RoP.019.1  flag=with_po
  inf.appeal_spawn   UPC_INF  parent=inf.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP
  rev.appeal_spawn   UPC_REV  parent=rev.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP

PATCH (4):
  de_inf.klage       legal_source NULL → 'DE.ZPO.253'
  de_inf.anzeige     no change (already correct — explicit in audit log)
  de_inf.erwidg      is_court_set false → true + §276 Abs.1 S.2 description
  de_inf.berufung    defensive verify legal_source = 'DE.ZPO.517'

Idempotent via WHERE NOT EXISTS (no unique index on (proceeding_type_id,
code) — mig 093 left archived rows sharing codes with their published
successors, so ON CONFLICT isn't available). UPDATEs guarded by clauses
that only fire when the row still has the old value.

Backup snapshot in paliad.deadline_rules_pre_095 (CREATE TABLE IF NOT
EXISTS); down migration restores from it. Hard assertions verify all 4
new rules landed active+published, de_inf.erwidg flipped to court-set,
both spawn rules chain to a valid proceeding_type id=11.

Dry-run verified end-to-end against the live Supabase corpus inside
BEGIN/ROLLBACK; idempotency confirmed by running INSERT+UPDATE twice
in the same transaction.
2026-05-18 11:46:12 +02:00
mAi
8833c6975a Merge: m's decisions on t-paliad-203 FLAGs (final shape: 4 new rules + de_inf.erwidg court-set flip) 2026-05-18 11:26:34 +02:00
mAi
0123d11c6e docs(t-paliad-203): capture m's decisions on the 12 FLAGs (2026-05-18)
m + paliadin walked the open questions; new §0.3 records the calls so
the proposal doc reflects the final shape before m ingests via
/admin/rules. Net stays at 4 new rules (2 PO + 2 always-fire merits-
appeal spawns). de_inf.erwidg flips to court-set per ZPO §276(1) S.2.
No ccr-defendant PO, no ccr.appeal duplication, no R.263 deadline.
2026-05-18 11:26:32 +02:00
mAi
4d2382679b Merge: t-paliad-203 — fristenrechner gap-fill proposals (curie's research doc, no code changes) 2026-05-18 11:19:06 +02:00
mAi
35aa5e63c0 docs(t-paliad-203): Fristenrechner gap-fill proposals — 4 new rules + 3 polish PATCHes
Drafts the 4 coverage questions the mig 093 commit body left open for
legal review (t-paliad-200 closeout):

  1. Preliminary Objection (RoP 19) on UPC_INF + UPC_REV — 2 new rules,
     party=defendant, 1 month from SoC/SfR served, flag-gated with_po.
  2. Cross-proceeding APP spawn (RoP 220.1(a)) from UPC_INF + UPC_REV
     into the UPC merits-appeal proceeding — 2 spawn rules, party=both,
     2 months from R.118 decision, flag-gated with_appeal. Third
     Pipeline-A relic (ccr.appeal) recommended not seeded — CCR appeal
     is structurally absorbed into inf.appeal_spawn because one R.118
     decision = one appeal window in the unified UPC_INF (CCR-as-flag)
     model.
  3. ccr.amend / rev.amend — claim "safe to drop" verified for patent
     amendment (fully covered by inf.app_to_amend / rev.app_to_amend
     chains under with_ccr+with_amend / with_amend flags). R.263 case-
     amendment is court-discretionary; recommended NOT modelled.
  4. zpo.* family — klage / vertanz / berufung redundancy verified
     (de_inf.klage, de_inf.anzeige, de_inf.berufung / de_inf_olg.berufung
     cover them). klageerw exposes a discrepancy on de_inf.erwidg
     (6-week heuristic vs ZPO §276.1 S.2 court-set 2-week floor) — flagged
     as a PATCH on the existing row, not a new rule. Task brief's mention
     of "Vertagungsantrag" is a misread of zpo.vertanz (= Verteidigungs-
     anzeige, not Vertagungsantrag); §227 itself recommended NOT modelled.

Net: 4 new rules drafted in Track B, 3 optional PATCHes in Track A, 12
FLAGs surfaced for m's decision before /admin/rules ingest. Appeal target
referenced by ROLE (not code) pending t-paliad-204 proceeding-code
rename — m picks final spawn_proceeding_type_id at ingest.

Per-rule template matches docs/proposals/orphan-concepts-2026-05-15.md.
Read-only research; no DB writes, no migration files. The spawn_proceeding_type_id
column is unused in live data today — these spawn rules will be the
first real consumer.
2026-05-18 11:18:23 +02:00
mAi
3c9ecabf17 Merge: t-paliad-202 — inbox grey-out illegal actions (replace alert-after-click with server-tagged viewer_can_approve / viewer_is_requester flags) 2026-05-17 12:45:32 +02:00
mAi
aa82434af9 fix(t-paliad-202): grey out inbox actions instead of erroring on illegal click
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."

Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
  booleans. Resolved server-side per caller — false on self-authored rows
  (caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
  and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
  (WHERE), and the new viewer_can_approve SELECT expression. Single
  source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
  $1 = callerID so the SELECT computes the flags inline (one query, no
  N+1). GetRequest's signature grows a callerID arg; the handler passes
  the authenticated user.

Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
  it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
  btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
  share the viewer_can_approve gate (self → self_approval tooltip;
  unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
  what's possible — the disabled+tooltip combo explains what's not.

i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
  not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
  secondary variant via opacity + not-allowed + muted tokens.

Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
  is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
  self-authored (false/true), eligible peer (true/false), non-eligible
  viewer (false/false), global_admin (true/false). Also asserts the flags
  on ListPendingForApprover + ListSubmittedByUser rows.

Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
2026-05-17 12:44:29 +02:00
mAi
4f66feffce Merge: fix(projects) — unbreak Create + 6-digit CM constraint 2026-05-17 12:30:58 +02:00
mAi
bdd4999213 fix(projects): unbreak Create — drop $1::text reuse + tighten CM CHECK to 6 digits
Two issues m hit and reported in one breath while adding a project:

1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
   ProjectService.Create and CreateCounterclaim re-referenced the uuid
   parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
   planner deduced conflicting types for `$1` (uuid in the id column,
   text in the cast) and rejected the prepared statement with 42P08
   "inconsistent types deduced for parameter". The path placeholder
   value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
   trigger from mig 018/021) always overwrites it from id and parent
   path. Fix: replace `$1::text` with a literal '' in both INSERTs,
   keeping the parameter list decoupled from the id column's type.
   Same comment now anchors the rationale on both call sites.

2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
   `^[0-9]{7}$` CHECK on paliad.projects.client_number and
   matter_number was wrong. Mig 094 snapshots affected rows to
   paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
   values (2 client_numbers, 1 matter_number), then swaps the legacy
   `projekte_*_check` constraints from {7} to {6}. Frontend pattern,
   maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
   on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.

Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
  0 rows violate the new 6-digit CHECK. ✓

go build, go test ./internal/..., bun run build all clean.
2026-05-17 12:30:53 +02:00
mAi
cbcc67bae7 Merge: t-paliad-200 — Slice 9 follow-up B (archive 40 Pipeline-A litigation rules, drop 7 litigation proceeding_types — Phase 3 closeout complete) 2026-05-16 01:30:03 +02:00
mAi
40e49e87d4 refactor(t-paliad-200): Slice 9 follow-up B — retire litigation category from rule corpus
Lorenz's Slice 9 (t-paliad-195) deferred mig 093 because 40 active
paliad.deadline_rules still pointed at the 7 litigation-category
proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
Slice 5 (mig 087/088) already retired the category from project-binding;
this migration retires it from the rule corpus.

PLAN CHOICE (audit-gated, paliadin-approved): archive-all-40 rather than
the original re-parent plan. The audit found that 23 of 40 Pipeline-A
rules share their `code` with an existing fristenrechner rule on the
proposed re-parent target (e.g. inf.oral exists on both INF and
UPC_INF). Re-parenting would leave two rules with identical
(proceeding_type_id, code), breaking the implicit per-proceeding
rule_code identity contract keyed off by projection / search /
rule_editor. The fristenrechner rules are clearly the production
version (proper German names, legal_source pinned to UPC.RoP citations,
full bilateral chains, intra-proceeding counterclaim handling); the
Pipeline-A rules are stubs (English-only, mostly NULL legal_source,
duration_value=0 for 28 of 40, no spawn_proceeding_type_id wiring).

Migration 093 sequence (atomic):
  1. Snapshot proceeding_types_pre_093 + deadline_rules_pre_093 as
     permanent audit anchors.
  2. INSERT _archived_litigation pt (category='archived',
     is_active=false, jurisdiction='UPC') to home the rules.
  3. UPDATE all 40 rules → archive pt + lifecycle_state='archived' +
     is_active=false. Captured in paliad.deadline_rule_audit via the
     mig 079 trigger.
  4. DELETE the 7 litigation rows from paliad.proceeding_types (now
     safe — nothing references them).
  5. Hard assertions: 0 litigation rows survive, exactly 40 rules on
     the archive pt, every snapshot row matches a surviving rule by id.

Critical FK note: deadline_rules.proceeding_type_id is ON DELETE CASCADE
→ proceeding_types(id). A naive DELETE of the 7 litigation rows would
cascade-delete all 40 rules and break the FK from the 1 live deadline
("Lecker Frist", completed) that still references inf.rejoin/INF.
Re-homing the rules before deleting the pt rows is mandatory.

Verified via BEGIN..ROLLBACK against live DB: assertions pass, all 30
intra-litigation parent_id chains preserved, the live deadline FK
stays valid.

Test impact:
  internal/services/project_service_test.go:72 used to look up
  category='litigation' AND code='INF' to exercise the Slice 5 negative
  case. Post-mig-093 that lookup returns NULL. Rewritten to fetch any
  category <> 'fristenrechner' row (the _archived_litigation pt is the
  canonical post-093 row); defence-in-depth coverage of both the Go
  service guard and the mig 088 SQL trigger is preserved.

SURFACED FOR LEGAL REVIEW (4 coverage questions the audit found, to be
triaged as follow-up tasks):

  1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not present
     on UPC_INF. Possible coverage gap; legal review to decide whether
     to add it to the fristenrechner ruleset.
  2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
     into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
     currently starts standalone with no spawn from UPC_INF/UPC_REV.
     Possible UX gap; Pipeline-A versions had
     spawn_proceeding_type_id=NULL so they weren't functional spawns
     either.
  3. ccr.amend / rev.amend (spawn rules) — superseded by
     inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe to
     drop; no action needed.
  4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
     analogue; redundant with the DE_INF / DE_INF_OLG / DE_INF_BGH and
     DE_NULL / DE_NULL_BGH chains. Safe to drop; no action needed.

Files:
  internal/db/migrations/093_retire_litigation_category.up.sql   (new)
  internal/db/migrations/093_retire_litigation_category.down.sql (new)
  internal/services/project_service_test.go                      (test rewrite)
2026-05-16 01:29:31 +02:00
mAi
2686d43a38 Merge: t-paliad-199 — Slice 9 follow-up A (drop legacy event_deadlines tables, EventDeadlineService refactored onto deadline_rules) 2026-05-16 01:18:21 +02:00
mAi
29a6b58747 refactor(t-paliad-199): Slice 9 follow-up A — drop legacy event_deadlines tables
EventDeadlineService.Calculate now reads source rows from
paliad.deadline_rules directly (WHERE trigger_event_id IS NOT NULL),
joining via UUID instead of title_de string. The legacy SELECTs against
paliad.event_deadlines + paliad.event_deadline_rule_codes are gone.

Migration 092:
- Snapshots both legacy tables into _pre_092 audit anchors.
- Adds paliad.deadline_rules.rule_codes text[] and backfills the 72
  multi-code citations from event_deadline_rule_codes via the
  sequence_order = 1000 + ed.id convention from mig 085 (70 of 77
  Pipeline-C deadlines carry codes; 7 are codeless).
- Hard assertion ties source-junction-row count to backfilled
  text[]-element count — any sequence_order mismatch aborts the drop.
- Drops the mig 086 read-only trigger (orphan once event_deadlines
  goes away).
- Drops paliad.event_deadlines + paliad.event_deadline_rule_codes.
- Final assertion: >=77 active deadline_rules with trigger_event_id
  NOT NULL — Slice 3 corpus must not have collapsed.
- audit_reason wrapper at top so the deadline_rules UPDATE row-trigger
  records the reason in deadline_rule_audit.

Verified via BEGIN..ROLLBACK against the live paliad DB: 72 codes
backfilled into 70 rule_codes arrays, multi-code rules (RoP.029.a +
RoP.030 for ed_id=6) preserve their ordering, composite rules
(combine_op=max) remain intact, both tables drop cleanly, all
assertions pass.

Parity test rebound to deadline_rules — independent computation still
re-runs applyDuration against raw column values for date/composite
parity. EventDeadlineResult.ID stays int64 via the sequence_order -
1000 convention so the public /api/tools/event-deadlines wire shape
is unchanged.
2026-05-16 01:17:23 +02:00
mAi
4361c65887 Merge: t-paliad-198 — Determinator row-cascade Slice 3 (mobile polish + inline search + tooltip polish — cascade redesign complete) 2026-05-16 00:58:50 +02:00
mAi
6fc8c0136e feat(t-paliad-198): Slice 3 — mobile polish + inline search + tooltip polish
Closes the Determinator cascade redesign. Three intertwined pieces:

1. The mode row is gone — the `🔍 Direkt suchen` icon at the top of the
   row stack now toggles an inline search overlay over the cascade
   instead of routing to the legacy B2 surface. Results render into the
   same `#fristen-b1-results` container the cascade uses, so users see
   one consistent concept-card layout regardless of whether they
   reached the rule via cascade narrowing or free-text search. ESC
   inside the input clears it on the first press and collapses on the
   second; "← Zurück zum Entscheidungsbaum" restores cascade + state.
   Deep-link `?mode=filter` still routes to the legacy B2 panel for
   backwards-compatible shared URLs but is no longer exposed in the
   cascade UI.

2. Mobile responsive per design §7. Three breakpoints layer onto the
   `.fristen-row` primitive: <640px (phone — chips full-width single
   column, ändern permanently visible, answer wraps to its own line),
   <768px (tablet — head wraps so ändern moves down, chips
   single-column), <1024px (small desktop / large tablet — chips drop
   to 2-column auto-fill). Active row autoscrolls into view on every
   render with 60px headroom; the helper is a no-op when the row is
   already visible so desktop doesn't jitter.

3. Auto-walk tooltip polish: 200ms fade-in + slide-down via an
   is-entering transition state; mobile (<640px) flips the insertion
   point so the tip lands below the prefilled row rather than above;
   any chip pick or ändern click counts as user-engagement and
   dismisses the tip (in addition to the explicit × button).

Refs: docs/design-determinator-row-cascade-2026-05-13.md §6 + §7 + §10 Slice 3.
2026-05-16 00:58:02 +02:00
mAi
8b6b9254ed Merge: t-paliad-197 — Determinator row-cascade Slice 2 (project-driven narrowing + auto-walk) 2026-05-16 00:50:59 +02:00
mAi
a33060e600 feat(t-paliad-197): Slice 2 — project-driven narrowing + cascade auto-walk
Wires the project context into the Determinator row stack so a UPC INF
matter doesn't need to be hand-walked through five obvious cascade picks.
Auto-walk descends single-option chains as `is-prefilled` rows, the inbox
row vanishes for UPC matters (CMS implied), and the first prefilled row
carries the project reference inline ("aus Akte: HL-2024-001").

Backend: `internal/services/proceeding_mapping.go` adds
MapLitigationToFristenrechner — single source of truth for bridging the
litigation conceptual codes (INF / REV / APP / CCR / AMD / APM / OPP) onto
fristenrechner codes (UPC_INF / DE_INF / EPA_OPP / …). Ambiguous combos
(APP+DE, ZPO_CIVIL, AMD+DE) return ok=false; callers degrade to "no
narrowing" instead of guessing. Table-driven test covers every documented
mapping plus the ambiguous-degrade cases.

Frontend: `buildRowStack` filters cascade children by project context
along the proceeding axis (kebab segment lookup against the project's
fristenrechner code); auto-walks while filtered scope narrows to one;
caps depth via `cascadeAutoWalkStopAfter` after an "ändern" on a prefilled
row so the user lands at an active chip set without the auto-walk
re-engaging. Result panel narrows on the post-auto-walk effective slug,
not the URL slug. A one-time inline tooltip ("Diese Schritte ergeben sich
aus Ihrer Akte") surfaces when ≥2 rows render prefilled — dismissal flag
persists in localStorage.

Narrowing is purely additive: an Akte without a fristenrechner code
(11/11 live projects pre-Slice-5 were NULL) degrades to today's
forum-only behaviour. Slice 3 (mobile polish + search relocation) follows.

Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 2 + §4 + §5.
2026-05-16 00:50:27 +02:00
mAi
d7b2292f8f Merge: t-paliad-180 — Determinator row-cascade Slice 1 (visual hierarchy + row-by-row layout) 2026-05-16 00:38:58 +02:00
mAi
ff8f95abaa feat(t-paliad-180): Slice 1 — Determinator row-stack cascade
Replace the four-layer Pathway B mess (mode radio + perspective chip strip
+ inbox chip strip + breadcrumb cascade) with a single `.fristen-row`
primitive rendered in a top-down stack. Every decision — mode, perspective,
inbox, cascade depth N — now uses the same shape (label · picked answer ·
inline "ändern") and three states (is-active / is-answered / is-prefilled).

The user finally sees their full decision path at a glance instead of
chasing breadcrumb crumbs after each drill. Click on any answered row (or
its ändern affordance) re-actives it; ändern on a cascade depth drops the
descendants (same drop-descendants semantic as today's breadcrumb-click).
Reset link and `🔍 Direkt suchen` escape-hatch live at the top of the stack
per design §6 Option B; the mode-toggle radio is gone, routing to
?mode=filter now flows through the mode row.

Visual-only refactor — narrowing engine (inboxFilterAllowsForums +
perspectiveAllowsParty) is unchanged. Slice 2 will add project-driven
prefills + auto-walk; Slice 3 covers mobile polish and search relocation.

Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 1.
2026-05-16 00:38:19 +02:00
mAi
84aadc838a Merge: t-paliad-195 — Fristen Phase 3 Slice 9 (mig 091 legacy column drops; 092+093 deferred per live-data audit) 2026-05-15 17:55:18 +02:00
mAi
c4564b4031 refactor(t-paliad-195): drop priorityRendering legacy fallback
Phase 3 Slice 9 frontend cleanup. The backend's UIDeadline wire
shape stopped emitting (isMandatory, isOptional) in this slice;
the matching legacy-fallback branch in priorityRendering is now
dead code. Drops:

  - CalculatedDeadline TS interface: isMandatory + isOptional
    fields removed. `priority` is required (not optional) since
    every backend response now populates it.
  - priorityRendering(): collapsed to a clean switch on `priority`.
    Unknown priority falls back to "render as mandatory" (safe
    default; never silently drop a rule) — the legacy
    (isMandatory, isOptional) inference is gone.
  - Save-modal optional-badge rendering in fristenrechner.ts now
    reads `dl.priority === "optional"` directly (was previously
    `dl.priority === "optional" || dl.isOptional`).
  - Timeline row's optional-badge rendering in
    verfahrensablauf-core.ts switched from `!dl.isMandatory` to
    `dl.priority === "optional"`. Slightly different semantic —
    pre-Slice-9 the badge fired on every non-mandatory row
    (recommended + optional + informational); post-Slice-9 only
    on opt-in rules (RoP.151 pattern). Recommended + informational
    are surfaced via their own rendering tier (notice card for
    informational) so the badge change tightens the meaning.

Frontend build clean; no i18n keys removed (the priority labels
shipped in Slice 8 stay live).
2026-05-15 17:53:59 +02:00
mAi
7dae9b2216 test(t-paliad-195): adapt fixtures + assertions to post-drop shape
Phase 3 Slice 9 test cleanup. Seeds + assertions no longer touch
the legacy columns (mig 091 dropped them).

  - projection_service_test.go (Slice 7 fixtures): INSERT seeds
    drop the is_mandatory / is_optional columns from the
    paliad.deadline_rules column list. Defaults are fine; the
    spawn-graph test doesn't read those.
  - rule_editor_service_test.go (Slice 11a fixtures): same drop
    on the SLICE11A_PREVIEW seed.
  - fristenrechner_test.go (Slice 8 wire-shape assertion): drops
    the wireFlagsFromPriority round-trip check (the bool pair is
    no longer on the wire). The enum-membership invariant
    survives. evalConditionExpr table-driven test rewritten —
    legacy condition_flag fallback cases removed (the fallback
    is gone in Slice 9), pure-jsonb cases retained.
  - deadline_rule_service_test.go (Slice 2 backfill integrity):
    legacy-pair bucket assertion dropped; the priority-non-NULL
    invariant still holds via the CHECK constraint. The
    condition_flag cross-check now joins the pre-mig-091 snapshot
    when present (a future cleanup slice drops the snapshot
    along with this code path).

Build + tests green.
2026-05-15 17:53:44 +02:00
mAi
99a72a744f refactor(t-paliad-195): drop legacy fields from Go service surface
Phase 3 Slice 9 Go cleanup. With mig 091's column drops live, the
service layer stops reading + emitting the legacy shape:

  - models.DeadlineRule: drop IsMandatory, IsOptional, ConditionFlag,
    ConditionRuleID fields. Comment block flags Slice 9 as the
    closeout slice.
  - DeadlineRuleService.ruleColumns: SELECT no longer enumerates the
    dropped columns. The post-Slice-9 schema is the live shape.
  - FristenrechnerService.UIDeadline: drops IsMandatory + IsOptional
    fields. Frontend reads `priority` directly post-Slice-8; the
    legacy emit was kept "for one release" and that release is now.
  - evalConditionExpr signature: drops the conditionFlag fallback
    param. NULL / "null" expressions return true (unconditional);
    the legacy text[] fallback was the only reason for the second
    param. New helpers hasConditionExpr + extractFlagsFromExpr fill
    the gaps (alt-swap guard + RuleCalculation.FlagsRequired list).
  - FristenrechnerService.Calculate + calculateByTriggerEvent +
    EventTriggerService.Trigger: switched to the new (single-arg)
    evalConditionExpr; alt-swap guard now uses
    hasConditionExpr(r.ConditionExpr) instead of the dropped
    len(r.ConditionFlag) > 0 check.
  - FristenrechnerService.CalculateRule: RuleCalculationRule.IsMandatory
    derived from priority via wireFlagsFromPriority (kept for the
    result-card panel TS contract). FlagsRequired walks the jsonb
    gate tree to enumerate {"flag":"X"} leaves (replaces the
    dropped condition_flag enumeration).
  - RuleEditorService.Create + CloneAsDraft INSERT statements:
    dropped is_mandatory / is_optional / condition_flag from the
    column lists. Live shape only.

Test fixtures (projection_service_test.go, rule_editor_service_test.go,
fristenrechner_test.go) all updated to write the live shape on
seed; the evalConditionExpr table-driven test dropped its legacy
fallback cases (the fallback no longer exists) and now exercises
20 pure-jsonb scenarios across AND/OR/NOT compositions.

The deadline_rule_service_test backfill assertion lost its
(is_mandatory, is_optional) bucket cross-check (those columns are
gone); the priority-non-NULL invariant still holds via the CHECK
constraint. condition_flag cross-check now joins the pre-mig-091
snapshot table (when present) instead of the live row.
2026-05-15 17:53:31 +02:00
mAi
f9305d6108 feat(t-paliad-195): mig 091 — drop legacy rule columns
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.

Drops four superseded columns on paliad.deadline_rules:

  is_mandatory      → priority='mandatory' | other (Slice 2 mig 083)
  is_optional       → priority='optional'  (Slice 2 mig 083)
  condition_flag    → condition_expr  (Slice 2 mig 084)
  condition_rule_id → DEAD (no live rows, Q13 m's approved drop)

Pre-drop snapshot: paliad.deadline_rules_pre_091 (id +
the four columns + snapshotted_at). Lets the down-migration
restore values to existing rows; a follow-up cleanup slice drops
the snapshot table once the rule editor's migration-export flow
has been used to roll any post-drop edits back into version
control.

Hard assertions at end:
  - count(priority IS NULL) == 0 (Slice 2 mig 083 must have run).
  - count(rule with pre-drop condition_flag but no condition_expr)
    == 0 (Slice 2 mig 084 must have populated every row).
Both raise EXCEPTION on violation — fails the migration loudly
before legacy code paths get pulled out from under the unified
calculator.

Audit-reason wrapper set; ALTER TABLE DROP COLUMN doesn't fire
the mig 079 row-level trigger, but the wrapper is the standard
Phase 3 pattern.

Sibling drops deferred — see live-data audit in head ping:
  - mig 092 (event_deadlines + trigger_events tables): SKIPPED.
    trigger_events has 33 event_types FKs + 77 deadline_rules
    FKs; event_deadlines + event_deadline_rule_codes still
    consumed by EventDeadlineService.Calculate for the frontend's
    "Was kommt nach…" tab (/api/tools/event-deadlines is still
    in use post-Slice-3 delegate).
  - mig 093 (retire litigation category): SKIPPED. 40 active
    deadline_rules still reference litigation-category
    proceeding_types (the Pipeline-A INF/REV/CCR/APM/APP/AMD/
    ZPO_CIVIL rules; Slice 5 retired them from project-binding,
    not from the rule corpus).

Both deferrals are tracked in the head ping; the litigation drop
can land after a focused slice that splits the Pipeline-A rules
off the litigation category onto a fristenrechner-side parent.
The event_deadlines drop needs EventDeadlineService.Calculate
to stop reading the source rows first.
2026-05-15 17:53:08 +02:00
mAi
7f72ee7b9e Merge: t-paliad-196 — orphan concept proposal doc (curie researcher draft for m's review) 2026-05-15 17:48:05 +02:00
mAi
d027b0874c docs(t-paliad-196): orphan-concept seed proposals (Fristen Phase 3 Slice 12, draft)
5 live orphans (not 9 — discrepancy flagged), 7 linkage-only UPDATEs and
12 net-new rule drafts. Sources cited; 12 FLAGs for m's review before
/admin/rules ingest.
2026-05-15 17:47:30 +02:00
mAi
7571e43078 chore(t-paliad-194): wire aichat env vars through docker-compose.yml
The Dokploy compose .env file got the new vars during the operational
flip but the docker-compose.yml environment block didn't list them, so
docker-compose silently dropped them during container start.

Adds PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN / AICHAT_PERSONA to
the environment block with safe defaults (PALIADIN_BACKEND=legacy,
AICHAT_PERSONA=paliadin). Existing deployments without aichat envs set
keep the legacy path; flipping PALIADIN_BACKEND=aichat in Dokploy now
takes effect on next deploy.

Discovered while doing the aichat Phase B activation flip.
2026-05-15 17:33:20 +02:00
mAi
c7b48f6ea7 Merge: t-paliad-194 / m/paliad#38 — aichat Phase B paliad migration (PALIADIN_BACKEND=aichat opt-in) 2026-05-15 03:04:56 +02:00
mAi
8f6cee5a83 chore(t-paliad-194): delete paliad-side paliadin skill bundle (SoT moved to m/mAi)
Per m's 2026-05-13 decision (m/mAi#207 §13 Q4): the paliadin SKILL.md
and references/sql-recipes.md are now owned by aichat. The aichat repo
already has the equivalents committed at skills/aichat/paliadin/ on
mai/darwin/issue-207-aichat (verified before this commit). Aichat's
own deploy doc handles installation on mRiver.

Deleted:
  scripts/skills/paliadin/SKILL.md
  scripts/skills/paliadin/references/sql-recipes.md
  scripts/install-paliadin-skill

Legacy LocalPaliadinService / RemotePaliadinService still depend on
~/.claude/skills/paliadin/ being present on whichever host they run
against. Until those paths retire (Phase C / Q15), operators install
the skill manually from m/mAi/skills/aichat/paliadin/.

CLAUDE.md updated:
  - PALIADIN_SESSION_PREFIX row points readers at m/mAi for the skill
    SoT and notes the legacy paths still expect a manual install.
  - New env-var rows for PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN /
    AICHAT_PERSONA so the operator runbook for the Phase B flip is
    self-contained.
2026-05-15 03:03:49 +02:00
mAi
edc81bbbc2 feat(t-paliad-194): AichatPaliadinService + PALIADIN_BACKEND=aichat env gate (m/paliad#38 Phase B)
Adds the Phase B paliad-side migration: a thin HTTP client of the
centralized aichat backend shipped in m/mAi#207 Phase A (darwin's
mai/darwin/issue-207-aichat branch). Implements the same services.Paliadin
interface as LocalPaliadinService / RemotePaliadinService — handler
plumbing is unchanged, the cutover is a single env-var flip.

internal/services/aichat_paliadin.go (~530 LoC):
  - POST /chat/turn + POST /chat/reset + GET /chat/health via the aichat
    JSON envelope (mirrors m/mAi internal/aichat/api/types.go verbatim;
    no module import to keep paliad self-contained).
  - Per-turn HS256 JWT mint (uses paliadin_jwt.go from the prior commit)
    when SUPABASE_JWT_SECRET is configured. Aichat owns file write +
    cleanup; we just sign and ship.
  - Service-wide health-gate cache (10 s success window, no failure
    cache — failures re-probe so recovery surfaces immediately).
  - Per-user-window primer cache. Pulls up to MaxPrimerTurns prior
    exchanges from paliad.paliadin_turns and ships them in TurnRequest.
    Primer so a pane respawn (pane_spawned=true in response) doesn't
    strand the user with a cold claude. Cleared on ResetSession +
    pane_spawned response.
  - Username from email_localpart per m's §13 Q2 pick (sanitized inside
    aichat). Nil-DB fallback: "user-<uuid8>".
  - Maps aichat's typed wire errors (auth_failed, persona_unknown,
    mriver_unreachable, bootstrap_failed, timeout, shim_error) onto
    paliad's existing audit-row codes — preserves the German i18n table
    in paliadin.ts unchanged (no new strings needed per design §11).

cmd/server/main.go:
  - PALIADIN_BACKEND env: "aichat" → AichatPaliadinService, anything
    else → existing remote/local/disabled tree. Default = legacy, so
    every existing deploy is byte-identical until flipped.
  - buildAichatPaliadinConfig validates AICHAT_URL + AICHAT_TOKEN at
    boot; AICHAT_PERSONA defaults to "paliadin". JWT secret threaded
    in so per-user RLS is on by default.

Tests cover constructor defaults, health-gate caching + retry +
expiry, ResetSession wiring, error-envelope decoding + classifier,
HTTP-layer auth/JSON wiring via a roundTripper, JWT mint integration,
TurnContext → meta packing, and the env-gate helper. go test ./...
green. NOT self-merged — head owns the merge per task instructions.
2026-05-15 03:03:34 +02:00
mAi
08e20883a5 feat(t-paliad-194): revive per-turn JWT mint for Paliadin (folded-in t-paliad-156)
Restored from mai/planck/paliadin-per-user-rls (parked, see m/paliad#12
cancel note). The aichat Phase B path (next commit) consumes mintTurnJWT
to sign a short-lived HS256 token per turn, scoped to the calling user
(sub=userID, role=authenticated, aud=authenticated, iss=paliad/paliadin).

Aichat passes the raw token through to the claude pane on mRiver via a
per-turn file (managed by aichat's runner, not paliad's transport). The
SKILL.md reads it and `SET LOCAL request.jwt.claims = …` before every
paliad.* query, which makes RLS evaluate as the user instead of as
service role.

TTL: 2 min default — covers aichat's 120 s persona timeout + HTTP slack,
short enough that a leaked JWT is uninteresting. Each turn mints fresh;
no caching.

No call sites yet — paliadin_remote.go / paliadin.go are unchanged on
this commit. The plumbing arrives with AichatPaliadinService.
2026-05-15 03:03:12 +02:00
mAi
86946ba441 Merge: t-paliad-192 — Fristen Phase 3 Slice 11b (rule editor FRONTEND — admin UI on /admin/rules) 2026-05-15 02:10:19 +02:00
mAi
193b988798 feat(t-paliad-192): admin rule-editor frontend (Slice 11b)
Surfaces the Slice 11a admin API at /admin/rules so editors can drive
the rule lifecycle without curling. Three new pages, each gated by
adminGate on the route + sidebar reveal via /api/me:

  /admin/rules              — list page with filters (proceeding,
                              trigger event, lifecycle chips, fuzzy
                              search) and a second "Orphans" tab that
                              loads paliad.deadline_rule_backfill_orphans
                              via the new GET /admin/api/orphans
                              endpoint. Pick-chip on each candidate
                              fires the reason modal → POST resolve.
                              "+ Neue Regel" opens the same reason modal
                              with minimal required fields (name DE/EN
                              + duration) and routes to the edit page
                              on success.

  /admin/rules/{id}/edit    — full form (37 columns grouped: identity /
                              proceeding / timing / party / display /
                              lifecycle / condition). Side panel hosts
                              the preview widget (trigger date + flags
                              → GET .../preview, drafts only) and the
                              audit-log timeline (paginated, 20 per
                              page). Bottom action bar adapts to
                              lifecycle_state — save-draft + publish on
                              drafts, clone on published/archived,
                              archive on draft/published, restore on
                              archived. Every action opens the reason
                              modal with ≥10-char client-side guard per
                              Slice 11a edge case #4.

  /admin/rules/export       — minimal SQL preview + "Download as file"
                              / "Copy to clipboard". Optional `since`
                              audit-id scopes the export window.

condition_expr ships with a raw JSON textarea + inline parse
validation; the tree-builder is out of scope for Slice 11b (raw JSON
is sufficient given the existing 172-row corpus and validates the
same grammar live). The dependency on document.querySelectorAll for
form binding follows the admin-event-types / admin-audit-log
playbook — no new component substrate needed.

Wiring:
  - frontend/build.ts: 3 new entrypoints + 3 new HTML writes.
  - frontend/src/admin.tsx: new "Regeln verwalten" card with ICON_TABLE.
  - frontend/src/components/Sidebar.tsx: two new admin nav entries
    (Regeln + Regel-Migrations).
  - frontend/src/client/i18n.ts: 162 new keys (DE+EN), under
    admin.rules.* and admin.rules.edit.* and admin.rules.export.*.
  - frontend/src/styles/global.css: new admin-rules-* CSS block
    appended (chips, pills, audit timeline, edit-grid, preview list,
    orphan cards, export pre). Uses paliad's existing CSS tokens so
    light/dark/auto themes inherit automatically.

Route registration:
  - GET /admin/rules                — list page shell
  - GET /admin/rules/{id}/edit      — edit page shell
  - GET /admin/rules/export         — export page shell

All routes adminGate + gateOnboarded, so non-admin users 404 before
the shell even loads. Backend audit and lifecycle invariants from
Slice 11a stay authoritative; the frontend never bypasses them.
2026-05-15 02:09:35 +02:00
mAi
1c45c93570 feat(t-paliad-192): admin orphan list/resolve endpoints
Slice 11b backend addition for the orphan-resolution flow in the
/admin/rules UI. The Slice 10 fuzzy-match backfill (mig 089) staged
legacy paliad.deadlines rows the matcher could not bind to a unique
deadline_rule into paliad.deadline_rule_backfill_orphans. This adds
the two endpoints the editor needs to surface and resolve them:

  GET  /admin/api/orphans              — unresolved staging rows,
                                         hydrated with the candidate
                                         rule rows in one round-trip.
  POST /admin/api/orphans/{id}/resolve — picks a rule_id from the
                                         candidate set, writes it onto
                                         the deadline, and flips
                                         resolved_at + resolved_rule_id
                                         on the staging row in a single
                                         tx.

The methods live on RuleEditorService because they share the same admin
surface and audit semantics; resolved_rule_id + resolved_at on the
staging row is the audit trail (mig 089 COMMENT). reason is captured
into paliad.audit_reason in the same tx so any future audit trigger on
paliad.deadlines picks it up automatically.

Typed errors:
  ErrOrphanAlreadyResolved   → 409 in handler
  ErrOrphanCandidateMismatch → 400 in handler

Route ordering matches Slice 11a's pattern: the static path is
registered alongside the existing /admin/api/rules family inside the
adminGate block in handlers.go.
2026-05-15 02:09:10 +02:00
mAi
36bdfecb04 Merge: t-paliad-191 — Fristen Phase 3 Slice 11a (rule editor backend — admin API + lifecycle + audit + preview) 2026-05-15 01:51:28 +02:00
mAi
936c4967fd test(t-paliad-191): rule-editor lifecycle + preview coverage
Live-DB tests (TEST_DATABASE_URL-gated) for Phase 3 Slice 11a:

TestRuleEditorService_Lifecycle — full create→update→publish→archive
→restore round-trip on synthetic fixtures (SLICE11A_TEST_PT
proceeding + rules). Asserts:

  1. Create returns lifecycle_state='draft' with published_at=NULL.
  2. UpdateDraft on a draft succeeds and lands the patch.
  3. CloneAsDraft from a published row creates a new draft with
     draft_of pointing at the source.
  4. Publish flips draft → published, sets published_at, AND archives
     the cloned-from peer (verified by re-reading the peer's
     lifecycle_state post-publish).
  5. Archive flips published → archived.
  6. Restore flips archived → published.
  7. ListAudit returns ≥ 3 rows newest-first with non-empty reason
     strings (the mig 079 trigger captured them).
  8. Empty audit_reason on UpdateDraft → ErrAuditReasonRequired.
  9. UpdateDraft on a published row → ErrInvalidLifecycleState.
 10. Restore on a non-archived row → ErrInvalidLifecycleState.

TestRuleEditorService_Preview — calculator override hook coverage
(SLICE11A_PREVIEW_PT proceeding + a published rule). Clone the
root rule, patch DurationValue 30 → 60 on the draft, call Preview
at trigger_date=2026-01-15. Asserts:

  - Baseline Calculate (no overrides) returns the published rule's
    dueDate (~30 days after trigger).
  - Preview returns a DIFFERENT dueDate (substitutes the draft's
    60-day duration via RuleOverrides) — sanity check that the
    override pipeline reached the calculator and shifted the date.
  - Both responses are non-empty (the rule is reachable).

Cleanup: WHERE name LIKE 'SLICE11A_TEST_%' / 'SLICE11A_PREVIEW_%'
AND code = 'SLICE11A_TEST_PT' / 'SLICE11A_PREVIEW_PT' so production
rules are untouched. audit_reason set on every seed / cleanup write
so the mig 079 trigger doesn't reject the seed transactions.

Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
2026-05-15 01:50:29 +02:00
mAi
7decc5095f feat(t-paliad-191): admin rule-editor HTTP API
Phase 3 Slice 11a admin endpoints under /admin/api/rules, all
gated through auth.RequireAdminFunc:

  GET    /admin/api/rules                  — paginated list with filters
  GET    /admin/api/rules/{id}             — full row
  POST   /admin/api/rules                  — create draft
  PATCH  /admin/api/rules/{id}             — update draft only
  POST   /admin/api/rules/{id}/clone-as-draft
  POST   /admin/api/rules/{id}/publish
  POST   /admin/api/rules/{id}/archive
  POST   /admin/api/rules/{id}/restore
  GET    /admin/api/rules/{id}/audit       — paginated audit log
  GET    /admin/api/rules/{id}/preview     — preview-on-trigger-date
  GET    /admin/api/rules/export-migrations — SQL blob for the
                                              migration-export flow

Every write endpoint takes a `reason` body field; missing reason →
HTTP 400 (ErrAuditReasonRequired surfaced by the service). The
service writes the reason into paliad.audit_reason in the same tx
as the UPDATE so mig 079's trigger captures it.

writeRuleEditorError maps service-level typed errors to HTTP
statuses (404 for ErrRuleNotFound, 409 for ErrInvalidLifecycleState
+ ErrCyclicSpawn, 400 for ErrAuditReasonRequired + ErrInvalidInput).

dbServices gains a ruleEditor field; Services.RuleEditor in the
public bundle gets wired from main.go via NewRuleEditorService.

Route ordering: export-migrations is registered BEFORE the
{id}-shaped routes so the static path doesn't get captured by the
{id} placeholder. (Go 1.22+'s ServeMux requires the explicit
registration order for shadowing-resolution.)

Frontend (Slice 11b) will hire a new coder to surface the API in
an admin UI. Slice 11a ships the backend in isolation so the editor
can drive the lifecycle via curl / mai instructions today.
2026-05-15 01:50:15 +02:00
mAi
b21ce6dd7b feat(t-paliad-191): RuleEditorService — admin rule lifecycle
Phase 3 Slice 11a (m's Q5 option C: "I need to see these things,
admin only"). RuleEditorService owns the admin-only lifecycle for
paliad.deadline_rules:

  Create        → INSERT row with lifecycle_state='draft', published_at=NULL.
  UpdateDraft   → UPDATE WHERE id=$1 AND lifecycle_state='draft'.
                   Published or archived rows must clone-as-draft first
                   (ErrInvalidLifecycleState otherwise — 409).
  CloneAsDraft  → INSERT deep copy of source row (published OR archived)
                   as a new draft with draft_of pointing at the source.
                   Lets editors propose changes to live rules without
                   mutating the live row.
  Publish       → UPDATE lifecycle_state='published', set published_at.
                   When draft_of != NULL, also archives the cloned-from
                   peer so each rule has at most one live row.
  Archive       → UPDATE lifecycle_state='archived' (allowed from
                   published OR draft).
  Restore       → UPDATE lifecycle_state='published' (only from archived).
  Preview       → Calls FristenrechnerService.Calculate with the draft
                   as a RuleOverrides entry — pure simulation, no DB
                   write. If draft_of is set, the override substitutes
                   for the peer (matching ID); otherwise it's appended.
  ListAudit     → SELECT paliad.deadline_rule_audit rows for one rule,
                   newest-first, with offset/limit pagination. Joined
                   with paliad.users.display_name for the changed_by
                   column.
  ListRules     → Admin list view with filters (proceeding_type_id,
                   trigger_event_id, lifecycle_state, fuzzy q over
                   name / name_en / rule_code).
  ExportMigrationsSince → SQL blob generator for the migration-export
                   admin flow (Q-H-5 pure SQL format). v1 emits one
                   statement per audit row in chronological order;
                   Slice 11b polishes the output (header comment,
                   collapse consecutive UPDATEs).

Audit-reason invariant: every write method requires a non-empty
reason string. setAuditReasonTx writes it into the session-local
paliad.audit_reason setting in the same transaction as the
INSERT/UPDATE, so mig 079's trigger captures the rationale
forever. Empty reason → ErrAuditReasonRequired (400 in the handler).

Spawn cycle guard: validateSpawnNoCycle pre-checks Create + UpdateDraft
edits that touch spawn_proceeding_type_id against the global rule
graph. Reuses the design §6 cycle-guard semantics — walks the
target proceeding's spawn rules transitively; raises ErrCyclicSpawn
if any reachable proceeding is the source. Slice 7's runtime guard
catches anything this misses; the editor surface catches it at
edit time so the editor sees a clear 409 instead of a silent
projection failure.

Typed errors:
  ErrRuleNotFound          → 404 in handler
  ErrInvalidLifecycleState → 409 in handler
  ErrAuditReasonRequired   → 400 in handler
  ErrInvalidInput          → 400 (re-uses the existing services-wide error)
  ErrCyclicSpawn           → 409 (re-uses Slice 7's typed error)

RuleAuditEntry struct extends models.DeadlineRuleAudit with a
display_name for the admin UI; distinct from services.AuditEntry
(the cross-source union for the site-wide audit panel) so the two
read paths don't conflict.
2026-05-15 01:50:03 +02:00
mAi
358c64d172 feat(t-paliad-191): CalcOptions.RuleOverrides + applyRuleOverrides
Phase 3 Slice 11a calculator hook for the rule-editor preview
(design §4.5, Q-H-4 option (a)). CalcOptions gains RuleOverrides
[]models.DeadlineRule. When non-empty, FristenrechnerService.Calculate
substitutes any rule with matching .ID in the rule list with the
override row, and appends overrides whose ID doesn't match an
existing rule (net-new drafts the editor wants to preview).

Wired into:
  - FristenrechnerService.Calculate (proceeding-tree path)
  - FristenrechnerService.calculateByTriggerEvent (Pipeline-C path)

Helper: applyRuleOverrides(src, overrides) — small linear scan since
the override slice is 1 row in practice (the draft being previewed).
Empty overrides → pass-through (existing behaviour unchanged).

No DB writes; pure simulation. The editor's "what would this rule
do?" affordance uses this to preview the draft against the rest of
the proceeding's rules without mutating the live corpus.
2026-05-15 01:49:43 +02:00
mAi
5d22e5db21 Merge: t-paliad-190 — Fristen Phase 3 Slice 10 (rule_id backfill + orphan staging) 2026-05-15 01:38:47 +02:00
298 changed files with 59449 additions and 4128 deletions

View File

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

View File

@@ -1,6 +1,3 @@
# Project-specific mai configuration
# Auto-generated by 'mai init' — run 'mai setup' to customize
provider: claude
providers:
claude:
@@ -47,21 +44,13 @@ worker:
name_scheme: role
default_level: standard
auto_discard: false
max_workers: 5
max_workers: 7
persistent: true
head:
name: "paliadin"
max_loops: 50
infinity_mode: false
max_idle_duration: 2h0m0s
backoff_intervals:
- 5
- 10
- 15
- 30
name: paliadin
capacity:
global:
max_workers: 5
max_workers: 7
max_heads: 3
per_worker:
max_tasks_lifetime: 0

73
Makefile Normal file
View File

@@ -0,0 +1,73 @@
# Paliad — developer entrypoints.
#
# Targets here are the gate tier from the test-strategy design
# (docs/design-paliad-test-strategy-2026-05-19.md). Slice 1 lands:
#
# make verify-migrations — dry-run every pending migration (BEGIN..ROLLBACK)
# plus the full boot smoke (apply + tracker
# advances + /healthz returns 200).
# make verify-mig — alias for verify-migrations.
# make test — short test pass: go test ./internal/... -short
# plus the cmd/server package. Includes the
# live-DB tests when TEST_DATABASE_URL is set,
# skips them otherwise.
# make test-go — go test ./... -race (full Go suite).
#
# Future slices will extend this with:
# make test-frontend — bun test (Slice 3 / Slice 6)
# make e2e — Playwright golden-path suite (Slice 4)
#
# All targets are idempotent. None of them write to the filesystem outside
# the test runner's working dirs. None of them touch internal/db/migrations/
# files.
.PHONY: help verify-migrations verify-mig test test-go
help:
@echo "Paliad — developer targets"
@echo ""
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
@echo " verify-mig Alias for verify-migrations"
@echo " test Short test pass — covers gate tier"
@echo " test-go Full Go suite with race detector"
@echo ""
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
# Gate target — the test that would have caught mig 098 / mig 099 before
# deploy. Combines:
# - TestMigrations_DryRun (internal/db): per-migration BEGIN..ROLLBACK
# - TestBootSmoke (cmd/server): apply-end-to-end + tracker advances
# + /healthz 200
#
# Requires TEST_DATABASE_URL. Without it, both tests skip and the target
# is effectively a no-op — guard against that explicitly so CI doesn't
# silently green a missing env var.
verify-migrations:
@if [ -z "$$TEST_DATABASE_URL" ]; then \
echo "ERROR: TEST_DATABASE_URL is not set."; \
echo " The migration gate cannot run without a scratch DB."; \
echo " Set TEST_DATABASE_URL to a Postgres URL the test can"; \
echo " open transactions against, e.g."; \
echo " export TEST_DATABASE_URL=postgres://paliad:PW@localhost:11833/paliad_test"; \
exit 2; \
fi
@echo "==> migration dry-run (per-mig BEGIN..ROLLBACK)"
go test -count=1 -run TestMigrations_DryRun ./internal/db/
@echo "==> boot smoke (apply + tracker + /healthz)"
go test -count=1 -run TestBootSmoke ./cmd/server/
verify-mig: verify-migrations
# Gate-tier test pass. -short skips the slow live-DB tests when the
# author opts out via `if testing.Short() { t.Skip(...) }`; today most of
# paliad's live-DB tests gate on TEST_DATABASE_URL instead, so -short is
# forward-compatible rather than load-bearing.
test:
go test -short ./internal/... ./cmd/...
# Full Go suite with race detection. Slower but catches concurrent-map
# regressions that -short would skip; intended for the merge-to-main gate
# (full suite, not per-PR).
test-go:
go test -race ./...

View File

@@ -117,7 +117,9 @@ func main() {
}
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
bindingSvc := services.NewCalendarBindingService(pool)
targetSvc := services.NewAppointmentTargetService(pool)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
appointmentSvc.SetCalDAVPusher(caldavSvc)
@@ -126,6 +128,20 @@ func main() {
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
// new "Konto direkt anlegen" path on /admin/team. The key is
// optional: when unset the client still wires (so dependents
// don't panic) but every call short-circuits with
// ErrSupabaseAdminUnavailable so the rest of the server stays
// runnable.
supabaseAdminClient := services.LoadSupabaseAdminClient()
if supabaseAdminClient.Enabled() {
log.Println("supabase admin API configured — /admin/team Add-User path active")
} else {
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
}
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
@@ -135,14 +151,29 @@ func main() {
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
partySvc := services.NewPartyService(pool, projectSvc)
// t-paliad-238 — dedicated submission draft editor. The variable
// bag service is shared between the renderer (export) and the
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
// (commits 3677c81 + 1765d5e + 8ea3509).
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
submissionRenderer := services.NewSubmissionRenderer()
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
sysAuditSvc := services.NewSystemAuditLogService(pool)
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
svcBundle = &handlers.Services{
Project: projectSvc,
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: services.NewPartyService(pool, projectSvc),
Party: partySvc,
SubmissionDraft: submissionDraftSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
CalDAVBindings: bindingSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
@@ -155,13 +186,18 @@ func main() {
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
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
ChecklistCatalog: checklistCatalogSvc,
ChecklistTemplate: checklistTemplateSvc,
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
@@ -174,43 +210,91 @@ func main() {
UserView: services.NewUserViewService(pool),
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),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
// t-paliad-214 Slice 1 — personal-scope data export. firm name
// is captured into __meta of every export and printed in the
// embedded README.
Export: services.NewExportService(pool, branding.Name),
}
// 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).
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
// for the inbox-approvals widget. Done post-construction to avoid
// a circular constructor dependency (ApprovalService doesn't need
// the dashboard, and DashboardService can render its other widgets
// without approvals — so keeping this a setter keeps both
// constructors simple).
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
// Slice C wires PinService into DashboardService for the
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
// schema, no circular dependency (Pin doesn't know about the
// dashboard).
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
// Slice C wires the firm-wide dashboard default into the
// per-user layout service so GetOrSeed/ResetToDefault prefer
// the admin-set firm default over the code-resident factory.
// Nil-safe: empty firm row falls back to the factory layout.
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
// t-paliad-230 — submission generator (format-only). No
// service wiring needed: handlers/submissions.go reuses the
// existing files.go HL Patents Style cache and calls
// services.ConvertDotmToDocx (stateless function).
// 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")
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)
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
@@ -381,3 +465,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"
}

View 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)
}
}

View File

@@ -0,0 +1,210 @@
// Boot smoke test — assert paliad reaches a serving state.
//
// Three checks against TEST_DATABASE_URL:
//
// 1. db.ApplyMigrations does not panic and returns nil.
// 2. paliad.applied_migrations covers every on-disk *.up.sql — no
// migration was silently skipped, no version is missing. The set
// contract is stronger than the old single-counter check: applied
// set must EQUAL on-disk set, not just reach the max version.
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
//
// This is the lightweight cousin of the migration dry-run gate
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
// errors before merge; this smoke confirms the apply+bind path the
// container actually runs at boot. Together they cover the mig-098 /
// mig-099 class of crash-loops end-to-end, plus the mig-103 parallel-merge
// skip-hole that t-paliad-218 closed (m/paliad#44).
//
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
//
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
package main
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/handlers"
)
func TestBootSmoke(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping boot smoke")
}
// (1) Apply migrations end-to-end. The same code path the prod
// container runs at boot before `http.ListenAndServe`. A regression
// like mig-098's digit-regex would surface here as a non-nil error.
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("db.ApplyMigrations: %v", err)
}
// (2) Assert the applied set equals the on-disk set. The new runner
// tracks applied state per-migration; a silently-skipped version
// would surface as a row missing from paliad.applied_migrations even
// though max(version) matches. Comparing sets — not just max —
// catches the failure mode the t-paliad-218 post-mortem documented.
onDisk := embeddedMigrationVersions(t)
applied := appliedMigrationVersions(t, url)
if missing := setDiff(onDisk, applied); len(missing) > 0 {
t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+
"(a migration was skipped — investigate before deploying)",
len(missing), missing)
}
if extra := setDiff(applied, onDisk); len(extra) > 0 {
t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+
"(orphan rows — either restore the file or DELETE the row)",
len(extra), extra)
}
// (3) Mount the public handlers (the same Register call main() makes,
// minus the DB-backed Services bundle which the /healthz route doesn't
// need) and assert /healthz returns 200. This is the bind-and-serve
// half of the smoke: catches a regression that would make /healthz
// 404 or break the mux registration order.
//
// We deliberately do not boot the full main() — that would require
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open
// listening socket and a real auth client. The /healthz handler is
// auth-independent by design, and Register registers it on the outer
// mux before any DB-backed route, so this minimal setup exercises the
// exact code path main() takes.
mux := http.NewServeMux()
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
handlers.Register(mux, authClient, "", nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String())
}
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
}
}
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
// internal/db/migrations/ on disk. The boot smoke compares this set
// against paliad.applied_migrations to detect skipped or orphan
// migrations.
//
// Read from disk (not the embed.FS inside the db package — it's unexported)
// since the test runs from the repo. The two views must agree for the
// build to be self-consistent; if they diverge, the smoke test is the
// wrong place to learn about it (the build is). We trust them to match.
func embeddedMigrationVersions(t *testing.T) []int {
t.Helper()
root, err := repoRoot()
if err != nil {
t.Fatalf("locate repo root: %v", err)
}
dir := filepath.Join(root, "internal", "db", "migrations")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read migrations dir %s: %v", dir, err)
}
var versions []int
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".up.sql") {
continue
}
base := strings.TrimSuffix(name, ".up.sql")
underscore := strings.IndexByte(base, '_')
if underscore <= 0 {
continue
}
v, err := strconv.Atoi(base[:underscore])
if err != nil {
continue
}
versions = append(versions, v)
}
if len(versions) == 0 {
t.Fatalf("no *.up.sql files found in %s", dir)
}
sort.Ints(versions)
return versions
}
// appliedMigrationVersions reads paliad.applied_migrations and returns
// the sorted list of versions. Fails the test if the table doesn't exist —
// db.ApplyMigrations is supposed to have created it by this point.
func appliedMigrationVersions(t *testing.T, url string) []int {
t.Helper()
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
if err != nil {
t.Fatalf("read applied_migrations: %v", err)
}
defer rows.Close()
var out []int
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
return out
}
// setDiff returns the elements of a that are not in b. Inputs are sorted
// ascending; output preserves that ordering.
func setDiff(a, b []int) []int {
bset := make(map[int]bool, len(b))
for _, v := range b {
bset[v] = true
}
var out []int
for _, v := range a {
if !bset[v] {
out = append(out, v)
}
}
return out
}
// repoRoot walks upward from the test binary's working directory until it
// finds a go.mod. `go test` runs in the package dir, so we typically have
// to climb a couple of levels.
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}

View File

@@ -8,6 +8,7 @@ services:
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}
- GITEA_TOKEN=${GITEA_TOKEN}
- DATABASE_URL=${DATABASE_URL}
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
@@ -34,5 +35,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

View File

@@ -0,0 +1,332 @@
# Design — "Suggest changes" action on approval flow
**Author:** hertz (inventor)
**Date:** 2026-05-19
**Task:** t-paliad-216 (m/paliad in-flight)
**Branch:** `mai/hertz/inventor-suggest-changes`
**Status:** DESIGN — open questions await m before any coder shift.
---
## 0. TL;DR
Add a fourth action **"Änderungen vorschlagen"** ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
**Mental model (m, 2026-05-19):** suggest-changes is not "ping the requester to fix it" — it's the approver **authoring a counter-proposal** that gets re-injected into the approval flow as a fresh `pending` row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
- the **old row** in their /inbox as `changes_requested` ("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt;
- the **new row** in /inbox as `pending` — the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
Click flow:
1. Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
2. POST `/api/approval-requests/{id}/suggest-changes` with `{note, counter_payload}`.
3. Server, in one tx: closes the old row (`changes_requested`, `decision_note=note`), reverts the entity from `pre_image`, then immediately inserts a **new** `pending` approval_requests row authored by the approver with `payload=counter_payload`, re-applies the counter to the entity, marks `pending_request_id` to the new row, emits two events (`*_approval_changes_suggested` + `*_approval_requested`). `previous_request_id` FK links new → old for chain traversal.
The pending audience for the new row is the same as any fresh `Submit*` — the existing notification + visibility plumbing handles it without special-casing.
---
## 0a. m's decisions (2026-05-19)
| # | Header | m picked | Reasoning note (when different from recommendation) |
|---|---|---|---|
| Q1 | State machine | **(a) New status `changes_requested`.** | As recommended. |
| Q2 | Entity state | **(a) Reverts to pre_image, same as Reject.** | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
| Q3 | Chain depth | **(a) Yes, across chained rows.** | As recommended. |
| Q4 | Note shape | **Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in `decision_note`.** | Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a `counter_payload jsonb` column on `approval_requests` and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
| Q5 | Surface | **(a) /inbox only — v1.** | As recommended. Email + entity-detail badge are Phase 2. |
| Q6 | Requester actions | **Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side.** | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as `changes_requested` (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and **may approve it themselves**, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
| Q7 | Notifications | **(b) Notify all eligible approvers + the original requester for the NEW pending row.** | Consistent with Q6. The counter is a fresh `pending` request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
| Q8 | Audit shape | **(a) New event_type `*_approval_changes_suggested` per entity.** | As recommended. The new row also emits a normal `*_approval_requested` event, so the Verlauf chronology naturally captures the chain. |
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
---
## 1. Context — what's already in the code (verified 2026-05-19)
- **State machine** in `internal/services/approval_service.go`:
- `paliad.approval_requests.status` CHECK is already `('pending', 'approved', 'rejected', 'revoked', 'superseded')` — the `superseded` value is defined as a Go constant `RequestStatusSuperseded` but never written by the live service (reserved).
- `paliad.{deadlines,appointments}.approval_status` CHECK is `('approved', 'pending', 'legacy')` — three values only.
- Shared kernel `decide(requestID, callerID, finalStatus, note)` powers Approve / Reject / Revoke. Approve invokes `applyApproved`; Reject + Revoke invoke `applyRevert` (restores entity from `pre_image`).
- Self-approval blocked at 3 layers: `canApprove` Go gate, `approval_requests_no_self_approval` DB CHECK, deadlock-check excludes requester from pool.
- **Handlers** in `internal/handlers/approvals.go`:
- `POST /api/approval-requests/{id}/approve`
- `POST /api/approval-requests/{id}/reject`
- `POST /api/approval-requests/{id}/revoke`
- `GET /api/approval-requests/{id}` — single hydrated request
- **Per-viewer flags** (t-paliad-202, shipped): every row carries `viewer_can_approve` + `viewer_is_requester` resolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint.
- **Frontend**:
- `frontend/src/client/inbox.ts` wires three buttons per pending row (approve/reject/revoke). Reject opens `window.prompt()` for the note; approve+revoke don't.
- `frontend/src/client/views/shape-list.ts` (row_action="approve") stamps the row with action buttons + diff + `decision_note` display if present.
- **Audit**: event types `*_approval_requested`, `*_approval_approved`, `*_approval_rejected`, `*_approval_revoked` emitted to `paliad.project_events` (one per entity_type prefix).
- **Decision note**: `paliad.approval_requests.decision_note text` — a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
---
## 2. Design questions (the open list — see §6 for answered)
Pre-recommendations from inventor. m will pick via AskUserQuestion.
### State machine
**Q1 — Where does "suggest changes" sit on the lifecycle?**
- **(a) New status `changes_requested` (RECOMMENDED).** The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked via `previous_request_id`).
- (b) Reuse `rejected` with `is_revisable=true` flag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again").
- (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
- (d) Other (you'll tell us).
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
**Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?**
- **(a) Entity reverts to pre_image — same as Reject (RECOMMENDED).** approval_status flips back to `approved`. The requester edits the entity in the normal flow; saving fires a fresh `Submit*` cycle.
- (b) Entity stays at `approval_status=pending` carrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields.
- (c) Entity goes to a new `approval_status=draft` (would require a new value on the entity-level CHECK + UI work to handle a third entity state).
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via `previous_request_id` so the chain stays inspectable.
**Q3 — Can the approver suggest changes multiple times (across a chain)?**
- **(a) Yes, across chained rows (RECOMMENDED).** Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
**Q4 — Note shape on the suggestion**
- **(a) Free-text — reuse `decision_note` (RECOMMENDED).** Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write").
- (b) Thread of notes — new `paliad.approval_notes` table, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc.
- (c) Structured per-field suggestions (`[{"field": "due_date", "current": "...", "suggested": "..."}]`) — a "diff-style" view.
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
### UX
**Q5 — Where does the requester see the suggestion?**
- **(a) /inbox under `a_role=self_requested` (RECOMMENDED for v1).** Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen".
- (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
- (c) Email + push notification.
- (d) All of the above.
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
**Q6 — What action(s) does the requester have on a `changes_requested` row?**
- **(a) Edit and resubmit (RECOMMENDED).** Primary action. Opens the entity's edit form pre-populated with the original `payload`. Saving fires `Submit*` → new pending request with `previous_request_id` linking back.
- (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
- (c) Both.
Recommend (a). The row is already terminal once status=`changes_requested`; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
### Notifications
**Q7 — Who gets notified when "suggest changes" fires?**
- **(a) Just the requester (RECOMMENDED for v1).** Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
- (c) Requester + approval-policy-defined watchers (would require a new `approval_policies.watchers` column).
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
### Audit
**Q8 — Audit row shape on `project_events`**
- **(a) New event_type `*_approval_changes_suggested` per entity (RECOMMENDED).** Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types: `deadline_approval_changes_suggested`, `appointment_approval_changes_suggested`. Note text goes in metadata.
- (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
---
## 3. Implementation sketch (decisions-locked, see §0a)
### 3.1 Migration `103_approval_suggest_changes.up.sql`
```sql
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
ALTER TABLE paliad.approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE paliad.approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
-- 2. Add counter_payload — the approver's edited values, becomes the
-- `payload` of the NEW pending row spawned in the same tx as the
-- suggest-changes call. Stored on the OLD (now changes_requested) row
-- too so the audit chain can show "approver edited X, Y, Z" without
-- joining to the next row.
ALTER TABLE paliad.approval_requests
ADD COLUMN counter_payload jsonb NULL;
-- 3. Add previous_request_id FK so the new row links back to its origin.
ALTER TABLE paliad.approval_requests
ADD COLUMN previous_request_id uuid NULL
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
CREATE INDEX approval_requests_previous_idx
ON paliad.approval_requests (previous_request_id)
WHERE previous_request_id IS NOT NULL;
```
`.down.sql`: drop the index + columns, restore the original CHECK (would reject existing `changes_requested` rows — that's normal for a breaking-change down).
### 3.2 Service layer
`SuggestChanges` is the only new public method on `ApprovalService`. It runs in **one transaction** and does five things:
```go
const RequestStatusChangesRequested = "changes_requested"
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
// SuggestChanges closes the pending request as `changes_requested`,
// reverts the entity, then immediately inserts a new pending
// approval_request authored by the caller carrying `counterPayload` as
// its new payload. The new row enters the standard pending flow — anyone
// eligible (including the original requester) can approve, reject,
// suggest-changes-again, etc.
//
// Authorization: caller satisfies canApprove on the OLD row (same gate
// as Approve / Reject). For the NEW row, the caller is the requested_by
// — self-approval is blocked by the standard 3-layer guard. Deadlock
// check (qualified-approver-exists-other-than-caller) runs on the new
// row to avoid spawning an unapprovable request.
//
// counterPayload must differ from the old row's payload OR a non-empty
// note must be present. A no-op suggest (same values, no note) is
// indistinguishable from "I have no opinion" and gets rejected with
// ErrSuggestionRequiresChange.
func (s *ApprovalService) SuggestChanges(
ctx context.Context,
requestID, callerID uuid.UUID,
counterPayload []byte, // jsonb-marshaled
note string,
) (newRequestID *uuid.UUID, err error) {
// 1. Begin tx, lock old row, validate status=pending + canApprove.
// 2. Validate: counterPayload differs from old payload OR note != "".
// 3. Update old row: status='changes_requested', decided_by=callerID,
// decision_note=note, counter_payload=counterPayload.
// 4. applyRevert on the entity (uses old row's pre_image).
// 5. Deadlock-check on the new row's required_role + projectID,
// excluding callerID.
// 6. INSERT new approval_requests row: requested_by=callerID,
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
// payload=counterPayload, required_role=old.required_role,
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
// entity_id=old.entity_id, status='pending',
// previous_request_id=requestID.
// 7. Re-apply the new payload to the entity (write-then-approve):
// apply the counter_payload's field updates + mark
// approval_status='pending' + pending_request_id=newRequestID.
// 8. Emit *_approval_changes_suggested project_events row
// (metadata: note, counter_payload diff vs original).
// 9. Emit *_approval_requested project_events row for the new
// request (same shape Submit* normally emits).
// 10. Commit.
}
```
Steps 6 + 7 reuse the existing `Submit*` plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both `Submit*` and `SuggestChanges` call. **decide()** does not need to know about `changes_requested` because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
### 3.3 HTTP layer
```
POST /api/approval-requests/{id}/suggest-changes
Body: {
"counter_payload": { ...same shape as Submit*'s payload... },
"note": "free-text explanation, optional iff counter_payload differs from original"
}
Returns: 200 { "new_request_id": "uuid" }
Errors:
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
400 "invalid_counter_payload" — schema validation failure
403 "self_approval_blocked" — caller == old row's requested_by
403 "not_authorized" — caller doesn't satisfy canApprove
404 — request not found / not visible
409 "request_not_pending" — old row already decided
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
```
Register in `internal/handlers/handlers.go` alongside the existing three:
```go
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
```
### 3.4 Frontend
`frontend/src/client/views/shape-list.ts` — extend the pending-row action group to four buttons:
```ts
actions.appendChild(approvalActionBtn("approve", detail));
actions.appendChild(approvalActionBtn("suggest_changes", detail));
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
```
The `action` union type gains `"suggest_changes"`. Disabled-reason logic is identical to approve/reject (`viewer_can_approve` gate). i18n: `approvals.action.suggest_changes` → DE "Änderungen vorschlagen" / EN "Suggest changes".
`frontend/src/client/inbox.ts` — clicking the suggest-changes button opens a **modal**, not a `window.prompt` (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
- Renders the same fields the entity edit form would show, pre-populated from `detail.payload` (the requester's proposed values).
- Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
- On submit: POST `/api/approval-requests/{id}/suggest-changes` with `{counter_payload: {...editedFields}, note}`.
- On success: refresh the bar — the old row flips to `changes_requested`, the new row appears as `pending`.
Where the modal's field-editor lives: a new `client/components/approval-edit-modal.ts` that takes `entity_type` + `payload` + `pre_image` and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
**Status pill for `changes_requested`** — i18n keys + colour:
- `approvals.status.changes_requested` → DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"
- Reuse the existing `approval-pill--historic` style; no new colour token needed for v1.
**The "Edit and resubmit" CTA on the requester's row is NOT needed** (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
### 3.5 Inbox filter
The /inbox `approval_status` filter chip cluster gains `changes_requested`. The `self_requested` viewer-role default already includes terminal statuses, so the original requester sees their `changes_requested` row without changing the default filter.
### 3.6 Linkage from old row to new row in /inbox
When showing a `changes_requested` row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has `previous_request_id` pointing at the old one — so the API response for the old row can hydrate `next_request_id` (computed: `SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1`).
### 3.7 Email notification (Phase 2 — defer until v1 ships)
The new row triggers the existing `*_approval_requested` notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to `changes_requested` doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
---
## 4. Slice plan
Three reviewable slices, each one PR. Combined scope is small/medium.
1. **Slice A — backend.** Migration 103 (CHECK extension + `counter_payload jsonb` + `previous_request_id` FK + index) + `SuggestChanges` service method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy.
2. **Slice B — frontend.** 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill `changes_requested` ("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright.
3. **Slice C — Verlauf integration.** Make sure the `*_approval_changes_suggested` event renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
Don't ship a chain-traversal UI in v1. The `previous_request_id` FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
---
## 5. Risks / open considerations
- **Chain depth runaway.** Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
- **Concurrent suggestions on the same pending row.** Two approvers click "suggest changes" at the same time? The existing `getRequestForUpdate` row-lock serialises them; the second caller gets `ErrRequestNotPending` (the first already flipped it). Same guarantee as Approve/Reject today.
- **Deadlock on the new row.** If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as `409 "no_qualified_approver"` to the suggesting approver, with the standard global_admin override path still available.
- **Counter-payload schema validation.** Server must validate `counter_payload` against the same schema as a normal `Submit*` for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly through `Submit*`. Reuse the existing payload-schema validator from the entity services; don't write a parallel.
- **No-op suggestion guard.** Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with `ErrSuggestionRequiresChange`. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text).
- **Migration safety.** Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
- **What about a structured per-field suggestion (Q4c)?** The `counter_payload` jsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable from `pre_image → counter_payload` on the new row.
- **What about thread-of-notes (Q4b)?** Implicit in the chain — each row's `decision_note` is one "note" by one author; following `previous_request_id` backwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
---
## 6. m's decisions
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
---
## 7. Out of scope for this design
- Email + push notifications (Phase 2; see §3.7).
- Structured per-field suggestion shape (Phase 2 enhancement).
- Approval-policy `watchers` column for notification fan-out.
- "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.

View File

@@ -0,0 +1,597 @@
# CalDAV multi-calendar sync — design
**Task:** t-paliad-212
**Inventor:** leibniz (2026-05-19)
**Branch:** mai/leibniz/inventor-caldav-multi
**Status:** READY FOR REVIEW — m's decisions on the §8 open questions captured in the addendum below (2026-05-19).
---
## §0 — One-paragraph summary
Paliad's CalDAV sync today is a single-target push: every user has one
`paliad.user_caldav_config` row, and every Appointment they can see gets
PUT into that one calendar. m wants users to pick their own organization —
one cal with everything, one cal per project (or per client / litigation /
patent / case), or any hybrid. This design splits the model in two:
**credentials stay per user** (one CalDAV server, one auth blob) and
**bindings become first-class rows** (a join table `paliad.user_calendar_bindings`
that points an Appointment-filter scope at a specific `calendar_path`).
Push/pull state migrates from scalar `appointments.caldav_uid`/`caldav_etag`
columns to a per-(appointment, binding) join table
`paliad.appointment_caldav_targets`, so the same Appointment can live in
N external calendars at once. The 60-second per-user sync goroutine survives
unchanged in shape; inside it the inner loop iterates bindings instead of
hard-coding `cfg.CalendarPath`. Sliced for safe rollout: Slice 1 introduces
the new tables behind a backfill that auto-creates one binding per
existing config row (zero behaviour change); Slice 2 ships the
binding-picker UI; Slice 3 wires scope-aware filtering (one cal per project).
Bidirectional sync stays exactly as it works today (last-write-wins on ETag,
Paliad-owned UIDs only) — multi-calendar does not change the conflict
model.
---
## §1 — What's already built (verified live, 2026-05-19)
Verified against the codebase, not the project's CLAUDE.md.
- **Schema** — `paliad.user_caldav_config` is one row per user with
`(user_id PK, url, username, password_encrypted bytea, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at)`. The
scalar `calendar_path` is the only handle on which external calendar
receives events. Per direct `information_schema` query.
- **Appointment binding** — `paliad.appointments` carries scalar
`caldav_uid text` and `caldav_etag text` (nullable). Set once after a
successful PUT via `AppointmentService.SetCalDAVMeta`. This is the
single-target assumption baked into the row itself.
- **Sync engine** — `internal/services/caldav_service.go:298502`. One
goroutine per enabled user, 60s ticker, `runSyncOnce``syncOnce`
`pushAll` (`AppointmentService.AllForUser` × `cli.PutEvent`) +
`pullAll` (`cli.PropfindCalendar``cli.GetEvent` → reconcile by UID).
`AllForUser` returns *every* personal-or-visible-project appointment
for the user; today they all funnel into the single `calendar_path`.
- **UID convention** — `paliad-appointment-<uuid>@paliad.de`
(`caldav_ical.go:3134`). Foreign UIDs are intentionally skipped on
pull (`caldav_service.go:436442`).
- **Hooks** — `OnAppointmentCreated/Updated/Deleted` push directly to
the configured `cfg.CalendarPath` on a 30s-timeout background goroutine
so user requests don't block (`caldav_service.go:510558`).
- **Approval flow (t-138)** — project-attached appointments may be
`approval_status = 'pending'`. CalDAV push already runs after approval
in `AppointmentService.Update` paths; `ApplyRemoteUpdate` from a remote
edit currently bypasses the approval gate. That's a pre-existing hole
flagged here only because multi-calendar makes "which calendar's edit
wins" more visible — fix belongs in t-138 follow-ups, not in this
design.
- **CalDAV verbs supported** — PUT / DELETE / GET / PROPFIND (depth 0
and 1). No MKCALENDAR, no REPORT, no calendar-multiget. Tested
against Nextcloud, Radicale, Baikal, mailcow SOGo per
`caldav_client.go:2224`.
**What is _not_ baked in and is therefore free to extend:**
- The 60s ticker is per-*user*, not per-*calendar*. Adding bindings does
not multiply tickers.
- `cfg.CalendarPath` is referenced in exactly two places (`pushAll`,
`pullAll`) plus the three hooks. Replacing it with a binding loop is
a contained edit.
- Credentials are server-scoped, not calendar-scoped — every binding
for the same user shares the existing decrypted credential, so the
encryption layer (`caldav_crypto.go`) is untouched.
---
## §2 — Per-provider calendar-count limits (verified 2026-05-19)
Real numbers, from current docs, so the design knows its envelope.
| Provider | Per-account / per-user limit | Source |
|---|---|---|
| **iCloud** | **100** calendars + reminder-lists combined | [Apple Support 103188](https://support.apple.com/en-us/103188) |
| **Google Calendar** | **~100 owned** (soft recommendation, post-Nov-2025 ownership model) | [Workspace Updates 2026-01](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html), [usecarly.com summary](https://www.usecarly.com/blog/how-many-calendars-google-account/) |
| **Fastmail** | **No documented cap on calendars.** 100 000 events/user. | [Fastmail account-limits page](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) |
| **Nextcloud** | **30 per user** default; admin-configurable, `-1` = unlimited. Rate limit: 10 calendar-creations/hour. | [Nextcloud admin manual — Calendar](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) |
| **Radicale / Baikal / mailcow SOGo** | No published per-account cap (file-system / DB bound). | server defaults |
**Implications for the design:**
- "One calendar per project" is comfortably within all providers'
envelopes for typical HLC caseloads. A senior PA who tracks 40
litigations would land 40+ calendars, still inside iCloud's 100 and
Nextcloud's default 30 (would need an admin bump on Nextcloud — flag
in onboarding).
- "One calendar per case" can blow past Nextcloud's default 30 fast and
is a real risk on iCloud at the 60+ mark when combined with the
user's existing personal calendars + reminder lists. We should
**soft-cap** scope choices at the UI layer (warn at 20 bindings, hard
block at 80) rather than discover the limit by 5xx on PUT.
- Google Calendar's CalDAV endpoint does **not** support `MKCALENDAR`
reliably — calendars must be pre-created in the Google UI. iCloud,
Fastmail, Nextcloud, Radicale, Baikal, SOGo all accept `MKCALENDAR`.
So the "auto-create a calendar per project" affordance is provider-
dependent and must degrade gracefully ("we couldn't create it for
you — please make `Project X` in your calendar app and paste its
URL").
---
## §3 — Proposed data model
Three schema changes, no destructive migrations. The scalar
`appointments.caldav_uid` / `caldav_etag` columns survive as a
denormalised "default-binding" pointer through Slice 1 and 2; Slice 4
drops them after telemetry confirms no path still reads them.
### §3.1 New table: `paliad.user_calendar_bindings`
```sql
CREATE TABLE paliad.user_calendar_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
calendar_path text NOT NULL, -- absolute URL or path under user_caldav_config.url
display_name text NOT NULL DEFAULT '', -- the label discovered via PROPFIND <displayname/>; what we show in the UI
scope_kind text NOT NULL, -- 'all_visible' | 'personal_only' | 'project' | 'client' | 'litigation' | 'patent' | 'case'
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, -- NULL for 'all_visible' / 'personal_only'
include_personal boolean NOT NULL DEFAULT false, -- only meaningful when scope_kind <> 'all_visible'/'personal_only'
enabled boolean NOT NULL DEFAULT true,
last_sync_at timestamptz,
last_sync_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, calendar_path), -- can't bind one calendar twice for the same user
UNIQUE (user_id, scope_kind, scope_id), -- one binding per scope per user — but a project can also be covered by 'all_visible'
CHECK ((scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
OR (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL))
);
CREATE INDEX user_calendar_bindings_user_idx ON paliad.user_calendar_bindings(user_id) WHERE enabled;
-- RLS: row visible/writable only when auth.uid() = user_id (mirrors user_caldav_config).
```
**Why per-scope unique but not per-appointment unique:** an Appointment in
project P is allowed to land in both the user's `all_visible` calendar
AND their `project=P` calendar — that's the explicit "master + per-project"
hybrid m asked about. What we forbid is two different `project=P` bindings
for the same user, which would have no useful semantics.
**`scope_kind = 'personal_only'`** is a separate scope from `'all_visible'`
because the existing pushAll already covers both personal and visible-project
appointments; users may want a "personal only" calendar that does *not*
get the noisy team events. Without this, every binding either includes
personal events or doesn't, and there's no way to say "the master
calendar = everything except personal".
### §3.2 New table: `paliad.appointment_caldav_targets`
```sql
CREATE TABLE paliad.appointment_caldav_targets (
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
caldav_uid text NOT NULL, -- still 'paliad-appointment-<uuid>@paliad.de' — same for all bindings of one appointment
caldav_etag text NOT NULL,
last_pushed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (appointment_id, binding_id)
);
CREATE INDEX appointment_caldav_targets_binding_idx ON paliad.appointment_caldav_targets(binding_id);
-- RLS: visible/writable when the underlying binding's user_id = auth.uid().
```
**UID stays per-appointment, not per-binding.** That keeps the iCal UID
canonical (still `paliad-appointment-<uuid>@paliad.de`), so when a user
removes a binding and re-adds it later, the same UID rebinds without
spurious duplicates. The `.ics` filename in the calendar — `<uid>.ics`
— is also identical across bindings, which means the same UUID
shows up in different calendars on the same server but never collides
because they're under different `calendar_path` collections.
### §3.3 Row examples for the four common organisations
| Organisation | Rows in `user_calendar_bindings` |
|---|---|
| **A — one cal, everything** | 1 row: `scope_kind='all_visible'`, `calendar_path='/cal/work'` |
| **B — one cal per project** | N rows, all `scope_kind='project'`, distinct `(scope_id, calendar_path)` |
| **C — master + per-project hybrid** | 1 row `scope_kind='all_visible'` + N rows `scope_kind='project'`. Each project event appears in both. |
| **D — personal split from work** | 1 row `scope_kind='personal_only'``/cal/personal` + 1 row `scope_kind='all_visible'` (which will include the same personal events, so the user will more commonly pair `personal_only` with a `scope_kind='client'` per-client work view instead). |
### §3.4 What stays unchanged
- `paliad.user_caldav_config` — still holds the server URL, username,
encrypted password, and a per-user `enabled` flag. The existing
`calendar_path` column becomes a hint for the **default binding** we
auto-create on migration and is no longer read by sync logic after
Slice 1 ships. We keep it nullable-on-read for forwards-compat then
drop in Slice 4.
- `paliad.caldav_sync_log` — still per-user; sync entries gain a
`binding_id` column (nullable for legacy rows) so the UI can show
per-calendar last-sync state.
- iCal serialisation (`caldav_ical.go`) — unchanged. Same VEVENT
formatter feeds every binding.
- AES-GCM credential encryption (`caldav_crypto.go`) — unchanged.
---
## §4 — Sync engine implications
The shape of the per-user goroutine stays. The body of `syncOnce`
moves from "push to one path / pull from one path" to "for each
enabled binding, push the scope-filtered slice / pull from that path".
### §4.1 Push fan-out
```go
// pseudocode for the new pushAll body
bindings := s.bindings.ListEnabled(ctx, userID) // 1..N rows
for _, b := range bindings {
appts := s.appointments.ForBinding(ctx, userID, b) // scope-filtered
for _, a := range appts {
body := formatAppointment(&a)
etag, err := cli.PutEvent(ctx, b.CalendarPath, terminUID(a.ID), body)
if err != nil { continue } // best-effort, per-binding error
s.targets.Upsert(ctx, a.ID, b.ID, terminUID(a.ID), etag)
}
// Remove events from this calendar that no longer belong to the scope.
for _, stale := range s.targets.DanglingForBinding(ctx, b.ID, currentIDs(appts)) {
cli.DeleteEvent(ctx, b.CalendarPath, stale.CalDAVUID)
s.targets.Delete(ctx, stale.AppointmentID, b.ID)
}
}
```
`ForBinding(userID, b)` is the scope filter:
- `all_visible` → existing `AllForUser(userID)`
- `personal_only` → appointments with `project_id IS NULL AND created_by = userID`
- `project` → appointments where `project_id = scope_id` AND visible to user
- `client` / `litigation` / `patent` / `case` → appointments where the
ancestor at the relevant hierarchy level = `scope_id` AND visible to user
- when `include_personal = true`, union with personal events on top of the above (only for non-`all_visible`/`personal_only` scopes)
This reuses the existing `can_see_project()` predicate (per project
CLAUDE.md, team-based RLS), so visibility shrinkage on a project unshare
falls out naturally: next push sees the appointment is no longer in
`ForBinding(...)`, sees a dangling target row, issues `DeleteEvent`.
### §4.2 Pull reconciliation
Each binding has its own pull pass against `b.CalendarPath`. The
matching key is still `caldav_uid` — same UID across all bindings, so
`appointments.FindByCalDAVUID(uid)` resolves the local row. The
**ETag check is per-target row** now, not per-appointment: a remote
edit in calendar X bumps the etag in `appointment_caldav_targets` for
binding X only. The local Appointment is updated once (last-write-wins
on Appointment.updated_at), the next push tick re-syncs the other
bindings with the new payload (they see their stored etag is older
than the appointment's `updated_at` and re-PUT).
**One subtle change:** the foreign-UID skip (`extractAppointmentID == ""`)
still applies per-binding pull. That preserves the v1 "Paliad owns its
UIDs" property — multi-calendar does not open the door to importing
events the user creates in their calendar app. (If/when that becomes
in-scope, it's a separate t-paliad-* design.)
### §4.3 Hooks (instant push)
`OnAppointmentCreated/Updated/Deleted` fan out across all the user's
enabled bindings that match the appointment's scope. Same 30s-timeout
background goroutine. The user-facing request still returns
immediately; the failure mode is identical (best-effort per binding,
logged on `slog.Warn`).
### §4.4 Bandwidth & rate limits
- Per user per tick: **N bindings × 1 PROPFIND + per-event GETs**.
The pull GET is the dominant cost; a 50-binding user with 20 events
per calendar is ~1 000 GETs/min, which is fine over HTTP/1.1 to a
decent CalDAV server but **does** put us inside iCloud's
~throttle-friendly band and risks Google's quota model.
- Mitigation: switch pull to **`REPORT` `calendar-multiget`** so each
binding's events come back in one round-trip. That's a single
iteration on `caldav_client.go` (the same multistatus parser
already handles the body) and pays for itself the moment a user
has >10 events per binding. We deliberately deferred this in
Phase F (one calendar, low volume) — multi-calendar makes it
table-stakes. Plan to land it in **Slice 2** alongside the picker.
- Rate limiting on the Paliad side: keep the 60s ticker, but stagger
per-binding pulls so we never fire N concurrent PROPFINDs against
the same provider. Sequential per binding is fine; we already do
this implicitly with the per-user goroutine.
### §4.5 Server-side cleanup on binding delete
User deletes a binding → service:
1. Lists every (appointment, binding) target row for that binding.
2. Issues `DELETE` per `.ics` on the remote calendar (best effort).
3. Deletes the target rows.
4. Deletes the binding row (or relies on `ON DELETE CASCADE` from
target FK — cleaner to delete remotely first, then drop the row,
so a half-failed cleanup leaves rows we can retry on next tick).
A "leave events behind in the external calendar" toggle is a real
ask (users sometimes archive bindings without wanting their calendar
app to suddenly empty). Plumb it as `binding.cleanup_on_delete bool`
in Slice 2 if there's demand; default `true` (delete).
---
## §5 — Bidirectional vs one-way
**Recommendation: stay bidirectional, identical to today's semantics,
per-binding.** Reasons:
1. **m's stated workflow expects round-trip.** Drag a deadline in
Outlook → Paliad sees the new date → approval flow triggers
(t-138). One-way push breaks that. Multi-calendar doesn't change
this expectation; if anything, it strengthens it (the user picked
the project-cal binding *because* they intend to edit there).
2. **The conflict model is already in place.** Last-write-wins on
ETag, foreign-UID skip, `LogConflict` audit append. Multi-calendar
adds one new question: "if the user edits the same event in two
different bindings between ticks, which wins?" Answer: the one
that lands first in our pull pass. Bindings are iterated in
`created_at` order so the behaviour is deterministic, and the
second edit gets overwritten on the next tick when we re-push the
resolved appointment to it. Acceptable trade-off; would only show
up if a user actually edits the same event in two of their own
calendars within 60s, which is vanishingly rare.
3. **Approval-flow integration is unchanged.** Pending-approval
events have the `[PENDING APPROVAL]` marker baked into the iCal
summary by `caldav_ical.go:76+`. That marker survives multi-binding
fan-out untouched; an external edit on a pending event still has
the pre-existing bypass-the-gate hole (flagged §1, not in scope).
**Tee-up for m's call:** if multi-calendar is the wrong moment to
keep bidirectional (e.g. because per-project calendars are about
**read-only visibility for partners**, not editing), we'd add a
`binding.read_only bool` column and skip the pull pass for that
binding. Cheap to add now or later. **I recommend defaulting
`read_only = false` (bidirectional like today) and only making it
optional if m's first session with the UI surfaces the need.**
---
## §6 — User-facing config model
Surface on `/einstellungen/caldav` (already exists for Phase F creds).
Two sections, in this order:
1. **Server** (existing) — URL, username, password, "test connection".
Unchanged.
2. **Calendars** (new) — list of bindings as cards / rows. For each:
`display_name`, `calendar_path`, `scope_kind` chip (master /
personal / project / …), `enabled` toggle, last-sync status, action
buttons "Edit scope" / "Remove".
3. **Add a calendar** — flow:
- **a)** click "Add". Modal opens. We do a `PROPFIND
<calendar-home-set>` against the user's server to discover their
existing calendars; show as a picker. (RFC 6638 / 4791 calendar
home set discovery — supported by iCloud, Fastmail, Nextcloud,
Radicale, Baikal, SOGo. Google CalDAV does not expose this
reliably; for Google users we degrade to a manual path entry box.)
- **b)** user picks an existing calendar, or chooses "Create new
calendar". Create-new attempts `MKCALENDAR` (works on iCloud,
Fastmail, Nextcloud, Radicale, Baikal, SOGo; fails on Google →
friendly error with copy-paste instruction).
- **c)** user picks the **scope**: a radio between "Everything I can
see", "Personal only", "One project", and (later) "One client /
litigation / patent / case". Project picker uses the existing
`/api/projects?…` autocomplete.
- **d)** "Save" → POST `/api/caldav-bindings`. The next 60s tick
starts pushing into the new calendar; the UI shows "Initial
sync running…" with a live last-sync indicator (already polled
by the existing `caldav-config` page).
4. **Quick-add affordances** (Slice 3 polish, not v1):
- On a project's `/projects/<id>` page: "Open in calendar app" link
if a binding already exists for that project, "Pin to a new
calendar" if none does (deep-links to the Add-a-calendar modal
pre-filled).
- Bulk action "Create one calendar per active litigation" on
`/einstellungen/caldav` (requires `MKCALENDAR` support; gated
behind a server-capability probe at first PROPFIND).
5. **Soft limits in the UI:**
- At **20 bindings**: yellow info banner "Most users keep ≤ 20
calendars; review your list before adding more."
- At **80 bindings**: red error, block adding new (we don't know
the user's provider for sure; 80 is a safe ceiling for iCloud
and Nextcloud-default).
- Provider hint surfaced under the Server form: parsed from the
URL host, with a "your provider's documented limit" line —
pure courtesy, not enforced.
### §6.1 What the API contract looks like
| Verb + Path | Body / Returns | Notes |
|---|---|---|
| `GET /api/caldav-bindings` | array of binding rows + sync status | replaces having to interpret `user_caldav_config.calendar_path` |
| `POST /api/caldav-bindings` | `{calendar_path, display_name, scope_kind, scope_id?, include_personal?}` → created binding | triggers immediate sync goroutine wake-up |
| `PATCH /api/caldav-bindings/{id}` | partial; toggle `enabled` or change `scope_*` | re-runs `pushAll` for this binding |
| `DELETE /api/caldav-bindings/{id}` | — | deletes external events first, then row |
| `GET /api/caldav-discover` | array of `{href, displayname}` from server `<calendar-home-set>` | populates the picker; cached 5 min |
| `POST /api/caldav-mkcalendar` | `{display_name, color?}` → `{calendar_path}` | issues `MKCALENDAR`; returns 501 on Google |
`GET /api/caldav-config` still works (back-compat for the server-creds
section); its `calendar_path` field is documented as "deprecated, see
/api/caldav-bindings".
---
## §7 — Slice plan
Tracer-bullet slices so each is independently shippable, safe to
revert, and gives the user something they can see.
**Slice 1 — Schema + backfill (no UI change).**
- Migration: create `user_calendar_bindings`, `appointment_caldav_targets`.
- Backfill: for every existing `user_caldav_config` row, insert one
`bindings` row `(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled)`.
For every Appointment with non-null `caldav_uid`, insert one
`appointment_caldav_targets` row pointing at the user's new default
binding.
- Refactor `CalDAVService.syncOnce` / `pushAll` / `pullAll` to drive
off bindings (loop of length 1 per existing user). Behaviour
observably identical: same calendars, same events, same logs.
- `appointments.caldav_uid` / `caldav_etag` columns still exist and
are written for compatibility (treat them as denormalised pointers
to the default binding's target row). UI unchanged.
- **Exit criterion:** existing users see no change in their calendar;
`caldav_sync_log.binding_id` is populated for all new rows; manually
inserted second binding via SQL syncs correctly end-to-end on a
staging account.
**Slice 2 — Binding-picker UI + multi-binding support.**
- `/api/caldav-bindings` CRUD + `/api/caldav-discover` (PROPFIND
`calendar-home-set`) + `/api/caldav-mkcalendar`.
- New "Calendars" section on `/einstellungen/caldav` with the modal
flow from §6.
- **Land `REPORT calendar-multiget` pull** alongside (per §4.4).
Required, not optional, for the bandwidth profile multi-binding
introduces.
- Scope kinds enabled in v1: `all_visible`, `personal_only`, `project`.
Hierarchy scopes (`client`, `litigation`, `patent`, `case`) parked
for Slice 3.
- **Exit criterion:** m can pin a second calendar via the UI on
staging; events for project X appear only in the X-bound calendar
if his master binding is disabled, and in both if it's enabled.
**Slice 3 — Hierarchy scopes + project-page quick-adds.**
- Enable `scope_kind ∈ {client, litigation, patent, case}` — pure
filter-predicate change in `ForBinding(...)` using the existing
project-tree walker.
- "Pin to a new calendar" button on `/projects/<id>` and on the
/einstellungen page.
- Bulk "calendar-per-active-litigation" provisioner (with
`MKCALENDAR` capability probe).
- **Exit criterion:** real HLC PA can set up "one cal per
litigation" in <5 min on first try without inventor help.
**Slice 4 — Polish + cleanup.**
- Drop `appointments.caldav_uid` / `caldav_etag` after instrumentation
shows zero readers outside `CalDAVService` (`grep` + a one-week
query-log audit on the read replica).
- Soft-limit banners (20 / 80).
- `binding.read_only` and `binding.cleanup_on_delete` toggles if
asked for by then.
- **Exit criterion:** schema is final; no legacy paths remain in
`caldav_service.go`.
**(Out of scope across all four slices:** foreign-UID import, custom
event types per binding, per-binding colour mapping, MKCALENDAR for
Google. These are easy to add later if the data says so.)
---
## §8 — Open questions for m
1. **Bidirectional default for new bindings: yes/no?** I recommend
**yes** (matches today's single-cal behaviour and the round-trip
workflow expectation). A `read_only` per-binding flag is cheap to
add later if a real use case shows up. Decide now → Slice 1; decide
later → Slice 4.
2. **`personal_only` scope — keep or drop?** It's useful for users
who want a "noisy team master + clean personal" split, but it's
redundant for users who only use the master calendar. I'd keep
it; trivial to remove if m disagrees.
3. **`MKCALENDAR` (auto-create calendar) — ship in Slice 2 or defer
to Slice 3?** Shipping it in Slice 2 means we need the
capability-probe + Google-degrade UX up-front. Deferring means
Slice 2 users have to pre-create the calendar in their app and
paste the URL — workable but clunky. Default plan: **Slice 2,
with a clean Google-degrade message**.
4. **Soft cap numbers (20 / 80) — sensible?** Picked from §2
provider limits + "most paliad users will pick 15". m may
want different numbers — easy to tune.
5. **`/admin/caldav-bindings` view for support debugging?** Not in
the slice plan; useful if a user calls confused about which
calendar holds which event. Add if m wants it.
6. **Approval-flow + remote-edit gap (§1, the bypass) — fix scope?**
Pre-existing in single-cal Phase F. Multi-cal makes it more
visible. Should this be a follow-up under t-138, or folded into
Slice 3? I'd file as a separate task.
---
## §9 — Why this is the right shape
- **Single CalDAV server per user, N bindings.** Matches every real
provider's auth model (one auth blob covers all the user's
calendars) and keeps `caldav_crypto.go` and `user_caldav_config`
untouched.
- **Binding scope is a row, not a static config.** Users compose
the organisation they want without us guessing; defaults (one
master binding on migration) preserve current behaviour.
- **UID stays per-appointment.** Means an event re-binding (move
from project-cal to master-cal) is just shuffling target rows,
not minting new UIDs. Re-importing into the same calendar later
rebinds cleanly.
- **Sync engine shape is unchanged.** Same per-user goroutine, same
60s tick, same hooks. The blast radius of multi-binding is one
inner loop, gated behind a feature that backfills to a no-op for
every existing user.
- **Slices give m a vertical demo at each step.** Slice 1 is
invisible-but-shippable; Slice 2 is the first user-facing change
("you can pin a second calendar"); Slice 3 is "now organise by
project tree"; Slice 4 is cleanup.
- **No new external dependencies.** Same hand-rolled CalDAV client.
Adds one new verb (`MKCALENDAR`) and one new report
(`calendar-multiget`) — both small, both already half-tested
against `caldav_client.go`'s patterns.
---
## §10 — Sources
- [Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps](https://support.apple.com/en-us/103188) — iCloud 100 combined calendars + reminder lists.
- [Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html) — Google ~100 owned recommendation.
- [Fastmail — Account limits](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) — 100k events/user, no documented calendar count cap.
- [Nextcloud admin manual — Calendar / CalDAV](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) — default 30, configurable, 10/hr rate limit.
- Live verification against `internal/services/caldav_*.go` and `paliad.user_caldav_config` / `paliad.appointments` schema on the youpc Supabase instance.
---
## Addendum — m's decisions (2026-05-19)
Walked through §8.1§8.6 with m via AskUserQuestion. Decisions are
locked in for the coder shift; revisit only on Slice-3 feedback.
| Q | Decision | Implication for the slice plan |
|---|---|---|
| **§8.1 — Bidirectional default** | **Yes — bidirectional by default** | No `read_only` flag in Slice 13. Multi-cal inherits Phase F's last-write-wins / foreign-UID-skip semantics unchanged. Per-binding `read_only` only added later if a real use case shows up. |
| **§8.2 — `personal_only` scope** | **Keep — first-class scope** | Ships in Slice 2 as one of the picker's radio options (`Everything I can see` / `Personal only` / `One project`). One enum value, one `ForBinding()` branch. |
| **§8.3 — MKCALENDAR timing** | **Slice 2 with Google-degrade UX** | Slice 2 includes `POST /api/caldav-mkcalendar` + capability probe. Google users get a friendly "create the calendar in your Google UI, paste the URL" fallback. iCloud / Fastmail / Nextcloud / Radicale / Baikal / SOGo get one-click "Create new calendar". |
| **§8.4 — Soft caps** | **No caps in v1, add later if data warrants** | Drop the 20-warn / 80-block UI guards from §6. Instrument `count(*)` on `user_calendar_bindings` per user as a Slice 2 telemetry add. Revisit if/when real distributions land. |
| **§8.5 — `/admin/caldav-bindings` view** | **Don't ship in v1** | Stays out of the slice plan. Support debugging goes via Supabase SQL until a real ticket lands. Frees Slice 4 polish for the legacy-column drop only. |
| **§8.6 — Approval-flow remote-edit gap** | **Separate task under t-138** | Out of scope for all four multi-cal slices. File the gap as a new `t-paliad-*` follow-up under t-138 so multi-cal stays clean and reverter-friendly. Pre-existing hole, surfaced not fixed. |
### Net effect on §7 slice plan
- **Slice 1** unchanged — schema + backfill, behaviour-equivalent.
- **Slice 2** = picker UI + `REPORT calendar-multiget` + **MKCALENDAR
with capability probe + Google-degrade message** + binding-count
telemetry. No `read_only` flag, no soft caps, no admin view.
Scopes enabled: `all_visible`, `personal_only`, `project`.
- **Slice 3** = hierarchy scopes (`client` / `litigation` / `patent` / `case`)
+ per-project quick-adds. **No** approval-gap fix folded in.
- **Slice 4** = drop legacy `appointments.caldav_uid` / `caldav_etag`.
Soft-cap banners only if Slice 2 telemetry says we need them.
### Net effect on §3 schema
No change. `user_calendar_bindings` still ships with the full
`scope_kind` enum (including `personal_only`). `appointment_caldav_targets`
unchanged. No `read_only` column in v1.
### Follow-ups to file as separate tasks
1. **`t-paliad-*` (under t-138):** approval-flow + CalDAV remote-edit
gap. `ApplyRemoteUpdate` bypasses the approval gate when an external
client edits a pending-approval event. Pre-existing in single-cal
Phase F. Owner: t-138 maintainer.
2. **(maybe) `t-paliad-*`:** soft-cap UI if Slice 2 telemetry shows
any user near the iCloud-100 / Nextcloud-30 envelope. Not pre-filed
— only opens if data warrants.

View File

@@ -0,0 +1,448 @@
# Design: Align calendar-view rendering between Events/Termine and Custom Views
**Task:** t-paliad-224 — m/paliad#55
**Author:** bohr (inventor)
**Date:** 2026-05-20
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
**Branch:** `mai/bohr/calendar-view-align`
---
## 0. Premise check (verified against live source 2026-05-20)
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
| | A — Events tab | B — Standalone | C — Custom Views |
|---|---|---|---|
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage``internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
---
## 1. m's intent (as I read it)
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
2. **Identical visual output** when the same items land in either surface.
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
---
## 2. What actually diverges today
Side-by-side after reading all three implementations (cited line numbers above):
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|---|---|---|---|
| Views offered | month only | month only | month + week + day |
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded**`views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
| Toolbar | inline month-label + Heute button | identical | view-switcher chips (M/W/D) + range-label + (in day/week) "Zurück zum Monat" link |
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
---
## 3. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type``kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
**Net code change (estimated by file):**
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
---
## 4. Architecture sketch
```
┌─────────────────────────────┐
│ frontend/src/client/ │
│ calendar/ │
│ mount-calendar.ts ★ │ ← new shared module
│ types.ts (CalendarItem)│
└──────────────┬──────────────┘
┌────────────────────────┼─────────────────────────┐
│ │ │
client/events.ts (Kalender tab) client/views/ │
│ shape-calendar.ts │
│ (thin wrapper) │
│ │ │
│ ▼ │
│ client/views.ts │
│ paintRows(…, "calendar") │
│ │
└──────────────────────────────────────────────────┘
Data flows:
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
→ toCalendarItem(items) → CalendarItem[]
→ mountCalendar(host, items, opts)
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
→ toCalendarItem(rows) (noop-ish: rename typekind already done)
→ renderCalendarShape() → mountCalendar(host, items, opts)
```
### 4.1 The shared module (`mount-calendar.ts`)
```ts
// frontend/src/client/calendar/mount-calendar.ts
import { t, tDyn, getLang, type I18nKey } from "../i18n";
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd
project_id?: string;
project_title?: string;
project_reference?: string;
}
export interface CalendarOpts {
defaultView?: "month" | "week" | "day";
/** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed
* equivalents); if false, state is in-memory only (use for embedded
* calendars where URL state belongs to the host page). */
urlState?: boolean;
/** Optional prefix for URL params (default: empty). Set if more than
* one calendar might live on the same URL. */
urlPrefix?: string;
/** Optional override: how to render a row's href. Default uses the
* kind→/deadlines|/appointments|/inbox|/projects routing the existing
* shape-calendar.ts ships with. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Re-render with a new item set (e.g. after a filter change in /events). */
update(items: CalendarItem[]): void;
/** Tear down listeners + clear host. */
destroy(): void;
}
export function mountCalendar(
host: HTMLElement,
items: CalendarItem[],
opts?: CalendarOpts,
): CalendarHandle;
```
Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks:
- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`.
- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today".
### 4.2 `shape-calendar.ts` (after refactor)
```ts
import type { RenderSpec, ViewRow } from "./types";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
export function renderCalendarShape(
host: HTMLElement, rows: ViewRow[], render: RenderSpec,
): void {
const items: CalendarItem[] = rows.map(r => ({
kind: r.kind,
id: r.id, title: r.title,
event_date: r.event_date,
project_id: r.project_id,
project_title: r.project_title,
project_reference: r.project_reference,
}));
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
```
### 4.3 `client/events.ts` (calendar arm only)
```ts
// near the top
import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar";
// state
let calendar: CalendarHandle | null = null;
// inside applyView() when switching to calendar view:
function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) {
if (calendar) { calendar.update(items); return; }
calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" });
}
// inside applyView() when switching AWAY from calendar:
function teardownCalendar() {
if (calendar) { calendar.destroy(); calendar = null; }
}
function toCalendarItem(it: EventListItem): CalendarItem {
return {
kind: it.type as CalendarKind, // type "deadline" | "appointment"
id: it.id, title: it.title,
event_date: itemDateISO(it) + "T00:00:00",
project_id: it.project_id,
project_title: it.project_title,
project_reference: it.project_reference,
};
}
```
`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.)
### 4.4 Standalone calendar redirects
```go
// internal/handlers/deadlines_pages.go
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
}
// internal/handlers/appointments_pages.go
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
}
```
The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view).
Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context).
---
## 5. Visual + interaction parity audit
Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor):
| Brief item | Today (A) | After refactor | Matches /views? |
|---|---|---|---|
| Event tile shape | dot | **pill with text** | ✓ |
| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ |
| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ |
| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ |
| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ |
| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract |
Two surfaces still differ after the refactor — and that's by design:
1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`).
2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way.
---
## 6. Mobile parity
`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `<p>` notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns).
After this refactor:
- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically.
- /views Kalender shape: behaviour unchanged from today.
Mobile audit boxes ticked:
| | Today A | Today B | Today C | After |
|---|---|---|---|---|
| Cell shrinks on narrow viewport | (min-height 64px) | | partial (cells stay 80px) | (carry the C behaviour, plus the @media min-height shrink ported) |
| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) but verify on a real phone during coder smoke | OK |
| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL natural back button) | drill-down across both surfaces |
| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical |
One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke).
---
## 7. Tests + smoke
Existing test coverage relevant to this refactor:
- `frontend/src/client/views/shape-timeline-cv.test.ts` sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module.
- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep).
- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` unchanged.
New test plan:
1. **`mount-calendar.test.ts` (new)** table-driven:
- Empty `items[]` month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views.
- `items[]` with mixed kinds pills get the correct `views-calendar-pill--{kind}` class.
- `?cal_view=week` week column grid renders.
- Today bucket flagged with `--today` class on the correct cell.
- `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3).
- `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click).
2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.
3. **Smoke (manual, with `bun run build` + dev server)**:
- /events Kalender tab loads, shows pills, click pill navigates to detail.
- Day-num click → day view (URL changes if urlState is on for /events per Q3).
- /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down.
- /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab.
- /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab.
- DE + EN language toggle on both surfaces.
- Light + dark theme on both.
4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief).
---
## 8. Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. |
| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. |
| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. |
| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. |
| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. |
| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. |
---
## 9. What stays "out of scope" (consistent with the issue body)
- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans.
- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side).
- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here.
- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it.
- Subtype dot colouring (deferred per §3 trade-off row).
---
## 10. Follow-ups (file as separate issues after this lands)
1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small.
2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks.
3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested.
4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope.
---
## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct)
> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12.
**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`?
- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes.
- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each.
- *(answer: yes / keep-standalone / something-else)*
**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely?
- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path.
- Alternative: keep the modal on /events only (parity break — defeats the point of the issue).
- *(answer: drop / keep)*
**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)?
- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events.
- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal.
- *(answer: persist / in-memory)*
**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring?
- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested.
- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours.
- *(answer: drop / preserve)*
**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape?
- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives.
- Alternative: dedicated key per surface — clearer copy but more strings to maintain.
- *(answer: reuse / dedicated)*
**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces?
- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar.
- Alternative: unit + Playwright.
- *(answer: unit-only / unit-plus-playwright)*
**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)?
- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes.
- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point.
- *(answer: one-pr / three-pr)*
**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release?
- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views).
- Alternative: leave for one release as a soft-deprecate.
- *(answer: drop / leave)*
---
## 12. m's decisions (2026-05-20, via head msg #2087)
Head accepted all 8 (R) defaults in one round-trip ("Design accepted in
full — all 8 (R) defaults stand"). Recorded verbatim below; each entry
is the (R) pick from §11.
- **Q1 — Canonical renderer:** Yes. Canonicalise on `shape-calendar.ts`; fold A into it via extracted `mountCalendar()`; retire B as 301 redirects to `/events?type=…&view=calendar`.
- **Q2 — Drill-down vs modal:** Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
- **Q3 — URL state on /events:** Persist. /events Kalender reads/writes `?cal_view=…&cal_date=…` like /views does. Adds `view=calendar` to `client/events.ts:readURLState()` so refreshes/redirects land on the Kalender tab.
- **Q4 — Subtype dot colouring:** Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
- **Q5 — Mobile fallback text:** Reuse the existing `views.calendar.mobile_fallback` key on /events as well — generic phrasing covers both surfaces.
- **Q6 — Test approach:** Unit tests (`mount-calendar.test.ts`) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
- **Q7 — Sequencing:** One PR. Extract + adopt + retire + CSS prune land together on `mai/bohr/calendar-view-align`.
- **Q8 — Empty-state i18n key:** Drop dead keys now (`events.calendar.empty`, `appointments.kalender.*`, `deadlines.kalender.*`, appointment-type legend keys not used elsewhere).
---
## 13. Coder hand-off (after m's go on §11)
Once §12 is filled in, the coder shift can proceed in this order:
1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through.
2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift).
3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3.
4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `<div id="events-shape-calendar" />` plus a wrapper class is enough — `mountCalendar` builds the DOM.
5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`.
6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s).
7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`.
8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none).
9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do.
10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label.
11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`.
12. Manual smoke per §7.3.
13. Commit. `mai report completed` with SHA per task brief.
Estimated coder shift: one PR per Q7 (R).
---

View File

@@ -0,0 +1,603 @@
# Paliad data export — Excel-first, scoped (org / project-subtree / personal)
Design: archimedes (inventor), 2026-05-19.
Task: **t-paliad-214**.
Branch: `mai/archimedes/inventor-excel-data`.
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §11 open questions.
---
## 0. Premise check (live state, 2026-05-19)
Verified directly against the youpc Postgres `paliad` schema rather than against memory or older design docs.
**Migration tracker.** Latest applied is `100_ccr_visible_rule`; next is **101**.
**Row counts (org-wide today):**
| table | rows |
|------------------------|-----:|
| users | 47 |
| projects | 11 |
| deadlines | 26 |
| appointments | 5 |
| parties | 0 |
| notes | 4 |
| documents | 0 |
| project_events (audit) | 93 |
| project_teams | 3 |
| approval_requests | 8 |
| approval_policies | 160 |
| checklist_instances | 4 |
| deadline_rules | 254 |
| user_views | 2 |
| partner_units | 11 |
A full org export today is **< 600 rows of user content** plus reference data synchronous streamed download is plausible for every scope. We design for an order-of-magnitude head-room.
**Auth.** Passwords live in Supabase Auth (separate `auth` schema, not `paliad`). The `paliad.users` table has **no `password_hash` column** so the "don't export credentials" rule from the brief is enforced by absence, not by a column-deny list. Good.
**Visibility.** Row-level via `paliad.can_see_project(project_id)` (subtree-aware through ltree path). Already used as the predicate that gates every list endpoint. We reuse it for the **personal** and **project** scopes; the **org** scope bypasses it under `global_admin`.
**Documents.** Table exists, 0 rows. Phase H (AI Frist-Extraktion) is deferred per m's 2026-04-16 call. No `ANTHROPIC_API_KEY` on Dokploy. Therefore **this design does not concern itself with binary attachments** only with the metadata row when documents start landing.
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
---
## 1. Why this exists
Two motivations, both load-bearing:
1. **Safety / backup.** A workbook on disk is a portable artifact independent of the running app. If paliad.de is down, a partner needs the matter file. If the Dokploy compose corrupts, IT needs a recent dump. If a deadline gets accidentally deleted, we want a recoverable snapshot.
2. **No lock-in.** A team or an entire org choosing to leave paliad must be able to walk away with their entire dataset in a format anyone can open. We promise this in writing as a trust signal exactly because the alternative (silently locking customers in) is what we built paliad to *not* be.
The export is therefore not a "nice analytics feature" it is **a contractual guarantee that the data is yours**. That framing shapes the design: completeness > convenience, portability > polish, every export auditable.
---
## 2. Scope definitions (precise)
Three scopes. The boundary is **what the caller is allowed to see**, joined with **what makes the artifact interpretable standalone**.
### 2.1 `org` scope
**Caller:** `global_role='global_admin'` only. There is no firm-admin role distinct from global_admin in paliad today (see §4).
**Content:** literally everything in the `paliad` schema that is user content or reference data the workbook needs to be readable. Specifically:
| sheet | source table(s) | notes |
|------------------------|-------------------------------------------------------------------|-------|
| `projects` | `paliad.projects` (all rows) | Full project tree including soft-deleted (status='deleted' / 'closed' if any). |
| `project_teams` | `paliad.project_teams` | profession + responsibility (post-t-148). |
| `project_partner_units`| `paliad.project_partner_units` | Derivation grants. |
| `deadlines` | `paliad.deadlines` | Including completed, cancelled. |
| `appointments` | `paliad.appointments` | Including completed. |
| `parties` | `paliad.parties` | All client / opposing-party data. |
| `notes` | `paliad.notes` | All four polymorphic targets resolved into the `target_kind`/`target_id` columns. |
| `documents` | `paliad.documents` metadata (file_path, file_size, mime_type, ai_extracted) | Binaries excluded (open Q1). |
| `audit_events` | `paliad.project_events` | Full audit trail per project. |
| `approval_requests` | `paliad.approval_requests` | Including completed / rejected, with `requester_kind` + `agent_turn_id`. |
| `approval_policies` | `paliad.approval_policies` | Both project-scoped and partner-unit-defaults. |
| `policy_audit_log` | `paliad.policy_audit_log` | Source #5 of the audit union. |
| `partner_units` | `paliad.partner_units` | Org chart. |
| `partner_unit_members` | `paliad.partner_unit_members` | Including unit_role. |
| `partner_unit_events` | `paliad.partner_unit_events` | Org-chart audit. |
| `checklist_instances` | `paliad.checklist_instances` | Per-project completion state. |
| `invitations` | `paliad.invitations` (status, role, expires_at) | Without raw tokens (open Q7). |
| `users` | `paliad.users` (id, email, display_name, office, profession, …) | Excludes `email_preferences` jsonb only if it carries channel secrets — none do today, but checked at export time. |
| `user_views` | `paliad.user_views` | Saved filters / custom layouts. |
| `user_card_layouts` | `paliad.user_card_layouts` | Project-card layouts. |
| `user_pinned_projects` | `paliad.user_pinned_projects` | Per-user pins. |
| `user_caldav_config` | `paliad.user_caldav_config` **without** the ciphertext column | URL + calendar IDs + last_sync; passwords NEVER exported. |
| `reminder_log` | `paliad.reminder_log` | Outbound digest history. |
| `caldav_sync_log` | `paliad.caldav_sync_log` | Per-user sync runs. |
| `paliadin_turns` | `paliad.paliadin_turns` | **Excluded by default** in org export (privacy — see §6) — admins opt in per Q5. |
| `email_broadcasts` | `paliad.email_broadcasts` | Outbound broadcast history. |
| `email_templates` + `_versions` | both | Custom firm templates. |
| **reference (read-only):** | `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_category_concepts`, `trigger_events`, `holidays`, `courts`, `countries` | One sheet per table, prefixed `ref__`. Embedded so the workbook is interpretable without paliad context. |
| **deferred audit (admin opt-in):** | `deadline_rule_audit`, `policy_audit_log`, `partner_unit_events`, `caldav_sync_log`, `paliadin_turns` | Behaviour per Q5/Q6. |
**Excluded unconditionally:**
- `auth.*` (Supabase Auth schema — not ours; the user can request their auth record from Supabase directly).
- `paliad_schema_migrations` (operational, no business meaning).
- `*_pre_NNN` shadow / pre-migration backup tables (rows are duplicates; the live table is canonical).
- Any future `*_secret` / `*_token` columns (see §6 deny-list mechanism).
**Edge cases:**
- **Soft-deleted rows:** paliad currently has no soft-delete columns (`deleted_at` etc.). When that lands, the org export includes them by default with a `deleted_at` column populated. Until then, this is a no-op.
- **Archived projects:** `projects.status` can be `'closed'` or future `'archived'` — export includes them (the whole point of backup is recoverability of closed matters).
- **Counterclaims:** `projects.counterclaim_of` is a self-FK. Export carries the column as-is; the relationship is reconstructable via the `id` column.
### 2.2 `project` scope
**Caller:** any team member of the project who passes the §4 profession-tier gate.
**Content:** one project + **all descendants** along the ltree path. The descendant walk is `WHERE path <@ root.path` (subtree-inclusive of root). Every entity gets filtered through `WHERE project_id IN (subtree_ids)`.
Per-sheet inclusion:
- `projects` (root + descendants, one row each)
- `project_teams` (membership for those projects)
- `project_partner_units` (derivation attachments)
- `deadlines`, `appointments`, `parties`, `notes`, `documents` (metadata), `audit_events`, `approval_requests`, `checklist_instances` — all scoped to subtree
- **users sheet — restricted columns:** only `id, email, display_name, office, profession` for users referenced by any FK in the export (created_by, assigned, etc.). Don't dump all 47 users when you only need 4. (Avoids accidental org-chart leak in a project-scope export shared externally.)
- **reference data:** `ref__proceeding_types`, `ref__event_types`, `ref__deadline_rules`, `ref__deadline_concepts`, `ref__courts`, `ref__countries`, `ref__holidays`. Same as org but a smaller universe is acceptable too — the v1 ships the full reference tables for simplicity (every row count is ≤ 300; size is moot).
- **Cross-project references** (e.g., a party referenced by a project outside the subtree): out of scope by the predicate. The export carries the foreign UUID so a re-import or merge could re-link, but the foreign row itself is not in the workbook. Edge case is rare — `counterclaim_of` is the only known cross-project pointer today.
**Edge cases:**
- **Partner-unit data:** `partner_units` is org-wide; project export carries only the unit ids attached via `project_partner_units`. The unit name + membership are loaded into the workbook on `partner_units` and `partner_unit_members` sheets (filtered to the attached units only).
- **Policies:** `approval_policies` rows include both project-scoped (the project + ancestors) **and** partner-unit-defaults attached to this project. Same MAX-of-sources logic as runtime.
- **Audit:** `project_events` for the subtree + (admin opt-in only) `deadline_rule_audit` rows whose rule was used by any deadline in the subtree. Default off — these are firm-wide curation logs and don't belong in a per-project handoff.
### 2.3 `personal` scope
**Semantics:** "everything I can see right now in paliad, framed as my data."
That definition resolves the ambiguity in the brief: personal scope is **not** "rows where I am `created_by`" — that misses everything I see by being on a team. It is **the RLS-visible projection of the schema for caller=me**, plus a handful of explicitly-personal sidecars (caldav config, my pins, my views).
Per-sheet inclusion:
| sheet | rows |
|---|---|
| `projects` | `WHERE paliad.can_see_project(id)` for the caller. |
| `project_teams` | Rows where `user_id = me` OR the row's project is in my visible set. |
| `deadlines` | Same project-visibility filter. |
| `appointments` | Same. |
| `parties`, `notes`, `documents` metadata, `audit_events`, `checklist_instances` | Same. |
| `approval_requests` | Rows where `requested_by = me` OR `decided_by = me` OR project ∈ visible set. |
| `me` (single-row sheet) | Caller's `users` row (id, email, display_name, office, profession, reminder_*, lang, escalation_contact_id). |
| `my_caldav_config` | The caller's `user_caldav_config` row **without** the encrypted password column — sync URL, calendar IDs, last_sync_at. |
| `my_views` | Caller's `user_views` rows. |
| `my_pinned_projects` | Caller's `user_pinned_projects` rows. |
| `my_card_layouts` | Caller's `user_card_layouts` rows. |
| `my_paliadin_turns` | Caller's `paliadin_turns` rows (currently restricted to `PaliadinOwnerEmail` = m, so this sheet is empty for everyone else). Sensitive: AI prompts + responses. **Default on for personal scope** — it's literally the caller's data. |
| `users_referenced` | Restricted: id + display_name + email for users referenced as FKs in the export. |
| reference tables | Same set as project scope. |
**Edge cases:**
- **Caller leaves a team:** the export reflects the moment-in-time visibility. A `generated_at` timestamp in the workbook header (`__meta` sheet) anchors this.
- **Caller is a global_admin:** their personal export is the entire org (because their visible set = all projects). This is by design — but we surface a banner ("Sie sehen alles, weil Sie global_admin sind. Ein org-scope-Export wäre identisch.") so they don't get confused thinking the personal-scope endpoint is broken.
- **Caller has no team memberships:** export contains the empty workbook + the `me` row + their caldav config + views/pins. Still useful — they can save their preferences.
### 2.4 Common columns across all scopes
Every export workbook contains a `__meta` sheet:
```
schema_version: 1
firm_name: HLC # from internal/branding.Name
scope: org | project | personal
scope_root_id: uuid or NULL # the project id for project-scope, NULL otherwise
generated_at: 2026-05-19T14:23:00Z
generated_by_user: <uuid> <email> # the caller
generated_by_label: archimedes / m / ... # display_name
row_counts: JSON {"projects": 11, ...}
paliad_version: <git sha at server build>
notes: free-form, e.g., "documents binaries excluded by design"
```
This pins provenance + reproducibility + diffability.
---
## 3. Format choices
### 3.1 xlsx as the primary format
**Library: `github.com/xuri/excelize/v2`.** De-facto Go xlsx library, pure-Go (no cgo, no external libreoffice), MIT, streaming writer for large workbooks, broad format-feature support (number formats, freeze panes, hyperlinks, sheet hide). The streaming writer (`NewStreamWriter`) is what we use — it writes rows one at a time without holding the whole sheet in memory. At 11-projects scale this is unnecessary; at 11k-projects scale it's essential, so we set the pattern now.
**Why not the alternatives:**
- `tealeg/xlsx` — older, unmaintained, no streaming.
- `qax-os/excelize` — same project as xuri/excelize (the github org renamed); xuri is the upstream.
- `360EntSecGroup-Skylar/excelize` — defunct fork.
**Workbook structure:** one **sheet per entity type**, *never* a mixed-type sheet with conditional columns. Reasons:
- Excel users sort + filter by column; a column that means "deadline due_date" on row 4 and "appointment start_at" on row 12 is unusable.
- The "self-describing" promise (no-lock-in) is satisfied by a workbook where every sheet is a flat table with stable column headers, not by a polymorphic blob.
- Cross-sheet relationships are represented by **UUIDs in foreign-key columns** + a `__lookup` sheet pairing UUID → display label (project title, user email) for the workbook's lifetime. This makes the workbook self-joining in Power Query / pivot tables.
**Sheet conventions:**
- Sheet names use `snake_case` matching SQL table names (`deadlines`, not `Fristen`). Reference tables prefixed `ref__`. Personal sidecars prefixed `my_`. Meta sheet `__meta`. The `__lookup` sheet sits last.
- Row 1 = column headers; frozen.
- Column 1 of every entity sheet is `id` (uuid).
- Dates: ISO 8601 UTC for timestamptz; `YYYY-MM-DD` for `date`. Always as Excel strings (not Excel date types) — Excel-date interpretation differs by locale (DE: `Tag.Monat.Jahr`, EN: `Month/Day/Year`) and silently corrupts on round-trip. A pinned ISO string is unambiguous and re-importable. Open Q4 covers whether to *also* mirror to native Excel dates for human convenience.
- Booleans: literal `TRUE` / `FALSE` strings, same reason.
- `jsonb` columns: serialised as compact JSON one-liners in the cell. Cell type = string. Power Query can `Json.Document` them.
- Arrays (e.g., `additional_offices text[]`): semicolon-joined string. Excel's CSV-array convention is the comma but our office codes use commas; semicolon avoids the collision.
- `text[uuid[]]` paths (the projects.path ltree): exported as the canonical dotted-uuid string.
**Encoding:** UTF-8 always. Excelize handles the xlsx packaging which is unicode-native. Umlaute round-trip correctly (verified pattern with tesla's CSV export in t-paliad-177).
### 3.2 CSV + JSON siblings
Per the no-lock-in promise, **xlsx is not enough on its own** — Excel is a proprietary format owned by Microsoft, and a workbook is opaque without a tool that understands it. For genuine portability we also produce:
- **CSV:** one file per entity sheet (no reference sheets — those go as JSON), UTF-8 with BOM (`\xEF\xBB\xBF`) for Excel-DE compat, RFC 4180 quoting, headers row 1. Identical column shape to the xlsx sheet.
- **JSON:** a single `paliad-export.json` per scope, top-level `{"meta": {...}, "tables": {"projects": [...], "deadlines": [...], ...}}`. Easiest for programmatic re-ingest. Reference tables included.
**Delivery shape:** all three formats live inside one `.zip` per export:
```
paliad-export-<scope>-<timestamp>.zip
├── README.txt # human-readable: what this is, how to read it
├── paliad-export.xlsx # canonical workbook
├── paliad-export.json # JSON twin (machine-readable)
├── csv/
│ ├── projects.csv
│ ├── deadlines.csv
│ ├── ...
│ └── ref/
│ ├── proceeding_types.csv
│ └── ...
└── __meta.json # standalone meta (same content as __meta sheet)
```
The `.zip` is the artifact users download. Default content is "all three" — there's no UI knob to pick (open Q1: should there be? Inventor pick = no, zip-only).
**Filename convention:**
```
paliad-export-{scope}-{timestamp}.zip
scope = org | project-<root-short> | personal
timestamp = YYYY-MM-DDTHHMMZ # UTC, no colons (Windows-safe)
```
Examples: `paliad-export-org-2026-05-19T1423Z.zip`, `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`, `paliad-export-personal-2026-05-19T1423Z.zip`. The project-short is `slugify(root.title)` capped 40 chars.
**Determinism (Q6 question).** Two exports of the same scope at the same row state must produce **byte-identical** workbooks. xlsx is internally a zip of XML — file order in the zip is significant; excelize's default zip writer is non-deterministic. We can make this deterministic by sorting the file list before writing. JSON: keys sorted alphabetically. CSV: rows ordered by `id ASC` (stable). The only inherently non-deterministic field is `generated_at`; we externalise it to the filename and the `__meta` sheet, but the rest of the workbook is byte-stable. **Inventor pick: yes, deterministic.** Lets users diff exports and prove "nothing changed between Tuesday and Thursday."
### 3.3 Future-proofing — schema_version
`__meta.schema_version = 1`. When we add columns (e.g., projects.archived_at lands), we bump to 2 and note the additions in a `docs/export-schema-changelog.md`. Importers (us in the future, or a re-importer at a different firm) read schema_version first.
---
## 4. Authorization model
**Tightly mirrored to existing paliad role surfaces.** No new roles introduced.
| Scope | Required auth |
|---|---|
| `org` | `paliad.users.global_role = 'global_admin'`. Same gate as `/admin/*` pages (`auth.RequireAdminFunc` in `handlers.go:417`). |
| `project` | Caller must (a) pass `can_see_project(root_id)`, AND (b) have effective project profession ≥ **associate** on the root. The associate floor mirrors the conservative seed in `approval_policies` (t-154); paralegals + PA can see data but not extract it. m-tunable per Q2. |
| `personal` | Any authenticated user. No additional gate. |
**Profession ladder check** for project scope uses the existing `DerivationService.EffectiveProjectRole` (t-139 phase 2) — direct membership > ancestor > derived via partner-unit. Same surface that gates approvals; same surface gates extracts.
**Audit row written on every export run.** A new event_type into `paliad.project_events` for project-scope (so it appears on the project's Verlauf), `partner_unit_events` for org-scope (so it appears on the partner-unit audit log of the firm-admin's home unit), and `policy_audit_log` is too narrow — we likely want a **new** audit table for org-wide actions, OR we widen `project_events` to allow `project_id = NULL` org-wide rows. **Inventor pick: new table `paliad.system_audit_log`** — clean separation, integrates into the existing 5-source AuditService union as source #6. Migration 101 adds it.
`system_audit_log` columns:
```sql
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type text NOT NULL, -- 'data_export'
actor_id uuid REFERENCES paliad.users(id),
actor_email text NOT NULL, -- captured at write time, survives user deletion
scope text NOT NULL, -- 'org' | 'project' | 'personal'
scope_root uuid, -- project_id for project scope, NULL otherwise
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"formats":["xlsx","json","csv"], "row_counts":{...}, "file_size_bytes":12345, "filename":"..."}
created_at timestamptz NOT NULL DEFAULT now()
```
The audit row is written **before** the export runs (so failed exports are still recorded) and **updated** with `file_size_bytes` + final `row_counts` on success. Failure case: separate `event_type='data_export_failed'` row with the error string in metadata. **The audit chain is the trust signal** — m sees who exfiltrated what, when, and how much.
**Headers on the response:**
- `Content-Disposition: attachment; filename="paliad-export-<scope>-<ts>.zip"`
- `X-Paliad-Export-Audit-Id: <system_audit_log.id>` — so an automated client can reference the audit row.
---
## 5. Trigger model
Three trigger surfaces:
### 5.1 On-demand button
- **Personal:** `/settings` → "Daten exportieren" card → button. POST `/api/me/export` → 200 with `Content-Type: application/zip`. Synchronous.
- **Project:** `/projects/{id}` → settings/cog menu → "Daten dieses Projekts exportieren". POST `/api/projects/{id}/export` → 200 zip. Synchronous. Includes a "Inkl. Unterprojekte" toggle hint (it's always subtree-inclusive — the toggle is purely informational, no off switch).
- **Org:** `/admin/data-export` (new page, card on `/admin`) → "Org-Export erstellen" button. POST `/api/admin/export/org`**async** by default (see §6.1). Returns 202 + `job_id`. UI polls `/api/admin/export/org/jobs/{id}` for status.
**Why org is async even at today's scale:** the principle isn't "is it slow now" — it's "the trigger model should not change as the firm grows." If the partner with the firm-wide button gets a different UX from the associate with the project button, we'd retrofit later. Sync at 600 rows works fine; the wrapping is `goroutine + channel + Server-Sent Events for live progress`, no new infra needed. See §6.1.
### 5.2 Scheduled exports
**Inventor pick — defer to slice 4.** Out of v1 scope. The reasoning: scheduling sits on storage + delivery + retention, all of which are *also* deferred to slice 3+. Building the scheduler before we know how + where the artifact lives is premature.
When it lands (slice 4), the model is:
- A new `paliad.scheduled_exports` table: `(id, scope, scope_root_id, owner_user_id, cadence, last_run_at, next_run_at, delivery)` where `delivery` is `{kind: 'email-link' | 'caldav-style-webdav', config: jsonb}`.
- A daily cron (mai cron or a `time.Ticker` goroutine) checks `next_run_at < now()`, runs the export, posts the link via the configured delivery channel.
- Cadence: weekly + monthly + on-status-change (e.g., "export when project closes" — a webhook from `projects.status` triggers).
For now (slice 1-2), users can right-click the on-demand button and bookmark the URL — that's the **only** scheduled-export-y thing we offer, and it's intentional: get the manual flow rock-solid before adding cadence.
### 5.3 API endpoint
Same endpoints as §5.1, callable directly with the standard cookie / bearer auth. We don't add a separate "API key" surface in v1 — paliad doesn't have personal access tokens today. If a user wants to script their personal export weekly, they can use cookie auth from `m/paliad` automation; that's enough until power-user volume justifies a real PAT surface.
For machine ergonomics: the `/api/...export` endpoints accept `?format=zip` (default), `?format=xlsx`, `?format=json`, `?format=csv-zip` query params. Only `zip` is documented; the others are internal but reachable for automation.
---
## 6. Storage + delivery
### 6.1 Synchronous vs async — per-scope picks
**Personal, project:** **Synchronous, streamed.** The handler holds the HTTP connection open, writes the zip directly to `http.ResponseWriter`. For 1MB-class exports (today's reality at every scale up to thousands of rows per entity) this is the right call — no persistence, nothing to garbage-collect, nothing leaking onto disk. Excelize's `NewStreamWriter` flushes rows as they're written so RAM stays bounded.
**Org:** **Asynchronous, in-process queue, on-disk artifact.**
- Submit (`POST /api/admin/export/org`) writes a `system_audit_log` row with status `pending` and dispatches a goroutine.
- The goroutine writes the zip to `/var/lib/paliad/exports/{audit_id}.zip` (configurable via `PALIAD_EXPORT_DIR`; on Dokploy this is a mounted volume).
- The goroutine updates the audit row's metadata with progress, then status `done` with `file_size_bytes` on success.
- The user polls `GET /api/admin/export/org/jobs/{audit_id}` (SSE or simple JSON) — when ready, a download link `GET /api/admin/export/org/jobs/{audit_id}/download` serves the file.
- Download deletes the file by default (one-shot link), or keeps it per Q3.
**Why not S3-style bucket?** Paliad already has a `documents` table that *will* need a binary store, eventually. Coupling export storage to that future store is right — but the future store doesn't exist yet, and we don't want to provision MinIO on mlake purely for exports. **Inventor pick: local disk in `PALIAD_EXPORT_DIR`** until/unless we provision a real object store; at that point the export storage moves there transparently.
### 6.2 Retention (Q3)
**Inventor pick: 7 days, then auto-delete.** Justifications:
1. Exports contain sensitive client data — minimising the retention window minimises blast radius if the Dokploy host is compromised.
2. 7 days covers a holiday-week round-trip ("I exported Friday, want to look at it Monday next week, missed the day-1 link").
3. The audit row in `system_audit_log` persists forever — you can always tell that an export happened, even after the artifact is deleted.
A cleanup goroutine runs daily, lists `system_audit_log` rows older than 7 days with non-NULL `file_path`, deletes the file, sets `metadata.deleted_at`. Audit row stays.
The `PALIAD_EXPORT_RETENTION_DAYS` env var is the knob (default `7`). m-tunable per firm.
### 6.3 PII / GDPR
This is where the design gets serious.
**At-rest encryption.** Files in `PALIAD_EXPORT_DIR` are plaintext on the Dokploy volume. The volume itself is encrypted at the host layer (Hostinger VPS disk encryption). We **do not** layer additional file-level encryption on the artifact — that would require a per-user key, key escrow, key rotation, all of which is over-engineered for a 7-day-retention exfil where the link is single-use behind cookie auth. The disk encryption + 7-day TTL + audit log is the trust boundary.
**In-transit encryption.** TLS via Dokploy + Traefik — paliad.de is Let's Encrypt-served. No raw HTTP path.
**Download authentication.** The download link `/api/admin/export/org/jobs/{audit_id}/download` requires the same cookie auth as the submit. No public signed URLs in v1 (deferred per Q8). When we add scheduled exports + email delivery (slice 4), we'll need expiring signed URLs — that design is captured then, not now.
**Data-subject requests.** A user invoking `/api/me/export` is, in effect, performing a self-serve GDPR Art. 15 data-portability request. Audit row records the request. If the firm receives a *third-party* DSR ("export the data my client Mr. Müller asked for"), a global_admin can run a project-scope export filtered to projects involving that client; this is a manual workflow we don't automate in v1 (open Q9).
**Right-to-erasure.** Out of scope. Erasure is a write path; export is read-only. They share no code.
**External sharing of export files.** A user who downloads an export and emails it to an external party has done so on their own authority and outside paliad's protection. We don't watermark the file (debated and rejected: watermarking introduces non-determinism, breaks diffability, and gives false security — anyone reading the zip can strip metadata). What we *do* document in the embedded `README.txt`:
> Diese Datei enthält möglicherweise vertrauliche Mandantsdaten. Sie wurde
> erzeugt am {generated_at} durch {actor_email} aus Paliad ({firm_name}).
> Die Weitergabe an Dritte erfolgt in eigener Verantwortung des Empfängers.
A simple "you broke the seal" notice is what we offer. It's a contract, not a control.
**PII column deny-list.** Hard-coded in `internal/services/export_service.go`:
- `paliad.users.password_hash` — doesn't exist, but the deny-list is the safety net if it ever does.
- `paliad.user_caldav_config.encrypted_password` — explicit drop.
- Any column whose name matches `(?i)secret|token|password|api[_-]?key|private[_-]?key` — caught at column-discovery time, errors loudly into `system_audit_log.metadata.warnings`.
- `paliadin_turns.assistant_response` — present in personal export of caller's own data; **off** in org export by default (m's call per Q5).
### 6.4 GDPR-completeness note
The export of one user's personal scope is **a partial Art. 15 disclosure** — it contains what's *in paliad's* control. Other systems (Supabase Auth row, mlake logs, CalDAV provider) are out of paliad's scope and not in the export. The embedded README states this explicitly so the user knows the workbook is the paliad-side answer, not a complete personal-data dump from "the firm."
---
## 7. Slice plan
Tracer-bullet shipping. Each slice is independently shippable and reviewable. The first slice closes the no-lock-in promise for the smallest, lowest-risk scope; later slices widen.
### Slice 1 — personal export, synchronous, xlsx + JSON
- Adds `excelize/v2` to `go.mod`.
- New `internal/services/export_service.go` with the column-discovery + writer plumbing for xlsx + JSON.
- New `internal/handlers/export.go` with `POST /api/me/export`.
- New `/settings` UI: "Daten exportieren" card + button.
- Migration 101: `paliad.system_audit_log` + `AuditService.ListEntries` 6th union branch.
- i18n keys (`settings.export.*`, `__meta.*`).
- Tests: `export_service_test.go` covers xlsx structure (one row each kind), JSON shape, PII deny-list.
Ships the no-lock-in promise for every user immediately. ~600-800 LoC + ~25 i18n keys.
### Slice 2 — project export, synchronous, xlsx + JSON + CSV-zip
- Generalises the export_service to scope-aware queries (the visibility predicate gets injected per scope).
- New `POST /api/projects/{id}/export`, gated by §4.
- Adds CSV writer alongside xlsx + JSON; bundles all three into `.zip`.
- Project-detail UI gets the export menu entry.
- README.txt template embedded.
- Tests + e2e (Playwright) on the project page button.
~800-1000 LoC. The CSV path generalises the xlsx column-discovery so the marginal cost is low. After this slice, two of three scopes are shipped and synchronous serves both.
### Slice 3 — org export, async with job tracking
- Adds the goroutine + on-disk artifact path + `PALIAD_EXPORT_DIR` env.
- `POST /api/admin/export/org` + job status + download endpoints.
- New `/admin/data-export` page (card on `/admin/`).
- Cleanup goroutine (daily, deletes artifacts > `PALIAD_EXPORT_RETENTION_DAYS`).
- Refactor: extract the now-common "writeExportToWriter" core from the synchronous path so async re-uses it.
~600-800 LoC. After this slice, all three scopes ship + audit trail is complete.
### Slice 4 — scheduled exports (deferred, not v1)
Designed in §5.2; building deferred until at least 2 firms ask. The contract surface is the `scheduled_exports` table + cadence + delivery channel.
### Slice 5 — API ergonomics (deferred)
Personal Access Tokens (the "I want to cron my own export" surface). Until there's a customer, we don't build the PAT issuer + revocation + audit.
### Slice 6 — GDPR DSR helpers (deferred)
A `/admin/data-subject-request` workflow to assemble a per-natural-person export across projects. Built on Slice 1-3 primitives; not blocked by them.
### Slice 7 — document binary inclusion (deferred until documents have rows)
When the `documents` table starts holding real files, the export adds a `documents/` subdir in the zip with the actual files, keyed by filename = `{document_id}.{ext}`. The metadata sheet links by id. Adds ~150 LoC + an env var for the file backend.
**Critical-path slices for v1: 1 + 2 + 3.** Everything after is layered, optional, m-prioritised when there's a real customer pull.
---
## 8. Trade-offs flagged
1. **xlsx-first means we own the `excelize` dependency forever.** Mitigation: excelize is the canonical Go xlsx — replacing it would be a multi-thousand-LoC migration, but the upstream is healthy (MIT, 17k+ stars, monthly releases). Acceptable lock-in.
2. **Determinism (sorted file order, sorted JSON keys, row-id-ordered CSV) is implementation discipline, not a library default.** Test that breaks if any future change introduces non-determinism is essential (helps reviewers + prevents regressions).
3. **Synchronous personal + project means a runaway export can block a request goroutine for seconds.** At today's data shape this is sub-second. Watchdog: a 30s context deadline on synchronous exports; over that, return 503 with "export too large — contact admin for async." Triggers slice 3 → slice 4 of the user's mental model.
4. **Per-scope endpoints triplicate similar code paths.** Mitigated by the shared `ExportSpec` struct + scope-aware predicate injection. Read carefully in code review — this is the place subtle scope leaks creep in.
5. **JSON twin is genuinely redundant for human users.** It's there for the no-lock-in promise (a Python script can re-ingest without Excel). The cost is one extra file in the zip + one extra serialisation pass. Acceptable.
6. **No diff tooling — yet.** Determinism enables `diff -r` between two extracted zips, but no in-app surface. Slice 4+ may layer "show me what changed between Monday's and Friday's export" once exports are scheduled and stored.
7. **`paliadin_turns` privacy default.** Currently restricted to `PaliadinOwnerEmail` so the table is empty for every other user. Personal export carries them by default ("your AI history"); org export by default does NOT (admin opt-in via `?include=paliadin_turns`). When Paliadin opens past owner-only (post-API cutover), revisit.
8. **Reference-data inclusion bloats every export.** 254 deadline_rules + 102 trigger_events + 56 concepts + … = ~1000 reference rows in every workbook regardless of scope. At zip-compressed sizes this is < 100KB and worth the standalone-interpretability. If the workbook gets too large later, ship reference data as a separate "paliad-reference-snapshot.zip" once + reference it from each export's README.
9. **Org export volume at firm-scale.** A 10k-project firm has ~50k deadlines and ~200k audit events. Even at 200 bytes/row average that's < 100MB comfortable for the async path with 4GB Dokploy RAM. Threshold concerns kick in at 1M+ rows, which is firm-class-of-100-attorneys territory. Designed for, not blocked on.
10. **Audit-log explosion.** A nightly cron + 47 users self-exporting = 50 audit rows / day. At a year that's 18k rows. Still trivial. No retention on the audit chain (the artifact retention does NOT touch audit-log retention the audit chain is the trust signal, see §4).
---
## 9. Recommended implementer
**Single PR, layered slices 1 → 2 → 3 as separate commits.** No DB-heavy migrations; the only schema add is `system_audit_log` (one table, one trigger if any). The hard work is in the writer abstraction.
- **Slice 1:** pattern-fluent Sonnet coder. ~600-800 LoC, mostly bookkeeping. Tests pin the shape.
- **Slice 2:** same hands as slice 1 (continuity matters here the writer abstraction is set in slice 1 and the project scope generalises it).
- **Slice 3:** same hands again. The async path is its own subsystem but the writer is unchanged.
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
**NOT m** this is a coder task end-to-end.
---
## 10. Inventor → coder transition (GATED per project CLAUDE.md)
Per `.claude/CLAUDE.md`: design phase ends here. No code touches the tree from inventor. Head's `mai-head` skill gates the coder shift after m's go on §11 open questions.
When approved, the coder shift opens on `mai/<coder-name>/data-export-slice-1` (fresh branch off main, NOT off the design branch design doc commit is the only artifact this branch carries forward via cherry-pick).
---
## 11. Open questions for m
The brief lists 8 candidate questions. After live-state verification I've collapsed + sharpened to 9, each with an inventor pick + reasoning. Will be asked sequentially via AskUserQuestion (paliad dogma no `## §X.Y` markdown dump on m, per t-paliad-154 lesson).
### Q1 — Bundle xlsx + CSV + JSON in one zip, or let user pick format?
**Inventor pick: bundle all three in one zip, no UI knob.**
Reasoning: the no-lock-in promise *requires* the JSON twin (Excel-independent re-ingest); the xlsx is the human-readable default; CSV is the universal lingua franca. Picking only one breaks the promise for some user. Bundle size at today's scale is < 1MB; even at firm-scale it's well under the email-attachment limit. The cost of a checkbox UI is more than the cost of three extra files.
Alternative: offer `?format=xlsx-only|json-only|csv-only` query params for the API surface, default to bundle. Documented in README only. We do this in v1 anyway since multi-format is what generates the zip in the first place.
### Q2 — Project-scope profession floor: associate (inventor pick) or member?
**Inventor pick: associate floor.**
A project export carries party names, addresses, decision-history, draft strategy notes. That's "I can write a paper for the partner" data, not "I can see the deadline calendar" data. Member is the bare-visibility tier (you got added to the team). Export is exfiltration needs the next tier up.
Alternative: gate by `responsibility ∈ {lead, member}` (no profession floor, only the project-team responsibility check). Cleaner architecturally separates the "can see" axis from the "can extract" axis using the same fields. Less restrictive in practice.
Worth choosing now because the gate text in the audit row mentions the tier.
### Q3 — Org-export artifact retention: 7 days (pick) or 30 / 90?
**Inventor pick: 7 days.**
Default conservative. m-tunable per firm via env var.
### Q4 — Excel dates: ISO strings only (pick) or also a mirrored native-Excel-date column?
**Inventor pick: ISO strings only.**
Native Excel dates are locale-poisoned (DE vs EN epoch interpretation flips, round-trip corruption when re-saved). ISO is the universal answer. Power users who want a sortable native-date column can derive it once in their workbook but the canonical export stays unambiguous.
### Q5 — `paliadin_turns` in org export: opt-in only (pick), or include by default?
**Inventor pick: opt-in via `?include=paliadin_turns` query.**
Today it's m-only data (`PaliadinOwnerEmail` gate), so the privacy stakes are low but the *moment* Paliadin opens beyond owner-only, the AI conversation history per user is the most sensitive personal data we carry. Setting the off-by-default precedent now means we don't accidentally start dumping it later.
### Q6 — Deterministic byte-for-byte exports: yes (pick) or accept timestamp drift in zip metadata?
**Inventor pick: yes, deterministic.**
Lets users diff exports across time. Cost: ~50 lines of `sort.Strings` + a custom zip writer with stable ordering. Worth it.
### Q7 — Invitation tokens in org export: drop them entirely (pick) or include as hash?
**Inventor pick: drop entirely.**
Tokens grant signup access. Including them in a backup creates a vulnerability surface an exfiltrated backup could be used to sign up as someone-else with their pending invite. Hashing doesn't help because the hash is what the URL contains. The invitation **row** (recipient, role, expiry, sent_at) is in the export; the token is not. If you need to re-issue, you do so from paliad's invite UI.
### Q8 — Public signed-URL downloads (for scheduled/email delivery): yes / not in v1 (pick)?
**Inventor pick: not in v1.**
Defer to slice 4. v1's download is cookie-authenticated only. Signed URLs are useful when the recipient is asynchronously notified (email link), which is the scheduled-export model and that whole subsystem ships later.
### Q9 — GDPR Art. 15 DSR helper UI: not in v1 (pick)?
**Inventor pick: not in v1.**
A global_admin can already assemble a DSR manually using project-scope exports filtered by client. v1 ships the primitives; v2 ships the workflow.
### Closing question for m: implementer
> Recommend pattern-fluent Sonnet for all three slices, same hands across (continuity matters for the writer abstraction). Specific name = your call.
---
## 12. m's decisions (addendum, 2026-05-19)
m walked the §11 questions live via AskUserQuestion. Results below these supersede the inventor picks where they differ.
- **Q1 Bundle format:** Bundle xlsx + JSON + CSV in one `.zip` per export. matches pick.
- **Q2 Project-scope floor:** **Any team member** (`responsibility ∈ {lead, member}`). **Deviation** from associate-floor pick m chose the looser axis-split gate. **Implementation update for §4:** project-scope auth becomes `(a) can_see_project(root_id) AND (b) caller is on project_teams for the root with responsibility ∈ {lead, member}`. The DerivationService profession check is dropped from the export gate; observers + externals + derived-only members still cannot extract. `system_audit_log.metadata` records the responsibility value the caller held at export time.
- **Q3 Org-export retention:** **90 days**. **Deviation** from 7-day pick. **Implementation update for §6.2:** `PALIAD_EXPORT_RETENTION_DAYS` default flips from `7` to `90`. The cleanup goroutine still runs daily; the threshold is just longer. Audit row unaffected (still persists forever).
- **Q4 Date format:** ISO 8601 strings only. matches pick.
- **Q5 paliadin_turns in org export:** **Never include in org export.** **Tighter** than opt-in pick. **Implementation update for §2.1 + §6.3:** the `paliadin_turns` row drops from the org-scope sheet table entirely no `?include=paliadin_turns` query param. Personal scope still carries the caller's own paliadin_turns (it's literally their data). The hard exclusion is enforced in `export_service.go`'s scope-aware sheet registry, not just in column-discovery, so a future schema addition can't accidentally re-include it.
- **Q6 Deterministic exports:** Yes. matches pick. (m answered freeform "1" alongside the batching request first option = deterministic.)
- **Q7 Invitation tokens:** Drop entirely. matches pick.
- **Q8 Signed URLs in v1:** Not in v1. matches pick.
- **Q9 GDPR DSR helper UI in v1:** Not in v1. matches pick.
**Net effect on slice plan:** unchanged shape, three modifications:
- Slice 2 gate logic uses `project_teams.responsibility` only (no profession lookup).
- Slice 3 default retention is 90 days (one env-var value change).
- Slice 1 + 3 sheet registry omits `paliadin_turns` from org scope entirely.
No other slice deltas. v1 still ships slices 1+2+3.
**Coder shift gating:** head still gates the implementation handoff; m's decisions here close §11 but don't auto-trigger coder work.
---
## 13. Adjacent / out-of-scope
- **Import path** explicitly out per brief. A round-trip "export then re-import" is appealing but is its own design (rebinding UUIDs, conflict resolution, schema_version migrations). Don't conflate.
- **Postgres replacement** the Excel workbook is a *backup* + *portability artifact*, not a data-model alternative. Postgres stays canonical.
- **t-paliad-212 (leibniz, CalDAV multi-calendar):** personal export already carries the caller's caldav config (minus ciphertext). When leibniz designs multi-calendar, the personal export's `my_caldav_config` sheet becomes a list rather than a single row handled by column-discovery automatically. No design conflict; flagged for confirmation when leibniz's design lands.
- **t-paliad-213 (mendel, test strategy):** export service warrants pure-function tests for column discovery, deny-list, scope predicate, plus one e2e (Playwright) per scope endpoint. Slice tests pin the contract; mendel's overall strategy decides framework choice.
---
## 14. References
- `docs/design-data-model-v2.md` projects + mandanten + ltree path + can_see_project predicate.
- `docs/design-approval-policy-ui-2026-05-07.md` 5-source audit union (this design adds the 6th source).
- `docs/design-profession-vs-project-role-2026-05-07.md` profession ladder for the §4 project gate.
- `internal/handlers/admin_rules.go:303` `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
- `internal/services/project_service.go:15` visibility predicate.
- `internal/services/derivation_service.go` `EffectiveProjectRole` for the project gate.
- `github.com/xuri/excelize/v2` chosen xlsx library.
---
**END OF DESIGN. Status: READY FOR REVIEW.**
Inventor parks until m's go/no-go on §11. No code touches the tree from this branch.

View File

@@ -0,0 +1,582 @@
# Design — Paliad Test Strategy (production-grade)
**Author:** mendel (inventor)
**Date:** 2026-05-19
**Task:** t-paliad-213
**Branch:** `mai/mendel/inventor-test-strategy`
**Status:** DESIGN READY FOR REVIEW. No test files / Make targets / CI configs touched. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift.
---
## 0. TL;DR
Paliad has accidental test discipline today: 59 `_test.go` files / 323 test functions in Go (≈45 % of services tested, ≈12 % of handlers tested) and 4 frontend test files for 90+ client modules (≈4 %). There is no committed end-to-end suite and no CI — every smoke pass is human-driven via the manual reports in `tests/`. The `mig 098` prod crash-loop, the `t-paliad-036` triple-bug after the German→English rename, and a long tail of UX regressions (deadline-done modal, calendar column drift) would all have been caught by a 10-test boot-and-click smoke pass.
This design proposes a six-layer test pyramid with a concrete tool per layer (stdlib `testing` + bun's built-in `bun:test` + `playwright` for E2E — nothing third-party we don't already use). It pins three lessons paliad has paid for in commits:
1. **No mocks at the service↔DB boundary.** Live-DB tests against a per-developer Postgres are the floor; in-memory mocks for `paliad.*` would have hidden every rename-after-DROP-CASCADE bug. Project preference is already in this direction (27/44 service tests are live-DB-gated); we double down rather than reverse.
2. **Migrations must dry-run before they merge.** Every recent prod-down (mig 098, mig 020-after-rename, mig 099 audit_reason gap) was a migration that compiled, passed `go test ./...` (which skips without `TEST_DATABASE_URL`), and broke on first apply against the real schema. A `make verify-migrations` target that does BEGIN/apply/ROLLBACK in CI fixes the entire failure mode.
3. **Browser-shaped bugs need a browser.** The fristenrechner cascade, shape-timeline render, calendar grid, inline paliadin widget — these are JS state machines. Bun's stdlib `bun:test` covers the pure parser/codec code; Playwright covers the auth-gated DOM. Don't try to substitute one for the other.
Six slices roll the strategy out as tracer-bullet PRs, each independently shippable. Slice 1 (migration dry-run harness) and Slice 4 (Playwright golden-path smoke) buy the most outage-prevention per LoC; the rest is widening proven patterns.
Six open questions for m at §6. Most surface a coverage-vs-cost trade-off — the picks that need m's call before any code lands are CI infrastructure choice (Q2), per-PR run-time budget (Q1), and live-DB-vs-dockerised Postgres (Q3).
---
## 1. Audit — what exists today
Counts taken on `mai/mendel/inventor-test-strategy` @ HEAD (2026-05-19, 100 migrations applied).
### 1.1 Go test inventory
| Package | Source files | Test files | Test functions | Notes |
|---|---|---|---|---|
| `internal/services` | 56 | 44 | ~200 | 26 live-DB-gated (`TEST_DATABASE_URL`), 18 pure-Go. 24 services have **no test file at all** — see §1.4. |
| `internal/handlers` | 59 | 7 | ~30 | Only auth-domain check, search, audit-parse, approval-error-mapping, redirects, verfahrensablauf-redirect, chart-404 covered. **53 handlers have no test file.** |
| `internal/auth` | small | 2 | ~10 | Session middleware + require-admin. |
| `internal/branding` | small | 1 | small | Firm-name override. |
| `internal/offices` | small | 1 | small | Office enum. |
| `internal/changelog` | small | 1 | small | Pure parser. |
| `internal/calc` | small | 1 | small | Fees / fee tables. |
| `cmd/server` | 1 | 1 | small | `main_paliadin_backend_test.go` covers env-gate selection. |
| **Total** | **133** | **58** | **323** | |
`go test ./...` runs all 58 files. Without `TEST_DATABASE_URL` set, 27 of them silently skip their live-DB cases — the suite still passes, but coverage of mutation paths drops to near zero.
### 1.2 Frontend test inventory
| Path | Test files | Tested |
|---|---|---|
| `frontend/src/client/filter-bar/url-codec.test.ts` | 1 | FilterBar URL codec round-trip. |
| `frontend/src/client/views/format.test.ts` | 1 | Date/time formatters (regression for t-paliad-153). |
| `frontend/src/client/views/shape-timeline-chart.test.ts` | 1 | Chart layout pure function. |
| `frontend/src/client/views/shape-timeline-cv.test.ts` | 1 | Continuous-view shape layout. |
| **Total** | **4** | Out of ~90 client modules (`frontend/src/client/*.ts`). |
All four use bun's built-in `bun:test` (no extra dep). No DOM/jsdom tests. No Playwright. No `bun test` script in `package.json` (`bun run build` is the only script).
### 1.3 End-to-end / smoke
- `tests/smoke-2026-04-25.md`, `tests/smoke-auth-2026-04-25.md`, `tests/smoke-auth-2026-04-26-cleanup.md` — human-written reports with screenshots committed under `tests/screenshots-*`. No code. No re-runnable script.
- `mai-tester` skill uses Playwright for ad-hoc runs; nothing committed.
- No `e2e/`, no `.gitea/workflows/`, no `.github/workflows/`, no `Makefile`.
### 1.4 Critical service paths with no test file
These are `internal/services/*.go` for which no `*_test.go` sibling exists:
| Service | Risk class | Why it matters |
|---|---|---|
| `caldav_service.go`, `caldav_client.go`, `caldav_crypto.go`, `caldav_ical.go` | High | Per-user push/pull goroutines + AES-GCM at rest. One pure parser test (`caldav_ical_timeline_test.go`) exists but the service + crypto + WebDAV client are blind. |
| `agenda_service.go` | High | Dashboard agenda query; reused by `/agenda` page. Exercised transitively by visibility tests but no direct test. |
| `dashboard_service.go` | High | Traffic-light + summary counts. Same story — transitively covered via visibility, no direct test. |
| `derivation_service.go` | Medium | Project-tree derivation (the new t-paliad-194-era subtree machinery). |
| `team_service.go` | Medium | Team membership / inheritance. |
| `partner_unit_service.go` | Medium | Dezernat replacement (t-paliad-070). |
| `party_service.go`, `note_service.go`, `link_service.go`, `checklist_instance_service.go` | Medium | All do project-scoped CRUD with the same RLS+audit pattern that `t-paliad-036` proved easy to break. |
| `appointment_service.go` | High | Hot — every calendar mutation. Exercised through approval tests but has no own test file. |
| `view_service.go` | Medium | Powers the substrate (`/views/*`). |
| `paliadin_jwt.go` | Medium | Per-turn JWT mint for the aichat path (`t-paliad-194`). No call sites in tests today. |
| `markdown.go` | Low | Glossary + checklist content render. |
### 1.5 Handlers with no test file
53 of 59. Notably: **`auth.go` itself** (login / logout / session creation), **`projects.go`** (the most-mutated entity), **`deadlines.go` / `appointments.go`** (writes), **`paliadin.go` / `paliadin_suggest.go`** (m-only routes — never click-tested), **`fristenrechner.go` / `fristenrechner_search.go` / `fristenrechner_event_categories.go`** (the cascade users live in), **`dashboard.go` / `agenda.go`** (landing), **`onboarding.go` / `onboarding_gate.go`** (every new user's first three minutes), **`invite.go`** (rate-limited write path). The currently-tested handlers (search, audit-parse, approval error mapping, etc.) are the cheap pure-Go ones; every handler that touches the DB is untested at handler level.
### 1.6 Live-DB test scaffold — is it sound?
The pattern (read from `internal/services/visibility_test.go`):
```go
url := os.Getenv("TEST_DATABASE_URL")
if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") }
if err := db.ApplyMigrations(url); err != nil { t.Fatalf(...) }
pool, _ := sqlx.Connect("postgres", url)
defer pool.Close()
// per-test seed + cleanup via DELETE + defer cleanup()
```
Verdict: **sound, but has rough edges that need addressing before we widen.**
- ✅ Migrations apply at test startup against the test DB — catches every "you forgot to add a CHECK" / "you reference a column that doesn't exist" before a real-DB-touching test runs.
- ✅ Per-test cleanup via `DELETE FROM ... WHERE id IN ($1,...)` is explicit and idempotent.
- ✅ The `paliad.paliad_schema_migrations` tracker collision noted in memory `0b900afa…` is a pre-existing issue, not introduced by this design.
- ⚠️ Cleanup-via-DELETE is fragile: a test that creates a row referenced by FK from another table needs to remember to clean both. A few existing tests (see `audit_service_test.go`) already chain 5+ DELETEs.
- ⚠️ Tests can't run in parallel against the same `TEST_DATABASE_URL` because they share schema state. `go test ./...` defaults to `-parallel` per-package; same-package tests with overlapping cleanup IDs can interfere.
- ⚠️ No CI today actually exercises `TEST_DATABASE_URL` — so every live-DB test is effectively run only on the author's laptop or not at all. Half the value is paid-for but unbilled.
### 1.7 Migration tooling
- `internal/db/migrate.go` embeds `migrations/*.sql` and applies on server boot via `golang-migrate/v4` with the `paliad_schema_migrations` tracker in `public` schema.
- 100 migrations on disk (`001``100`).
- **No dry-run gate today.** A bad migration breaks `paliad.de` at boot (Dokploy crash-loops the container). Recent prod incidents: mig 098 (submission code rename), mig 099 (with_po flag drop missed audit_reason gap), mig 020 (function rename without body rewrite — see memory `49a05cfa…`).
- `down.sql` exists for every migration but no test ever exercises it.
### 1.8 CI / deploy loop
- No CI. Push-to-main → Gitea webhook → Dokploy auto-builds the Dockerfile and replaces the container. The Dockerfile runs `bun run build` then `go build`. **Neither `go test` nor `bun test` runs in the build pipeline.**
- Pre-commit hooks: none in repo. Each worker runs `go build / go vet / go test / bun run build` by convention (see memories — every shipped task report ends with "build hygiene held").
---
## 2. Test pyramid — recommended shape
```
┌─────────────────┐
│ E2E (Playwright)│ ~10 flows
│ L6 │
└─────────────────┘
┌─────────────────────────┐
│ Handler integration │ ~30 routes
│ L5 (httptest + real DB)│
└─────────────────────────┘
┌──────────────────────────────────┐
│ Service-layer (live DB) │ ~60 tests
│ L4 (BEGIN/ROLLBACK harness) │
└──────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Frontend DOM / cascade (bun:test+jsdom) │ ~15 modules
│ L3 │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Frontend unit (bun:test pure TS) │ ~30 modules
│ L2 │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Go unit (stdlib testing, table-driven, pure functions) │ ~150 tests
│ L1 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Migration dry-run (make verify-migrations) │ 100 mig
│ L0 — gate on every PR │
└──────────────────────────────────────────────────────────────┘
```
### Layer 0 — Migration dry-run
**What:** Every `*.up.sql` in `internal/db/migrations/` is applied inside a single `BEGIN ... ROLLBACK` transaction against a scratch Postgres, in numeric order. The harness asserts each statement succeeds *and* asserts no statement leaves the schema in a `paliad_schema_migrations.dirty=true` state. A second pass applies all up-migrations end-to-end (no rollback) and then re-applies the latest up-migration to assert idempotency (every paliad migration since `t-paliad-070` has been written to be idempotent — this enforces it).
**Tool:** stdlib `testing` package, no third-party. Pattern: `internal/db/migrate_test.go` with a `TestMigrations_DryRun` driven from `TEST_DATABASE_URL`. A `make verify-migrations` target wraps it.
**Why this layer matters most:** Every recent prod-down was a migration. Catching them on a CI run before merge is the highest-leverage test investment paliad can make. Cost: one ~100-line Go file + one Postgres in CI.
**Coverage target:** 100 % of `*.up.sql` files. Hard gate on PR — no exceptions.
### Layer 1 — Go unit (pure)
**What:** `go test ./...` against pure functions — formatters, parsers, validators, calculators, fee tables, deadline calculators, projection lookahead clamping, codec round-trips. No DB, no HTTP.
**Tool:** stdlib `testing`. Table-driven `cases := []struct{...}{...}` style is already the house pattern (see `auth_test.go` / `projection_anchor_test.go`). **Do not introduce testify or any matcher library** — the current code reads cleanly without one, and 323 existing test functions don't need a rename pass.
**What's already there:** 19 pure-Go test files (calculator, mapping, codec, holiday, fees, etc.). Density is good; targeted infill rather than re-architecture.
**Coverage target:** Every pure function in `internal/services/`, `internal/handlers/`, `internal/calc/`, `internal/changelog/`. Aim for "every branch in a decision table has at least one test row." Don't chase % — chase "the obvious edge that would burn a coworker".
### Layer 2 — Frontend unit (pure)
**What:** `bun test` against pure TS modules — URL codecs (`filter-bar/url-codec`), formatters, parsers, i18n key correctness (every `data-i18n` attribute used in TSX has a key in `i18n.ts`), view-spec parsers, projection-row mapping helpers.
**Tool:** `bun:test` (built into bun, no install). Already in use in 4 files — extend the same pattern. Add `bun test` to `package.json` `scripts`.
**What to add:**
- i18n key audit (every `t("foo.bar")` and `data-i18n="foo.bar"` resolves in both `de` and `en`).
- `filter-bar/` types + render helpers (paliad has shipped 4 FilterBar slices; coverage is one codec test).
- `paliadin-context.ts` route table + entity extraction (the `[ctx …]` envelope is a stable contract paliadin's SKILL.md depends on; any drift here is a silent failure).
- `paliadin-starters.ts` registry — every route maps to ≥1 starter; every starter is bilingual.
- View-spec parsers in `views/`.
**Coverage target:** Every pure TS module in `frontend/src/client/`. Pages (TSX renderers) are E2E concern, not unit concern.
### Layer 3 — Frontend DOM (cascade / jsdom)
**What:** `bun test` with jsdom global, exercising the interactive cascade modules — the fristenrechner cascade builder, the shape-timeline render, the FilterBar UI (chips, panels), the calendar grid, the inline Paliadin widget message stream, the inbox-row click handler, the dashboard activity item navigation.
These modules contain enough state that pure-function tests miss real bugs (e.g. the t-paliad-098 `.entity-table` row-cursor lie was a CSS+DOM bug; t-paliad-099's modal close was a DOM-event bug; t-paliad-103's `::before` overlay click-swallow was a DOM bug).
**Tool:** bun + `happy-dom` is the lighter choice; if it can't handle event ordering, fall back to `jsdom`. Both are ESM-clean and bun-friendly. **Pick one and stick with it — running both means twice the dependency surface.** Default pick: `happy-dom` (smaller, paliad doesn't need legacy IE semantics).
**Pattern:** import the cascade module, build a minimal DOM (`document.body.innerHTML = …`), dispatch synthetic events, assert resulting state. Reuses the production renderers — no test-only fakes.
**Coverage target:** ~15 modules. Specifically:
- `client/filter-bar/index.ts` chip render + active-state.
- `client/fristenrechner.ts` cascade — most complex JS in the codebase; depend chains light up every UPC bug we know.
- `client/shape-timeline.ts` lane mode + track mode (envelope wire shape brittle to refactor).
- `client/projects-detail.ts` row click + Verlauf render.
- `client/paliadin-widget.ts` + `paliadin-context.ts` interaction.
- `client/inbox.ts` row-action click routing.
- `client/dashboard.ts` activity-item nav.
- `client/deadlines-calendar.ts` / `appointments-calendar.ts` column layout (the calendar-column-drift bug class).
Not unit tests; not E2E. They are the missing middle.
### Layer 4 — Service-layer (live DB)
**What:** Go service methods against a real Postgres, using the existing `TEST_DATABASE_URL` pattern. Two improvements:
1. **Replace per-test DELETE cleanup with a per-test transaction harness** — open a transaction, run the test inside it, ROLLBACK. Faster, isolating, no cleanup forgotten. Already viable because the service layer accepts `*sqlx.DB`-or-tx-shaped interfaces in many places; needs a small `internal/services/internal/testdb` package that exposes `WithTx(t *testing.T, fn func(*sqlx.Tx))`. Migration is mechanical, can happen alongside infill.
*Caveat:* some service methods open their own transactions internally (`approval_service.submit` is one). Those keep DELETE cleanup; the tx harness is a default, not a mandate.
2. **Make `TEST_DATABASE_URL` mandatory in CI.** Today these tests are skipped on every machine that doesn't `export TEST_DATABASE_URL=…` — i.e. they don't run on autoatic pipelines because there's no pipeline. Once CI exists (§3.5), it becomes a required env var.
**Tool:** stdlib `testing` + `sqlx` (already in `go.mod`). **No mocks at the service↔DB boundary.** This is m's hardest line — see global CLAUDE.md memory pattern and `t-paliad-036` (the bug that masked two other bugs would have been caught instantly by a real-DB test).
**Where to invest first:** Approval (already heavy), Projection (already heavy), Fristenrechner (already heavy), DeadlineService Create/Update/Complete/Delete with `pending_request_id` interplay, AppointmentService same, ProjectService visibility predicate, CalDAV push (the four CalDAV `*.go` files have zero direct test).
**Coverage target:** Every service method that mutates the DB has at least one happy-path live-DB test. RLS predicate (`visibilityPredicatePositional`) has one test per role (global_admin, member, non-member).
### Layer 5 — Handler integration (httptest + real DB)
**What:** Spin a real `services.DBService`, mount the protected mux, drive `httptest.NewRequest` + `ServeHTTP` against it. Auth via a fake session cookie produced by a `testauth.Login(t, userID)` helper that mints the same Supabase JWT shape `auth.UserIDFromContext` expects.
**Why:** The 53 untested handlers are where the request shape ↔ service interaction lives. Examples that would have caught real bugs:
- `t-paliad-036`'s "`/projects/{id}` 404 while `/api/projects/{id}` 200" mismatch — a 5-line handler test would have failed before the migration ran.
- mig 020's three-stacked bug — a handler test that POSTs a deadline and asserts a 200 + read-back row would have failed at submit-time, not boot-time.
- The audit-log query timezone bug — handler test asserts the JSON contains the expected `event_date`.
**Tool:** stdlib `net/http/httptest`. **No new framework.** Pattern: handler tests live next to the handler file (`internal/handlers/deadlines_test.go` next to `deadlines.go`).
**Coverage target:** Every handler that gates a state-changing route — `POST/PATCH/DELETE` flavour. Plus `GET` handlers that compose a non-trivial query (dashboard, agenda, search, audit-log).
### Layer 6 — End-to-end (Playwright)
**What:** A small Playwright suite (~10 flows) committed at `e2e/` with a `bun run e2e` entry. Targets a local `./paliad` against a scratch Postgres (the same `TEST_DATABASE_URL`). Each test logs in, drives the UI through one user journey, asserts visible state.
**Why ~10 not 100:** Per-PR budget caps at ~2 min total (§6 Q1). Playwright tests are the most expensive minute-per-confidence in this stack; they pay for themselves on the *golden path* and nothing else. The deep-coverage layer is L5; E2E is *"is the app still alive end to end?"*.
**Tool:** `playwright` (npm; bun installs cleanly). No third-party test runner — Playwright ships its own. Tests live in `e2e/*.spec.ts`. **Not bun:test.** Playwright's runner is purpose-built for browser-driving and integrates with their tracing — don't fight it.
**Cap:** 10 flows. If a new test wants in, an existing one must drop out (or we have a real reason to widen). This is the cheapest discipline available: it forces the suite to remain a smoke pass, not a regression-test dumping ground.
**Coverage target:** See §4.
---
## 3. Tooling — concrete picks per layer
| Layer | Tool | Already in deps? | Install? |
|---|---|---|---|
| L0 — migration dry-run | stdlib `testing` + `migrate/v4` | yes | no |
| L1 — Go unit | stdlib `testing` | yes | no |
| L2 — Frontend unit | `bun:test` | yes (built into bun) | no |
| L3 — Frontend DOM | `bun:test` + `happy-dom` | bun yes, happy-dom **new** | `bun add -d happy-dom` (one dep, ~200 KB) |
| L4 — Service live-DB | stdlib + sqlx | yes | no |
| L5 — Handler integration | stdlib `net/http/httptest` + sqlx | yes | no |
| L6 — E2E | `@playwright/test` | **new** | `bun add -d @playwright/test` + `npx playwright install chromium` |
Net new deps: **2** (happy-dom + playwright). Both are mainstream, both have small surface area, both align with bun's ecosystem.
Explicit rejects:
-**testify** — current tests read cleanly with stdlib; adding it forces a rename pass nobody wants.
-**vitest** — bun's built-in test runner is faster and the tests are already in `bun:test` shape.
-**dockertest / testcontainers-go** — m's preference is real-DB tests against the existing Postgres; spinning ephemeral Docker Postgres per package run adds latency and surface area for marginal isolation gain. See Q3.
-**sqlmock / gomock for DB** — banned by §0 lesson 1.
-**cypress** — Playwright is the better tool today, and the team's existing skill (`/mai-tester`) already uses it.
### 3.1 Per-PR run-time budget
Target (subject to m's call in Q1): **≤ 90 s for the gating tier (L0+L1+L2+L4 subset+L5 happy-path)**, ≤ 4 min for the full suite (add L3+L4 full+L6). The gating tier blocks merge; the full suite blocks deploy.
Indicative times (estimated, validate when slice 1 lands):
| Tier | Layers | Est. time | Blocks |
|---|---|---|---|
| **Gate (every PR)** | L0 + L1 + L2 + L5 happy-path + L4 critical | 6090 s | merge |
| **Full (every merge to main)** | + L4 full + L3 + L6 | 34 min | deploy |
### 3.2 CI — proposal, not commitment
paliad has no CI today. Two routes:
- **Gitea Actions** (m's stack already runs `mgit.msbls.de`). Self-hosted; same auth model as the rest of mAi. Adds a `.gitea/workflows/test.yml`. Postgres comes from a service container.
- **Stay click-deploy.** No CI. Workers run tests locally; Dokploy auto-deploys on green-main convention.
Recommendation: **Gitea Actions for the gate tier only** (L0 + L1 + L2), driven by a single short workflow. The L3-L6 expansion can be a follow-up once the gate tier proves stable. Deferred to Q2 for m's call.
### 3.3 Test DB — live YouPC vs ephemeral
The `paliad` schema lives on the shared YouPC Postgres (port 11833). Three options:
| Option | Pros | Cons |
|---|---|---|
| **Per-developer separate DB on YouPC** (`TEST_DATABASE_URL` per laptop) | Closest to prod; existing pattern. | Cleanup discipline matters; cross-developer contention possible. |
| **Ephemeral docker postgres per CI run** | Full isolation; parallel-safe; reset for free. | New infra; ~5 s container startup per CI invocation. |
| **Dedicated test DB on a paliad-only Postgres** | Isolated; cheap. | New infra to maintain. |
Recommendation: **option 1 for developers (no-op change), option 2 for CI** (Gitea Actions postgres service container). Deferred to Q3 for m's call.
### 3.4 Coverage targets
Don't gate on percentage. Gate on critical-path coverage (§4). Add `go test -coverprofile=` output to CI for visibility, not as a merge gate. Coverage % gating produces tests-for-tests'-sake; we want the tests that catch the bugs we've shipped.
---
## 4. Critical journeys — what MUST be covered
These are the golden-path flows. Anything not on this list is L1-L5 territory, not L6. The list is intentionally short; if it grows beyond 10, we are doing E2E wrong.
| # | Flow | Why it's critical | Layer mix |
|---|---|---|---|
| 1 | **Login → dashboard renders → traffic-light counts match** | Every user does this every day; broken auth = paliad is offline. | L6 (Playwright) + L5 handler (auth.go) |
| 2 | **Create project (Client → Litigation → Patent → Case)** | Hierarchy with team inheritance — the data model's spine. | L6 + L5 + L4 (project_service) |
| 3 | **Submit deadline → routes to /inbox → approver approves → state flips** | The 4-eye flow (t-paliad-138). Most-mutated paliad surface. | L6 + L5 (deadlines, approvals) + L4 (approval_service) |
| 4 | **Fristenrechner: pick proceeding → cascade fires → result shows** | The platform's flagship interactive tool. JS cascade. | L6 + L3 (fristenrechner cascade) + L4 (fristenrechner) |
| 5 | **SmartTimeline: anchor a projected row → predecessor-missing-error handled** | Recent Slice-2 work (t-paliad-173 / #31). High-touch surface. | L6 + L3 (shape-timeline) + L4 (projection_service) |
| 6 | **CalDAV sync: PUT a Termin → external client sees it, edits there → pull reconciles** | Owned-event semantics + foreign-UID skip rule from Phase F. Untested today. | L4 (caldav_service push/pull) — gated on Q3 (live YouPC vs ephemeral) |
| 7 | **Paliadin chat: anon visit hits 404; m's session opens widget; turn renders** | Owner-gated `/paliadin` is the only m-only surface. Quiet failures here are silent. | L6 (smoke) + L5 (paliadin_suggest) + L4 (paliadin / aichat_paliadin) |
| 8 | **/admin/rules: filter → edit one rule → lifecycle transition → audit log row** | Rules drive the cascade; bad edits break every user's fristenrechner. | L6 + L5 (admin_rules) + L4 (rule_editor_service) |
| 9 | **Onboarding: new user with allowed email → onboarding form → first project membership** | The new-user funnel; gateOnboarded middleware traps. | L6 + L5 (onboarding, invite) |
| 10 | **Migration boot smoke: spin paliad against an empty DB → server binds 8080** | Catches every mig-N crash-loop. | L0 (migration dry-run) + L4 boot-smoke variant |
Picks 1, 3, 4 and 10 are the highest-value-per-cost — they cover the routes most regressions land on (auth, mutation, cascade, boot).
---
## 5. Slice plan — tracer-bullet roll-out
Each slice is a shippable PR with a concrete deliverable, in order of expected outage-prevention payoff. Sized for a single coder shift unless flagged. No slice depends on a later one being merged. Hour estimates intentionally omitted (per global CLAUDE.md).
### Slice 1 — Migration dry-run harness + boot smoke (highest leverage)
**Branch:** `mai/<coder>/test-strategy-slice-1-migrations`
**Deliverable:**
- `internal/db/migrate_test.go``TestMigrations_DryRun` (per-mig BEGIN/ROLLBACK), `TestMigrations_EndToEnd` (full apply, then re-apply latest to assert idempotency), `TestMigrations_Down` (apply N→0).
- `Makefile` with `make verify-migrations` (the gate target), `make test` (run everything), `make test-go`, `make test-frontend`.
- `cmd/server/main_paliadin_backend_test.go` already exists; extend with a `TestMain_BindsHTTPAfterMigrate` that boots the full server against `TEST_DATABASE_URL`, asserts `:8080` is listening, then shuts down. Catches the mig-098-class crash-loop in a single test.
- README section: how to set `TEST_DATABASE_URL` locally.
**Catches:** Every mig-98-class crash-loop; every drop-cascade-with-stale-policy-name regression (t-paliad-036).
### Slice 2 — Service-layer infill: critical mutators
**Branch:** `mai/<coder>/test-strategy-slice-2-services`
**Deliverable:**
- Test files for the three highest-impact untested services:
- `internal/services/agenda_service_test.go` (live-DB, dashboard agenda query)
- `internal/services/dashboard_service_test.go` (traffic-light counts)
- `internal/services/team_service_test.go` (membership + inheritance — RLS-load-bearing)
- Tighten existing `approval_service_test.go` + `deadline_service_test.go` coverage of the create/update/complete/delete × pending-request matrix where there are demonstrable gaps.
- Add `internal/services/internal/testdb/withtx.go` — the per-test tx harness (optional adoption; existing tests stay).
**Catches:** RLS regressions, approval interplay regressions, dashboard count drift after schema renames.
### Slice 3 — Frontend bun:test setup + L2 infill
**Branch:** `mai/<coder>/test-strategy-slice-3-frontend-unit`
**Deliverable:**
- `frontend/package.json` `scripts.test = "bun test"`.
- New tests under `frontend/src/client/`:
- `paliadin-context.test.ts` (route table, entity extraction, selection truncation).
- `paliadin-starters.test.ts` (every route ≥1 starter, every starter bilingual).
- `filter-bar/index.test.ts` (chip render + active state — pure DOM-less helpers).
- i18n key audit: `frontend/scripts/i18n-audit.test.ts` parses every `data-i18n="…"` from `dist/` HTML and every `t("…")` call from `src/`, asserts both `de` and `en` resolve. Runs as part of `bun test`.
- `make test-frontend` wires `cd frontend && bun test`.
**Catches:** i18n drift (untranslated key shipped to user), context-envelope contract drift (paliadin SKILL.md depends on it), starter-registry regressions.
### Slice 4 — Playwright golden-path smoke
**Branch:** `mai/<coder>/test-strategy-slice-4-e2e`
**Deliverable:**
- `e2e/` directory at repo root.
- `playwright.config.ts` pointing at `http://localhost:8080` (paliad started by the test, not assumed).
- Five Playwright `*.spec.ts` files covering critical journeys 1, 3, 4, 7, 9 from §4.
- `make e2e` target that:
1. starts paliad against `TEST_DATABASE_URL`,
2. waits for `:8080` to be live,
3. runs `npx playwright test`,
4. tears the server down.
- `bun add -d @playwright/test` + `npx playwright install chromium`.
**Catches:** Auth regressions, deadline-mutation regressions, fristenrechner cascade regressions, owner-gated /paliadin leaks, onboarding-gate misbehaviour.
### Slice 5 — Handler integration tests for the 5 most-touched routes
**Branch:** `mai/<coder>/test-strategy-slice-5-handlers`
**Deliverable:**
- `internal/handlers/auth_test.go` extended with `TestLogin_HappyPath` + `TestLogout_ClearsCookie` (real DB).
- `internal/handlers/projects_test.go``TestProjectsCreate` (POST 200, row inserted, audit emitted), `TestProjectsGetByID_RespectsVisibility` (404 for non-member).
- `internal/handlers/deadlines_test.go``TestDeadlinesCreate_TriggersApproval` (verifies pending pill).
- `internal/handlers/appointments_test.go` — same shape.
- `internal/handlers/paliadin_test.go``TestPaliadinPage_404ForNonOwner`, `TestPaliadinPage_200ForOwner`.
- Shared `internal/handlers/testauth/testauth.go` — mints a session cookie for `userID` so handler tests don't reinvent auth seeding.
**Catches:** Handler ↔ service wiring drift, visibility-predicate handler-side bugs (t-paliad-036 bug 2 was exactly this), owner-gate bypass.
### Slice 6 — Frontend L3 (DOM) cascade tests
**Branch:** `mai/<coder>/test-strategy-slice-6-frontend-dom`
**Deliverable:**
- `bun add -d happy-dom`.
- DOM-driven tests for the three most-touched cascades:
- `client/fristenrechner.test.ts` (cascade activate → row appears → date-set fires fetch).
- `client/shape-timeline.test.ts` (lane render, track render, projected-row click).
- `client/filter-bar/index.test.ts` (chip click toggles state, URL params update).
**Catches:** The whole class of "the function exists and is unit-tested but the cascade in the browser doesn't fire it" bugs. This is the layer that catches t-paliad-098 / 099 / 102 / 103.
### Slice 7 — CI wiring (deferred — Q2 dependent)
**Branch:** `mai/<coder>/test-strategy-slice-7-ci` (gated on m's Q2 pick)
**Deliverable:**
- `.gitea/workflows/test.yml` (or stay click-deploy if m picks that).
- Gate tier runs on every PR; full suite runs on merge to main.
- Postgres service container provides `TEST_DATABASE_URL`.
- Slack/Gotify ping on red main.
**Catches:** Drift between "tests pass on my laptop" and prod reality.
### Slice 8 — Coverage reporting + dashboard (lowest priority)
**Branch:** `mai/<coder>/test-strategy-slice-8-coverage`
**Deliverable:**
- `go test -coverprofile=` aggregated into a single `coverage.html`.
- Bun's coverage output similarly.
- A `docs/coverage.md` index updated by CI.
- **Not a merge gate.** Visibility only.
**Catches:** Slow drift; nice-to-have once the floor is in.
### Slice order rationale
1, 4, 5 are the highest outage-prevention per LoC: migration dry-run kills crash-loops, E2E kills regressions, handler tests kill wiring drift. 2, 3, 6 widen the floor; 7-8 are infrastructure.
---
## 6. Open questions for m
These need m's call before any coder shift starts (or before specific slices start, where noted).
### Q1 — Per-PR test-run budget
How long is acceptable to wait on the gate tier before merge?
- 30 s — only L0 + L1 (no L2+ on the gate).
- **6090 s (recommended)** — L0 + L1 + L2 + L5 happy-path + L4 critical.
- 2 min — add L3 + L4 full.
- 4+ min — add L6 (E2E on gate).
The pick determines whether E2E gates merge or only deploy.
### Q2 — CI infrastructure
- **Gitea Actions** (self-hosted, gate tier only, recommended) — minimal new infra; aligns with m's existing stack.
- **Stay click-deploy** — workers run tests locally; merge discipline enforced by convention. Today's reality; we keep it.
- **Both:** start with click-deploy, add Gitea Actions in Slice 7 once gate tier proves stable.
### Q3 — Live-DB vs ephemeral docker Postgres for tests
- **Per-developer YouPC DB (current pattern)** — closest to prod; existing tests work unchanged.
- **Ephemeral docker postgres in CI, YouPC for devs (recommended hybrid)** — keeps local-dev simple, gives CI deterministic isolation.
- **YouPC everywhere** — simplest, but parallel CI runs would contend.
### Q4 — Coverage targets — % or critical-path?
- **Critical-path only (recommended)** — §4's 10 flows + every state-mutating service method has a test. No % gate.
- **% gate** — set a floor (e.g. 60 % lines, 50 % branches) and refuse merges below it.
- **Both** — critical-path is mandatory, % is informational.
m's prior preference (memory pattern: "tests that catch real bugs > coverage theatre") points at critical-path-only. Confirming.
### Q5 — Which slices land before paliad is "production-grade"?
paliad is already live at `paliad.de` and being used by HLC colleagues. "Production-grade" here means "next time someone ships, we don't go down."
Picks:
- **Slices 1 + 4 + 5 are the production-grade floor (recommended).** Migration dry-run + golden-path E2E + handler integration tests cover the failure modes that hit prod since the rebrand.
- Add Slice 2 + 3 + 6 as widening passes, on their own cadence.
- Slice 7-8 are nice-to-haves.
Confirming the floor pick — and whether m wants all three to land before any new feature work, or whether they roll out alongside.
### Q6 — Who owns each slice?
Recommendation: rotate coder slots so the same person isn't on every slice. Suggested assignment (head can override):
| Slice | Profile fit |
|---|---|
| 1 — migrations | Backend-heavy coder (knuth, gauss, cronus). |
| 2 — service infill | Backend-heavy coder; whoever owns approval/projection. |
| 3 — frontend unit | Frontend-heavy coder. |
| 4 — Playwright E2E | Cross-stack coder; ideally one familiar with `/mai-tester`. |
| 5 — handler integration | Backend coder. |
| 6 — frontend DOM | Frontend coder (same person as 3 makes sense). |
Inventor does **not** decide assignments; head + m do.
---
## 7. Out of scope (explicit)
- **No rewrite of any existing test.** The 323 existing test functions stay. New tests use the new patterns; old tests are migrated only when their files are touched for unrelated reasons.
- **No third-party framework where stdlib + bun:test suffice** (testify, vitest, etc. — see §3).
- **No mocks at the service↔DB boundary.** This is the lock-in. Mocks lie; the live-DB tests we already have are paliad's most useful safety net.
- **No new feature work in this strategy.** The doc proposes infra; feature scope is unchanged.
- **No retirement of the `tests/smoke-*.md` human-written reports.** Those are great for one-shot regression hunts; they coexist with the automated suite.
---
## 8. Implementation notes for the eventual coder
(For whichever coder picks up a slice. Not exhaustive.)
- **Test-name collisions in Go's flat package namespace bite when a service grows N implementations.** Memory note from `t-paliad-194` already records this. Prefix tests with the service name (e.g. `TestAichatPaliadin_RunTurn_…` not `TestRunTurn_…`).
- **`httptest.NewRequest` does not URL-encode** — use `url.QueryEscape` for any `?q=…` argument. Memory note from `t-paliad-026`.
- **sqlx v1.4.0 `Named` parser strips one colon from `::uuid[]`** — known pitfall, repro lives at `internal/services/project_service.go`. Use `CAST(... AS uuid[])` in new query strings.
- **Live-DB cleanup must DELETE FKs first.** Order matters (auth.users last). Look at `audit_service_test.go` for the chain pattern.
- **`paliad.paliad_schema_migrations` tracker collision** is documented but unresolved. Slice 1 should add a `make reset-test-db` target that drops both `public.paliad_schema_migrations` *and* `paliad.paliad_schema_migrations` to keep developers unblocked.
- **`bun:test` matchers are Jest-compatible** — `expect().toEqual()`, `expect().toHaveBeenCalled()`, etc. No deps needed.
- **happy-dom does not implement** every DOM method (notably some `<dialog>` semantics). If a cascade test fails on something missing, jsdom is the escape hatch.
---
## 9. Decision summary — pick list for m
| # | Question | Inventor recommends |
|---|---|---|
| Q1 | Per-PR budget | 6090 s gate, 34 min full |
| Q2 | CI infra | Gitea Actions, gate tier only |
| Q3 | Test DB | YouPC for devs, ephemeral docker for CI |
| Q4 | Coverage target | Critical-path only, no % gate |
| Q5 | Production-grade floor | Slices 1 + 4 + 5 before new feature work |
| Q6 | Slice ownership | Rotate per profile; head decides |
If m's calls match inventor's, the implementer's brief writes itself: Slice 1 first, then 4 + 5 in parallel, then 2/3/6 as widening passes.
---
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift starts.
---
## 10. m's decisions (2026-05-19, locked)
Walked through §6 with m via the AskUserQuestion interview (per head's 2026-05-19 workflow rule: inventor questions are resolved before parking, not after). Six picks locked, all matching inventor's recommendation.
| # | Question | m's answer | Effect on plan |
|---|---|---|---|
| Q1 | Per-PR test-run budget | **Inventor's call** (m deferred). Pick: **6090 s gate, 34 min full.** | Gate tier = L0 + L1 + L2 + L5 happy-path + L4 critical. L6 E2E gates deploy, not merge. |
| Q2 | CI infrastructure | **Gitea Actions, gate tier only.** | Slice 7 adds `.gitea/workflows/test.yml` running the gate tier; full suite stays on merge-to-main. |
| Q3 | Test DB topology | **YouPC for devs + ephemeral docker for CI.** | Local dev unchanged. Slice 7 wires Postgres service container in Gitea Actions. |
| Q4 | Coverage target | **Critical-path only, no % gate.** | §4's 10 flows + every state-mutating service method gets a test. Coverage % output is informational in Slice 8, never a merge gate. |
| Q5 | Production-grade floor | **Slices 1 + 4 + 5 before new feature work.** | These three land before any new paliad feature gets a coder shift. Slices 2, 3, 6 widen the floor on their own cadence. Slices 7-8 are nice-to-haves. |
| Q6 | Slice ownership | **Head decides + rotate per profile.** | Backend slices (1, 2, 5) → backend-heavy coder. Frontend slices (3, 6) → frontend-heavy coder. E2E (4) → cross-stack. Head picks at dispatch time. |
**Implementer brief (post-m-decisions):**
1. **Slice 1 starts first** — migration dry-run harness + `make verify-migrations` + boot-smoke variant of `cmd/server/main_paliadin_backend_test.go`. Backend-heavy coder.
2. **Slice 4 + Slice 5 in parallel** once Slice 1 is merged — Playwright golden-path (cross-stack coder, 5 specs) and handler integration (backend coder, auth/projects/deadlines/appointments/paliadin).
3. Slice 7 (Gitea Actions wiring) follows once Slice 1 gate tier is proven locally.
4. Slices 2, 3, 6 enter rotation alongside feature work — not blocking.
5. Slice 8 (coverage reporting) lowest priority.
**Status:** DESIGN APPROVED — awaiting head's dispatch of Slice 1 coder shift.

View File

@@ -0,0 +1,172 @@
# Proceeding-code taxonomy (t-paliad-204 ratified 2026-05-18)
> Source of truth for `paliad.proceeding_types.code`. Every active row's
> `code` MUST conform to the convention below. This document anchors
> migration 096 (`internal/db/migrations/096_proceeding_code_rename.up.sql`)
> and the post-migration determinator + fristenrechner mapping in
> `internal/services/proceeding_mapping.go`.
## 0. Why we renamed
The historical `code` strings (`UPC_INF`, `DE_INF`, `EPA_OPP`, …) were
UPPER_SNAKE jurisdiction-glued-to-acronym slugs. They were structurally
opaque and the taxonomy grew unevenly as more proceedings entered the
fristenrechner — `UPC_APP` covers all UPC appeals, `DE_INF_OLG` /
`DE_INF_BGH` carry the instance hint inline, `EP_GRANT` is the only EPA
row with no `EPA_` prefix at all. The mapping in
`internal/services/proceeding_mapping.go` had to special-case appeal
ambiguities (no instance hint on UPC_APP, none on the DE side either).
After mig 095 landed the t-paliad-205 fristen gap-fill, m and paliadin
ratified a uniform convention for the corpus, captured here.
## 0.1 Convention
Active proceeding codes are lowercase, dot-separated, three positions:
<jurisdiction>.<X>.<Y>
* **`<jurisdiction>`** — one of `upc`, `de`, `epa`, `dpma`.
* **`<X>` / `<Y>`** — contextual; for first-instance proceedings they are
`<substantive-type>.<forum>` (e.g. `de.inf.lg` for Verletzungsklage am
Landgericht). For appeals they are `<appeal-type>.<scope>` (e.g.
`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`).
* The CHECK constraint installed by mig 096 enforces
`code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'` on every active row, with a
carve-out for the legacy `_archived_litigation` bucket
(`code ~ '^_archived_'`).
The convention is forward-looking: any new fristenrechner row added
after mig 096 MUST conform — no further UPPER_SNAKE codes.
## 0.2 Ratified taxonomy
### UPC
| New code | Old code | id | Notes |
|--------------------|------------------|----|------------------------------------------------------------------------|
| `upc.inf.cfi` | `UPC_INF` | 8 | Verletzungsverfahren, CFI |
| `upc.rev.cfi` | `UPC_REV` | 9 | Nichtigkeitsverfahren, CFI |
| `upc.ccr.cfi` | _new_ | _new_ | Widerklage auf Nichtigkeit — illustrative peer of `upc.inf.cfi`. Rules live on `upc.inf.cfi` with `with_ccr=true`. See §1 sub-decision S1. |
| `upc.pi.cfi` | `UPC_PI` | 10 | Einstweilige Maßnahmen |
| `upc.dmgs.cfi` | `UPC_DAMAGES` | 17 | Schadensbemessung |
| `upc.disc.cfi` | `UPC_DISCOVERY` | 18 | Bucheinsicht |
| `upc.apl.merits` | `UPC_APP` | 11 | Hauptberufung — covers inf + rev + ccr + damages-merits appeals |
| `upc.apl.order` | `UPC_APP_ORDERS` | 20 | 15-Tage-Beschwerde gegen Anordnungen (R.220 (1)(c)) |
| `upc.apl.cost` | `UPC_COST_APPEAL`| 19 | Kostenbeschwerde |
### DE
| New code | Old code | id | Notes |
|---------------------|------------------------|----|-------------------------------------------------------------|
| `de.inf.lg` | `DE_INF` | 12 | Verletzungsklage am Landgericht |
| `de.inf.olg` | `DE_INF_OLG` | 25 | Berufung am OLG |
| `de.inf.bgh` | `DE_INF_BGH` | 26 | Revision + NZB merged — `with_nzb` flag on NZB-detour rules |
| `de.null.bpatg` | `DE_NULL` | 13 | Nichtigkeitsverfahren am BPatG |
| `de.null.bgh` | `DE_NULL_BGH` | 27 | Nichtigkeitsberufung am BGH |
### EPA
| New code | Old code | id | Notes |
|---------------------|--------------|----|------------------------------------------------|
| `epa.grant.exa` | `EP_GRANT` | 16 | EP-Erteilungsverfahren |
| `epa.opp.opd` | `EPA_OPP` | 14 | Einspruchsverfahren |
| `epa.opp.boa` | `EPA_APP` | 15 | Einspruchsbeschwerde (Board of Appeal) |
### DPMA
| New code | Old code | id | Notes |
|-----------------------|-------------------------|----|----------------------------------------------------------------|
| `dpma.opp.dpma` | `DPMA_OPP` | 28 | Einspruch beim DPMA |
| `dpma.appeal.bpatg` | `DPMA_BPATG_BESCHWERDE` | 29 | Beschwerde am BPatG (generic — source differentiated at rule level) |
| `dpma.appeal.bgh` | `DPMA_BGH_RB` | 30 | Rechtsbeschwerde am BGH (generic — source differentiated at rule level) |
### Archived
| Code | id | Notes |
|-------------------------|----|----------------------------------------|
| `_archived_litigation` | 32 | Unchanged — Pipeline-A retired corpus |
IDs are stable. Only the `code` STRING changes. The FKs
`deadline_rules.proceeding_type_id`, `projects.proceeding_type_id`, and
`deadline_rules.spawn_proceeding_type_id` reference IDs, so the existing
rule corpus and spawn wiring (incl. mig 095's `spawn_proceeding_type_id=11`)
continue to work unchanged.
## 0.3 Sub-decisions (m's calls, 2026-05-18)
### S1 — `upc.ccr.cfi` visibility
`is_active=true`, visible in the determinator + dropdowns. **No rules
attached.** When the determinator surfaces it, the UI shows the hint:
> "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
> weiter."
Routing logic lands in `internal/services/proceeding_mapping.go` — when
the cascade resolves to `upc.ccr.cfi`, the mapping returns the
`upc.inf.cfi` id (=8) with `with_ccr=true` as a default flag. The peer
exists for taxonomic completeness so users searching for
"Widerklage" find an entry; it is not a separate rule namespace.
### S2 — Abbreviations
`dmgs` for damages, `disc` for discovery. m's call: short form keeps the
codes terse and the dot-separated shape readable.
### S3 — Damages appeal
**NO separate code.** `upc.apl.merits` covers damages appeals — the
spawn rules from `upc.dmgs.cfi` (none seeded today) would carry their
own `spawn_label`. Avoids a code like `upc.apl.dmgs` whose rules would
be empty for the foreseeable future.
### S4 — NZB at BGH
Single bucket `de.inf.bgh`. Rules diverging in the NZB-detour-path
(Nichtzulassungsbeschwerde when the OLG didn't grant Revision) use a
`with_nzb` flag instead of a separate proceeding type. Keeps the dropdown
list shorter and matches how m practitioners think about the BGH
instance — same destination, two ways to arrive.
### S5 — DPMA appeals
Generic `dpma.appeal.bpatg` / `dpma.appeal.bgh` — source-of-decision
differentiation (was it a DPMA decision being appealed? a BPatG
decision being further appealed to BGH?) lives at the rule level, not
the proceeding-type level. Keeps the code namespace flat.
## 0.4 Spawn-FK invariant
After mig 096, the spawn FK invariant from mig 095 still holds:
deadline_rules.spawn_proceeding_type_id = 11
↔ paliad.proceeding_types[id=11].code = 'upc.apl.merits'
Spawn rules from `upc.inf.cfi` / `upc.rev.cfi` chain to the appeal-merits
proceeding without code-string awareness. Same for any future spawn FK.
## 0.5 Not in scope
* `paliad.event_categories.slug` segments (`upc-inf`, `de-bgh-null`, …)
are NOT renamed. They are stable identifiers in a separate taxonomy and
their kebab form is presentation-layer (it appears in URL fragments).
Mig 096 only updates the `proceeding_type_code` text column on
`paliad.event_category_concepts` rows so the soft join through
`event_category_concepts → proceeding_types.code` keeps resolving.
* Fee-table keys (`EPA_OPPOSITION`, `UPC_APPEAL`, …) in
`internal/calc/fees.go` are NOT proceeding codes — they are fee-table
bucket keys with their own naming. Untouched.
* Forum bucket slugs (`upc_cfi`, `de_lg`, …) in
`ForumToProceedingCodes` are presentation buckets, not codes. The
values inside (`UPC_INF`, …) are the codes being renamed.
## 0.6 References
* `internal/db/migrations/096_proceeding_code_rename.up.sql` — the
migration that lands this rename.
* `internal/services/proceeding_mapping.go` — post-mig 096 mapping with
the ccr-routing helper (S1).
* `internal/services/proceeding_codes_shape_test.go` — Go test asserting
every active fristenrechner-category code matches the new shape regex.
* mig 095 (`internal/db/migrations/095_fristen_gap_fill.up.sql`) — the
immediate predecessor; spawn_proceeding_type_id=11 carries through.

View File

@@ -0,0 +1,686 @@
# Project metadata rework — Client Role + auto-derived project codes
Status: design, ready for head review (2026-05-20)
Task: t-paliad-222
Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes)
Branch: `mai/kepler/inventorcoder-project`
Pairs two related changes because both touch `paliad.projects` schema, the
project form, and downstream consumers (Fristenrechner Determinator,
submission templates, Verlauf, picker / breadcrumb surfaces). One design,
two migrations, one coder shift.
---
## §1 Scope & non-goals
In scope:
- Drop "Wir vertreten" entirely on `type='client'`, `'litigation'`, `'patent'`.
- Rename to "Client Role" / "Mandantenrolle" on `type='case'` with new
option set (Active / Reactive / Third Party / Other).
- Widen `paliad.projects.our_side` CHECK to the new sub-role values; drop
`'court'` and `'both'`; backfill existing rows to NULL.
- Add `paliad.projects.opponent_code text` on `type='litigation'` rows
(segment source for project codes).
- New Go helper `services.BuildProjectCode(ctx, projectID) (string, error)`
that walks the ancestor chain via the existing ltree `path` and assembles
the dotted code. Custom `paliad.projects.reference` on the project itself
wins.
- Wire the helper into project header, breadcrumb, picker labels, the
submission-template variable bag (`{{project.code}}`), and the Excel
export `__meta` sheet.
Out of scope (handled separately or dropped):
- Reshaping `paliad.parties` (per-party role rows are unchanged).
- New analytics / reports breaking out sub-roles.
- Bulk-renaming user-facing copy that says "Klägerseite" /
"Beklagtenseite" outside the project form.
- Reverse lookup (project by code) — already works via `reference`.
- Audit-history for who changed an override and when — not requested.
- Bulk regeneration of existing `reference` strings — manual entries stay
intact; auto-derive only fills empty slots.
- Renaming the `our_side` DB column — see §2.2 / Q1.
---
## §2 Issue #47 — Client Role rework
### §2.1 Current state (verified 2026-05-20)
- Column: `paliad.projects.our_side text`, CHECK constraint
`projects_our_side_check` allows `('claimant','defendant','court','both',NULL)`
(mig 072).
- Live data audit (`SELECT our_side, count(*) FROM paliad.projects
GROUP BY our_side`): **all 12 rows are NULL**. Zero rows on
`'court'` or `'both'` — backfill is a no-op. The migration is risk-free
on the current dataset.
- Form: rendered for every project type by
`frontend/src/components/ProjectFormFields.tsx:156-168` (one
`<select id="project-our-side">` with five static `<option>`s, no
conditional render).
- Downstream consumers (verified by grep on `our_side` /
`OurSide` in `internal/` and `frontend/src/`):
- `frontend/src/client/fristenrechner.ts:2187,2734,3754-3776` —
Determinator Slice 3c, `ourSideToPerspective()` maps
`claimant → claimant`, `defendant → defendant`, anything else
(incl. `'court'`, `'both'`, NULL) → `null` (chip free-pick).
- `internal/services/submission_vars.go:276-278,390-418` —
`{{project.our_side_de}}` / `_en` legal-prose forms. `ourSideDE` /
`ourSideEN` switch on the 4 enum values.
- `internal/services/project_service.go:1083-1104` —
`our_side_changed` project-event row on writes.
- `internal/services/project_service.go:1228,1372,1955-` — CCR
counterclaim child default-inverts `our_side`; `nullableOurSide()`
and `isValidOurSide()` (`project_service.go:1915`) gate writes.
### §2.2 Decisions
**Q1 — Rename column `our_side → client_role`?**
**Pick: NO. Keep `our_side`.** Renaming forces churn in eleven Go files,
the Determinator client bundle (`fristenrechner.ts` type literal +
`ourSideToPerspective`), all submission-template tests
(`submission_render_test.go:275`), the project-event title key
(`event.title.our_side_changed`), and every `{{project.our_side*}}` template
that exists in the wild on user systems. The label is purely UI; the column
name is internal. Future grep stays clean because the new label
("Client Role") and the column (`our_side`) describe the same concept from
different perspectives ("which side the firm represents" =
"what role the client plays"). Keeping the column avoids a 200-line
mechanical rename with non-trivial risk for zero functional gain. The
i18n keys *do* rename (`projects.field.our_side` → `projects.field.client_role`)
so user-facing copy stays clean.
**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
**Pick: 7 sub-roles** — `claimant, defendant, applicant, appellant,
respondent, third_party, other`. Lawyers care about the specific
procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI
applications use "Applicant"). Group-level aggregation is trivial at
display time (`switch role { case claimant, applicant, appellant:
return "Active" }`). Storing the group only would be a lossy choice we
cannot reconstruct from.
**Q3 — Project types where the field is visible?**
**Pick: ONLY `type='case'`.** m's wording is unambiguous ("only plays a
role in case projects — and even there the question should be 'Client
Role'"). Hide on `client`, `litigation`, `patent`, and the generic
`project` type. The client-level "industry / country" block stays as is
(those are client-attributes, not procedural roles). The form already
has `projekt-fields-case` conditional render (`ProjectFormFields.tsx:143`)
— moving the role select into that block is a 4-line change.
**Q4 — Existing `'court'` / `'both'` row backfill?**
**Pick: backfill to NULL** in the same migration that widens the CHECK.
Zero rows in production (verified 2026-05-20), so the backfill is a
no-op today; it's there for safety if any test fixture or
not-yet-deployed instance has them. No audit-event emission for the
backfill (it's schema cleanup, not user action).
**Q5 — Determinator perspective mapping for new sub-roles?**
**Pick: Active group → `claimant`, Reactive group → `defendant`, Third
Party / Other → `null` (chip free-pick).** Concretely:
- `claimant`, `applicant`, `appellant` → perspective `'claimant'`
- `defendant`, `respondent` → perspective `'defendant'`
- `third_party`, `other`, NULL → perspective `null`
This keeps the Determinator's existing claimant-rule / defendant-rule
filter logic unchanged; only `ourSideToPerspective()`'s switch widens.
**Q6 — Submission template `_de` / `_en` prose for new sub-roles?**
| value | `_de` (Nominativ) | `_en` |
|---------------|-------------------------------|---------------|
| `claimant` | Klägerin | Claimant |
| `defendant` | Beklagte | Defendant |
| `applicant` | Antragstellerin | Applicant |
| `appellant` | Berufungsklägerin | Appellant |
| `respondent` | Antragsgegnerin | Respondent |
| `third_party` | Streithelferin | Third Party |
| `other` | sonstige Verfahrensbeteiligte | other party |
Existing `'court'`/`'both'` switch arms get deleted (no live rows; if a
stale `our_side='court'` slipped through somehow, the function returns
`""` — same fallback as today for unknown values).
### §2.3 Migration `112_client_role_rework`
```sql
-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
-- t-paliad-222 / m/paliad#47.
-- Widens projects.our_side CHECK to seven sub-role values and drops
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
-- runs defensively in case any test fixture / staging instance still
-- carries the old values.
BEGIN;
-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
UPDATE paliad.projects
SET our_side = NULL
WHERE our_side IN ('court', 'both');
-- 2. Drop the old CHECK, add the widened one. Both are idempotent
-- against partially-applied state.
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_our_side_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_our_side_check
CHECK (our_side IS NULL OR our_side IN (
'claimant', 'defendant',
'applicant', 'appellant',
'respondent',
'third_party', 'other'
));
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this case project (renamed in '
'the UI to "Client Role" — t-paliad-222 / m/paliad#47). Allowed '
'sub-roles, grouped at display time: Active (claimant, applicant, '
'appellant); Reactive (defendant, respondent); Third Party / Other '
'(third_party, other). NULL = unknown. Hidden in the form on '
'non-case project types. Drives the Fristenrechner Determinator '
'perspective chip (Active→claimant, Reactive→defendant, else null).';
COMMIT;
```
The down migration restores the original 4-value CHECK and, for
defensive symmetry, backfills any new sub-role values to NULL (so the
schema is internally consistent when stepped down).
### §2.4 Frontend changes
`frontend/src/components/ProjectFormFields.tsx`:
1. Move the `<div className="form-field">` containing
`#project-our-side` from the always-visible block (line 156) into
the `projekt-fields-case` block (after the court / case-number
row).
2. Rename label `data-i18n="projects.field.our_side"` →
`projects.field.client_role`.
3. Replace the five flat `<option>`s with three `<optgroup>`s + the
seven new options + an "Unbekannt" empty option.
4. Update the hint text to mention the Determinator group mapping
(Active/Reactive).
`frontend/src/client/i18n.ts` — add new keys (DE + EN):
```
projects.field.client_role → "Mandantenrolle" / "Client Role"
projects.field.client_role.hint → "..."
projects.field.client_role.unset → "Unbekannt" / "Unknown"
projects.field.client_role.group.active → "Aktiv (wir greifen an)" / "Active (we initiate)"
projects.field.client_role.group.reactive → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
projects.field.client_role.group.other → "Dritte / Sonstige" / "Third Party / Other"
projects.field.client_role.claimant → "Klägerseite" / "Claimant"
projects.field.client_role.applicant → "Antragsteller" / "Applicant"
projects.field.client_role.appellant → "Berufungsführer" / "Appellant"
projects.field.client_role.defendant → "Beklagtenseite" / "Defendant"
projects.field.client_role.respondent → "Antragsgegner" / "Respondent"
projects.field.client_role.third_party → "Streithelfer / Dritter" / "Third Party"
projects.field.client_role.other → "Sonstige Beteiligte" / "Other party"
```
The legacy `projects.field.our_side.*` keys stay deprecated-but-present
for one release so any cached browser bundle keeps rendering. They get
deleted in a follow-up housekeeping shift once the rollout is confirmed.
`frontend/src/client/project-form.ts:182-230` — adjust the payload
read/write to only include `our_side` when the field is in the DOM
(non-case forms no longer emit it). The current code does
`if (v) payload.our_side = v` which already handles the "field absent"
case gracefully (osSel becomes `null`, no payload key set).
`frontend/src/client/fristenrechner.ts:3754-3776` —
`ourSideToPerspective` switch widens:
```ts
function ourSideToPerspective(os: string | null | undefined): Perspective {
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
```
`frontend/src/projects-detail.tsx` Verlauf — the `our_side_changed`
event description currently renders the raw enum. Update the renderer
to use a label lookup so "Mandant: Beklagte → Antragsteller" reads
correctly. Same `event.title.our_side_changed` key stays (the *title*
is "Vertretene Seite geändert" / "Represented side changed", which is
still accurate semantically).
### §2.5 Backend changes
`internal/services/project_service.go:1915` — `isValidOurSide()` widens
its allowlist:
```go
case "", "claimant", "defendant",
"applicant", "appellant",
"respondent",
"third_party", "other":
return nil
```
`internal/services/project_service.go:1372` —
`derivedCounterclaimOurSide()` (CCR flip logic): widen the flip map to
mirror the Determinator grouping:
- claimant ↔ defendant (current behaviour)
- applicant ↔ respondent
- appellant → defendant (CCR against an appellant is rare; pick
the most-likely procedural posture; can be overridden by
explicit `flip_our_side=false`)
- third_party / other / NULL → keep as-is (no flip)
`internal/services/submission_vars.go:391-418` — `ourSideDE` /
`ourSideEN` switch arms add the five new values per the table in
§2.2 Q6. `'court'` and `'both'` arms get deleted.
`internal/services/project_service.go:1083-1104` — `our_side_changed`
audit emission unchanged (it just records old → new on the column).
`frontend/build.ts` — no change; bundling already picks up
`projects.field.client_role.*` i18n keys via `i18n-keys.ts` regeneration.
`frontend/src/i18n-keys.ts` — regenerate via existing scripted path
(adds the new keys, keeps the legacy ones as deprecated entries until
the housekeeping pass).
### §2.6 Tests
- `internal/services/submission_render_test.go:275` —
`TestOurSideTranslations` widens the table to cover the 7 new values
in both DE and EN.
- `internal/services/projection_service_unit_test.go:319` —
`TestDerivedCounterclaimOurSide` widens to cover the new flip map.
- New: `TestProjectFormHidesOurSideForNonCase` — unit test on the
project-form payload reader confirms `our_side` is silently dropped
when the form renders for a non-case project type.
### §2.7 Acceptance (issue #47)
- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
`'project'` does **not** show the field.
- [x] Creating a project of `type='case'` shows the field labelled
"Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
and seven options.
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
are migrated to NULL.
- [x] Submission templates referencing `{{project.our_side_de}}` /
`_en` render coherent prose for the five new values.
- [x] Determinator perspective chip pre-fills correctly from each
sub-role (Active→claimant, Reactive→defendant, Other→null).
- [x] CCR counterclaim flip yields a sensible child role for the new
sub-roles.
- [x] `go build && go test ./internal/... && cd frontend && bun run
build` clean.
---
## §3 Issue #50 — Auto-derived project codes
### §3.1 Current state (verified 2026-05-20)
- `paliad.projects.reference text` exists and is informally used (live
values: `EXMPL` on a client, `L-2026-001` on a litigation, `C-UPC-0001`
on a case, `P-EP1111222` on a patent). No format enforcement.
- `paliad.projects.path ltree` is maintained by a Postgres trigger
(`projects.path` joined UUIDs root-to-self). Walking ancestors in Go
is straightforward: `SELECT * FROM paliad.projects WHERE path @>
$1::ltree ORDER BY nlevel(path)`.
- No `opponent` field exists anywhere. Opponent text lives only inside
the litigation `title` (e.g. "Siemens AG ./. Huawei Technologies").
- `paliad.proceeding_types.code` is dot-separated:
`upc.inf.cfi`, `upc.rev.cfi`, `de.inf.lg`, `upc.apl.merits`, etc.
Splitting on `.` and upper-casing yields `INF`, `REV`, `LG`,
`APL.MERITS`. Suitable as the case segment.
- `paliad.projects.court text` is free-text on cases (live values:
`UPC`, `UPC CoA`, `LG München I`). Not normalised; use the
proceeding_type code instead — it carries the same info structurally.
### §3.2 Decisions
**Q1 — Litigation opponent source: new column or regex on title?**
**Pick: new column `paliad.projects.opponent_code text` on litigation
rows.** Regex on title is brittle ("./.", "v.", "vs", "—", varying
order) and the user already knows the short code at creation time. New
field with explicit validation (slug-cased, max 16 chars) is clean and
takes one form field + one migration. Title stays as the human-readable
caption; `opponent_code` is the machine-readable segment source.
NULL → segment skipped silently.
**Q2 — Patent segment: always last 3, or last-N variable?**
**Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full
digit-stream when shorter.** m's example (`EP3456789 → 789`) is 7
digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to
their last 3 just fine — uniqueness inside the same litigation tree is
near-certain because the same litigation tree won't hold two patents
sharing the same last-3. If it ever does, the user can set a custom
`reference` (Q5). No need for last-4 / last-N logic.
The patent-number regex extracts the digit-stream from any common
format (`EP1234567`, `EP 1 234 567`, `EP1234567A1`, `WO2020/123456A1`):
strip non-digits, take last 3 (or whole if shorter), upper-cased.
**Q3 — Case segment from `proceeding_types.code`?**
**Pick: take `proceeding_types.code` (e.g. `upc.inf.cfi`), split on `.`,
drop the leading jurisdiction segment, uppercase the rest, join with
`.`.** Examples:
- `upc.inf.cfi` → `INF.CFI`
- `upc.rev.cfi` → `REV.CFI`
- `upc.pi.cfi` → `PI.CFI`
- `upc.apl.merits` → `APL.MERITS`
- `de.inf.lg` → `INF.LG`
- `de.inf.olg` → `INF.OLG` (appeal instance → segment already
encodes "OLG", so we get the appeal level for free; no separate
instance segment needed)
The jurisdiction is dropped because the parent client/patent already
implies the jurisdiction context. If the user wants explicit
jurisdiction in the code, custom `reference` wins.
If `proceeding_type_id` is NULL on the case, segment is omitted
silently. No fallback to `court` text — that's free-text and noisy.
**Q4 — Override semantics: wholesale or per-segment?**
**Pick: wholesale.** When `paliad.projects.reference` is non-empty on
the project the helper is asked about, that string is returned
verbatim — no auto-derivation, no string-concatenation, no merging.
Per-segment override doubles the implementation complexity for a UX
nobody asked for. Users who want partial overrides set the
`reference` on the relevant ancestor and let the rest auto-derive
naturally.
**Q5 — Where the user types the override?**
**Pick: existing `paliad.projects.reference` field.** Already there,
already labelled "Interne Referenz (optional)", already used by users.
Adding a second "project_code_override" alongside `reference` would
confuse the form. The hint text gets a small addendum: "Leer lassen
für automatischen Code aus dem Projekt-Baum."
**Q6 — Collision handling (two cases derive to the same code)?**
**Pick: advisory in v1; no disambiguator.** Codes are display-only
(not a primary key, not a unique constraint). Real-world collisions
inside the same litigation tree are vanishingly rare; if they happen,
the user notices in the picker and sets a custom `reference` on one.
Adding `-N` suffixes silently would mask a data issue the user should
see. A future surface could flag duplicates as a project-detail warning,
but it's not in v1.
**Q7 (new) — Helper signature and call site?**
**Pick: `ProjectService.BuildProjectCode(ctx context.Context, projectID
uuid.UUID) (string, error)`.** Lives on the existing ProjectService
(it needs DB access for the ancestor walk). Internally builds segments
with a small `projectCodeSegment(p Project) string` pure function per
type that's table-test-friendly. The helper is called from the
projection layer when a project gets serialised for the API
(adds a `code` field to the JSON), so every surface — header,
breadcrumb, picker, dashboard tile, Excel export — gets the code for
free without each surface re-walking the tree. Pricier than a
display-time call but eliminates N+1 walks in list views.
**Q8 (new) — Cache strategy?**
**Pick: no cache in v1.** Each ancestor walk is one indexed lookup
on `paliad.projects(path)`. With 12 projects in prod and order-of-100s
in any plausible firm-scale future, this is microsecond-cheap. If
profiling later shows it as a hotspot in list views (which fetch many
projects), introduce a materialised view
`paliad.projects_derived_codes(project_id, derived_code)` refreshed by
trigger on `projects` writes. Don't pre-optimise.
### §3.3 Migration `113_projects_opponent_code`
```sql
-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
-- t-paliad-222 / m/paliad#50.
-- Add an opponent-code field on litigation projects. Used as the
-- middle segment when assembling auto-derived project codes from the
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
-- skipped silently. No backfill — existing litigation rows simply
-- yield codes without an opponent segment until the user sets one.
BEGIN;
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS opponent_code text;
-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'projects_opponent_code_check'
AND conrelid = 'paliad.projects'::regclass
) THEN
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_opponent_code_check
CHECK (opponent_code IS NULL
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
AND type = 'litigation'));
END IF;
END $$;
COMMENT ON COLUMN paliad.projects.opponent_code IS
'Short slug for the opposing party on a litigation project '
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
'middle segment when BuildProjectCode walks the ancestor tree to '
'assemble a dotted project code (t-paliad-222 / m/paliad#50). '
'NULL = segment skipped silently.';
COMMIT;
```
The down migration drops the constraint then the column.
### §3.4 Go helper
New file `internal/services/project_code.go`:
```go
// Package-level function (not a method) so it can be called from any
// service that already has a *sqlx.DB. ProjectService has a thin
// wrapper that calls into this.
//
// BuildProjectCode assembles the dotted ancestor code for projectID
// from the existing paliad.projects.path ltree. If the target row's
// reference column is non-empty, it wins outright (no derivation).
// Missing ancestor segments are skipped silently — there is no
// "unknown" placeholder.
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)
// projectCodeSegment is the per-type segment derivation. Pure, table-
// test friendly, never touches the DB.
//
// client → opts.PreferShortReference (reference if set, else slug(title))
// litigation → opts.PreferShortReference (opponent_code if set, else "")
// patent → last 3 digits of patent_number (full digits if <4)
// case → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
// project → "" (generic projects don't contribute a segment)
//
// proceedingCode is only needed for case rows; the caller resolves
// it via a single join (or a cached small lookup) before calling.
func projectCodeSegment(p models.Project, proceedingCode string) string
```
Sanitisation helpers live alongside as unexported funcs:
- `sanitizeClientShort(s string) string` — uppercase, strip diacritics
via `golang.org/x/text/unicode/norm` + filter, replace non-alnum
with `-`, trim, cap at 8 chars. Already similar to what
`internal/util/slug` does for the global slug helper.
- `patentLast3(s string) string` — strip non-digits, take last 3
characters (or the whole digit-stream when shorter); uppercase.
Empty → "".
- `proceedingTail(code string) string` — split on `.`, drop element 0
(jurisdiction), uppercase + join the rest. `""` → `""`.
`BuildProjectCode` SQL is a single round-trip:
```sql
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
p.patent_number, p.proceeding_type_id,
pt.code AS proceeding_code
FROM paliad.projects p
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
ORDER BY nlevel(p.path);
```
It returns the chain root-to-target. The function:
1. If the last row (the target) has non-empty `reference` → return it
verbatim. Done.
2. Otherwise walk the chain top-to-bottom, call `projectCodeSegment`
on each row, skip empty segments, join with `.`, return.
### §3.5 Wiring into surfaces
- `internal/services/project_service.go` projection — add a `Code`
string field to the read-side struct and populate it in the single
fetch path. For list endpoints, do **one** ancestor-chain query per
page (CTE that groups by target id) rather than N+1.
- `internal/services/submission_vars.go:277` — add
`bag["project.code"] = derefString(p.Code)` so submission templates
can reference `{{project.code}}`.
- `frontend/src/components/ProjectHeader.tsx` (current header
component on `/projects/{id}`) — render `code` next to the title
(small monospace badge) if non-empty.
- `frontend/src/components/Breadcrumb*.tsx` — when rendering the
trail, use `project.code` as the trailing badge per segment if the
caller asks for it (opt-in to avoid breaking other consumers).
- `frontend/src/client/project-form.ts` and any project-picker
typeahead — show `code · title` in the dropdown labels when `code`
is non-empty.
- Excel `__meta` sheet — add a `Project Code` row (already enumerates
project metadata).
The "copy reference" affordance in the header gets a second line: if
both `reference` (user override) and the auto-derived code differ, both
are visible (override above, derived below, smaller).
### §3.6 Tests
- `TestProjectCodeSegment` (table) — every project type × multiple
shapes (with/without reference, NULL ancestors, patent_number
formats, proceeding codes with 1/2/3 segments).
- `TestBuildProjectCodeFullChain` — fixture tree
Client → Litigation → Patent → Case yields `EXMPL.OPNT.567.INF.CFI`.
- `TestBuildProjectCodeRespectsOverride` — non-empty `reference` wins
outright.
- `TestBuildProjectCodeMissingAncestors` — case directly under client
(no litigation, no patent) yields `EXMPL.INF.CFI`.
- `TestBuildProjectCodeCollisionDoesNotDisambiguate` — two sibling
cases with identical derived codes both return the same string (v1
contract per Q6).
- Migration sanity test (existing harness in
`internal/db/migrations_test.go` if present) — up → down → up.
### §3.7 Acceptance (issue #50)
- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
reference tree (Client EXMPL → Litigation OPNT → Patent
EP1234567 → Case `upc.inf.cfi`).
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
returns `CUSTOM-CODE` verbatim.
- [x] Missing ancestor segments are skipped silently
(no `..` collapses, no "?" placeholder).
- [x] `{{project.code}}` resolves in submission templates.
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
code when set/derived.
- [x] Litigation form has a new "Opponent Code" field (DE:
"Gegner-Kürzel") with the slug pattern validation. Hidden on
non-litigation types.
- [x] `go build && go test ./internal/... && cd frontend && bun run
build` clean.
---
## §4 Open questions for the head
(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something
material pushes back. Coder shift only after head signs off.)
1. **§2.2 Q1** — Keep column name `our_side`? (Recommend YES; rename
touches 11+ Go files + bundled-template wire format for zero gain.)
2. **§2.2 Q2** — Store 7 sub-roles? (Recommend YES; group-only is
lossy.)
3. **§2.2 Q3** — Hide the field on `litigation` and `patent` too, not
just on `client`? (Recommend YES per m's "only on case projects".)
4. **§2.2 Q6** — German prose forms use feminine grammatical gender
(Klägerin, Beklagte) per the existing translation table? Or
masculine / neutral? (Recommend feminine to match existing
`ourSideDE` — keeps consistency with already-rendered templates.)
5. **§3.2 Q1** — Add a dedicated `opponent_code` column on
litigations? (Recommend YES; regex-on-title is brittle.)
6. **§3.2 Q2** — Patent segment = last 3 digits (variable for
<4-digit numbers)? (Recommend YES, matches m's example.)
7. **§3.2 Q3** — Case segment drops the jurisdiction prefix from
`proceeding_types.code` (so `upc.inf.cfi` → `INF.CFI`, not
`UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
ancestor client/patent context.)
8. **§3.2 Q7** — `BuildProjectCode` populates a `code` field on every
projected Project JSON (not lazy per-render)? (Recommend YES;
simpler consumers, one DB round-trip per list page.)
9. **§3.2 Q8** — No cache / materialised view in v1? (Recommend YES;
profile later if list views get slow.)
---
## §5 Implementation order (coder phase)
1. **Mig 112** (client role widen + backfill) → mig 113 (opponent_code).
*Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump.*
Run `ls internal/db/migrations/ | tail` first to verify slot
availability (boltzmann's gap-tolerant runner means 110 is fine
even if 109 was the last applied).
2. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
`derivedCounterclaimOurSide`, new `project_code.go` package
+ ProjectService wiring + projection `Code` field.
3. **Frontend** — `ProjectFormFields.tsx` (conditional render + new
options + opponent_code field on litigation block), `i18n.ts` keys,
`fristenrechner.ts` `ourSideToPerspective` widen, header /
breadcrumb / picker code-badge wiring.
4. **Tests** — pinning tests above; `go test ./internal/...` clean.
5. **Build verification** — `go build && cd frontend && bun run build`
clean.
6. **Commit per slice** — three commits (migration + backend, frontend,
tests) keep review tractable.
---
## §6 Risks & rollback
- **Submission templates in the wild.** Users may have downloaded /
customised submission templates that still reference
`{{project.our_side_de}}` for `our_side='court'` or `'both'`. After
this change those values are unreachable, so the template arm
returns `""`. Already the fallback behaviour for unknown values;
no breakage, just an empty render. Mention in release notes.
- **Browser cache.** Users with a stale bundle still see the old
"Wir vertreten" form for one cache-bust cycle. The legacy i18n keys
stay until housekeeping (§2.4), so labels still resolve.
- **Migration down path.** Stepping down from 110 restores the old
4-value CHECK; new sub-role rows would violate it. The down
migration backfills new sub-roles → NULL to stay consistent.
- **Per-tree opponent_code uniqueness.** Two litigations under the
same client with the same `opponent_code` would derive identical
case codes. Per Q6 we accept this; users see it in the picker and
customise `reference` if it bothers them.
- **No new env vars, no Dokploy compose change** — both changes are
pure code + schema; deploy is the existing main-push → webhook →
Dokploy auto-redeploy path.

View File

@@ -0,0 +1,784 @@
# Design — Submission generator (t-paliad-215)
**Author:** copernicus (inventor)
**Date:** 2026-05-19
**Issue:** m/paliad (task t-paliad-215, no Gitea issue filed yet)
**Branch:** `mai/copernicus/inventor-submission`
**Status:** DESIGN READY FOR REVIEW
---
## §0 TL;DR
Each row in `paliad.deadline_rules` represents a SUBMISSION — a filing,
hearing, or decision inside a proceeding (`submission_code` shape
`de.inf.lg.erwidg`, `upc.inf.cfi.soc`, …). The submission generator
takes a project + a submission_code, pulls a `.docx` template from
Gitea, merges in project variables (party names, court, case number,
patent number, our_side, deadline date, legal_source citation, firm
header), and streams the result to the browser as a download.
- **Scope (locked by m):** template-render to `.docx`. No LLM in v1.
- **Template registry (locked):** Gitea — same proxy pattern as the
existing HL Patents Style `.dotm` in `internal/handlers/files.go`.
- **Output (locked):** direct download, NO server-side binary
persistence. One audit row per generation; the bytes themselves are
regenerable from inputs on demand.
- **Lookup (locked):** fallback chain — firm-specific override →
base for the exact `submission_code` → generic for the proceeding
family → ultra-generic skeleton.
- **Slice 1 (locked):** one template, end-to-end, on one project.
Pick `de.inf.lg.erwidg` (Klageerwiderung) as the proof template.
- **AI-drafted body:** explicitly OUT of scope for this task. Lives
in §11 as a follow-up sketch only.
This design is read-only. No code, no migrations, no schema
additions. Implementation gate is m's go/no-go on this doc.
---
## §1 Premises verified live (2026-05-19)
Anchored against the running paliad codebase + youpc Supabase, not
against CLAUDE.md or memory. Where a claim load-bears the design, it
was checked against the live system.
| Claim | Verification |
|---|---|
| Migration tracker at **102** (next is 103) | `ls internal/db/migrations/``102_system_audit_log` is the latest applied. |
| `paliad.documents` table exists, is empty, no code writes to it yet | `SELECT COUNT(*) FROM paliad.documents` → 0 rows. Columns: `id, title, doc_type, file_path NULLABLE, file_size, mime_type, ai_extracted jsonb, uploaded_by, created_at, updated_at, project_id NOT NULL`. `grep` shows only `export_service.go` (audit-export only) and a comment in `render_spec.go`. No `document_service.go`, no `/api/documents` handler. |
| `paliad.deadline_rules` carries the submission corpus | 254 total rows, 158 unique `submission_code`s, 214 `published`. Per-row fields used by the generator: `name`, `name_en`, `submission_code`, `primary_party` (claimant/defendant/court/both), `event_type` (filing/hearing/decision), `legal_source` (e.g. `DE.ZPO.276.1`, `UPC.RoP.23.1`), `is_bilateral`. |
| Slice 1 target row exists in published state | `SELECT … WHERE submission_code='de.inf.lg.erwidg'``{name:"Klageerwiderung", name_en:"Statement of Defence", primary_party:"defendant", legal_source:"DE.ZPO.276.1"}`. |
| Project rows carry all variables we need to merge | `paliad.projects` has `case_number, court, patent_number, filing_date, grant_date, our_side, instance_level, proceeding_type_id, title, reference, client_number, matter_number`. |
| Party rows carry party variables | `paliad.parties` has `name, role, representative, contact_info jsonb` and is project-scoped via `project_id`. |
| The HL Patents Style proxy pattern is reusable | `internal/handlers/files.go`: `fileRegistry` map → Gitea raw URL + SHA-based cache + 5-min refresh check + binary download response with `Content-Disposition`. Cache is in-process (`sync.Mutex` over a `map[string]*cacheEntry`). Single web replica today (`docker-compose.yml`), so in-process cache is fine. |
| Email templates already use `{{.VarName}}` placeholders + a "variable contract" sidebar pattern | `internal/services/email_template_variables.go``EmailTemplateVariable{Name, Type, Description, SampleDE, SampleEN}` rendered in `/admin/email-templates`. Submission generator can copy this contract pattern. |
| Audit infrastructure landed in mig 102 | `paliad.system_audit_log(id, event_type, actor_id, actor_email, scope, scope_root, metadata jsonb, created_at, updated_at)` — submission_generated events slot straight in. |
| Branding source is `internal/branding.Name` | Default `"HLC"`, overridable via `FIRM_NAME`. Inlined into client bundles by `frontend/build.ts`. Submission templates honour this via the `{{firm.name}}` placeholder. |
| `paliad.can_see_project(project_id)` is the canonical visibility predicate | mig 055; `internal/services/visibility.go` mirrors it. Generator gates on this; no new auth surface. |
| Paliadin runs on the aichat backend (mRiver) with persona system | `internal/services/aichat_paliadin.go` + `personas.yaml` in `m/mAi/internal/aichat/persona/`. Owner-gated to `PaliadinOwnerEmail = matthias.siebels@hoganlovells.com`. A future AI-drafted body would be a new persona, not a new Go service. |
**Doc-vs-live conflicts found:** none material for this design.
`docs/project-status.md` still lists "Phase H AI Frist-Extraktion
deferred" — this design does NOT revive Phase H (different surface;
this is template merge, not document understanding).
---
## §2 m's decisions (2026-05-19)
Locked via AskUserQuestion before drafting the rest of the design.
| # | Question | m's pick | Inventor recommended? |
|---|---|---|---|
| Q1 | Generator scope (template / AI-draft / brief / other) | **Template-render to `.docx`** | ✅ yes |
| Q2 | Template registry (Gitea / paliad DB / hybrid) | **Gitea** | ✅ yes |
| Q3 | Output flow (download-only / persist binary / attach to Frist) | **Direct download, no server-side binary** | ✅ yes |
| Q4 | Mapping (fallback chain / 1:1 / 1:N user picks) | **Fallback chain** | ✅ yes |
| Q5 | Slice 1 scope (1 template / 35 templates / full corpus / skeleton-only) | **One template, end-to-end on one project** (`de.inf.lg.erwidg` Klageerwiderung) | ✅ yes |
Inventor-defaulted (not asked because there's a clear right answer or
because the question is implementation-level, not architecture-level):
| # | Topic | Default | Reasoning |
|---|---|---|---|
| D1 | Variable engine | `{{path.dot.notation}}` placeholders in the .docx body, replaced via a Go library that handles run-fragmentation | Matches the existing email-template `{{.Var}}` shape lawyers already see in `/admin/email-templates`. See §6. |
| D2 | Authorization | Project-team visibility only (`paliad.can_see_project`) + audit row | Matches every other write surface in paliad. No profession floor (generation is read-only on source data and produces a draft, not a binding action). |
| D3 | Naming convention | `{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx`, slashes → underscores, FIRM_NAME-aware | Mirrors how lawyers name files manually. See §7. |
| D4 | Missing-variable behaviour | Render `[KEIN WERT: {field}]` / `[NO VALUE: {field}]` marker inline | Lets the lawyer see the gap in Word, fix in paliad, regenerate. Better than 400ing. |
| D5 | Editor surface | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo) | A paliad uploader UI is Phase 2 affordance if Gitea round-trip is painful. |
| D6 | AI-drafted body | OUT of scope for this task | §11 sketches the natural follow-up shape (new aichat persona) but does not commit to it. |
---
## §3 Architecture overview
```
┌────────────────────────────────────────────────────────────────────────┐
│ Project detail page │
│ ├─ "Submissions" panel (or button row) │
│ │ [Generate Klageerwiderung] [Generate Klageerhebung] [...] │
│ │ Each button enabled iff a template exists for that │
│ │ submission_code AND user passes paliad.can_see_project. │
│ └─ Click → POST /api/projects/{id}/submissions/{code}/generate │
└──────────────────────────────────┬─────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ handlers/submissions.go (NEW) │
│ 1. Auth: UserIDFromContext + can_see_project gate │
│ 2. Load deadline_rule by submission_code │
│ 3. Resolve template via fallback chain (TemplateRegistry) │
│ 4. Build variable bag (services/submission_vars.go) │
│ 5. Render via SubmissionRenderer (services/submission_render.go) │
│ 6. Write paliad.documents audit row (NO file_path) │
│ 7. Write paliad.system_audit_log entry (event_type= │
│ 'submission.generated') │
│ 8. Stream .docx bytes with Content-Disposition: attachment │
└──────────────────────────────────┬─────────────────────────────────────┘
│ (template fetch)
┌────────────────────────────────────────────────────────────────────────┐
│ TemplateRegistry (services/submission_templates.go) — NEW │
│ • In-process cache (same shape as handlers/files.go cacheEntry) │
│ • Lookup path: │
│ (1) templates/{FIRM_NAME}/{submission_code}.docx │
│ (2) templates/_base/{submission_code}.docx │
│ (3) templates/_base/{proceeding_family}.docx (e.g. upc.inf.cfi) │
│ (4) templates/_base/_skeleton.docx │
│ • Fetched from m/mWorkRepo via Gitea raw URL │
│ • 5-min SHA refresh check (identical pattern to files.go) │
└──────────────────────────────────┬─────────────────────────────────────┘
Gitea: m/mWorkRepo
templates/HLC/de.inf.lg.erwidg.docx
templates/_base/de.inf.lg.erwidg.docx
templates/_base/de.inf.lg.docx
templates/_base/_skeleton.docx
```
**No new tables.** `paliad.documents` already exists; we write audit
rows there but leave `file_path` NULL. The fallback chain uses
filesystem-style paths inside the existing Gitea repo; no
`submission_templates` table needed for Slice 1.
---
## §4 Slice 1 — what ships first
Locked by Q5: **one template, end-to-end, on one project.**
### 4.1 Target submission
**`de.inf.lg.erwidg`** — Klageerwiderung (DE Verletzungs-LG).
Reasoning:
- High-frequency submission in patent practice; lawyers draft these
often enough that the tool earns its keep on day 1.
- `primary_party='defendant'` — exercises the our_side variable.
- `legal_source='DE.ZPO.276.1'` — exercises citation injection.
- Pure-DE (no UPC complexity); easier first template for HLC's
Munich/Düsseldorf practice to author and review.
- Klageerhebung (`de.inf.lg.klage`) is an obvious alternative; either
works. m can flip the target in his decision review if Klageerhebung
is the better proof case.
### 4.2 Surfaces in Slice 1
- **Project detail page** — new "Submissions" panel listing every
submission_code from the project's `proceeding_type` (via existing
`DeadlineRuleService`) with a `[Generieren]` button per row. Button
is enabled iff a template resolves AND `event_type='filing'` (no
`[Generieren]` on hearings/decisions — those don't have submissions).
- **Project detail API** — `GET /api/projects/{id}/submissions` returns
the list of (submission_code, name, has_template) so the frontend
can render enabled/disabled state.
- **Generate endpoint** — `POST /api/projects/{id}/submissions/{code}/generate`
returns `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
with `Content-Disposition: attachment; filename="..."`.
Slice 1 does NOT add:
- A `/admin/submission-templates` editor (Gitea is the editor).
- A Frist-detail "Generate" button (project-detail only in Slice 1;
Frist-level surface is a Slice 2 affordance).
- A "Submissions" tab as a dedicated page (project-detail panel only).
- Per-firm overrides beyond `templates/HLC/...` (the fallback chain is
WIRED but the only override directory exercised in Slice 1 is HLC).
- The variable-contract sidebar UI (mirrors email-template editor) —
the contract is documented in §6 as code constants, surfaced as a
Slice 2 admin affordance.
### 4.3 Slice 1 LoC estimate (informational, no time estimate)
| File | Approx |
|---|---|
| `internal/handlers/submissions.go` (NEW) | 180 |
| `internal/services/submission_templates.go` (NEW — registry + Gitea proxy, reuses files.go cache idea) | 200 |
| `internal/services/submission_vars.go` (NEW — variable bag builder) | 220 |
| `internal/services/submission_render.go` (NEW — docx merge engine wrapper) | 120 |
| `internal/services/submission_render_test.go` (placeholder coverage + missing-var marker) | 180 |
| `frontend/src/components/SubmissionsPanel.tsx` (NEW) | 80 |
| `frontend/src/client/submissions.ts` (NEW — fetch + download) | 60 |
| Wiring in `cmd/server/main.go` + `internal/handlers/handlers.go` | 30 |
| i18n keys (`submissions.*`) DE+EN | 20 |
| **Total** | **~1090 LoC** |
Plus: ONE `.docx` template authored by HLC at
`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`, lawyer-reviewed
before Slice 1 closes.
---
## §5 Template registry (Gitea-backed)
### 5.1 Gitea layout
```
m/mWorkRepo (existing repo)
└── templates/
├── HLC/ # FIRM_NAME-keyed override dir
│ └── de.inf.lg.erwidg.docx # Slice 1 ships THIS file
├── _base/ # Cross-firm baseline
│ ├── de.inf.lg.erwidg.docx # (Phase 2+)
│ ├── de.inf.lg.docx # proceeding-family fallback
│ ├── upc.inf.cfi.docx # (Phase 2+)
│ └── _skeleton.docx # ultra-generic fallback
└── README.md # placeholder reference for authors
```
Naming convention is the submission_code with a `.docx` suffix.
Proceeding-family fallback is the submission_code's first two
dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`).
### 5.2 Lookup algorithm
```go
// services/submission_templates.go
func (r *TemplateRegistry) Resolve(ctx context.Context, code string) (Template, error) {
firm := branding.Name // "HLC", or whatever FIRM_NAME is
family := familyOf(code) // "de.inf.lg" from "de.inf.lg.erwidg"
candidates := []string{
fmt.Sprintf("templates/%s/%s.docx", firm, code),
fmt.Sprintf("templates/_base/%s.docx", code),
fmt.Sprintf("templates/_base/%s.docx", family),
"templates/_base/_skeleton.docx",
}
for _, path := range candidates {
if tmpl, ok := r.fetch(ctx, path); ok {
return tmpl, nil
}
}
return Template{}, ErrNoTemplate
}
```
`fetch` does the same SHA-cache dance `handlers/files.go` already
does, scoped to the templates subtree.
### 5.3 Gitea auth
Reuses `GITEA_TOKEN` env var that already exists for the HL Patents
Style proxy. `m/mWorkRepo` is the same repo, same access token. No
new secret to configure.
### 5.4 What happens when no template resolves
The fallback chain ends at `_skeleton.docx`. The skeleton is an
intentionally bare-bones .docx (firm letterhead + party block + court
address + case number + signature stub) that ships as part of the
initial template set. In practice every Generate request resolves to
something — but if even the skeleton 404s (misconfigured repo), the
generator returns `503` with a clear error, the SubmissionsPanel
button surfaces "Vorlagen-Repository nicht erreichbar".
---
## §6 Variable interpolation
### 6.1 Engine
Plain text replacement of `{{path.dot.notation}}` placeholders in the
.docx body. Whitespace inside braces is trimmed
(`{{ project.case_number }}``{{project.case_number}}`).
Implementation: a Go library that handles Word's run-fragmentation
correctly (Word may split `{{project.case_number}}` across multiple
`<w:r>` runs during editing; naive find/replace breaks). Candidates:
- **`github.com/lukasjarosch/go-docx`** (~2k stars, MIT, pure Go,
maintained). Handles run-merging before replacement. **Inventor
recommendation.**
- `github.com/nguyenthenguyen/docx` — older, less active.
- Custom in-house implementation — ~200 LoC for a minimal robust
replacer that walks the document XML and merges runs that fall
inside a `{{…}}` span. Fallback if the library doesn't pan out.
Slice 1: try `lukasjarosch/go-docx` first; if it has dealbreaker bugs
(e.g. blows up on Word's autocorrect runs), fall back to the in-house
~200 LoC walker. The library choice is an implementation detail; the
placeholder syntax stays the same either way.
### 6.2 Variable contract (v1 placeholder set)
```
{{firm.name}} — HLC (or whatever FIRM_NAME is)
{{firm.signature_block}} — Phase 2; v1 renders empty string
{{today}} — 2026-05-19 (ISO)
{{today.long_de}} — "19. Mai 2026"
{{today.long_en}} — "19 May 2026"
{{user.display_name}} — "Maria Schmidt"
{{user.email}} — "maria.schmidt@hlc.com"
{{user.office}} — "Munich"
{{project.title}} — paliad.projects.title
{{project.reference}} — paliad.projects.reference
{{project.case_number}} — paliad.projects.case_number
{{project.court}} — paliad.projects.court
{{project.patent_number}} — paliad.projects.patent_number
{{project.filing_date}} — ISO date
{{project.grant_date}} — ISO date
{{project.our_side}} — "claimant" | "defendant"
{{project.our_side_de}} — "Klägerin" | "Beklagte"
{{project.instance_level}} — "lg" | "olg" | "bgh" | ...
{{project.proceeding.code}} — e.g. "de.inf.lg"
{{project.proceeding.name}} — Verletzungsklage am Landgericht
{{project.client_number}} — paliad.projects.client_number
{{project.matter_number}} — paliad.projects.matter_number
{{parties.claimant.name}} — first paliad.parties row with role='claimant'
{{parties.claimant.representative}} — paliad.parties.representative
{{parties.defendant.name}} — first row with role='defendant'
{{parties.defendant.representative}} — paliad.parties.representative
{{parties.other.name}} — first row with role NOT IN ('claimant','defendant') — court, intervener, etc.
{{rule.submission_code}} — "de.inf.lg.erwidg"
{{rule.name}} — "Klageerwiderung"
{{rule.name_en}} — "Statement of Defence"
{{rule.legal_source}} — "DE.ZPO.276.1"
{{rule.legal_source_pretty}} — "§ 276 Abs. 1 ZPO"
{{rule.primary_party}} — "defendant"
{{rule.event_type}} — "filing"
{{deadline.due_date}} — date of the next pending deadline for this rule on this project
{{deadline.due_date_long_de}} — "26. Juni 2026"
{{deadline.computed_from}} — anchor description (e.g. "Klageerhebung am 12.05.2026 +6 Wochen")
```
Per-firm extensions (e.g. `{{firm.signature_block}}` filled from a
table) are Phase 2.
### 6.3 Variable bag construction
`services/submission_vars.go` builds a flat `map[string]string`
keyed by the dotted-path placeholders above. One pass over:
1. `branding.Name` for `{{firm.*}}`
2. `time.Now()` (with `Europe/Berlin` locale for the long forms) for
`{{today.*}}`
3. `userService.GetByID()` for `{{user.*}}`
4. `projectService.GetByID()` for `{{project.*}}`
5. `partyService.ListByProject()` for `{{parties.*}}`
6. `deadlineRuleService.GetByCode()` for `{{rule.*}}`
7. `deadlineService.NextByRuleOnProject()` for `{{deadline.*}}`
Missing values render as `[KEIN WERT: {dotted.path}]` (DE) or
`[NO VALUE: {dotted.path}]` (EN) based on user locale. This is by
design — the lawyer sees the gap in Word, fixes it (either in Word
or in paliad and regenerates), rather than getting a 400 with a list
of missing fields they then have to chase.
### 6.4 Pretty-printing the legal_source
`legal_source` in the rule corpus is shorthand
(`DE.ZPO.276.1`, `UPC.RoP.23.1`). Lawyers don't want that in a brief;
they want `§ 276 Abs. 1 ZPO` or `Rule 23.1 RoP UPC`.
Slice 1 ships a small pretty-printer (`legalSourcePretty`) that knows
the families we currently use:
| Prefix | Pretty form (DE) | Pretty form (EN) |
|---|---|---|
| `DE.ZPO.<§>.<Abs>` | `§ <§> Abs. <Abs> ZPO` | `Section <§>(<Abs>) ZPO` |
| `DE.ZPO.<§>` | `§ <§> ZPO` | `Section <§> ZPO` |
| `UPC.RoP.<Rule>.<Sub>` | `Regel <Rule>.<Sub> VerfO UPC` | `Rule <Rule>.<Sub> RoP UPC` |
| `UPC.RoP.<Rule>` | `Regel <Rule> VerfO UPC` | `Rule <Rule> RoP UPC` |
| `DE.PatG.<§>` | `§ <§> PatG` | `Section <§> PatG` |
| `EPC.<Art>` | `Art. <Art> EPÜ` | `Art. <Art> EPC` |
| (unknown) | original string | original string |
Unrecognised prefixes pass through unchanged (better than an
incorrect prettification). The function is pure and unit-tested.
---
## §7 File naming
Generated file name:
```
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
```
Concrete example for the Slice 1 happy path:
```
Klageerwiderung-2 O 123_25-2026-05-19.docx
```
Rules:
- `rule.name` honours user locale (`Klageerwiderung` for DE,
`Statement of Defence` for EN).
- `project.case_number` slash/backslash → underscore (Word file name
hygiene), other characters preserved.
- Date is ISO at server-local (`Europe/Berlin`) date.
- If `project.case_number` is empty → fall back to a short hash of
`project_id` (8 hex chars) so the file still has a stable identifier
the lawyer can rename without losing track.
---
## §8 Authorization
- **Visibility gate:** `paliad.can_see_project(project_id)` — anyone
who can see the project can generate. Matches every other write
surface on the project. The endpoint inlines the predicate;
unauthorised callers get 404 (not 403, to avoid project
enumeration).
- **No profession floor.** A paralegal can generate a draft of a
Klageerwiderung; the draft is a Word doc that needs the associate's
approval downstream (in Word, on the document itself). Adding an
approval gate on generation would slow the workflow without
preventing anything that paliad's existing approval system doesn't
already cover at the substantive-act layer.
- **Owner gate (Paliadin) does NOT apply.** This is the
submission-template engine, not Paliadin. All paliad users get the
feature once a template exists for the proceeding their project is
in.
---
## §9 Audit trail
Two records per generation:
### 9.1 `paliad.documents` row (audit-only, no binary)
```sql
INSERT INTO paliad.documents (id, title, doc_type, file_path, file_size,
mime_type, ai_extracted, uploaded_by,
project_id)
VALUES (gen_random_uuid(),
'{rule.name} (generiert {YYYY-MM-DD})',
'generated_submission', -- new doc_type value
NULL, -- no on-disk path
NULL, -- no file size (binary not persisted)
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
jsonb_build_object(
'submission_code', $1,
'template_path', $2, -- the gitea path we resolved
'template_sha', $3, -- pinned SHA from the cache fetch
'firm', $4),
$user_id,
$project_id);
```
- `doc_type='generated_submission'` is a new value; no CHECK constraint
on doc_type today so this is additive.
- `file_path NULL` is the marker that says "regenerate from inputs on
demand". The /api/projects/{id}/documents listing UI (Phase 2) will
surface a `[Erneut generieren]` action for these rows.
- `ai_extracted` jsonb is repurposed for generation provenance
(template SHA, firm at time of generation). Naming is unfortunate
but the column shape fits; renaming the column is out of scope for
this task.
### 9.2 `paliad.system_audit_log` row
```sql
INSERT INTO paliad.system_audit_log (event_type, actor_id, actor_email,
scope, scope_root, metadata)
VALUES ('submission.generated',
$user_id,
$user_email,
'project',
$project_id::text,
jsonb_build_object(
'submission_code', $1,
'template_path', $2,
'template_sha', $3,
'document_id', $document_id,
'firm', $4));
```
Mirrors the existing `system_audit_log` event_type convention
(`*.created`, `*.updated`, etc., from t-paliad-214).
### 9.3 Verlauf entry (project event)
`paliad.project_events` gets a row with `event_type='submission_generated'`
and `timeline_kind='custom_milestone'` so the generation surfaces in
SmartTimeline's audit-log toggle and on the project's Verlauf list.
This is the user-visible footprint; the `system_audit_log` entry is
the admin-visible audit footprint.
---
## §10 Frontend surface
### 10.1 Slice 1 — SubmissionsPanel on project detail
A new panel below the existing Verlauf / Deadlines panels on
`/projects/{id}`:
```
┌─────────────────────────────────────────────────────────────────┐
│ Schriftsätze │
├─────────────────────────────────────────────────────────────────┤
│ Klageerhebung [— Vorlage fehlt] │
│ Klageerwiderung [Generieren ↓] │
│ Replik [— Vorlage fehlt] │
│ Duplik [— Vorlage fehlt] │
└─────────────────────────────────────────────────────────────────┘
```
- Filter: only `event_type='filing'` rules from the project's
`proceeding_type` are listed. Hearings and decisions don't have
submissions.
- Per-row state: `has_template` returned by
`GET /api/projects/{id}/submissions`. Disabled buttons show the
"Vorlage fehlt" hint (German default, English in EN locale).
- Click `[Generieren ↓]` → POST → browser triggers download.
- `aria-busy="true"` on the panel while a generation is in flight
(cheap, but lawyers feel slow networks).
### 10.2 Out of scope for Slice 1
- A standalone `/submissions` index page.
- A Frist-detail "Generate" button.
- A picker for template variants (1:N) — locked to fallback chain
(Q4), which is 1:1 from the user's perspective.
- An "edit project, then regenerate" loop on the same UI.
---
## §11 AI-drafted body (deferred — sketch only)
NOT in scope for t-paliad-215. Documented here so the next inventor
picking up the "AI Klageerwiderung body" task has a clear starting
shape.
The natural fit: a new aichat persona (e.g. `paliadin-draft`) on
mRiver, parallel to the existing `paliadin` persona.
```
{{ai.draft_body}} # placeholder in the template
→ generator detects {{ai.*}} placeholders in the template
→ POSTs to aichat with persona=paliadin-draft + context:
- project state (variables already built)
- relevant project notes (paliad.notes)
- the deadline_rule corpus (rule + family)
- HL Patents Style guide chunks (RAG, eventually)
→ aichat returns Markdown body
→ generator injects into the .docx as one or more <w:p> paragraphs
(Word-friendly Markdown → docx mapping needed; substantive
formatting question for that follow-up)
```
Open shape questions for that follow-up (NOT for this design):
- One persona per submission type, or one persona that branches on
`submission_code` in its system prompt?
- Owner gate (m only) like current paliadin, or open to all
authenticated users?
- Approval gate before the AI body lands in the .docx?
- Cost accounting per generation?
- Where does the prose context come from (notes / uploaded patent
spec / prior pleadings)?
Re-uses, when that task fires:
- This task's template engine, variable contract, fallback chain,
audit trail — all unchanged.
- Just a new placeholder family (`{{ai.*}}`) + a new aichat persona +
a new admin gate.
---
## §12 Slice plan beyond Slice 1
| Slice | Scope |
|---|---|
| 1 | One template (`de.inf.lg.erwidg`), engine + fallback chain + audit + SubmissionsPanel on project detail. THIS DESIGN. |
| 2 | 35 more templates (Klageerhebung, SoC `upc.inf.cfi.soc`, SoD `upc.inf.cfi.sod`, Berufungsbegründung `de.inf.olg.begruendung`). Template authoring effort, no new architecture. |
| 3 | Variable-contract sidebar in a new `/admin/submission-templates` page (mirrors `/admin/email-templates` shape). Shows what placeholders exist, with samples. Does NOT add an uploader UI — Gitea remains the editor. |
| 4 | Per-firm override directory exercised (first non-HLC firm onboarded). |
| 5 | Frist-detail "Generate" button + paliad.documents.deadline_id FK (mig 103+) for per-Frist draft history. |
| 6 | (Separate task) AI-drafted body via Paliadin persona — see §11. |
| 7 | (Future) Paliad UI uploader as alternative to Gitea, if the round-trip is friction. |
Slices 25 are roadmap markers, not commitments — m decides cadence.
---
## §13 Trade-offs flagged
1. **No binary persistence is a deliberate retention choice.** If a
lawyer regenerates after the project state changes (party renamed,
case_number corrected), the "regenerated" doc differs from the
"original generated" doc. This is a feature, not a bug — the source
of truth is paliad's project state, and the .docx is a derivative.
But the lawyer needs to be aware: there is no "what did I generate
last Thursday" recovery without re-saving locally. The
`paliad.documents` audit row records WHAT was generated (template
SHA + project state hash, optionally), but not the bytes.
2. **Gitea round-trip for template edits is friction.** Template
authors edit `.docx` in Word, save, drag to Gitea web UI (or push
from a local clone). The 5-min SHA cache means edits surface
within 5 minutes (or instantly via `POST /api/files/refresh`
already wired for the HL Patents Style template). If lawyers
complain, Phase 7 adds an in-paliad uploader. Until then, Gitea is
the editor.
3. **Variable contract changes are coordinated edits.** Adding a new
`{{project.*}}` placeholder needs both a code change (var bag) AND
template edits (templates won't auto-discover new placeholders).
The variable-contract sidebar (Slice 3) is the mitigation —
template authors see what's available without reading the Go code.
4. **`lukasjarosch/go-docx` library risk.** ~2k stars, MIT, maintained
— but it's a third-party dep we haven't used before. Fallback is
the in-house ~200-LoC walker. The placeholder syntax doesn't change
either way; Slice 1 can swap engines without touching templates or
callers.
5. **`paliad.documents.ai_extracted` is repurposed for generation
provenance.** Slightly ugly naming because the column was added for
Phase H (AI Frist-Extraktion), which never shipped. Renaming the
column to something like `metadata` is out of scope for this task
but should be folded into the migration that lands when Phase 5
adds `deadline_id`.
6. **`paliad.parties.role='claimant'`** — multiple claimants on a
project (multi-party suit) → Slice 1 picks the first row. v1
shortcut. Templates needing multi-claimant blocks become Phase 2
work (with a `{{#each parties.claimants}}` shape on top of
`lukasjarosch/go-docx`'s loop support).
7. **No Word-side `MERGEFIELD` support.** Lawyers who insert Word
merge fields (via Insert → Quick Parts → Field) instead of typing
`{{…}}` will get untouched MERGEFIELD codes in the rendered output.
Decision: standardise on `{{…}}` syntax (cheap to type, visible
in the template, predictable). Document this in the `templates/
README.md`.
8. **No template versioning UI.** Gitea provides git history; that's
the canonical version trail. Bumping to "use template X as of
commit Y" for an old project is a manual git-checkout-and-pin
exercise. Phase 2+ if anyone asks; not today.
---
## §14 Open follow-ups (NOT blocking)
These items are NOT m-decisions; they're follow-ups for the coder
shift or future inventor passes:
- **Template authoring effort.** Slice 1 needs HLC to author/review
the actual Klageerwiderung template. That's a legal-review task that
can run in parallel with the engine code (template uploaded last
before the slice ships). Coordinate with m on who reviews.
- **English version of `legalSourcePretty`.** Pretty-printer table in
§6.4 needs an EN column for every prefix — populated from existing
glossary entries where possible.
- **i18n key sweep.** `submissions.*` namespace; ~20 keys for Slice 1
(panel title, button labels, "Vorlage fehlt" hints, error messages
for 503/404/422).
- **README for template authors.** A `templates/README.md` in
m/mWorkRepo listing the available placeholders + naming convention
+ a screenshot of a working template. Coder ships this alongside
Slice 1.
- **CLAUDE.md update.** Add a "Submission templates" section
documenting the Gitea proxy, placeholder syntax, and the
`submission.generated` audit event_type.
- **Cleanup task for `ai_extracted` naming.** Issue + Phase 5 mig.
---
## §15 What this design does NOT do
To set the scope boundary cleanly:
- ❌ Generate PDFs.
- ❌ Generate emails or any non-.docx format.
- ❌ Edit `.docx` files inside paliad (no in-browser Word editor).
- ❌ Upload .docx to NetDocuments or any external DMS.
- ❌ Translate templates DE↔EN automatically.
- ❌ Validate the generated draft against any legal rule.
- ❌ Sign, certify, or notarise the output.
- ❌ Send the draft to court / e-filing.
- ❌ AI-draft any prose. (See §11.)
- ❌ Provide a paliad-UI template editor. (Gitea is the editor.)
- ❌ Persist generated .docx bytes server-side. (Audit row only.)
- ❌ Add a new database table. (`paliad.documents` is enough for v1.)
- ❌ Require a database migration. (Slice 1 is migration-free.)
Each of these is a defensible future-scope item; none belong in
Slice 1.
---
## §16 Recommended implementer
Pattern-fluent Sonnet coder. The substrate is well-trodden:
- Gitea proxy + cache: `internal/handlers/files.go` is the template
to lift.
- Variable contract pattern: `internal/services/email_template_variables.go`
is the template to mirror (different surface, identical shape).
- Visibility gate: `internal/services/visibility.go` +
`paliad.can_see_project()` — standard everywhere.
- Audit insert: `paliad.system_audit_log` (mig 102) + `paliad.documents`
(existing table, first writer).
- Frontend SubmissionsPanel: stock TSX + client/.ts pattern, same shape
as the existing CardLayout / EventsList panels.
The only novel piece is the docx merge library integration — that's a
~200 LoC isolated module the coder can prototype on a sample .docx
before wiring into the project flow.
NOT cronus per project memory directive.
---
## §17 Acceptance criteria for Slice 1
The coder considers Slice 1 done when:
1. Pushing a `.docx` to `m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`
and visiting any project with `proceeding_type=de.inf.lg` surfaces
a `[Generieren]` Klageerwiderung button.
2. Clicking it downloads a `.docx` named per §7 with all §6.2
placeholders resolved (or `[KEIN WERT: …]` markers for genuinely
missing project fields).
3. Opening the downloaded .docx in Word renders cleanly (no run
fragmentation artefacts, no broken styles).
4. A row appears in `paliad.documents` with `doc_type='generated_submission'`,
`file_path=NULL`, and `ai_extracted` jsonb carrying the template
path + SHA.
5. A row appears in `paliad.system_audit_log` with `event_type='submission.generated'`.
6. A row appears in `paliad.project_events` with
`event_type='submission_generated'` and shows up in the project's
Verlauf / SmartTimeline.
7. Calling the endpoint without project visibility returns 404.
8. Calling the endpoint with no template anywhere in the fallback
chain returns 503 with a clear error.
9. Unit tests cover: placeholder rendering happy path, missing-var
marker, fallback chain (all 4 levels), file naming, slash
sanitization, legalSourcePretty for every prefix in §6.4.
10. `go build ./... && go vet ./... && go test ./... && bun run build`
all clean.
11. Manual test on the live database (test admin
`tester@hlc.de` per memory) against a project with a real
`de.inf.lg` proceeding succeeds end-to-end.
---
## §18 Approval gate
Per inventor SKILL.md and project CLAUDE.md: this design needs m's
go/no-go before any coder is hired. After m approves:
- The head decides whether to hire the same worker as `/mai-coder`
with this design as the brief, or a fresh coder.
- A coder shift takes this doc as the spec, ships Slice 1, opens a
PR (no self-merge — maria's gate).
- Phase 11 (AI-drafted body) is a SEPARATE task — not auto-spawned.
Inventor parks here.

View File

@@ -0,0 +1,668 @@
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
**Author:** cronus (inventor)
**Date:** 2026-05-22
**Issue:** m/paliad (mai task t-paliad-238)
**Branch:** `mai/cronus/inventor-dedicated`
**Status:** DESIGN READY FOR REVIEW
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
---
## §0 TL;DR
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
1. Picks (or creates) a named **draft** for one (project, submission_code).
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
3. Sees a read-only preview pane showing the merged document body as HTML.
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
---
## §1 Premises verified live (2026-05-22)
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
| Claim | Verification |
|---|---|
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123``Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID``can_see_project`. |
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/``…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52``PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
---
## §2 m's decisions (2026-05-22)
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
| # | Question (from task brief) | Default adopted | Source |
|---|---|---|---|
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export``.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
Inventor-defaulted (not in the (R) matrix; clear right answer):
| # | Topic | Default | Reasoning |
|---|---|---|---|
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
---
## §3 Architecture overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Project detail (existing) — /projects/{id} with #tab-submissions │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ Schriftsätze tab │ │
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
│ │ "Bearbeiten" deep-link │ "Generieren" = │
│ │ │ existing one-click │
│ ▼ │ format-only export │
└───────────┼───────────────────────────────────────────┼──────────────────────┘
│ │
▼ │
┌──────────────────────────────────────────────────────────────────────────────┐
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
│ │
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
│ │ ───────────────────────────────── │ │ overrides] │ │
│ │ project.case_number │ │ │ │
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
│ │ BMW AG [override?] │ │ durch … │ │
│ │ deadline.due_date │ │ │ │
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
│ │ ... │ │ │ │
│ │ │ │ [KEIN WERT: project.our_side] │ │
│ │ [✎ Bearbeiten] inline │ │ │ │
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
│ │
│ [⬇ Als .docx exportieren] │
└──────────────────────────────────┬───────────────────────────────────────────┘
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
┌──────────────────────────────────────────────────────────────────────────────┐
│ handlers/submission_drafts.go (NEW) │
│ 1. Auth: ProjectService.GetByID → can_see_project │
│ 2. Load draft row (RLS via project visibility) │
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
│ 4. Apply draft.overrides on top of bag │
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
│ (Slice A: skips registry, fetches the universal .dotm directly) │
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
│ 7. Audit: system_audit_log + project_events │
│ 8. Stream .docx with Content-Disposition: attachment │
└──────────────────────────────────────────────────────────────────────────────┘
```
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
---
## §4 Schema (`paliad.submission_drafts`)
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
```sql
CREATE TABLE paliad.submission_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
-- empty value = "don't override, use bag"
-- present key = "use this verbatim"
last_exported_at timestamptz, -- NULL until first export
last_exported_sha text, -- template SHA at last export (audit aid)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_drafts_unique_per_user
UNIQUE (project_id, submission_code, user_id, name)
);
CREATE INDEX submission_drafts_project_user_idx
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
CREATE POLICY submission_drafts_visible
ON paliad.submission_drafts
FOR ALL
USING (paliad.can_see_project(project_id));
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
CREATE TRIGGER submission_drafts_updated_at
BEFORE UPDATE ON paliad.submission_drafts
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
```
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not**`system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
### 4.1 RLS read-vs-write
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
### 4.2 Down migration
```sql
DROP TABLE IF EXISTS paliad.submission_drafts;
```
No data loss concern at design time — feature ships without legacy drafts.
---
## §5 Service layer
### 5.1 Resurrect from git (no new code)
```
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
strip macros from the universal .dotm before the merge step runs.
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
NOT wired in Slice A (universal .dotm only); wired in Slice B.
```
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
### 5.2 New service — `SubmissionDraftService`
```go
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
type SubmissionDraftService struct {
db *sqlx.DB
projects *ProjectService
}
type SubmissionDraft struct {
ID uuid.UUID
ProjectID uuid.UUID
SubmissionCode string
UserID uuid.UUID
Name string
Overrides PlaceholderMap // jsonb → map[string]string
LastExportedAt *time.Time
LastExportedSHA *string
CreatedAt, UpdatedAt time.Time
}
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
// Visibility flows through projects.GetByID.
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
// Get returns a single draft by ID, gated on project visibility.
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
```
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
### 5.3 Wiring
```go
// cmd/server/main.go (additions, no replacements)
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
// Slice B only:
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
```
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
---
## §6 UI surface
### 6.1 Page layout
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
│ [⬇ Als .docx exportieren] │
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
│ │ 80333 München │
│ ───────────────────────────────────────── │ │
│ Variablen │ In der Sache │
│ firm.name HLC │ │
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
│ project.court LG München I [✎] │ — Klägerin — │
│ parties.claimant.name BMW AG [✎] │ │
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
│ parties.defendant.representative │ │
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
│ rule.legal_source_pretty │ │
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
│ … │ │
│ │ [KEIN WERT: project.our_side] │
│ ───────────────────────────────────────── │ │
│ [Entwurf umbenennen] [Entwurf löschen] │ │
└────────────────────────────────────────────┴────────────────────────────────┘
```
Sidebar grouping (top-to-bottom, locale-aware labels):
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
2. **Mandanten & Parteien** (parties.*)
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
4. **Frist** (deadline.* — due_date, computed_from)
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
### 6.2 Preview pane
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
### 6.3 Routing + handlers
```
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
```
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
### 6.4 Schriftsätze tab — additive change
```diff
- // Each row: [Generieren ↓]
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
```
`[Bearbeiten ↗]``window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
---
## §7 Variable contract (v1 placeholder set)
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
```
firm.name — branding.Name (HLC or FIRM_NAME override)
firm.signature_block — empty in v1 (Phase 2 affordance)
today — 2026-05-22 (ISO, Europe/Berlin)
today.iso — ISO short
today.long_de — "22. Mai 2026"
today.long_en — "22 May 2026"
user.display_name — paliad.users.display_name
user.email — paliad.users.email
user.office — paliad.users.office
project.title — paliad.projects.title
project.reference — paliad.projects.reference
project.case_number — paliad.projects.case_number
project.court — paliad.projects.court
project.patent_number — DE/inline form "EP 1 234 567 B1"
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
project.filing_date — ISO date
project.grant_date — ISO date
project.our_side — claimant | defendant
project.our_side_de — "Klägerin" | "Beklagte"
project.our_side_en — "Claimant" | "Defendant"
project.instance_level — lg | olg | bgh | cfi | …
project.client_number — paliad.projects.client_number
project.matter_number — paliad.projects.matter_number
project.proceeding.code — e.g. "de.inf.lg"
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
project.proceeding.name_de — explicit DE
project.proceeding.name_en — explicit EN
parties.claimant.name — first paliad.parties row with role='claimant'
parties.claimant.representative
parties.defendant.name — first row with role='defendant'
parties.defendant.representative
parties.other.name — first non-claimant/defendant row
parties.other.representative
rule.submission_code — "de.inf.lg.erwidg"
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
rule.name_de
rule.name_en
rule.legal_source — "DE.ZPO.276.1"
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
rule.primary_party — claimant | defendant | court | both
rule.event_type — filing | hearing | decision
deadline.due_date — ISO of next pending deadline for this rule on this project
deadline.due_date_long_de — "12. Juni 2026"
deadline.due_date_long_en — "12 June 2026"
deadline.original_due_date — ISO if extended
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
deadline.title — deadline.title
deadline.source — "rule" | "manual" | …
```
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
### 7.1 Override semantics
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
```go
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
for k, v := range draft.Overrides { // lawyer's edits
if v == "" {
delete(bag, k) // empty override means "force missing marker"
} else {
bag[k] = v // non-empty override replaces
}
}
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
```
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
---
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
### 8.1 Slice A — universal .dotm only
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
### 8.2 Slice B — fallback-chain registry
Layout reproduced from the 2026-05-19 design §5.1:
```
m/mWorkRepo (existing repo, already proxied)
└── templates/
├── HLC/ # FIRM_NAME-keyed override dir
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
│ ├── de.inf.lg.klage.docx # Slice B addition
│ ├── upc.inf.cfi.soc.docx # Slice B addition
│ └── upc.inf.cfi.sod.docx # Slice B addition
├── _base/ # Cross-firm baseline
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
│ ├── de.inf.lg.docx # proceeding-family fallback
│ ├── upc.inf.cfi.docx
│ ├── _skeleton.docx # ultra-generic fallback
│ └── _universal.docx # the v1 Slice A "any code" template
└── README.md # placeholder reference for template authors
```
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
### 8.3 Lookup algorithm
```go
// services/submission_templates.go (resurrected from 3677c81)
func (r *TemplateRegistry) candidates(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
}
if family != "" && family != submissionCode {
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
}
out = append(out, "templates/_base/_skeleton.docx")
return out
}
```
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
### 8.4 No template at all — Slice A vs Slice B
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
### 8.5 Template authoring task lands outside this design
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
---
## §9 Slice plan
### Slice A — schema + new page + variables-only export against universal .docx
Ships the editor end-to-end with one template.
| Deliverable | Files |
|---|---|
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
Acceptance:
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
2. Sidebar renders ~30 placeholders, pre-filled from project state.
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
7. `project_events` row appears on export and surfaces in Verlauf.
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
### Slice B — per-submission_code templates + fallback chain
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
| Deliverable | Files |
|---|---|
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
Acceptance:
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
### Slice C — toggleable passages
Lawyer can include/exclude boilerplate sections before export.
| Deliverable | Notes |
|---|---|
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb``{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
---
## §10 Out of scope
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
- Citation insertion from the sources system. Waits for the sources surface m parked.
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
---
## §11 Material picks escalated to head
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
### Q-E1 — Template authoring effort
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
### Q-E2 — `paliad.documents` row on export
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
### Q-E3 — Preview render — server or client?
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
### Q-E4 — Inter-user draft visibility
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
---
## §12 Implementation notes
For the coder, not for head.
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
---
## §13 Acceptance gate
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
- Slices B and C are SEPARATE tasks — not auto-spawned.
Inventor parks here.

View File

@@ -0,0 +1,918 @@
# User-authored checklists: authoring, sharing, admin-promotion
**Task:** t-paliad-225 — Gitea m/paliad#61
**Inventor:** dirac, 2026-05-20
**Branch:** `mai/dirac/user-checklists`
**Status:** DESIGN READY FOR REVIEW
## 1. Problem statement
Paliad ships a curated catalog of UPC / DE / EPA checklists today
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
on Akten and check items off; per-instance state lives in
`paliad.checklist_instances` and is gated by the parent project's
team-based visibility.
m wants three new capabilities (m 2026-05-20 14:14):
1. **User-authored templates** — any non-`global_admin` can create a
checklist template they own (title, sections, items, references).
2. **Sharing** — author shares with specific colleagues, an Office, a
Dezernat (partner-unit), a project team, or the whole firm.
3. **Admin promotion to global**`global_admin` promotes an authored
template into the firm-wide catalog so it appears alongside the
curated UPC/DE/EPA templates for every user.
This design covers all three across three sequential slices.
## 2. Premises verified live (load-bearing findings)
The Gitea issue body says "Add `owner_id uuid NULL` to
`paliad.checklists`". That table **does not exist**. Verifying against
the live DB and the code corrected several premises:
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
are pure Go data in `internal/checklists/templates.go` (6 entries,
~310 lines), served by `internal/handlers/checklists.go` via
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
`paliad.checklist_instances` (per-user state) and
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
design has to introduce `paliad.checklists` from scratch.
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
validity is enforced in `ChecklistInstanceService.Create` against the
static Go registry. This is what lets the design keep the static
catalog as one source of truth and add the DB catalog as a parallel
source: instance creation just resolves the slug against the merged
view and snapshots the template body.
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
109 user_dashboard_layouts, 110 project_type_other, 111
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
today). At inventor time the next free slot is **112**. The coder
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
start — the slot can drift if other branches merge first.
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
branches on global_admin shortcut + project_teams responsibility =
'admin'. **Used by this design** to gate the "Make global" button (we
reuse the global_admin shortcut, not the project-admin branch — see
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
predicates we add.
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
`scope` ∈ {org, project, personal}, `scope_root uuid`,
`metadata jsonb`. RLS: self-read for the actor +
global_admin read-all. **Pattern to follow:** insert event row at
state transition (see `ExportService.WriteAuditRow` in
`internal/services/export_service.go:1120` for the canonical shape).
- **`paliad.project_events`** is the project-timeline audit sink and is
already wired for checklist instance lifecycle events
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
`_deleted`). We do NOT need to invent a new event_type for instance
events; we'll add a few `_snapshot_taken` / template-level events to
`system_audit_log` and keep instance events on `project_events`.
- **`paliad.users.office`** is `text` (CHECK against the office key
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
have `additional_offices text[]`. Both are first-class columns; no
separate `offices` table.
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
timestamps) is the Dezernat / practice-group table. Membership lives
in `paliad.partner_unit_members`. Projects attach via
`paliad.project_partner_units` (with derivation flags). All three
are referenceable from a share recipient.
- **`paliad.users.global_role`** is `text`; values include
`'global_admin'`. Used for the firm-wide promote/demote authority.
- **`paliad.project_teams`** (mig 111 just added) carries
`responsibility` ∈ {admin, lead, member, observer, external}. We
reuse `can_see_project` (visibility) for share-to-project recipients,
NOT `effective_project_admin`. The semantic of "share with a project
team" is "anyone on the matter sees it", not "anyone who can edit
membership sees it".
- **No precedent for entity-level sharing in paliad.** The personal-
sidecar tables (`user_views`, `user_dashboard_layouts`,
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
share columns. Existing visibility predicates
(`paliad.can_see_project`) walk the project tree, not arbitrary
entities. This design introduces the first multi-axis share pattern
in the codebase (§3.2).
## 3. Architecture: hybrid templates + share table
### 3.1 Two template sources, one read layer
**KEEP** the static Go template registry as the firm's curated catalog.
It's version-controlled, code-reviewed, immutable at runtime, and the
right substrate for legally-curated content (RoP citations, EPC rule
references). Migrating those into DB rows would lose the git review
trail for content that requires lawyer eyes.
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
Same Template shape (slug, titles, regime, court, groups[], items[])
but stored as JSONB so the schema doesn't have to chase content
evolution.
A `ChecklistCatalogService` unifies the two at read time:
- `ListVisible(user)` → static templates DB rows the user can see
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
rejected if they collide with a static slug).
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
their JSON shape — they just delegate to the catalog service instead of
the bare static registry.
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
The task brief asks for a "modular / abstract" solution. I considered a
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
recipient_*)` table that could later carry shares for views, dashboards,
saved searches, project templates, etc.
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
v1.** Reasons:
1. There is NO second entity in paliad that requests sharing today —
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
`user_pinned_projects` are all explicitly owner-only by design (see
migration comments). The "future reuse" is hypothetical.
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
needs its own deletion trigger. That complexity is real, the
reusability gain is not.
3. The CORRECT abstraction emerges by extracting *after* the second use
case shows up. Right now we don't know whether dashboards want the
same recipient axes (user / office / partner-unit / project) or a
different set (e.g. dashboards probably want "everyone on a project"
not "the whole firm").
The design IS modular in the sense that the recipient resolution logic
(below) is centralized in one SQL predicate (§4.3) which a future
polymorphic refactor can lift verbatim.
If the second entity asks for sharing within ~3 months, refactor to
`paliad.entity_shares` as a single-mig follow-up. Until then,
`paliad.checklist_shares` keeps the schema honest.
### 3.3 Visibility states
`paliad.checklists.visibility text` (CHECK enum):
| state | who sees | who edits |
|-----------|----------------------------------------------------|---------------------|
| `private` | owner only | owner |
| `shared` | owner + explicit recipients in checklist_shares | owner |
| `firm` | owner + every authenticated paliad user | owner |
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
`firm` vs `global` distinction:
- `firm` = author self-published. Author can flip back to private/shared
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
- `global` = admin-promoted into the firm catalog. Appears in the main
Vorlagen tab alongside the static templates. Author retains edit
authority by default; only `global_admin` can demote.
Demotion target: `global → firm` (preserves visibility for users who
already started instances). Author can subsequently narrow further.
### 3.4 Template snapshot on instance create
m's brief calls this out as a design decision: when an author edits a
template, do existing instances pick up the changes (propagate) or stay
on the version they were created from (snapshot)?
**Pick: snapshot.** Inventor pick (R). Rationale:
1. **Data integrity.** Instances are working artefacts. A user halfway
through a Klageerwiderung instance shouldn't have items disappear or
reorder under them because the author edited the template.
2. **Audit story.** The completed instance shows exactly what the
author saw when they started. Reconstruction without git-blame on
the template.
3. **Visibility narrowing safe by construction.** If author unshares
from a colleague who already has an instance, the instance survives
because the snapshot is local.
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
exceed a few per user per template. Even 10× the row size of today
is fine.
Schema cost: one nullable `template_snapshot jsonb` column on
`paliad.checklist_instances`. Backfilled lazily existing instances
keep `NULL`, service falls back to looking the slug up in the catalog;
new instances always get a snapshot. Slice C can backfill the column
for already-existing rows via a one-off `UPDATE` if we want strict
consistency.
## 4. Schema (migration 112 — verify slot at coder shift)
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
+ matching `.down.sql`. Idempotent throughout
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
> Slot caveat: at design time, latest disk = 111, live tracker = 106
> (mig 107-111 pending deploy). Coder MUST re-verify
> `ls internal/db/migrations/ | tail` at shift start. If a higher
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
> 112), bump to the next free slot.
### 4.1 `paliad.checklists` — authored template catalog
```sql
CREATE TABLE paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
-- Authoring metadata
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
-- Body
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
-- Lifecycle
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz, -- set on transition to 'global'
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- Timestamps
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
```
**Slug-collision safety net:** application layer validates that the
chosen slug doesn't collide with a static template slug. The static
list is loaded into a `map[string]bool` at boot. New authored slugs
auto-prefixed with `u-` so collisions with static slugs are structurally
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
### 4.2 `paliad.checklist_shares` — explicit grants
```sql
CREATE TABLE paliad.checklist_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
recipient_office text,
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
-- XOR check: exactly one recipient_* column populated per kind
CONSTRAINT checklist_shares_recipient_xor CHECK (
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
)
);
-- Avoid duplicates per recipient
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
-- Hot-path index for the visibility predicate
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
```
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
```sql
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner can always see
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.owner_id = _user_id
)
-- 'firm' / 'global' visible to all authenticated users
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
)
-- Explicit share: user
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = _user_id
)
-- Explicit share: office (matches user.office OR additional_offices)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
-- Explicit share: partner_unit (caller is a member)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'partner_unit'
)
-- Explicit share: project (caller can see the project via existing predicate)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'project'
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
);
$$;
```
> Note on `can_see_project` self-reference: that function reads
> `auth.uid()` internally — when called from inside another SECURITY
> DEFINER body it picks up the caller's uid via search_path inheritance
> (same pattern as `effective_project_admin` reuse in mig 111).
### 4.4 RLS on `paliad.checklists`
```sql
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist
CREATE POLICY checklists_select
ON paliad.checklists FOR SELECT TO authenticated
USING (paliad.can_see_checklist(auth.uid(), id));
-- INSERT: caller can only create templates owned by themselves
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
CREATE POLICY checklists_update
ON paliad.checklists FOR UPDATE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
)
WITH CHECK (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- DELETE: owner OR global_admin
CREATE POLICY checklists_delete
ON paliad.checklists FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
```
### 4.5 RLS on `paliad.checklist_shares`
```sql
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
CREATE POLICY checklist_shares_select
ON paliad.checklist_shares FOR SELECT TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- INSERT: only the checklist owner can grant
CREATE POLICY checklist_shares_insert
ON paliad.checklist_shares FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
AND granted_by = auth.uid()
);
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
CREATE POLICY checklist_shares_delete
ON paliad.checklist_shares FOR DELETE TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
```
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
```sql
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
```
Existing RLS on `checklist_instances` untouched.
## 5. Service layer
### 5.1 `internal/services/checklist_catalog_service.go` (new)
Unified read facade over static + DB templates.
```go
type ChecklistCatalogService struct {
db *sqlx.DB
}
type CatalogEntry struct {
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
Origin string // "static" | "authored"
OwnerID *uuid.UUID // nil for static
OwnerName string // empty for static
Visibility string // "static" | "private" | "shared" | "firm" | "global"
Template checklists.Template
}
// ListVisible returns every catalog entry the caller can see.
// Static entries are always returned. DB entries pass through RLS.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
// Find returns one entry by slug (static lookup first, then DB).
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
```
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
CRUD on `paliad.checklists`.
```go
type ChecklistTemplateService struct {
db *sqlx.DB
users *UserService
}
type CreateTemplateInput struct {
Title string
Description string
Regime string
Court string
Reference string
Deadline string
Lang string
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
}
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
```
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
suffix (collision retry up to 3x). Validator enforces
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
`internal/checklists/checklists.go` Templates rejected at write time.
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
```go
type ChecklistShareService struct { db *sqlx.DB }
type ShareGrantInput struct {
RecipientKind string
UserID *uuid.UUID
Office string
PartnerUnitID *uuid.UUID
ProjectID *uuid.UUID
}
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
```
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
`global_admin`-only operations.
```go
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
```
Promote: assert caller.global_role = 'global_admin' UPDATE visibility =
'global', promoted_at = now(), promoted_by = caller audit row
`event_type='checklist.promoted_global'`.
Demote: assert caller is global_admin UPDATE visibility = target
(default 'firm') audit row `event_type='checklist.demoted'`.
### 5.5 Wire instance create to take snapshot
`ChecklistInstanceService.Create` extends to capture
`template_snapshot` at insert time via
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
(NULL snapshot, fallback path in read layer).
### 5.6 Endpoints
| Method | Path | Slice | Purpose |
|--------|------|-------|---------|
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
| `POST` | `/api/checklists/templates` | A | Create authored template |
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
## 6. Instance snapshot lifecycle
**On Create (`ChecklistInstanceService.Create`):**
1. Resolve slug via `catalog.Find(userID, slug)` enforces visibility.
2. `snapshot = catalog.SnapshotBody(userID, slug)` captures the
template body (groups + items) at this moment, as JSONB.
3. Insert into `checklist_instances` with
`template_snapshot = snapshot`, `template_slug = slug`,
`state = '{}'::jsonb`.
**On Read (`ChecklistInstanceService.GetByID`):**
- Return the instance with `template_snapshot` if non-null.
- If NULL (legacy row created before mig 112), fall back to
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
**On Template Edit (Slice A):**
- Owner edits template via PATCH DB row mutated `checklists.updated_at`
bumped no propagation. Existing instances continue rendering their
snapshot. New instances pick up the edit.
- Audit row `event_type='checklist.edited'`,
`metadata={ checklist_id, slug, changes:[...] }`.
**On Template Delete:**
- DB row deleted. Instances that snapshotted survive (snapshot is
local). Instances that DIDN'T snapshot (NULL) gracefully degrade
service detects "template not found in catalog" and returns the
instance with a sentinel "template withdrawn" body (renders a small
banner client-side; checkboxes still work because `state` is the
source of truth, not the template).
**On Visibility Narrow (firm → shared → private):**
- Existing instances unaffected (snapshot is local; visibility check is
on the template, not instance).
- New instance attempts fail with `ErrNotVisible` (the user can no
longer see the template to instantiate it).
## 7. Frontend (concise sketch — coder owns the detail)
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
```
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
```
- **Vorlagen** (existing): static catalog + global-promoted DB
templates, grouped by Regime, filter pills (UPC/DE/EPA).
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
Vorlage" CTA. Each card shows title, description, visibility chip,
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
optionally render an "📌 Snapshot" badge when `template_snapshot` is
non-null (Slice A backfill marker).
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
templates not yet promoted discovery surface).
### 7.2 `/checklists/new` (NEW — Slice A)
Authoring wizard. Three steps:
1. Metadata title, description, regime (UPC/DE/EPA/OTHER), court,
reference, deadline.
2. Sections + items repeating editor (group title items[] of
{label, note, rule}).
3. Visibility radio: privat / firm-weit. (Sharing flow comes in
Slice B.)
Save POST `/api/checklists/templates` redirect to
`/checklists/{slug}` detail.
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
Same wizard, prefilled. Owner-only (404 otherwise).
### 7.4 `/checklists/{slug}` detail page
Existing detail page renders the template (static OR authored).
Additions:
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
entfernen" button (Slice B).
- Provenance line under the title: "Erstellt von <author> · <date>"
(only for DB templates).
### 7.5 Share modal (Slice B)
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
- Kollegen (user-picker, multi-select)
- Office (chip-select from `offices.All`)
- Dezernat (chip-select from `partner_units`)
- Projekt (autocomplete from owner-visible projects)
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
"firm-weit" greys out the picker (firm-weit doesn't need grants).
Apply → POST grants individually → audit emits one
`event_type='checklist.shared'` per grant with
`metadata={ recipient_kind, recipient_id, checklist_id }`.
### 7.6 i18n keys
~28 new keys (DE+EN) under `checklisten.authoring.*`,
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
## 8. Audit events
Org-scope (`paliad.system_audit_log` via a small new helper
`SystemAuditLogService.WriteChecklistEvent`):
| event_type | actor | metadata keys |
|----------------------------------|-------------|----------------------------------------------------|
| `checklist.authored` | owner | checklist_id, slug, visibility |
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
Project-scope (`paliad.project_events` — existing helper
`insertProjectEventWithMeta`): existing checklist-instance events
unchanged. NO new project_events types for templates — templates are
not project-scoped.
`AuditService.ListEntries` already reads from `system_audit_log` via
the UNION ALL branch added in t-paliad-214 — no changes needed there;
new event_types surface automatically in the audit log UI.
## 9. Slice plan
### Slice A — Foundation (~700 LoC)
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
no share table yet; visibility limited to private/firm.
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
`SystemAuditLogService.WriteChecklistEvent` helper.
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
`/checklists/{slug}/edit`, owner controls on detail page.
**Test pass:** unit tests for slug validation, snapshot capture,
visibility predicate (without share rows), audit emit, fallback to
catalog when snapshot NULL.
**No share, no admin promote, no gallery.** Ships immediately useful
for solo authoring + firm-wide publishing.
### Slice B — Sharing + Promotion (~600 LoC)
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
sub-enum (Slice A schema already includes 'shared' as valid value —
just no grants point at it yet).
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
**Endpoints:** shares endpoints + admin promote/demote.
**Frontend:** Share modal, "Make global" admin button on detail page,
share-grant chip list on detail page (owner-only).
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
### Slice C — Discoverability + UX polish (~400 LoC)
**Gallery page** `/checklists/gallery`: browses every template the user
can see that's NOT their own, grouped by Regime / Author / Recency.
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
**Backfill** existing `checklist_instances` with `template_snapshot`
via a one-off migration (mig 114) — pure data move, no schema change.
After backfill, the catalog-fallback path can be removed (deferred to
Slice D / cleanup).
**Optional**:
- "Vorlage kopieren" action — clone an existing template (static OR
authored) into the caller's "Meine Vorlagen" for personal adaptation.
- Per-template instance counter ("12 Kollegen haben diese Vorlage
benutzt") — surfaced from `checklist_instances` group-by.
## 10. Trade-offs flagged
1. **Hybrid catalog (static + DB).** Two sources of truth means two
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
reserved-list rejection. Refactoring all static templates into DB
loses the git review trail; the hybrid is the right cost.
2. **Polymorphism deferred.** A future second sharable entity will need
to either copy the `checklist_shares` pattern (cheap but duplicative)
or refactor to `entity_shares` (one mig). The refactor is small;
premature abstraction now would pay complexity for no current
benefit.
3. **Snapshot semantics may surprise.** A user who edits their template
expecting downstream instances to update will be confused.
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
detail page that re-snapshots from the current template (preserves
the user's checkbox state to the extent items still match).
4. **Office membership is set-membership, not hierarchy.** Sharing to
"munich" reaches every user with `office='munich'` OR
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
plus its sub-teams" because offices don't nest in paliad. Fine.
5. **Partner-unit membership join is N+1 on the predicate.** Each
visibility check touches `partner_unit_members` if any partner-unit
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
already exist (per mig 027 lineage); the join is single-row.
6. **Share-to-project recipient resolution uses
`can_see_project(s.recipient_project_id)`.** That predicate reads
`auth.uid()` from the session, so it works correctly inside our
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
in `paliad.can_see_project` source — same pattern that
`effective_project_admin` uses in mig 111.
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
Means a global_admin can edit content of any user's template, not
just visibility. This is intentional for catalog hygiene
(correcting typos, removing inflammatory content) but should be used
sparingly and audited. The audit log captures every
global_admin-attributed edit via `checklist.edited` with actor_id.
8. **Instance snapshot fallback path lives indefinitely.** Existing
pre-mig-112 instances stay NULL until Slice C backfills. The
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
no hot-path concern — but it's "dead code" once the backfill runs.
Acceptable until Slice C.
9. **Cascade on owner deletion.** If an authored template's owner is
removed (`paliad.users.id` cascades), the template is wiped along
with all its shares. Existing instances survive via snapshot. The
alternative (transfer ownership to global_admin on user-delete) is
more polite but introduces governance questions ("which admin?")
that aren't worth Slice A complexity. Flag for Slice C if it bites.
10. **Slug uniqueness across origins enforced application-side.**
The static catalog is in-memory at boot. If a deploy adds a static
slug that collides with an existing DB slug, the deploy boots
cleanly but the DB row becomes unreachable via the catalog read
layer (static wins on slug lookup). Mitigation: a boot-time
integrity check in `cmd/server/main.go` logs WARN if collision
detected. Owner can rename their template manually via the edit UI.
## 11. m's decisions ledger (all defaulted to (R) per task brief)
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
material." I have not escalated; all picks below default to (R).
| # | Question | (R) pick |
|---|---------------------------------------------------------|-------------------------------------------|
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
Material escalation list: empty. If m disagrees with any of the above,
amend §11 in the next inventor shift; the schema is designed to be
forward-compatible with most reversals (e.g. flipping snapshot →
propagate is a service-layer change, not a schema change).
## 12. Acceptance criteria — Slice A
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
`paliad` schema).
2. **`/api/checklists` returns merged catalog** — static templates
plus DB templates the caller can see (visibility ∈ {firm, global}
OR owner = caller).
3. **POST `/api/checklists/templates`** creates a row, returns the
created template with auto-generated `u-…` slug, emits
`checklist.authored` audit row.
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
fields, rejects 403 from non-owner non-admin, emits
`checklist.edited`.
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
private↔firm; rejects `shared` and `global` in Slice A (those land
in Slice B); emits `checklist.visibility_changed`.
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
existing instances survive via snapshot.
7. **Instance create snapshots the template body**
`template_snapshot` non-null on every new instance row.
8. **Legacy instances (NULL snapshot) still render** via catalog
fallback (covered by a regression test).
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
CTA navigates to `/checklists/new`; wizard saves successfully.
10. **`go build ./... && go vet ./... && go test ./internal/...`
clean.** `bun run build` clean (i18n key count incremented by ~20).
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
template; setting visibility to `firm` makes it visible to a second
tester account; deleting the template doesn't break existing
instances.
## 13. Recommended implementer
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
directive 2026-05-06). Substrate is well-trodden:
- Migration shape mirrors mig 111 (gauss) for the predicate function +
policy replacement pattern.
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
emit + visibility check.
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
- Frontend tab pattern mirrors the existing
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
Novel pieces:
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
prototype before committing to the full slice. Pure function; easy
to unit-test.
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
into a STABLE SECURITY DEFINER function; pattern matches mig 111
exactly.
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
or one branch with three commits — coder's call. Each slice ends with
acceptance criteria; head merges between slices for fast feedback.
## 14. Out of scope (explicitly)
- Importing checklists from external sources (Notion, Trello, .docx).
- Approval-policy gating on checklist edits (admin pre-publish review).
- Cross-firm template marketplace.
- Translation workflow (de↔en) for authored templates — Slice A
ships single-language; if firm appetite shows up post-launch, file
a follow-up.
- Static-catalog editor UI (the static templates remain code-only).
- Versioning UI ("show me the version this instance was created from")
— snapshot is captured; surfacing it is Slice C polish.
---
**Inventor parked per gate protocol.** No auto-shift to coder. Head
decides: same worker as `/mai-coder` with this brief, fresh coder, or
rescope. Slice ordering A → B → C is independent enough that the head
can also greenlight Slice A alone and re-design B/C after Slice A
ships.

View File

@@ -0,0 +1,435 @@
# Fristenrechner Gap-Fill Proposals — t-paliad-203
**Date:** 2026-05-18
**Author:** curie (researcher)
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
**Branch:** `mai/curie/fristenrechner-gap`
**Supersedes:** t-paliad-201 (cancelled)
**Source audit:** the four gaps surfaced by mig 093 commit message (t-paliad-200, `internal/db/migrations/093_retire_litigation_category.up.sql:40-54`) when 40 Pipeline-A litigation rules were archived under `_archived_litigation` and 7 litigation proceeding_types were dropped
---
## 0. Read-this-first — what was archived, what's left
mig 093 (commit `40e49e8`) retired the entire `category='litigation'` rule corpus by:
1. Snapshotting the 40 rules into `paliad.deadline_rules_pre_093` and the 7 proceeding_types into `paliad.proceeding_types_pre_093`.
2. Re-homing all 40 rules under a holding proceeding_type `_archived_litigation` (id 32, `category='archived'`, `is_active=false`, `lifecycle_state='archived'`).
3. Dropping `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL` from `paliad.proceeding_types`.
The commit's own body listed four open coverage questions for legal review (lines 40-54 of `093_retire_litigation_category.up.sql`):
| # | Pipeline-A rule(s) | Claim in commit body | This doc's verdict |
|---|---|---|---|
| 1 | `inf.prelim` (R.19, 1 month) | "not present on UPC_INF — possible coverage gap" | **Real gap.** Drafts 1.1 + 1.2 below. |
| 2 | `inf.appeal` / `rev.appeal` / `ccr.appeal` (RoP.220.1, 2 months) into UPC_APP | "fristenrechner UPC_APP starts standalone with no spawn" | **Real gap.** Drafts 2.1 + 2.2 below. Pipeline-A's three rules collapse to two in the unified UPC_INF (CCR-as-flag) world — see § 2 FLAG. |
| 3 | `ccr.amend` / `rev.amend` (spawn into AMD) | "superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop" | **Claim confirmed for patent amendment.** No new rules. § 3 documents the verification and surfaces R.263 (case-amendment) as a separate not-modelled item. |
| 4 | `zpo.klage` / `zpo.vertanz` / `zpo.klageerw` / `zpo.berufung` | "no UPC analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH / DE_NULL / DE_NULL_BGH" | **Claim confirmed for klage / vertanz / berufung.** `klageerw` exists on DE_INF but with a duration discrepancy worth m's attention. § 4 details. |
**Net: 4 substantive rule drafts** (1 PO on UPC_INF + 1 PO on UPC_REV + 2 merits-appeal spawns) — well under the "~4-10" estimate in the brief, and at the low end because two of the four gaps don't need new rules.
### 0.1 Naming convention notes
- **Appeal proceeding code referenced by ROLE, not by current code.** Per task brief and pairing with t-paliad-204 (proceeding-code abbreviation rework, m's review pending), the current `UPC_APP` (id=11) is referred to in proposals 2.1/2.2 as **"UPC infringement-appeal proceeding (RoP 220.1(a) main-judgment appeal)"** rather than by code. m picks the final `spawn_proceeding_type_id` when ingesting via `/admin/rules`.
- **Existing rule-code pattern.** Live `UPC_INF` rules use bare prefix `inf.*` (not `upc.inf.*`), e.g. `inf.sod`, `inf.def_to_ccr`. Live `UPC_REV` rules use `rev.*`. I follow that pattern: proposed PO rules are `inf.prelim` (matching Pipeline-A's archived name) and `rev.prelim`; proposed spawn rules are `inf.appeal_spawn` / `rev.appeal_spawn` (the `_spawn` suffix disambiguates them from the existing UPC_APP-root `app.notice`, which is the *target*, not the *source*).
- **Anchor semantics** (per `docs/audit-fristen-logic-2026-05-13.md` § 4 and `docs/proposals/orphan-concepts-2026-05-15.md` § 0.2): `parent_id NOT NULL` chains the new rule off an existing rule in the same proceeding. `trigger_event_id NOT NULL` roots the rule on a paliad/youpc trigger event. The unified Phase 2 schema (Slice 4, mig 081+082) supports both — proposals use `parent_id` whenever the natural anchor is an existing intra-proceeding rule (e.g. `inf.soc` for inf.prelim), which matches the pattern set by `inf.sod`, `inf.def_to_ccr`, etc.
- **`condition_expr` form.** Existing UPC_INF / UPC_REV conditional rules use `{"flag":"with_ccr"}` or `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`. The proposals add three new flag names — `with_po`, `with_appeal`, and reuse `with_amend` only where existing. Flag names are surfaced as **FLAG** items for m to confirm before ingest.
### 0.2 What's deliberately out of scope
- **Order-appeals (R.220.2/R.220.3) spawn wiring** — the brief specifies RoP 220.1(a) (main-judgment, 2-month appeal → `UPC_APP`). The 15-day order/discretion track lives in `UPC_APP_ORDERS` and has its own root rules (`app_ord.with_leave`, `app_ord.discretion`). Spawn rules from UPC_INF/UPC_REV/UPC_PI for that track would be a separate proposal — flagged as future-work in § 6.
- **Cost-decision-appeal spawn (R.221.1)** — `UPC_COST_APPEAL` exists with `cost.leave_app` as a root rule. Same shape as the order-appeals: future-work, not this proposal.
- **R.263 application to amend the case** — surfaced in § 3 but not drafted as a rule because it's court-discretion (no calendar deadline computable from a fixed anchor).
- **Vertagungsantrag (ZPO §227)** — the brief's description of Gap 4 named "Vertagungsantrag" but the Pipeline-A rule code `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction of "Verteidigungs-Anzeige"), not Vertagungsantrag. There is no Vertagungsantrag rule anywhere in the corpus today; if m wants one, that's a fresh proposal. Documented in § 4 FLAG.
---
## 0.3 m's decisions on the open FLAGs (2026-05-18)
Captured live with paliadin/head. Anything not explicitly answered defaults to curie's recommendation.
### Gap 1 — Preliminary Objection
- **F1.4 (CCR-defendant PO):** **NO** — do not seed a third PO rule for the patentee on a CCR. Final shape stays at 2 PO rules: `inf.prelim` + `rev.prelim`.
- F1.1 (flag name): default to curie's `with_po`.
- F1.2 (priority): default to curie's `optional`.
- F1.3 (citation pattern): default to curie's `UPC.RoP.19.1` substantive-cite for both rules (cross-ref to R.46 lives in the description, not the legal_source field).
### Gap 2 — Appeal spawns
- **F2.1 (drop `ccr.appeal`):** **CONFIRMED** — one decision under R.118 = one 2-month appeal window. Rule 2.3 explicitly NOT seeded. Final shape stays at 2 spawn rules.
- **F2.3 (appeal flag-gated or always-fire):** **ALWAYS-FIRE.** Rationale (m): "the appeal deadline should always be triggered by a decision … the flags for ccr / amend are different because that is something which only comes up during the proceedings and depends on a party. Appeal is always a possibility." So both `inf.appeal_spawn` and `rev.appeal_spawn` ship **without `condition_expr`** — the 2-month window unconditionally appears once `inf.decision` / `rev.decision` is anchored. Visibility filtering ("hide appeal deadlines on projects where the user doesn't care") is a frontend concern, not a rule-level flag — surfaced as follow-up (see § 6.X below).
- F2.2 (anchor): default to curie's `parent_id = inf.decision` / `rev.decision` (consistent with how `inf.cost_app` already chains).
### Gap 3 — `ccr.amend` / `rev.amend`
- **F3.1 (model R.263?):** **NO** — court-discretion, no calendar deadline computable. If R.263 ever needs surfacing, it goes on the project page as a checklist item, not the fristenrechner.
### Gap 4 — `zpo.*` family
- **§4.3 — `de_inf.erwidg` discrepancy (6 weeks vs. court-set 2-week minimum):** **FLIP to court-set.** Klageerwiderung is statutorily court-set with a 2-week minimum under ZPO §276(1) S.2; the existing 6-week fixed-duration rule is wrong. Action at ingest: `is_court_set=true`, keep `duration_value=6, duration_unit='weeks'` as the **default display value** when no court order is yet attached, with the description noting "Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO)." This matches the pattern existing court-set rules use elsewhere.
- F4.1 (legal_source backfills on `de_inf.klage` etc.): default to curie's "yes — apply the polish patches in § 4.1, § 4.2, § 4.4".
### Final delta to ingest via `/admin/rules`
```
NEW RULES (4):
inf.prelim UPC_INF parent=inf.soc 1mo RoP.19.1 flag=with_po optional
rev.prelim UPC_REV parent=rev.app 1mo RoP.19.1 flag=with_po optional
inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
PATCHES on existing rules:
de_inf.klage set legal_source = 'DE.ZPO.253'
de_inf.anzeige (no change — already correct)
de_inf.erwidg flip is_court_set = true; description note about §276 Abs.1 S.2
de_inf.berufung (verify legal_source — curie's §4.4 polish patch)
```
### Follow-up surfaced — not for this proposal
- **Frontend visibility toggle for appeal deadlines** — m flagged that appeals "always fire" at the rule level but the UI could hide them on projects where the user doesn't want to see them. NOT a rule-corpus question; file as a separate frontend task if/when m signals.
- **`ccr.appeal` in `_archived_litigation`** — the Pipeline-A `ccr.appeal` row stays archived (m's call F2.1). No further action.
- **Vertagungsantrag (ZPO §227)** — never modelled; not in scope. Open follow-up if m wants it.
---
## 1. Gap 1 — Preliminary Objection (RoP 19)
**Status:** Real gap. Pipeline-A had `inf.prelim` (defendant, 1 month, R.19, "Rarely triggers separate decision; usually decided with main case") — archived without a fristenrechner replacement.
Verification — current UPC_INF / UPC_REV corpus has zero rules with `rule_code` matching `R.19`, `RoP.019`, or any "Preliminary Objection" variant; verified via `SELECT * FROM paliad.deadline_rules WHERE rule_code ILIKE '%19%' OR name ILIKE '%vorab%' OR name ILIKE '%prelim%' AND lifecycle_state <> 'archived'` returns empty.
Legal context — RoP 19 itself (Application of the Rules of Procedure, Part 1, Chapter 1, Section 4):
- **R.19.1**: The defendant may, within 1 month of service of the Statement of claim, lodge a Preliminary objection concerning (a) jurisdiction and competence of the Court including any objection to the decision of the Registry to assign a case to a particular division, (b) the language of the Statement of claim (R.14), or (c) the competence of the panel to which the action has been assigned.
- **R.19.7 / R.19.8**: The Court decides on a preliminary objection by way of order, typically before the interim conference, but may join it to the main proceedings.
- **R.46**: The Rules in Part 1, Chapter 1 (including R.19) apply *mutatis mutandis* to revocation actions — i.e. the defendant in a revocation action (the patent proprietor) may also lodge a preliminary objection within 1 month of service of the Statement for revocation.
The Pipeline-A note "Rarely triggers separate decision; usually decided with main case" is accurate practice — but the **1-month deadline to raise the objection** is hard and statutory. That deadline is what the fristenrechner needs to model.
### Rule 1.1 — Preliminary Objection on UPC_INF
- **Rule code:** `inf.prelim`
- **Proceeding type:** UPC_INF (id=8)
- **Name (DE):** Vorab-Einrede (R. 19 VerfO)
- **Name (EN):** Preliminary Objection (RoP 19)
- **Party:** defendant
- **Anchor:** `parent_id = inf.soc` (the existing root rule "Klageerhebung") — same anchor pattern as `inf.sod` (Klageerwiderung, also parent=inf.soc). `inf.soc` is the trigger-date anchor; computing 1 month after `inf.soc` reads as "1 month from service of the Statement of Claim", consistent with R.19.1's wording.
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional *(party decides whether to raise the objection; the 1-month period is statutory once invoked)*
- **is_court_set:** false *(statutory period from service; not court-set)*
- **condition_expr:** `{"flag":"with_po"}` *(only renders when the defendant indicates a PO will be filed — same shape as existing `with_ccr` / `with_amend` flags)*
- **Legal source:** `UPC.RoP.19.1`
- **`rule_code`:** `RoP.019.1`
- **event_type:** `filing`
- **Notes:** R.19.1 covers three independent grounds (a) jurisdiction/competence, (b) language under R.14, (c) panel competence. All share the same 1-month deadline. The UI rendering decision (one row vs. three rows by ground) is downstream UX, not a rule-corpus question.
- **FLAG (F1.1):** Flag name — `with_po` is suggested by analogy to `with_ccr` / `with_amend` / `with_cci`. Alternative names: `with_preliminary_objection`, `prelim`. m's call.
- **FLAG (F1.2):** Priority — proposed `optional` (defendant chooses); m may prefer `recommended` to surface it as a sanity-check chip on every defendant timeline. The Pipeline-A predecessor had `is_optional=true / is_mandatory=false` per the old binary schema, which maps cleanly to `priority='optional'` in the post-Slice-3 enum.
### Rule 1.2 — Preliminary Objection on UPC_REV
- **Rule code:** `rev.prelim`
- **Proceeding type:** UPC_REV (id=9)
- **Name (DE):** Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)
- **Name (EN):** Preliminary Objection (RoP 19 in conjunction with RoP 46)
- **Party:** defendant *(in a revocation action the patentee is the defendant)*
- **Anchor:** `parent_id = rev.app` (the existing root rule "Nichtigkeitsklage" — analogous to `rev.defence` which also parents off `rev.app`)
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** `{"flag":"with_po"}` *(same flag as 1.1 — a PO is a PO; the user sets `with_po=true` on a UPC_REV project when the patentee plans to lodge one)*
- **Legal source:** `UPC.RoP.46` *(R.46 makes R.19 applicable to revocation actions; cite R.46 as the operative provision because RoP 19's literal text only addresses infringement)*
- **`rule_code`:** `RoP.046` *(or `RoP.019.1` with a note — m's call; see FLAG F1.3)*
- **event_type:** `filing`
- **Notes:** Functionally identical to Rule 1.1 but rooted on UPC_REV. The grounds are narrower in practice (language and panel competence are the main triggers — jurisdiction is rarely contested in pure revocation actions because the UPC's jurisdiction over revocation of unitary patents is exclusive). But the 1-month statutory window is identical.
- **FLAG (F1.3):** Legal-source citation — should this read `UPC.RoP.46` (operative provision for revocation) or `UPC.RoP.19.1` (substantive content)? Existing rules use the substantive citation (e.g. `inf.def_to_ccr` cites `UPC.RoP.29.a`, not the cross-reference that brings R.29 into the UPC_INF flow). I lean `UPC.RoP.19.1` with `rule_code='RoP.019.1'` to match that pattern; the cross-reference to R.46 belongs in the description, not the citation field.
- **FLAG (F1.4):** Does paliad want **counterclaim-defendant** PO rules too? Specifically, when UPC_INF has `with_ccr=true`, the *claimant* (patentee) becomes the de-facto-defendant for the CCR portion. Does the claimant get a 1-month PO window from service of the CCR? My read of R.19 + R.46 + R.25: yes — the CCR triggers a fresh R.19 window for the claimant, anchored on service of the SoD-with-CCR. But this would be a third rule (`inf.prelim_ccr`, party=claimant, parent=inf.sod, 1 month, condition_expr={"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_po_ccr"}]}). I'm **not** drafting it pending m's confirmation; either it's truly there in the case law or it's an over-reading on my part. Lex-research won't help here because there's no relevant published UPC PO case on a CCR yet (R.46 + R.25 cross-reads are theoretical).
**Summary for Gap 1:** 2 new rules drafted (one on UPC_INF, one on UPC_REV). 4 FLAGs. Potential third rule (CCR-PO) deferred pending m's read.
---
## 2. Gap 2 — Cross-proceeding APP spawns (RoP 220.1(a))
**Status:** Real gap. Pipeline-A had three placeholder rules (`inf.appeal`, `rev.appeal`, `ccr.appeal`, all 2 months, RoP.220.1, is_spawn=true) — but their `spawn_proceeding_type_id` was NULL so they weren't functional spawns either. Fristenrechner UPC_APP currently starts standalone with `app.notice` as its root rule (party=both, 2 months, RoP.220.1).
Verification — current corpus has zero `is_spawn=true AND is_active=true AND lifecycle_state<>'archived'` rules; the `spawn_proceeding_type_id` column on `paliad.deadline_rules` is unused in the live data (Slice 7 wiring was the design intent but no real spawns have been seeded yet).
Legal context — RoP 220 (Decisions and orders which may be appealed):
- **R.220.1(a)**: Final decisions under R.118 may be appealed. The appeal period is **2 months of service** of the decision (R.224.1(a)).
- **R.224.1(a)**: The Statement of appeal must be lodged within 2 months of service of the decision.
- **R.224.2(a)**: The Statement of grounds of appeal must be lodged within 4 months of service of the decision (independent from R.224.1(a), not chained off it).
The spawn target — the proceeding rooted by `app.notice` (Berufungseinlegung, RoP.220.1, 2 months) and `app.grounds` (Berufungsbegründung, 4 months from decision) — is what the task brief calls the "UPC infringement-appeal (RoP 220.1(a) main-judgment appeal)" proceeding. Today that's `UPC_APP` (id=11); per t-paliad-204, the code may be renamed before m ingests these proposals, so I refer to it by role only.
### Rule 2.1 — Appeal spawn from UPC_INF
- **Rule code:** `inf.appeal_spawn`
- **Proceeding type:** UPC_INF (id=8)
- **Name (DE):** Berufung gegen Endentscheidung
- **Name (EN):** Appeal against final decision
- **Party:** both *(either party may appeal an R.118 final decision adverse to them)*
- **Anchor:** `parent_id = inf.decision` (existing court-set rule "Entscheidung"). The chain: `inf.soc → … → inf.decision (court-set, no statutory date) → inf.appeal_spawn (2 months after service of decision)`. Because `inf.decision` is `IsCourtSet=true` (per `isCourtDeterminedRule` in `internal/services/fristenrechner.go`), the appeal-spawn deadline only becomes a concrete date once the user anchors `inf.decision` via the smart-timeline click-to-anchor mechanism (Slice 2, `POST /api/projects/{id}/timeline/anchor` per memory `ab966313-cae6-49b0-8223-9adb62a64370`).
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional *(party decides whether to appeal; the 2-month period is statutory once invoked)*
- **is_court_set:** false *(deadline is statutory once the decision is served)*
- **condition_expr:** `{"flag":"with_appeal"}` *(only renders when the user has indicated an appeal is contemplated — keeps non-appealing projects' timelines clean)*
- **Legal source:** `UPC.RoP.220.1`
- **`rule_code`:** `RoP.220.1.a`
- **event_type:** `filing`
- **is_spawn:** true
- **spawn_proceeding_type_id:** → UPC infringement-appeal proceeding (currently `UPC_APP`, id=11; m picks final code at ingest per t-paliad-204).
- **spawn_label (DE):** "Berufungsverfahren öffnen"
- **spawn_label (EN):** "Open appeal proceedings"
- **Notes:** Spawning into the appeal proceeding creates a child project (or routes into the standalone UPC_APP fristenrechner depending on how spawn rendering works on the project page). The 4-month Statement of grounds period (R.224.2(a), `app.grounds`) is already a root rule on UPC_APP — once the appeal child opens, that timeline takes over. **No need** to also model `app.grounds` as a spawn rule from UPC_INF; the existing UPC_APP root rules cover it.
- **FLAG (F2.1):** Does the spawn fire on the CCR portion of the decision too? In a `with_ccr=true` UPC_INF, the R.118 final decision adjudicates both the infringement *and* the counterclaim for revocation. Either side may appeal either part. My read: **one spawn covers both** — there's only one R.118 decision, one 2-month window. The Pipeline-A `ccr.appeal` was a relic of the days when CCR was a separate proceeding type. **Recommend dropping the third "ccr.appeal" entirely**, because in the unified UPC_INF (CCR-as-flag) model it would duplicate Rule 2.1. m to confirm.
- **FLAG (F2.2):** Anchor — should the spawn rule chain off `inf.decision` (court-set, requires anchor-click) or be event-rooted on a `final_decision_service` trigger event (paliad has trigger_event id=88 "Endentscheidung (Zustellung)")? Both work. Chaining on `inf.decision` keeps the rule visually attached to its parent proceeding in the UI; event-rooted is more flexible if the user wants to compute an appeal deadline standalone without a project. Recommend `parent_id = inf.decision` to match how `inf.cost_app` chains off `inf.decision` already.
- **FLAG (F2.3):** Flag name — `with_appeal` mirrors the existing `with_ccr` / `with_amend` flag naming. Alternative: spawn rules might always fire (no flag), letting the timeline show the appeal window as a "predicted/court-set" placeholder. The latter is closer to what the SmartTimeline projection (`projection_service.go`) already does for cross-proceeding rules per memory `686f0b8c-02ed-4807-8785-b088e3a3e515` § 6 gap 7. If m wants the appeal window to *always* appear after the decision (unconditionally), drop `condition_expr` here and on Rule 2.2.
### Rule 2.2 — Appeal spawn from UPC_REV
- **Rule code:** `rev.appeal_spawn`
- **Proceeding type:** UPC_REV (id=9)
- **Name (DE):** Berufung gegen Endentscheidung (Nichtigkeit)
- **Name (EN):** Appeal against final decision (revocation)
- **Party:** both
- **Anchor:** `parent_id = rev.decision` (existing court-set rule "Entscheidung")
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** `{"flag":"with_appeal"}`
- **Legal source:** `UPC.RoP.220.1`
- **`rule_code`:** `RoP.220.1.a`
- **event_type:** `filing`
- **is_spawn:** true
- **spawn_proceeding_type_id:** → same UPC infringement-appeal proceeding as Rule 2.1. The UPC CoA hears both INF and REV appeals; in a `with_cci=true` UPC_REV (Verletzungswiderklage / counterclaim-for-infringement), the R.118 decision may also adjudicate the infringement piece, but again it's one decision, one appeal window.
- **spawn_label (DE):** "Berufungsverfahren öffnen"
- **spawn_label (EN):** "Open appeal proceedings"
- **Notes:** Functionally a mirror of Rule 2.1 on the revocation proceeding. Same FLAGs F2.1-F2.3 apply.
### Rule 2.3 — (proposed) NOT drafted: separate `ccr.appeal` from UPC_INF with_ccr
**See FLAG F2.1.** In the unified model, the CCR portion of an UPC_INF decision is appealed via the same R.118 final-decision spawn (Rule 2.1) — a single 2-month window covers infringement, revocation, and patent-amendment claims because they all sit in one R.118 decision. Drafting `ccr.appeal` as a third rule would duplicate Rule 2.1 conditionally (`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}`) and produce a redundant timeline row. **Recommendation: do not seed.** If m disagrees, the rule shape would be:
```
inf.appeal_spawn_ccr (UPC_INF)
condition_expr: {"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}
spawn_label: "Berufung Nichtigkeit öffnen" (specifically the CCR portion)
```
Only useful if the appeal UI needs to distinguish "appealing the infringement finding" from "appealing the revocation finding". Today's fristenrechner UI doesn't make that distinction; the appeal proceeding handles both.
**Summary for Gap 2:** 2 new spawn rules drafted. 3 FLAGs. The third Pipeline-A relic (`ccr.appeal`) is structurally redundant and recommended **not** to seed.
---
## 3. Gap 3 — `ccr.amend` / `rev.amend` (verification of "safe to drop" claim)
**Status:** No new rules needed. The migration's claim ("superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop") is **confirmed for the patent-amendment scope**. There is a separate concept (R.263 application to amend the case) that has never been modelled and probably shouldn't be — see § 3.2.
### 3.1 Verification — patent-amendment coverage
Pipeline-A's `ccr.amend` and `rev.amend` were both:
- duration_value=0, duration_unit='months', event_type='filing', is_spawn=true, party='claimant'
- legal_source=NULL, rule_code=NULL
- source proceeding=AMD (now archived)
- "Application to Amend Patent" / no German name
These were placeholder spawns into a hypothetical "AMD" (Application to amend the patent) proceeding type that never existed as a real fristenrechner tree. They modelled the concept "filing a patent amendment", not its deadline.
The unified UPC_INF / UPC_REV corpus already covers patent amendment with real deadlines and flag-gated chains:
| Existing rule | Proceeding | Trigger / parent | Duration | Legal source | Flag-gating |
|---|---|---|---|---|---|
| `inf.app_to_amend` | UPC_INF | parent=inf.sod | 2 months | UPC.RoP.30.1 | `with_ccr+with_amend` |
| `inf.def_to_amend` | UPC_INF | parent=inf.app_to_amend | 2 months | UPC.RoP.32.1 | `with_ccr+with_amend` |
| `inf.reply_def_amd` | UPC_INF | parent=inf.def_to_amend | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
| `inf.rejoin_amd` | UPC_INF | parent=inf.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
| `rev.app_to_amend` | UPC_REV | parent=rev.defence | 0 months (filed-with-parent) | UPC.RoP.49.2.a | `with_amend` |
| `rev.def_to_amend` | UPC_REV | parent=rev.app_to_amend | 2 months | UPC.RoP.43.3 | `with_amend` |
| `rev.reply_def_amd` | UPC_REV | parent=rev.def_to_amend | 1 month | UPC.RoP.32.3 | `with_amend` |
| `rev.rejoin_amd` | UPC_REV | parent=rev.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_amend` |
The flag-gated chain on UPC_INF (`with_ccr+with_amend`) is the post-2026-05-05 ship from t-paliad-131 PR-2 (memory `ba1517a3-2294-4c58-aeb6-87e82067834d`); the UPC_REV chain (`with_amend` and `with_cci`) is from the same PR. Both fully replace what `ccr.amend` / `rev.amend` ever could have represented.
**Verdict on Gap 3:** "Safe to drop" is correct. **No new rules.**
### 3.2 R.263 — Application to amend the case (not modelled, probably shouldn't be)
R.263 ("Leave to change claim or amend case") is conceptually different from R.30 (Application to amend the patent). R.263 governs amendment of the **pleadings / case** — adding a new infringement allegation, narrowing claims, etc. The current corpus has no R.263 rule.
I'm **not proposing one** because R.263 is purely court-discretionary (R.263.1: "An application may be made by a party at any time to … amend its case … Leave shall be granted only if … the requesting party could not with reasonable diligence have made the application earlier and the amendment will not unreasonably hinder the other party in the conduct of its action"). There is no statutory deadline computable from a fixed anchor — the party files when it needs to, and the court grants or refuses leave by order. Modelling it as a deadline_rule would either:
- (a) Produce a phantom row with no computable date (the existing `is_court_set=true` pattern would technically work but offers no UX value because the deadline is "whenever you need to amend").
- (b) Produce a misleading row anchored on the SoC date with some heuristic period.
**Recommendation: don't seed.** If m wants R.263 surfaced anywhere, it belongs as a checklist item on the project page, not as a fristenrechner rule.
**FLAG (F3.1):** Confirm "don't model R.263" is acceptable. If R.263 *should* be modelled, what anchor + duration heuristic should it use?
**Summary for Gap 3:** 0 new rules. 1 FLAG. The claim "safe to drop" is verified for patent amendment. R.263 is a separate concept and intentionally left unmodelled.
---
## 4. Gap 4 — `zpo.*` family vs. existing DE_INF / DE_INF_OLG / DE_INF_BGH
**Status:** No new rules needed for `klage`, `vertanz`, `berufung`. **Existing rule `de_inf.erwidg` (Klageerwiderung) has a duration discrepancy worth m's attention.** Task brief's mention of "Klageerweiterung" / "Vertagungsantrag" is a misread of Pipeline-A rule names — those concepts are not in scope here. § 4.1-4.4 verify each Pipeline-A rule; § 4.5 surfaces what *would* be a real gap if m wants ZPO §227 modelled.
### 4.1 `zpo.klage` (Klageerhebung, ZPO §253) — ✓ redundant
Pipeline-A: claimant, 0 months, filing, `§ 253 ZPO`, legal_source=NULL.
Existing rule `de_inf.klage` on DE_INF: claimant, 0 months, filing. Functionally identical as a root rule (a 0-duration "trigger" anchor). Legal source on the existing rule is NULL — could be backfilled to `DE.ZPO.253` as a minor polish, but no new rule needed.
**Verdict: no gap.** *Optional polish:* set `de_inf.klage.legal_source = 'DE.ZPO.253'` (one-line UPDATE; not a new rule). FLAG F4.1.
### 4.2 `zpo.vertanz` (Verteidigungsanzeige, ZPO §276(1) Satz 1) — ✓ redundant
**Task-brief naming note:** the brief described this gap as "Vertagungsantrag" but Pipeline-A's `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction "VertAnz" not "VertA. (Antrag)"). The rule name in the snapshot reads "Verteidigungsanzeige" verbatim. Vertagungsantrag (§ 227 ZPO) is a different concept entirely — see § 4.5.
Pipeline-A: defendant, 2 weeks, filing, `§ 276 Abs. 1 S. 1 ZPO`, deadline_notes "Notfrist ab Zustellung der Klageschrift".
Existing rule `de_inf.anzeige` on DE_INF: defendant, 2 weeks, `DE.ZPO.276.1`, "Anzeige der Verteidigungsbereitschaft". Same period, same legal basis, same party.
**Verdict: no gap.**
### 4.3 `zpo.klageerw` (Klageerwiderung, ZPO §276(1) Satz 2) — ⚠ duration discrepancy
Pipeline-A: defendant, **2 weeks**, filing, `§ 276 Abs. 1 S. 2 ZPO`, legal_source=NULL, deadline_notes "Vom Gericht gesetzt, mindestens 2 Wochen".
Existing rule `de_inf.erwidg` on DE_INF: defendant, **6 weeks**, `DE.ZPO.276.1`, "Klageerwiderung", is_court_set=false.
**This is a substantive discrepancy.** Both rules cite the same statutory anchor (ZPO §276(1) Satz 2), but:
- Pipeline-A modelled the **statutory floor** ("mindestens 2 Wochen") with `is_court_set` implicit (the deadline_notes said "Vom Gericht gesetzt").
- DE_INF models a **typical court-practice heuristic** (6 weeks is a common Munich/Düsseldorf LG setting, though 4-8 weeks is the realistic range).
The DE_INF rule is **strictly more useful** for a practitioner planning a defence schedule (the 2-week floor is rarely the actual deadline; the court order sets the real date). But it's **technically wrong** to mark `is_court_set=false` because the date *is* set by court order — the 6 weeks is a guess at what the court will set, not a statutory period.
**No new rule needed**, but two corrections are worth flagging on the existing rule:
- **FLAG F4.2 (correctness):** Set `de_inf.erwidg.is_court_set = true`. The deadline date is set by the court's Klageerwiderungsfrist order under §276(1) Satz 2, not by the statute directly. This matches how Schriftsatznachreichung (§296a) was flagged in `docs/proposals/orphan-concepts-2026-05-15.md` § 2.1 FLAG F8.
- **FLAG F4.3 (heuristic transparency):** 6 weeks vs. the statutory 2-week floor — the deadline_notes (DE) on `de_inf.erwidg` should probably say "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" rather than just rendering as a hard 6-week deadline. UX consideration, not a rule-shape question.
Neither change is a new rule; both are PATCH operations on the existing row via `/admin/rules`.
### 4.4 `zpo.berufung` (Berufung, ZPO §517) — ✓ redundant (twice over)
Pipeline-A: both, 1 month, filing, `§ 517 ZPO`, `DE.ZPO.517`, deadline_notes "Notfrist ab Zustellung des vollständigen Urteils".
Existing rules:
- `de_inf.berufung` on DE_INF: both, 1 month, `DE.ZPO.517`. Same shape.
- `de_inf_olg.berufung` on DE_INF_OLG: both, 1 month, `DE.ZPO.517`. Same shape (covers the OLG-instance entry point).
Either rule covers it. **Verdict: no gap.**
### 4.5 Real gap (if m wants): Vertagungsantrag (ZPO §227)
The task brief mentioned "Vertagungsantrag" by name. Pipeline-A had no Vertagungsantrag rule (the `zpo.vertanz` rule code is a contraction of *Verteidigungsanzeige*, not Vertagungsantrag — see § 4.2). The current corpus has no Vertagungsantrag rule either.
ZPO §227 governs applications to adjourn a hearing ("Aufhebung und Verlegung von Terminen, Vertagung der Verhandlung"). §227.1 requires "erhebliche Gründe", §227.2 gives examples (verhinderter Anwalt etc.), §227.3 restricts adjournment of evidence hearings (Beweisaufnahme). **There is no statutory deadline for filing a Vertagungsantrag** — it's "as soon as the ground arises and, in practice, as early as possible before the hearing date". The application is court-discretionary (§227.1: "kann").
I would **not** recommend modelling Vertagungsantrag as a deadline_rule for the same reason as R.263 in § 3.2: there's no statutory deadline anchor; it's a checklist concept, not a calendar deadline. But m may have a different view — flag F4.4.
**FLAG (F4.4):** Should Vertagungsantrag be modelled? If yes, what anchor + duration? Most natural seed would be `condition_expr={"flag":"with_vertagung"}` on the relevant hearing rule (de_inf.termin, de_null.termin, etc.), is_court_set=true, no duration. But that's an oddly-shaped rule that produces no useful date.
**Summary for Gap 4:** 0 new rules. 4 FLAGs (F4.1-F4.4). The migration's "redundant — safe to drop" claim is confirmed for `klage` / `vertanz` / `berufung`. `klageerw` exposes a discrepancy on the existing `de_inf.erwidg` rule (`is_court_set=false` is wrong; 6-weeks heuristic should be transparent in notes) — both are PATCH operations on the existing row, not new rules. Vertagungsantrag is a separate concept that probably shouldn't be modelled as a deadline_rule.
---
## 5. Track A — Polish UPDATEs on existing rows (no new rules, no legal review)
Distinct from new rules, three existing rows could be PATCH'd via `/admin/rules` to improve correctness or transparency. **None of these are required for the gap-fill to be considered "done"** — they're flagged so they don't get lost if m wants to address them in the same ingest session.
| # | Row | Field | From | To | Reason |
|---|---|---|---|---|---|
| P1 | `de_inf.klage` (DE_INF) | `legal_source` | NULL | `DE.ZPO.253` | Polish; matches existing convention (Rule 1.1's `UPC.RoP.19.1` etc.). |
| P2 | `de_inf.erwidg` (DE_INF) | `is_court_set` | false | true | Correctness; deadline is court-order-set per ZPO §276(1) Satz 2. |
| P3 | `de_inf.erwidg` (DE_INF) | `deadline_notes` (DE) | (current text) | "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" | Transparency; the 6-week duration is a heuristic, not statutory. |
---
## 6. Track B — Genuinely new rule drafts (this proposal's substantive output)
| # | Gap | Rule code | Proceeding (by role) | Source |
|---|---|---|---|---|
| 1.1 | 1 (PO) | `inf.prelim` | UPC_INF | RoP 19.1 |
| 1.2 | 1 (PO) | `rev.prelim` | UPC_REV | RoP 19.1 i.V.m. R.46 |
| 2.1 | 2 (APP spawn) | `inf.appeal_spawn` | UPC_INF, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
| 2.2 | 2 (APP spawn) | `rev.appeal_spawn` | UPC_REV, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
**Total new rules: 4.** Plus 3 optional polish PATCHes in § 5. None of the proposed rules introduce new flag-name conventions (other than `with_po` and `with_appeal`, which mirror existing `with_ccr` / `with_amend` / `with_cci`).
### Future-work (not this proposal)
- Order-appeals spawn (R.220.2 / R.220.3) from UPC_INF / UPC_REV / UPC_PI → UPC_APP_ORDERS (15-day track). Today UPC_APP_ORDERS has only standalone root rules.
- Cost-decision-appeal spawn (R.221.1) from UPC_INF / UPC_REV → UPC_COST_APPEAL.
- CCR-defendant PO (FLAG F1.4): claimant's 1-month PO window when receiving SoD-with-CCR — only if confirmed against real case law or m's read.
- R.263 (case amendment) and ZPO §227 (Vertagungsantrag): both court-discretionary, no statutory deadline — recommend leaving unmodelled (FLAGs F3.1, F4.4).
- DE_NULL / DE_NULL_BGH appeal spawns: PatG §110 chains DE_NULL → DE_NULL_BGH (Berufung BGH). Currently DE_NULL_BGH is a standalone tree rooted on `de_null_bgh.urteil_bpatg`. Same pattern as the UPC spawn gap. Out of brief scope but worth a parallel proposal.
---
## 7. 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 (or rule edit).
| ID | Section | Question |
|---|---|---|
| F1.1 | § 1.1 | Flag name for Preliminary Objection — `with_po` vs `with_preliminary_objection` vs `prelim`. |
| F1.2 | § 1.1 | Priority for PO — `optional` (recommended) vs `recommended` (always-surface as sanity-check chip). |
| F1.3 | § 1.2 | Legal-source citation for UPC_REV PO — `UPC.RoP.19.1` (substantive) vs `UPC.RoP.46` (operative). Recommend substantive. |
| F1.4 | § 1.2 | Add a third PO rule for CCR-defendant (party=claimant, fires when `with_ccr=true`)? |
| F2.1 | § 2.1 | Recommend **not seeding** `ccr.appeal` as a third rule — CCR appeal is covered by `inf.appeal_spawn` (one R.118 decision, one window). Confirm. |
| F2.2 | § 2.1 | Anchor for spawn — `parent_id = inf.decision` (chain) vs `trigger_event_id = 88 final_decision_service` (event-rooted). Recommend chain. |
| F2.3 | § 2.1 | Flag-gated (`with_appeal`) vs always-rendered. Recommend flag-gated to keep non-appealing timelines clean; SmartTimeline's "predicted" rendering of cross-proceeding rules is the alternative. |
| F3.1 | § 3.2 | R.263 (case amendment) — confirm not modelled as a deadline_rule. |
| F4.1 | § 4.1 | Polish P1: backfill `de_inf.klage.legal_source = 'DE.ZPO.253'`? |
| F4.2 | § 4.3 | Polish P2: set `de_inf.erwidg.is_court_set = true`? |
| F4.3 | § 4.3 | Polish P3: improve `de_inf.erwidg.deadline_notes` to expose the 6-week heuristic vs the 2-week statutory floor? |
| F4.4 | § 4.5 | Vertagungsantrag (ZPO §227) — confirm not modelled. |
---
## 8. Sources cited
| Citation key | Reference |
|---|---|
| `UPC.RoP.19.1` | UPC Rules of Procedure, Rule 19(1) — Preliminary objection |
| `UPC.RoP.19.7` | UPC RoP Rule 19(7) — Court decides preliminary objection by order |
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation (cross-ref for FLAG F1.4) |
| `UPC.RoP.30.1` | UPC RoP Rule 30(1) — Application to amend the patent (cross-ref for § 3.1) |
| `UPC.RoP.46` | UPC RoP Rule 46 — Part 1 Chapter 1 (incl. R.19) applies *mutatis mutandis* to revocation actions |
| `UPC.RoP.118` | UPC RoP Rule 118 — Final decisions on the merits |
| `UPC.RoP.151` | UPC RoP Rule 151 — Cost decision (cross-ref for existing `inf.cost_app`) |
| `UPC.RoP.220.1.a` | UPC RoP Rule 220(1)(a) — Appeal against R.118 final decision |
| `UPC.RoP.220.2` | UPC RoP Rule 220(2) — Order appeals with leave (cross-ref, future work) |
| `UPC.RoP.220.3` | UPC RoP Rule 220(3) — Discretionary review (cross-ref, future work) |
| `UPC.RoP.221.1` | UPC RoP Rule 221(1) — Cost-decision appeal (cross-ref, future work) |
| `UPC.RoP.224.1.a` | UPC RoP Rule 224(1)(a) — Statement of appeal lodged within 2 months |
| `UPC.RoP.224.2.a` | UPC RoP Rule 224(2)(a) — Statement of grounds within 4 months |
| `UPC.RoP.263` | UPC RoP Rule 263 — Leave to change claim or amend case |
| `DE.ZPO.227` | ZPO §227 — Vertagung und Terminsänderung |
| `DE.ZPO.253` | ZPO §253 — Klageschrift |
| `DE.ZPO.276.1` | ZPO §276(1) — Verteidigungsanzeige (S.1) und Klageerwiderungsfrist (S.2) |
| `DE.ZPO.517` | ZPO §517 — Berufungsfrist (1 Monat ab Zustellung) |
---
## 9. What's next (if m approves)
1. **Decide the 12 FLAGs in § 7** (mostly flag names, priorities, and the three PATCH operations on existing rows). None require legal-side research — they're product/UX calls.
2. **Confirm the appeal target's final proceeding-code** post-t-paliad-204 rename. Until then, ingest using whatever code lives at id=11 (currently `UPC_APP`) and rename via mig if t-paliad-204 lands with a different code.
3. **Ingest the 4 new rules** via `/admin/rules` POST (Slice 11a backend, Slice 11b frontend). Each goes into `lifecycle_state='draft'` first. Promote to `published` after spot-checking via the calculator preview endpoint with a test project (e.g. UPC_INF with `with_po=true` should show the new `inf.prelim` row 1 month after the trigger date).
4. **Optionally apply the 3 PATCHes in § 5** in the same session.
5. **Verify spawn rendering** end-to-end — the spawn_proceeding_type_id column is unused in live data today, so this is the first real consumer. The SmartTimeline projection (per `internal/services/projection_service.go`, memory `686f0b8c-…`) early-returns on spawn rules when "we don't have that rule in our map" — that code path needs to actually render a spawn row now, not no-op. May require a Slice 7 follow-up tweak in `projection_service.go` to honour `spawn_proceeding_type_id` and surface the appeal proceeding's root deadline as a spawned child row.
**Estimated corpus delta after ingest:** Track B = 4 new rules → `paliad.deadline_rules` row count grows from 249 to **253**. Track A polish = 3 row-level PATCHes (no row count change). One new `is_spawn=true` row goes live for the first time, exercising the previously-unused `spawn_proceeding_type_id` wiring.

View File

@@ -0,0 +1,429 @@
# Legal-citation Backfill Proposals — t-paliad-208 (Workstream A)
**Date:** 2026-05-18
**Author:** huygens (researcher)
**Status:** DRAFT — for m's review, not yet migrated
**Branch:** `mai/huygens/workstream-a-backfill`
**Adjacent:** parallel-track with t-paliad-209 (workstream B — `code` rename + UI cleanup; different fields, no overlap)
**Successor:** mig 097 will UPDATE the rows m approves; backup snapshot `deadline_rules_pre_097`
---
## 0. Read-this-first
### 0.1 What this doc is
Today's audit (paliadin/head, 2026-05-18) found that **130 of 213 active+published rows in `paliad.deadline_rules`** have `rule_code IS NULL`, and 122 have `legal_source IS NULL`. The internal slug field `code` (e.g. `inf.sod`, `de_null.berufung`) had been mistaken for a legal citation; it is just the per-proceeding submission identifier. The actual RoP / ZPO / EPÜ / PatG / UPCA citation belongs in `rule_code` (display form) + `legal_source` (structured locator).
This document proposes a citation per rule. m approves; head re-tasks for migration 097.
### 0.2 Field convention (profiled from the 83 already-populated rows)
| Field | Purpose | Examples from live data |
|---|---|---|
| `rule_code` | **Human display form**, what we'd write in a brief | `§ 276 ZPO`, `§ 110 PatG`, `Art. 99 EPÜ`, `R. 71(3) EPÜ`, `R. 116 EPÜ`, `RPBA Art. 12`, `RoP.029.a`, `RoP.220.1.a`, `RoP.151`, `RoP.49.1` |
| `legal_source` | **Structured locator** (forum-prefixed, no zero padding) for cross-system joins / lex extraction | `DE.ZPO.276.1`, `DE.PatG.111.1`, `EU.EPÜ.108`, `EU.EPC-R.71.3`, `EU.RPBA.12.1.c`, `UPC.RoP.29.a`, `UPC.RoP.220.1` |
**Sub-conventions observed in live data**
- `legal_source` prefixes: `DE.<statute>.<n>.<para>`, `EU.EPÜ.<n>.<para>`, `EU.EPC-R.<n>.<para>`, `EU.RPBA.<n>.<para>.<letter>`, `UPC.RoP.<n>.<sub>`.
- `rule_code` padding for UPC RoP is **inconsistent today**: rules below 100 are mostly 3-digit padded (`RoP.029.a`, `RoP.030.1`, `RoP.049.2.a`, `RoP.056.1`) but `rev.defence` carries an un-padded `RoP.49.1`. Rules ≥100 are never padded (`RoP.137.2`, `RoP.220.1`).
- **Proposed normalization:** 3-digit pad for rules <100, no pad for 100. mig 097 should also normalize `RoP.49.1 → RoP.049.1` (1 outlier row, `rev.defence`) as a side-fix. m to confirm.
- `legal_source` for UPC RoP **never** pads (`UPC.RoP.29.a`, not `UPC.RoP.029.a`). I follow that.
### 0.3 Triage philosophy — events vs. deadlines
Of the 130 NULL-rule_code rows, 53 carry a `proceeding_type_id` and 77 are orphans (`proceeding_type_id IS NULL`, also `code IS NULL`). Within the proceeding-typed bucket, most are **event markers** (zero `duration_value`, `event_type ∈ {hearing, decision, filing}`) that anchor other deadlines rather than computing one of their own.
I classify each row as one of:
| Category | Treatment | Examples |
|---|---|---|
| **Deadline** (positive duration, fires off an anchor) | Cite the operative procedural norm. Confidence usually HIGH. | `inf.sod` Klageerwiderung 3 months RoP.23 |
| **Constitutive event** (zero duration, but a statute defines it) | Cite the constitutive norm (matches existing convention: `de_inf.klage` already has `DE.ZPO.253`). Confidence HIGH where the norm is canonical. | Klageerhebung § 253 ZPO; Anmeldung EP Art. 75 EPÜ; Klage UPC RoP.13.1 |
| **Service / trigger event** (zero duration, third-party delivery) | Cite the service norm 317 ZPO etc.) with MEDIUM confidence these are anchor events for downstream timers, not deadlines on a party. m may prefer NULL here. **FLAG.** | `de_inf_olg.urteil_lg` Zustellung LG-Urteil |
| **Court-scheduled event** (hearing, judgment-issuance) | Either NULL (recommended) or cite the general norm authorising the court to schedule. **FLAG.** | Mündliche Verhandlung BGH; OLG-Urteil |
| **Court-set duration** (positive duration but `is_court_set=true`, or local practice) | Cite the framing norm (e.g. § 273 ZPO for ZPO patent practice), MEDIUM, FLAG. | `de_inf.replik` 4 weeks (LG patent practice) |
**Where I am proposing NULL**, the row stays as-is on the DB side (mig 097 simply doesn't touch it). The FLAG list at the bottom of this doc enumerates every NULL proposal so m can override with an explicit citation if desired.
### 0.4 Counts
- 130 rows in scope (rule_code IS NULL; is_active=true; lifecycle_state='published')
- 53 proceeding-typed + 77 orphan (no proceeding_type_id, no code)
- 8 rows already carry a `legal_source` those are **easy wins**: only `rule_code` needs proposing
- ~ 40 HIGH-confidence proposals
- ~ 35 MEDIUM-confidence proposals
- ~ 55 FLAG entries (court-scheduled events, combined-pleading rows, ambiguous orphans)
The orphan bucket carries a noticeable number of **duplicates** (six "Mängelbeseitigung / Zahlung" rows, two "Beginn des Hauptsacheverfahrens", two "Antrag auf Patentänderung", etc.). Those are likely vestiges of older Fristenrechner pipelines; backfilling them with the same citation is fine, but m may want a separate dedup pass (out of scope here; flag in § 4).
---
## 1. Easy wins — rows with `legal_source` already set, `rule_code` missing (8)
For these, the structured locator is already in the DB; only the display form is missing.
| id | code / name | duration | existing `legal_source` | proposed `rule_code` | conf |
|---|---|---|---|---|---|
| `1f532c82…` | `de_inf.klage` / Klageerhebung | event | `DE.ZPO.253` | `§ 253 ZPO` | HIGH |
| `20254f4e…` | (orphan) Einspruch gegen Versäumnisurteil | 2 weeks | `DE.ZPO.339.1` | `§ 339 ZPO` | HIGH |
| `3c36f149…` | (orphan) Schriftsatznachreichung 296a ZPO) | 3 weeks | `DE.ZPO.296a` | `§ 296a ZPO` | HIGH |
| `f1099cf6…` | (orphan) Weiterbehandlungsantrag (Art. 121 EPÜ) | 2 months | `EU.EPC-R.135.1` | `R. 135 EPÜ` | HIGH |
| `c24d494c…` | (orphan) Wiedereinsetzungsantrag 123 PatG) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
| `d40d9be7…` | (orphan) Wiedereinsetzungsantrag 233 ZPO) | 2 weeks | `DE.ZPO.234.1` | `§ 234 ZPO` | HIGH |
| `23c6f445…` | (orphan) Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 months | `EU.EPC-R.136.1` | `R. 136 EPÜ` | HIGH |
| `b588fa64…` | (orphan) Wiedereinsetzungsantrag (DPMA) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
**Naming note on the two Wiedereinsetzung-`§ 123 PatG` rows.** Both `c24d494c…` ("§ 123 PatG" name) and `b588fa64…` ("DPMA" name) map to the same statute § 123 PatG (Wiedereinsetzung) applies to all DPMA-Verfahren, so the duplication is a pure naming choice. mig 097 fills both; potential dedup is a separate question 4 FLAG-A).
---
## 2. Proceeding-typed rows (53)
Grouped by `proceeding_types.code`. Within each group: alphabetical by `code`.
### 2.1 `upc.inf.cfi` — Verletzungsverfahren CFI (4 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `inf.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.118 but this is the court's own decision, not a party deadline | **FLAG-B** |
| `inf.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | RoP.101 ff. governs interim procedure; not a single norm | **FLAG-B** |
| `inf.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.111-117 (oral procedure); court-scheduled | **FLAG-B** |
| `inf.soc` | Klageerhebung (Statement of claim) | event | filing | `RoP.013.1` | `UPC.RoP.13.1` | RoP.13 Statement of claim contents | HIGH |
### 2.2 `upc.rev.cfi` — Nichtigkeitsverfahren CFI (6 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `rev.app` | Nichtigkeitsklage | event | filing | `RoP.042` | `UPC.RoP.42` | RoP.42 Statement for revocation | HIGH |
| `rev.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | court-issued, not a party deadline | **FLAG-B** |
| `rev.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | not a single norm | **FLAG-B** |
| `rev.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
| `rev.reply` | Replik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 Reply to defence in revocation | MED (**FLAG-C**: duration vs. norm) |
| `rev.rejoin` | Duplik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 Rejoinder | MED (**FLAG-C**: duration vs. norm) |
**FLAG-C:** RoP.52(1) sets the reply to 2 months but RoP.52(2) sets the rejoinder to 1 month from service of the reply. m's `rev.rejoin` says 2 months verify whether the rule duration is correct or whether `RoP.52.2` (1 month) is the right citation. Cross-check with the existing `rev.rejoin_cci` row which uses RoP.056.4 (cci context); the main-pleadings rejoinder lives in RoP.52.
### 2.3 `upc.pi.cfi` — Einstweilige Maßnahmen (4 rules)
All four rules are currently NULL on both fields.
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `pi.app` | Antrag | event | filing | `RoP.206` | `UPC.RoP.206` | RoP.206 Application for provisional measures | HIGH |
| `pi.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.209 at judge's discretion | **FLAG-B** |
| `pi.order` | Beschluss | event | decision | *(NULL)* | *(NULL)* | RoP.211 court-issued | **FLAG-B** |
| `pi.response` | Erwiderung | event | filing | *(NULL)* | *(NULL)* | RoP.209.1 judge sets time; no statutory period | **FLAG-B** (alt: `RoP.209.1` / `UPC.RoP.209.1` to flag as court-set) |
### 2.4 `upc.apl.merits` — Berufungsverfahren Merits (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `app.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.350 appellate decision | **FLAG-B** |
| `app.oral` | Mündliche Verhandlung | event | hearing | `RoP.243` | `UPC.RoP.243` | RoP.243 oral procedure in appeal | MED |
| `app.response` | Berufungserwiderung | 2 months | filing | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 Statement of response | MED (**FLAG-C**: RoP.235.1 says 3 months for main-judgment appeals; 2 months may be a residual from a different appeal track. Verify duration vs. norm.) |
### 2.5 `upc.apl.order` — Berufungsverfahren Anordnungen (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `app_ord.order` | Anordnung / angegriffene Entscheidung | event | decision | *(NULL)* | *(NULL)* | trigger event for orders-appeal; RoP.220.1.c references it | **FLAG-B** (alt: `RoP.220.1.c` to surface) |
### 2.6 `upc.apl.cost` — Berufungsverfahren Kosten (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `cost.decision` | Kostenfestsetzungsbeschluss | event | decision | *(NULL)* | *(NULL)* | RoP.150 ff. cost decision in the assessment proceedings | **FLAG-B** |
### 2.7 `upc.dmgs.cfi` — Schadensbemessungsverfahren (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `damages.app` | Antrag auf Schadensbemessung | event | filing | `RoP.131` | `UPC.RoP.131` | RoP.131 Application for damages determination | HIGH |
### 2.8 `upc.disc.cfi` — Bucheinsichtsverfahren (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `disc.app` | Antrag auf Bucheinsicht | event | filing | `RoP.141` | `UPC.RoP.141` | RoP.141 Application for order to lay open books | HIGH |
### 2.9 `de.inf.lg` — Verletzungsverfahren LG (5 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf.klage` | Klageerhebung | event | filing | `§ 253 ZPO` | `DE.ZPO.253` *(already set)* | § 253 ZPO Klageschrift | HIGH (rule_code only) |
| `de_inf.replik` | Replik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | § 273 ZPO vorbereitende Anordnungen / court-set period (Düsseldorfer Praxis) | MED (**FLAG-D**: 4 weeks is local LG practice, no statutory period; flag `is_court_set=true` already true in DB) |
| `de_inf.duplik` | Duplik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | same | MED (**FLAG-D**) |
| `de_inf.termin` | Haupttermin | event | hearing | *(NULL)* | *(NULL)* | § 272 / § 137 ZPO court-scheduled | **FLAG-B** |
| `de_inf.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 300 ZPO court-issued | **FLAG-B** |
### 2.10 `de.inf.olg` — Berufungsverfahren OLG Verletzung (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf_olg.urteil_lg` | Zustellung LG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO Zustellung von Urteilen | MED (**FLAG-E**: service-trigger event may be NULL per philosophy) |
| `de_inf_olg.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
| `de_inf_olg.urteil_olg` | OLG-Urteil | event | decision | *(NULL)* | *(NULL)* | court-issued | **FLAG-B** |
### 2.11 `de.inf.bgh` — Revision/NZB BGH Verletzung (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf_bgh.urteil_olg` | Zustellung OLG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO Zustellung | MED (**FLAG-E**) |
| `de_inf_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 555 i.V.m. § 137 ZPO court-scheduled | **FLAG-B** |
| `de_inf_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 562, § 563 ZPO court-issued | **FLAG-B** |
### 2.12 `de.null.bpatg` — Nichtigkeitsverfahren BPatG (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_null.klage` | Nichtigkeitsklage | event | filing | `§ 81 PatG` | `DE.PatG.81.1` | § 81 PatG Nichtigkeitsklage einreichen | HIGH |
| `de_null.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | § 89 PatG | **FLAG-B** |
| `de_null.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 84 PatG | **FLAG-B** |
### 2.13 `de.null.bgh` — Berufung BGH Nichtigkeit (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_null_bgh.urteil_bpatg` | Zustellung BPatG-Urteil | event | filing (trigger) | `§ 99 PatG` | `DE.PatG.99.1` | § 99 PatG verweist auf ZPO; Zustellung der BPatG-Urteile | MED (**FLAG-E**) |
| `de_null_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 113 PatG i.V.m. ZPO | **FLAG-B** |
| `de_null_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 119 PatG | **FLAG-B** |
### 2.14 `dpma.opp.dpma` — Einspruchsverfahren DPMA (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_opp.publish` | Veröffentlichung der Erteilung | event | filing (trigger) | `§ 58 PatG` | `DE.PatG.58.1` | § 58(1) PatG Veröffentlichung der Erteilung im Patentblatt | HIGH |
| `dpma_opp.entscheidung` | DPMA-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 47 PatG ff. | **FLAG-B** |
### 2.15 `dpma.appeal.bpatg` — Beschwerdeverfahren BPatG vs. DPMA (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_bpatg.entscheidung` | Zustellung DPMA-Entscheidung | event | filing (trigger) | `§ 47 PatG` | `DE.PatG.47.1` | § 47 PatG Zustellung der Entscheidung im DPMA-Verfahren | MED (**FLAG-E**: trigger-event citation. Alternative `§ 127 PatG` for service procedure.) |
| `dpma_bpatg.entsch_bpatg` | BPatG-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 79 PatG | **FLAG-B** |
| `dpma_bpatg.termin` | Mündliche Verhandlung BPatG | event | hearing | *(NULL)* | *(NULL)* | § 78 PatG | **FLAG-B** |
### 2.16 `dpma.appeal.bgh` — Rechtsbeschwerdeverfahren BGH (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_bgh.entsch_bpatg` | Zustellung BPatG-Entscheidung | event | filing (trigger) | `§ 79 PatG` | `DE.PatG.79.1` | § 79 PatG Zustellung der BPatG-Entscheidung | MED (**FLAG-E**) |
| `dpma_bgh.entsch_bgh` | BGH-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 107 PatG | **FLAG-B** |
### 2.17 `epa.grant.exa` — EP-Erteilungsverfahren (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `ep_grant.filing` | Anmeldung | event | filing | `Art. 75 EPÜ` | `EU.EPÜ.75` | Art. 75 EPÜ Filing of European patent application | HIGH |
| `ep_grant.search` | Recherchenbericht | 6 months | decision | `Art. 92 EPÜ` | `EU.EPÜ.92` | Art. 92 EPÜ Drawing up of the European search report | MED (the 6-month figure is a Richtwert per `deadline_notes` not a statutory deadline. Could also cite `R. 65 EPÜ` if we want the issuance procedure.) |
| `ep_grant.grant` | Erteilung (B1) | event | decision | `Art. 97 EPÜ` | `EU.EPÜ.97.1` | Art. 97(1) EPÜ Decision to grant | HIGH |
### 2.18 `epa.opp.opd` — Einspruchsverfahren EPA (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `epa_opp.grant` | Veröffentlichung der Erteilung | event | filing (trigger) | `Art. 97 EPÜ` | `EU.EPÜ.97.3` | Art. 97(3) EPÜ mention of grant; trigger for the 9-month Einspruchsfrist (Art. 99(1) EPÜ) | HIGH |
| `epa_opp.entsch` | Entscheidung | event | decision | `Art. 101 EPÜ` | `EU.EPÜ.101` | Art. 101 EPÜ Decision on opposition | HIGH |
### 2.19 `epa.opp.boa` — Beschwerdeverfahren BoA (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `epa_app.entsch` | Zustellung der Beschwerdeentscheidung | event | filing (trigger) | `R. 111 EPÜ` | `EU.EPC-R.111` | R. 111 EPÜ Form and notification of decisions | MED (**FLAG-E**: service-trigger citation. Could also cite `Art. 119 EPÜ` for notification.) |
| `epa_app.oral` | Mündliche Verhandlung | event | hearing | `Art. 116 EPÜ` | `EU.EPÜ.116` | Art. 116 EPÜ Oral proceedings | HIGH |
| `epa_app.entsch2` | Entscheidung | event | decision | `Art. 111 EPÜ` | `EU.EPÜ.111` | Art. 111 EPÜ Decision in respect of appeals | HIGH |
---
## 3. Orphan rows — `proceeding_type_id IS NULL` and `code IS NULL` (77)
Identified by `id` (UUID first 8 chars) + name. These are the older Fristenrechner catalogue rows that pre-date the proceeding-typed slice and were never re-anchored to a proceeding. Many are 1:1 duplicates of rules that now live in proceeding-typed form.
### 3.1 UPC RoP — main-pleadings track (15)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `e34097d6…` | Klageerwiderung | 3 mo | `RoP.023` | `UPC.RoP.23.1` | RoP.23.1 Statement of defence | HIGH | dup of `inf.sod` |
| `7d8a4804…` | Nichtigkeitswiderklage | 3 mo | `RoP.025.1` | `UPC.RoP.25.1` | RoP.25.1 Counterclaim for revocation | HIGH | |
| `c7523e6b…` | Verletzungswiderklage | 2 mo | `RoP.049.2.b` | `UPC.RoP.49.2.b` | RoP.49.2.b Counterclaim for infringement in revocation | HIGH | dup of `rev.cc_inf` |
| `c57f62f8…` | Vorgängige Einrede | 1 mo | `RoP.019.1` | `UPC.RoP.19.1` | RoP.19.1 Preliminary objection | HIGH | dup of `inf.prelim` / `rev.prelim` |
| `cec1a865…` | Erwiderung Nichtigkeitswiderklage **+** Replik Klageerwiderung | 2 mo | `RoP.029.a` | `UPC.RoP.29.a` | RoP.29.a / .b combined Defence-to-CCR + Reply to SoD | HIGH (**FLAG-F**: combined-pleading orphan m to confirm one citation is sufficient or whether row should be split) |
| `84b390e0…` | Replik auf die Klageerwiderung | 2 mo | `RoP.029.b` | `UPC.RoP.29.b` | RoP.29.b Reply to defence | HIGH | dup of `inf.reply` |
| `176cc1ca…` | Duplik zur Replik auf die Klageerwiderung | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | RoP.29.c Rejoinder | HIGH | dup of `inf.rejoin` |
| `02ae9c1f…` | Duplik zur Replik, Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | combined: RoP.29.c + RoP.32.3 | MED (**FLAG-F**) |
| `ec2a1274…` | Replik auf Erwiderung Widerklage, Duplik Replik Klageerwiderung, Erwiderung Patentänderungsantrag | 2 mo | `RoP.029.d` | `UPC.RoP.29.d` | combined: RoP.29.d + RoP.29.c + RoP.32.1 | MED (**FLAG-F**: three-norm combined row) |
| `a32dcec1…` | Erwiderung auf die Nichtigkeitsklage | 2 mo | `RoP.049.1` | `UPC.RoP.49.1` | RoP.49.1 Defence to revocation | HIGH | dup of `rev.defence` |
| `37bd034b…` | Replik Erwiderung Nichtigkeitsklage + Erwiderung Patentänderungsantrag + Erwiderung Verletzungswiderklage | 2 mo | `RoP.051` | `UPC.RoP.51` | combined: RoP.51 + RoP.49.2.a-reply + RoP.56.1 | MED (**FLAG-F**) |
| `1b5c6dee…` | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 mo | `RoP.052` | `UPC.RoP.52` | RoP.52 Rejoinder in revocation | MED |
| `bea86f9b…` | Erwiderung auf die Verletzungswiderklage | 2 mo | `RoP.056.1` | `UPC.RoP.56.1` | RoP.56.1 | HIGH | dup of `rev.def_cci` |
| `4834c957…` | Replik auf die Erwiderung zur Verletzungswiderklage | 1 mo | `RoP.056.3` | `UPC.RoP.56.3` | RoP.56.3 | HIGH | dup of `rev.reply_def_cci` |
| `7b548c48…` | Duplik (Verletzungswiderklage + Patentänderungsantrag) | 1 mo | `RoP.056.4` | `UPC.RoP.56.4` | combined: RoP.56.4 + RoP.32.3 | MED (**FLAG-F**) |
### 3.2 UPC RoP — Patentänderungs-Track (5)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `fb7050c6…` | Antrag auf Patentänderung | 2 mo | `RoP.030.1` | `UPC.RoP.30.1` | RoP.30.1 (infringement context) | MED (**FLAG-G**: 2 rows with identical name + 2-month dur; one likely refers to `RoP.30.1` infringement, other to `RoP.49.2.a` revocation) |
| `21e67ac1…` | Antrag auf Patentänderung | 2 mo | `RoP.049.2.a` | `UPC.RoP.49.2.a` | RoP.49.2.a (revocation context) | MED (**FLAG-G**) |
| `7e65a434…` | Erwiderung auf den Antrag auf Patentänderung | 2 mo | `RoP.032.1` | `UPC.RoP.32.1` | RoP.32.1 Defence to application to amend | HIGH | dup of `inf.def_to_amend` |
| `dfd52792…` | Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 Reply | HIGH | dup of `inf.reply_def_amd` |
| `8cdf54eb…` | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 Rejoinder | HIGH | dup of `inf.rejoin_amd` |
### 3.3 UPC RoP — appeal track (16)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `1dfba5b1…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | RoP.224.1.a Notice of appeal, main-judgment track | HIGH | dup of `app.notice` |
| `5c0508f4…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
| `d560b3b6…` | Berufungsschrift gegen Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | RoP.224.1.b Notice of appeal, orders/leave track | HIGH | dup of `app_ord.with_leave`-family |
| `791fd0f7…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | RoP.225.1 Statement of grounds, main track | HIGH | dup of `app.grounds` |
| `573df3d1…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
| `c3a369f9…` | Berufungsbegründung Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.225.2` | `UPC.RoP.225.2` | RoP.225.2 Statement of grounds, orders/leave | MED (**FLAG-H**: RoP.225.2 form; verify 15d figure aligns with current RoP version) |
| `91e367dd…` | Berufung (Anordnungen & mit Zulassung) | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | same | MED | dup of `app_ord.with_leave` |
| `ccb916df…` | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d | `RoP.221.1` | `UPC.RoP.221.1` | RoP.221.1 Leave to appeal cost decisions | HIGH | dup of `cost.leave_app` |
| `342e749d…` | Antrag auf Ermessensüberprüfung | 15 d | `RoP.220.3` | `UPC.RoP.220.3` | RoP.220.3 Discretionary review | HIGH | dup of `app_ord.discretion` |
| `d4f739cd…` | Anfechtung einer Entscheidung über Verwerfung der Berufung als unzulässig | 1 mo | `RoP.234.1` | `UPC.RoP.234.1` | RoP.234 Inadmissibility of appeal review | MED (**FLAG-H**: confirm sub-paragraph; RoP.234 governs the topic but the 1-month review window may sit elsewhere) |
| `10374392…` | Berufungserwiderung (zur Berufung nach R. 224.2(a)) | 3 mo | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 Statement of response, main track | HIGH |
| `4c585c6d…` | Berufungserwiderung (zur Berufung nach R. 224.2(b)) | 15 d | `RoP.235.4` | `UPC.RoP.235.4` | RoP.235.4 Statement of response, orders/leave track | MED (**FLAG-H**: confirm RoP.235.4 vs. RoP.235.2 in current RoP version) |
| `6e39b653…` | Anschlussberufungsschrift (zur Berufung R. 224.2(a)) | 3 mo | `RoP.237.1` | `UPC.RoP.237.1` | RoP.237.1 Cross-appeal | HIGH |
| `a00e51bb…` | Anschlussberufungsschrift (zur Berufung R. 224.2(b)) | 15 d | `RoP.237.2` | `UPC.RoP.237.2` | RoP.237 Cross-appeal in orders track | MED (**FLAG-H**) |
| `6b989e85…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(a)) | 2 mo | `RoP.238.1` | `UPC.RoP.238.1` | RoP.238.1 Reply to cross-appeal | HIGH | dup of `app.cross_a_reply` |
| `e78f4652…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(b)) | 15 d | `RoP.238.2` | `UPC.RoP.238.2` | RoP.238.2 Reply to cross-appeal, orders track | HIGH | dup of `app_ord.cross_reply` |
### 3.4 UPC RoP — Schadensbemessung / Rechnungslegung (7)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `d414f603…` | Erwiderung Antrag auf Schadensersatzbemessung | 2 mo | `RoP.137.2` | `UPC.RoP.137.2` | RoP.137.2 | HIGH | dup of `damages.defence` |
| `9f39e263…` | Replik Erwiderung Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.reply` |
| `067ffdf0…` | Duplik Replik Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.rejoin` |
| `429b8ec0…` | Erwiderung Antrag auf Rechnungslegung | 2 mo | `RoP.142.2` | `UPC.RoP.142.2` | RoP.142.2 Defence in account procedure | HIGH | dup of `disc.defence` |
| `8d36fc76…` | Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.reply` |
| `ed82fec9…` | Duplik Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.rejoin` |
| `eed69e8b…` | Antrag auf Kostenentscheidung | 1 mo | `RoP.151` | `UPC.RoP.151` | RoP.151 Application for cost decision | HIGH | dup of `inf.cost_app` |
### 3.5 UPC RoP — provisional / PI (6)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `ba335c99…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | RoP.213.1 31 days or 20 working days after PI granted | HIGH |
| `d886f46f…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | same duplicate row (**FLAG-A**) | HIGH |
| `1f1f72ef…` | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d | `RoP.197.3` | `UPC.RoP.197.3` | RoP.197.3 Review of evidence preservation order | HIGH |
| `3e2f5697…` | Erneuerung der Schutzschrift | 6 mo | `RoP.207.9` | `UPC.RoP.207.9` | RoP.207.9 Protective letter, 6-month validity | HIGH |
### 3.6 UPC RoP — feststellungs / Widerruf-Track (4)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `521bf607…` | Erwiderung auf negative Feststellungsklage | 2 mo | *(NULL)* | *(NULL)* | UPC declaration of non-infringement procedure follows RoP.49 ff. by analogy (RoP.69 references) | **FLAG-I**: negative declaration track has no single statutory norm; cite either `RoP.069` / `UPC.RoP.69` (general procedure) or leave NULL pending m's call |
| `e887b1fb…` | Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
| `0cf1d755…` | Duplik Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
### 3.7 UPC RoP — formalities / Registry (14)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `d058f412…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | RoP.16.4 Notice to remedy defects | HIGH |
| `c690c323…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | same duplicate (**FLAG-A**) | HIGH |
| `5f2884a4…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `13600049…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `ceb780ba…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `d51c50eb…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `3bc40027…` | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d | `RoP.016.5` | `UPC.RoP.16.5` | RoP.16.5 Written observations after Registry notice | MED |
| `69e356b7…` | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d | `RoP.262.2` | `UPC.RoP.262.2` | RoP.262.2 Confidentiality vis-à-vis public (note in DB confirms) | HIGH |
| `57e6eeca…` | Berichtigung von Entscheidungen und Anordnungen | 1 mo | `RoP.353` | `UPC.RoP.353` | RoP.353 Rectification of decisions/orders | HIGH |
| `8ec233b9…` | Antrag auf Überprüfung verfahrensleitender Anordnung | 15 d | `RoP.333.1` | `UPC.RoP.333.1` | RoP.333.1 Review of procedural order | HIGH |
| `d124c95b…` | Antrag auf Aufhebung oder Änderung Entscheidung des Amtes | 1 mo | *(NULL)* | *(NULL)* | unclear which Amts-Entscheidung this targets Registry order? Unitary-effect refusal? | **FLAG-J** (recommend NULL; ask m what proceeding-context this row maps to) |
| `0531b6ba…` | Antrag auf Aufhebung Entscheidung EPA über einheitliche Wirkung | 3 wk | `RoP.097.1` | `UPC.RoP.97.1` | RoP.97.1 Action against EPO decision on unitary effect | MED (**FLAG-H**: verify 3-week period vs. norm; current RoP gives 1 month for such applications under R.88 EPÜ-UPC; possibly outdated) |
| `6b6b967c…` | Antrag auf Verweisung an die Zentralkammer | 10 d | `RoP.037.4` | `UPC.RoP.37.4` | RoP.37 governs division apportionment; .4 is the 10-day observation period | MED (**FLAG-H**: confirm sub-paragraph) |
| `002c2ba7…` | Antrag auf Folgemaßnahmen rechtskräftiger Validitätsentscheidung | 2 mo | *(NULL)* | *(NULL)* | likely refers to post-revocation register-correction request; norm uncertain | **FLAG-J** |
### 3.8 UPC RoP — translation / interpretation (3)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `bb7bafcb…` | Antrag auf Simultanübersetzung | 1 mo (before) | `RoP.109.1` | `UPC.RoP.109.1` | RoP.109.1 Request for simultaneous interpretation | HIGH |
| `8c682cff…` | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 wk (before) | `RoP.109.5` | `UPC.RoP.109.5` | RoP.109.5 Notice of own-cost interpreter | MED (**FLAG-H**: confirm sub-paragraph; RoP.109 governs interpretation but the specific 2-week notice rule may sit at .4 or .5) |
| `9ed513c1…` | Einreichung von Übersetzungen von Schriftstücken | 1 mo | `RoP.007.2` | `UPC.RoP.7.2` | RoP.7.2 Language of documents | MED (**FLAG-H**: alternative `RoP.7.4` for translations of party-submitted documents) |
| `902cc5d5…` | Klärung von Übersetzungsfragen | 2 wk | *(NULL)* | *(NULL)* | unclear which "Übersetzungsfrage" rule | **FLAG-J** |
### 3.9 UPC RoP — review / rehearing (2)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `372e86e3…` | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.2 Application for rehearing within 2 months | HIGH |
| `58de9573…` | Antrag auf Wiederaufnahme (Straftat) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.1(b) substantively (criminal act ground); RoP.247.2 for the 2-month period | HIGH |
### 3.10 Already-cited orphans (covered in § 1 Easy wins, 7 rows)
`20254f4e…`, `3c36f149…`, `f1099cf6…`, `c24d494c…`, `d40d9be7…`, `23c6f445…`, `b588fa64…` see § 1.
---
## 4. FLAG summary — items needing m's call
| FLAG | Topic | Count | Decision needed |
|---|---|---|---|
| **A** | Genuine duplicate orphan rows (same name + dur + citation) | ~10 | Confirm the dedup pass should happen in mig 097 (or a follow-up). Recommended: leave duplicates in place for mig 097 (fills all of them with the same citation); dedup separately so the rule-resolution semantics don't drift. |
| **B** | Court-scheduled / court-issued event rows (Mündliche Verhandlung, Urteil, Entscheidung) | ~22 | Confirm NULL is the right default. Alternative: cite the framing norm with a "context" note. |
| **C** | UPC RoP duration vs. norm mismatch (`rev.reply` / `rev.rejoin` / `app.response`) | 3 | Verify the rule durations are correct as stored proposed citations are canonical but rule duration may be from an older RoP version. |
| **D** | German LG patent practice: 4-week replik/duplik (court-set) | 2 | Confirm `§ 273 ZPO` is the cite m wants (no statutory period, framing norm only). |
| **E** | Service / trigger-event citations (`§ 317 ZPO`, `R. 111 EPÜ` etc.) | 6 | These are anchor-events for downstream timers, not deadlines. Confirm whether to cite (current proposal) or leave NULL. |
| **F** | Combined-pleading orphan rows (one row = several norms) | 5 | Confirm one citation is acceptable, or whether the rows should be split before mig 097 (out of scope here). |
| **G** | Twin "Antrag auf Patentänderung" orphans (2-mo, identical name) | 2 | Confirm one is infringement-context (`RoP.30.1`), the other revocation-context (`RoP.49.2.a`). |
| **H** | RoP sub-paragraph uncertainty (current text vs. older version) | ~8 | Spot-check against current published RoP; my citations are canonical but small `.x` numbers may need a tweak. |
| **I** | Negative-declaration track (no single UPC norm) | 3 | Confirm citing `RoP.69` (procedure-by-analogy) vs. leaving NULL. |
| **J** | Orphan with unclear scope | 3 | `d124c95b…` (Aufhebung Entscheidung des Amtes), `002c2ba7…` (Folgemaßnahmen Validitätsentscheidung), `902cc5d5…` (Klärung Übersetzungsfragen). m to identify which UPC norm. |
---
## 5. Side-fix (recommend bundled in mig 097)
**RoP-display normalization**: `rev.defence` currently carries `rule_code = "RoP.49.1"`. All other RoP rules under 100 use 3-digit padding (`RoP.029.a`, `RoP.049.2.a` etc.). mig 097 should normalize `RoP.49.1 → RoP.049.1` in that one row, while filling the 130 NULL rows with consistently padded values.
```sql
-- side-fix candidate
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.049.1'
WHERE rule_code = 'RoP.49.1'
AND code = 'rev.defence'; -- only one row; idempotent
```
This is opt-in; m to confirm before mig 097 ships.
---
## 6. Migration 097 hints (for the coder who writes it)
**Shape m has asked for:**
- `UPDATE paliad.deadline_rules SET rule_code = …, legal_source = … WHERE id = … AND rule_code IS NULL AND legal_source IS [NULL|expected];`
- Idempotent: `WHERE rule_code IS NULL` (or `IS DISTINCT FROM`) guard so re-applying is a no-op.
- Backup snapshot: `CREATE TABLE paliad.deadline_rules_pre_097 AS SELECT * FROM paliad.deadline_rules` before any UPDATEs.
- Wrap in `audit_reason = 't-paliad-208 legal-citation backfill'` (matches `paliad.audit_log` pattern used elsewhere).
- Touch only the m-approved rows from § 1, § 2, § 3 FLAG rows (those with `*(NULL)*` in the proposed columns) stay untouched until m resolves them.
- Side-fix § 5 (`RoP.49.1 → RoP.049.1`) only if m confirms.
**Counts the migration should match (assuming m approves all HIGH proposals as-is):**
- Easy wins 1): 8 `rule_code` UPDATEs (legal_source already set)
- Proceeding-typed HIGH/MED proposals 2): ~25 rows
- Orphan HIGH/MED proposals 3): ~50 rows
- Total expected `rule_code` writes: ~83 rows
- Total expected `legal_source` writes: ~75 rows (8 of the easy wins already have one)
- FLAG rows left NULL: ~47 rows pending m's decisions
---
## 7. Open questions for m
1. **NULL for event-markers (FLAG-B):** confirm NULL is correct for the 22 court-scheduled / court-issued event rows. If m wants citations there too, I'll do a second pass.
2. **Trigger-event citations (FLAG-E):** apply `§ 317 ZPO` to LG/OLG service rows, or leave NULL?
3. **Duplicates (FLAG-A):** mig 097 fills duplicates with the same citation; do you want a separate dedup pass scheduled (filing `t-paliad-21x`) or is the duplicate count acceptable for now?
4. **Combined-pleading orphans (FLAG-F):** keep one citation per row, or split each row into N rows before mig 097?
5. **Negative-declaration track (FLAG-I):** cite `RoP.69` by analogy, or leave NULL?
6. **Side-fix (§ 5):** normalize the one `RoP.49.1` outlier as part of mig 097?
Once m answers, head can re-task this same worker (or a fresh coder) to write mig 097 against the approved proposals.

View 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).

View File

@@ -0,0 +1,52 @@
# t-paliad-207 follow-up scope — close-out assessment
**Author:** fermi (inventor)
**Date:** 2026-05-20
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
---
## 0. What shipped under t-paliad-207
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
4. **mig 100**`upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
7. **Notes toggle**`Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
Filed two follow-up issues during the session:
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
## 1. Why (A) DONE
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
## 2. Optional tail — would file as discrete issues, not a fermi slice
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
| # | Candidate | Size | Already covered? |
|---|---|---|---|
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
| 3 | **Touch-device fallback for the ⓘ hover hint**`title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
## 3. Recommendation
Close t-paliad-207. Fire fermi. The remaining tail (items 16 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.

View File

@@ -10,6 +10,7 @@ import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklists } from "./src/checklists";
import { renderChecklistsAuthor } from "./src/checklists-author";
import { renderChecklistsDetail } from "./src/checklists-detail";
import { renderChecklistsInstance } from "./src/checklists-instance";
import { renderCourts } from "./src/courts";
@@ -17,13 +18,14 @@ import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderSubmissionDraft } from "./src/submission-draft";
import { renderSubmissionsIndex } from "./src/submissions-index";
import { renderSubmissionsNew } from "./src/submissions-new";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
@@ -42,6 +44,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";
@@ -242,6 +247,7 @@ async function build() {
join(import.meta.dir, "src/client/glossary.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklists.ts"),
join(import.meta.dir, "src/client/checklists-author.ts"),
join(import.meta.dir, "src/client/checklists-detail.ts"),
join(import.meta.dir, "src/client/checklists-instance.ts"),
join(import.meta.dir, "src/client/courts.ts"),
@@ -249,13 +255,14 @@ async function build() {
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/submission-draft.ts"),
join(import.meta.dir, "src/client/submissions-index.ts"),
join(import.meta.dir, "src/client/submissions-new.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
@@ -274,6 +281,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
@@ -364,6 +374,7 @@ async function build() {
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
await Bun.write(join(DIST, "courts.html"), renderCourts());
@@ -371,6 +382,9 @@ async function build() {
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());
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights
@@ -378,10 +392,8 @@ async function build() {
await Bun.write(join(DIST, "events.html"), renderEvents());
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
@@ -400,6 +412,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());

Binary file not shown.

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HL Patents Style</title>
<style>
:root {
--bg: #002236;
--fg: #e8e8ed;
--muted: #8a9aa6;
--accent: #bff355;
--rule: #0f3a55;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
line-height: 1.55;
font-size: 17px;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 4rem 1.5rem 6rem;
}
h1 {
font-size: 2.25rem;
margin: 0 0 0.25rem;
letter-spacing: -0.02em;
}
h1 .accent { color: var(--accent); }
.lead {
color: var(--muted);
margin: 0 0 3rem;
font-size: 1.05rem;
}
h2 {
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin: 2.5rem 0 0.75rem;
border-bottom: 1px solid var(--rule);
padding-bottom: 0.5rem;
}
ul { padding-left: 1.25rem; margin: 0.5rem 0 1rem; }
li { margin: 0.35rem 0; }
p { margin: 0.6rem 0; }
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
a:hover { border-bottom-color: var(--accent); }
code, kbd {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
background: #0a2d44;
padding: 0.1em 0.35em;
border-radius: 3px;
color: var(--accent);
}
.download {
display: inline-block;
margin-top: 0.5rem;
padding: 0.7rem 1.2rem;
background: var(--accent);
color: var(--bg);
font-weight: 600;
border-radius: 4px;
border: 0;
}
.download:hover { border-bottom: 0; filter: brightness(1.05); }
footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
color: var(--muted);
font-size: 0.85rem;
}
footer code { color: var(--muted); background: transparent; padding: 0; }
</style>
</head>
<body>
<main>
<h1>HL <span class="accent">Patents Style</span></h1>
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
<h2>Was es kann</h2>
<ul>
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
</ul>
<h2>Aktualisierungen</h2>
<p>Im Ribbon-Tab <em>HL Patent</em> &rarr; Gruppe <em>Manage</em> &rarr; <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
<h2>Frische Installation</h2>
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
<h2>Hilfe &amp; Feedback</h2>
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
<footer>
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> &middot; Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
<p id="ver"></p>
</footer>
<script>
// Best-effort: show the currently-served version
fetch('version.json', { cache: 'no-cache' })
.then(r => r.ok ? r.json() : null)
.then(j => {
if (j && j.version) {
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
}
})
.catch(() => {});
</script>
</main>
</body>
</html>

View File

@@ -0,0 +1,5 @@
{
"version": "v0.260518",
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
}

View 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 &mdash; 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">&larr; 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&auml;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-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.proceeding">Verfahren &amp; 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 &amp; 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&auml;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 &amp; 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&auml;t &amp; Flags</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-priority" data-i18n="admin.rules.edit.field.priority">Priorit&auml;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>&#123;"flag":"name"&#125;</code> · <code>&#123;"op":"and|or","args":[...]&#125;</code> · <code>&#123;"op":"not","args":[...]&#125;</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&uuml;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&auml;tigen</h2>
<button className="modal-close" id="rules-action-modal-close" type="button" aria-label="Close">&times;</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&auml;tigen
</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-edit.js"></script>
</body>
</html>
);
}

View 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 &mdash; 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">&larr; 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&auml;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>
);
}

View File

@@ -0,0 +1,187 @@
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 &mdash; 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 &rarr; published &rarr; 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, Submission Code, Rechtsgrundlage..."
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.submission_code">Submission Code</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
<th data-i18n="admin.rules.col.modified">Zuletzt ge&auml;ndert</th>
</tr>
</thead>
<tbody id="rules-tbody">
<tr><td colspan={7} 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&uuml;r die gew&auml;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&auml;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">&times;</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 &mdash; 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&uuml;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&auml;tigen
</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-list.js"></script>
</body>
</html>
);
}

View File

@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
Konto direkt anlegen
</button>
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
Bestehendes Konto onboarden
</button>
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
</div>
</div>
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
Creates BOTH the auth.users row (via Supabase Admin API) and
the paliad.users row in one click. New user is visible in
dropdowns immediately. */}
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erh&auml;lt eine E-Mail mit einem Link, &uuml;ber den sie ein Passwort setzt.
</p>
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
<input type="text" id="admin-af-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
<select id="admin-af-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
<select id="admin-af-profession" name="profession">
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
<select id="admin-af-lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
<label className="form-checkbox">
<input type="checkbox" id="admin-af-send-welcome" checked />
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
</label>
<div id="admin-af-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-team.js"></script>

View File

@@ -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&uuml;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>

View File

@@ -1,103 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsCalendar(): 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="appointments.kalender.title">Terminkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
Monats&uuml;bersicht aller Termine.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="termin-cal-legend">
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-hearing" />
<span data-i18n="appointments.type.hearing">Verhandlung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-meeting" />
<span data-i18n="appointments.type.meeting">Besprechung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-consultation" />
<span data-i18n="appointments.type.consultation">Beratung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-deadline_hearing" />
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
</span>
</div>
<div className="frist-calendar" id="appointment-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="appointment-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
Keine Termine im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,120 @@
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";
// Authoring wizard for paliad.checklists. Both /checklists/new and
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
// window.location.pathname to decide create vs edit mode.
export function renderChecklistsAuthor(): 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="checklisten.author.title">Vorlage erstellen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 id="author-heading" data-i18n="checklisten.author.heading.new">Neue Checklisten-Vorlage</h1>
<p className="tool-subtitle" data-i18n="checklisten.author.subtitle">
Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten.
</p>
</div>
<form id="author-form" className="form-stack" autoComplete="off">
<div className="form-row">
<label className="form-label" htmlFor="title" data-i18n="checklisten.author.field.title">Titel</label>
<input className="form-input" id="title" name="title" type="text" required maxLength="200" />
<p className="form-hint" data-i18n="checklisten.author.field.title.hint">z.B. &bdquo;UPC SoC &mdash; interne Checkliste&ldquo;.</p>
</div>
<div className="form-row">
<label className="form-label" htmlFor="description" data-i18n="checklisten.author.field.description">Kurzbeschreibung</label>
<textarea className="form-input" id="description" name="description" rows="3" maxLength="2000" />
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="regime" data-i18n="checklisten.author.field.regime">Regime</label>
<select className="form-input" id="regime" name="regime">
<option value="UPC">UPC</option>
<option value="DE">DE</option>
<option value="EPA">EPA</option>
<option value="OTHER" selected>OTHER</option>
</select>
</div>
<div className="form-row">
<label className="form-label" htmlFor="lang" data-i18n="checklisten.author.field.lang">Sprache</label>
<select className="form-input" id="lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="court" data-i18n="checklisten.author.field.court">Gericht / Beh&ouml;rde</label>
<input className="form-input" id="court" name="court" type="text" maxLength="200" />
</div>
<div className="form-row">
<label className="form-label" htmlFor="reference" data-i18n="checklisten.author.field.reference">Rechtsgrundlage</label>
<input className="form-input" id="reference" name="reference" type="text" maxLength="200" />
</div>
</div>
<div className="form-row">
<label className="form-label" htmlFor="deadline" data-i18n="checklisten.author.field.deadline">Deadline (optional)</label>
<input className="form-input" id="deadline" name="deadline" type="text" maxLength="200" />
</div>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.field.visibility">Sichtbarkeit</legend>
<label className="form-radio">
<input type="radio" name="visibility" value="private" checked />
<span><strong data-i18n="checklisten.mine.visibility.private">Privat</strong> &mdash; <span data-i18n="checklisten.author.visibility.private.hint">Nur f&uuml;r Sie sichtbar.</span></span>
</label>
<label className="form-radio">
<input type="radio" name="visibility" value="firm" />
<span><strong data-i18n="checklisten.mine.visibility.firm">Firmenweit</strong> &mdash; <span data-i18n="checklisten.author.visibility.firm.hint">F&uuml;r alle angemeldeten Kolleginnen und Kollegen sichtbar.</span></span>
</label>
</fieldset>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.groups.heading">Sektionen und Punkte</legend>
<div id="groups-container" />
<button type="button" className="btn btn-secondary" id="add-group" data-i18n="checklisten.author.groups.add">+ Sektion hinzuf&uuml;gen</button>
</fieldset>
<p id="author-error" className="form-error" style="display:none" role="alert" />
<div className="form-actions">
<button type="submit" className="btn btn-primary" id="author-save" data-i18n="checklisten.author.save">Speichern</button>
<a className="btn btn-secondary" href="/checklists?tab=mine" data-i18n="checklisten.author.cancel">Abbrechen</a>
</div>
</form>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-author.js"></script>
</body>
</html>
);
}

View File

@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
{/* Provenance line — visible only for authored
templates; populated by the client from the
catalog response's owner_display_name. */}
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
Neue Instanz
</button>
{/* Owner controls (Slice B) — toggled on by the
client once /api/checklists/{slug} returns
origin='authored' AND owner_email matches the
logged-in user. Kept hidden by default so
guests / non-owners never see them. */}
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
{/* global_admin controls — revealed by the client
when /api/me reports global_role='global_admin'. */}
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
</div>
</div>
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
opens it. Four recipient kinds in a single modal: pick the kind,
then the matching entity (user / office / partner_unit / project). */}
<div className="modal-overlay" id="share-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
<button className="modal-close" id="share-close" type="button">&times;</button>
</div>
<div className="form-field">
<label data-i18n="checklisten.share.kind">Empf&auml;ngertyp</label>
<div className="filter-pills" id="share-kind-pills">
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
</div>
</div>
<div className="form-field share-kind-section" data-kind="user">
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
<select id="share-user">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="office" style="display:none">
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
<select id="share-office">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
<select id="share-partner-unit">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="project" style="display:none">
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
<select id="share-project">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
</div>
<p className="form-msg" id="share-msg" />
{/* Existing grants — populated on open from
/api/checklists/templates/{slug}/shares. */}
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
<ul className="share-grants-list" id="share-grants-list">
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
</ul>
</div>
</div>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">

View File

@@ -58,6 +58,10 @@ export function renderChecklistsInstance(): string {
</div>
<p className="tool-subtitle" id="instance-template-title">&nbsp;</p>
<dl className="checklist-meta" id="instance-meta" />
{/* Slice C: 'template updated since this instance
was created' banner. Populated by the client
when instance.template_version &lt; template.version. */}
<div id="instance-outdated-slot" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
@@ -118,6 +122,21 @@ export function renderChecklistsInstance(): string {
</div>
</div>
{/* Slice C: template-diff modal — opened from the
"Änderungen anzeigen" button on the outdated banner. */}
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.instance.diff.title">Ge&auml;nderte Punkte</h2>
<button className="modal-close" id="instance-diff-close" type="button">&times;</button>
</div>
<div id="instance-diff-body" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="instance-diff-close-bottom" data-i18n="checklisten.instance.diff.close">Schlie&szlig;en</button>
</div>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-instance.js"></script>

View File

@@ -34,6 +34,8 @@ export function renderChecklists(): string {
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
</nav>
@@ -49,6 +51,36 @@ export function renderChecklists(): string {
<div className="checklist-grid" id="checklist-grid" />
</section>
{/* Meine Vorlagen tab — caller's own authored templates */}
<section className="entity-tab-panel" id="tab-mine" style="display:none">
<div className="tool-actions" style="margin-bottom:1rem">
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
</div>
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-mine-empty" style="display:none" data-i18n="checklisten.mine.empty">
Sie haben noch keine eigene Vorlage angelegt.
</p>
<div className="checklist-grid" id="checklists-mine-grid" style="display:none" />
</section>
{/* Geteilte Vorlagen tab — discovery surface for templates
that aren't owned by the caller (firm-published,
globally-promoted, or explicitly shared). Slice C. */}
<section className="entity-tab-panel" id="tab-gallery" style="display:none">
<div className="checklist-filters" id="checklist-gallery-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
<button className="filter-pill" data-regime="OTHER" type="button" data-i18n="checklisten.filter.other">Sonstige</button>
</div>
<p className="entity-events-empty" id="checklists-gallery-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-gallery-empty" style="display:none" data-i18n="checklisten.gallery.empty">
Noch keine geteilten Vorlagen sichtbar.
</p>
<div className="checklist-grid" id="checklists-gallery-grid" style="display:none" />
</section>
{/* Instances tab — every visible instance across templates */}
<section className="entity-tab-panel" id="tab-instances" style="display:none">
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">L&auml;dt&hellip;</p>

View File

@@ -0,0 +1,667 @@
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;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
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-submission-code", rule.submission_code ?? "");
setInput("f-rule-code", rule.rule_code ?? "");
setInput("f-legal-source", rule.legal_source ?? "");
setInput("f-proceeding", rule.proceeding_type_id ?? "");
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&auml;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);

View 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);

View File

@@ -0,0 +1,524 @@
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;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
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.submission_code || "")}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
<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);

View File

@@ -468,11 +468,125 @@ function initInviteButton() {
});
}
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
// the auth.users row (via Supabase Admin API) and the paliad.users row in
// one POST. New user appears in dropdowns immediately. Welcome email with
// magic-link is sent by default; admin can opt out via the checkbox.
function openAddFullModal() {
const modal = document.getElementById("admin-add-full-modal")!;
const fb = document.getElementById("admin-af-feedback")!;
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
fb.style.display = "none";
emailField.value = "";
nameField.value = "";
jobTitleField.value = "";
profSel.value = "associate";
langSel.value = "de";
sendWelcome.checked = true;
officeSel.innerHTML = officeOptions("munich");
modal.style.display = "flex";
emailField.focus();
}
function closeAddFullModal() {
document.getElementById("admin-add-full-modal")!.style.display = "none";
}
function initAddFullModal() {
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeAddFullModal();
});
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
// Pre-fill the display name from the email local-part the first time the
// admin tabs out of the email field — mirrors the existing onboard flow.
emailField.addEventListener("blur", () => {
if (nameField.value || !emailField.value) return;
const local = emailField.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
});
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-af-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
const payload: Record<string, unknown> = {
email: emailField.value.trim().toLowerCase(),
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
profession: profSel.value,
lang: langSel.value,
send_welcome_mail: sendWelcome.checked,
};
submitBtn.disabled = true;
try {
const resp = await fetch("/api/admin/users/full", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
// Map two friendly cases inline; everything else surfaces the
// server message so the admin can act on it.
if (resp.status === 503) {
fb.textContent = t("admin.team.add_full.error.unavailable")
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
} else if (resp.status === 409) {
fb.textContent = body.error
|| (t("admin.team.add_full.error.email_exists")
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
} else {
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
}
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeAddFullModal();
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
render();
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initAddFullModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();

View File

@@ -1,193 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Appointment {
id: string;
project_id?: string;
title: string;
start_at: string;
end_at?: string;
appointment_type?: string;
project_reference?: string;
project_title?: string;
}
let allAppointments: Appointment[] = [];
let viewYear = 0;
let viewMonth = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
async function loadAppointments() {
// Pull a wide window (current month plus a little buffer either side).
// We could narrow this, but the user typically navigates ±1-2 months
// and the dataset is small.
try {
const resp = await fetch("/api/appointments");
if (resp.ok) allAppointments = await resp.json();
} catch {
/* non-fatal */
}
}
function appointmentsForDate(iso: string): Appointment[] {
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
}
function typeClass(t?: string): string {
return t ? `termin-type-${t}` : "termin-type-default";
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = appointmentsForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("appointment-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allAppointments.some((tt) => {
const iso = tt.start_at.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("appointment-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = appointmentsForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((tt) => {
const akteRef = tt.project_id
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
return `<li class="frist-cal-popup-item">
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
${akteRef}
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadAppointments();
render();
});

View File

@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
interface Appointment {
id: string;
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
interface Me {
@@ -43,6 +47,10 @@ let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
// "Termin bearbeiten" in the withdraw warning modal.
let pendingEditMode = false;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -207,10 +215,14 @@ function renderHeader() {
}
// Freeze the edit form + delete button while a request is in flight.
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
// withdraw modal, pendingEditMode unfreezes the form so Save can route
// to /edit-entity (which keeps the request pending + merges payload).
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
const freeze = isPending && !pendingEditMode;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
.forEach((el) => { el.disabled = freeze; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
submitBtn.disabled = true;
try {
// t-paliad-252 — pending-edit mode routes through /edit-entity which
// keeps the request pending + merges fields into payload. clear_project
// and project_id are NOT in the counter-allowlist (yet) — the requester
// can't move projects on a pending request from this surface.
if (pendingEditMode && pendingRequest) {
const editFields = { ...payload };
delete editFields.clear_project;
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: editFields }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) appointment = await fresh.json();
await loadPendingRequest();
// Exit pending-edit mode so the form re-freezes (still pending).
pendingEditMode = false;
renderHeader();
fillEditForm();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
return;
}
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -312,12 +357,37 @@ async function deleteAppointment() {
}
}
// t-paliad-252 — withdraw warning modal replaces the old confirm().
// Returns:
// "edit" → unfreeze the edit form (pending-edit mode); Save will
// route through /api/approval-requests/{id}/edit-entity
// "withdraw" → destructive: the existing /revoke endpoint
// null → user cancelled
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "appointment",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
if (btn) btn.disabled = false;
return;
}
if (action === "edit") {
pendingEditMode = true;
if (btn) btn.disabled = false;
// renderHeader re-evaluates the freeze and unfreezes the form now
// that pendingEditMode is set. Focus the first editable field so the
// user can type immediately.
renderHeader();
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
titleEl?.focus();
return;
}
// action === "withdraw" → destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
renderHeader();
fillEditForm();
} else {
// CREATE lifecycle: entity gone → back to the list.
window.location.href = "/events?type=appointment";
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;

View File

@@ -1,16 +1,23 @@
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
//
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
// collects subject + body + (optional) template and posts to
// /api/team/broadcast. On success it shows a per-recipient send report
// and closes.
// and closes after a short delay.
//
// Per-recipient privacy: each member receives their own envelope. The
// modal lists every addressee so the sender knows exactly who will be
// mailed; there is no surprise to-line.
//
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
// close button, and browser back-button are now owned by openModal().
// The body is built imperatively so the submit handler can read form
// state from the modal-body element it constructed.
import { t } from "./i18n";
import { openModal } from "./components/modal";
export interface BroadcastRecipient {
user_id: string;
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
is_default: boolean;
}
interface BroadcastResult {
sent: number;
failed: number;
total: number;
}
const RECIPIENT_CAP = 100;
function esc(s: string): string {
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
return;
}
// Existing modal? Remove. Avoids stacking on rapid double-click.
document.getElementById("broadcast-modal")?.remove();
const body = renderBody(args);
wireBody(body);
const overlay = document.createElement("div");
overlay.id = "broadcast-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args);
document.body.appendChild(overlay);
// Close handlers
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener("keydown", function escClose(e) {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escClose);
}
});
// Recipient toggle
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
// Submit
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await onSubmit(form, overlay, args);
void openModal<BroadcastResult>({
title: t("team.broadcast.title") || "E-Mail an Auswahl",
body,
size: "lg",
primary: {
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
handler: async (close) => {
await onSubmit(body, args, close);
},
},
secondary: { label: t("common.cancel") || "Abbrechen" },
});
}
function renderShell(args: OpenBroadcastModalArgs): string {
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
const root = document.createElement("div");
root.className = "broadcast-body";
const count = args.recipients.length;
const previewItems = args.recipients
.slice(0, 5)
.map((r) => esc(r.display_name) + " &lt;" + esc(r.email) + "&gt;")
.join(", ");
const more = count > 5 ? ` +${count - 5}` : "";
const fullList = args.recipients
.map(
(r) =>
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
)
.join("");
return `
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
<header class="modal-header">
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">&times;</button>
</header>
<form data-broadcast-form>
<div class="modal-body">
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
</footer>
</form>
root.innerHTML = `
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<div class="form-field">
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
</div>
<div class="form-field">
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
</div>
<div class="form-field">
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
</div>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
`;
return root;
}
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
function wireBody(body: HTMLElement): void {
// Recipient list toggle.
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown — populates subject/body from the selected template.
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
}
async function onSubmit(
body: HTMLElement,
args: OpenBroadcastModalArgs,
close: (result: BroadcastResult) => void,
): Promise<void> {
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
errEl?.classList.add("hidden");
okEl?.classList.add("hidden");
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
return;
}
if (!body) {
if (!bodyText) {
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
return;
}
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
}
// The modal primary button lives in the footer (owned by openModal),
// not in the body. We surface "sending..." feedback via the in-body
// success/error areas; the primary button stays clickable but the
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
const recipientFilter: Record<string, unknown> = {};
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
if (args.projectID) recipientFilter.project_id = args.projectID;
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
body: JSON.stringify({
project_id: args.projectID ?? null,
subject,
body,
body: bodyText,
template_key: templateKey || undefined,
lang,
recipient_filter: recipientFilter,
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
if (!res.ok) {
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
showError(errEl, (errBody as { error?: string }).error || "Send failed");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
return;
}
const report = (await res.json()) as { sent: number; failed: number; total: number };
const report = (await res.json()) as BroadcastResult;
if (okEl) {
okEl.classList.remove("hidden");
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
.replace("{total}", String(report.total))
.replace("{failed}", String(report.failed));
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
}
setTimeout(() => overlay.remove(), 2500);
// Give the sender a moment to see the report, then close.
setTimeout(() => close(report), 2500);
} catch (e) {
showError(errEl, String(e));
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
}
}

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import {
bucketByDate,
filterByDay,
isToday,
isoDate,
shift,
startOfDay,
startOfWeek,
type CalendarItem,
} from "./mount-calendar";
// Regression tests for t-paliad-224: the calendar bucket / week / shift
// helpers underpin both /events Kalender and the Custom Views shape=
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
// ts comment), so the pure date-math goes here.
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Klageerwiderung",
event_date: "2026-05-08T00:00:00Z",
...overrides,
});
describe("isoDate / startOfDay / startOfWeek", () => {
test("isoDate pads month + day", () => {
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
});
test("startOfDay strips time", () => {
const d = new Date(2026, 4, 8, 13, 47, 22);
const out = startOfDay(d);
expect(out.getHours()).toBe(0);
expect(out.getMinutes()).toBe(0);
expect(out.getSeconds()).toBe(0);
expect(isoDate(out)).toBe("2026-05-08");
});
test("startOfWeek snaps to Monday (Mon=0)", () => {
// 2026-05-08 was a Friday.
const fri = new Date(2026, 4, 8);
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
// Sunday wraps backward to the same Monday, not forward to the next.
const sun = new Date(2026, 4, 10);
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
// Monday is its own startOfWeek.
const mon = new Date(2026, 4, 4);
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
});
});
describe("shift", () => {
test("month shift lands on day=1 of the target month", () => {
const out = shift(new Date(2026, 4, 15), "month", 1);
expect(out.getFullYear()).toBe(2026);
expect(out.getMonth()).toBe(5);
expect(out.getDate()).toBe(1);
});
test("month shift wraps year boundary", () => {
const out = shift(new Date(2026, 11, 15), "month", 1);
expect(out.getFullYear()).toBe(2027);
expect(out.getMonth()).toBe(0);
expect(out.getDate()).toBe(1);
});
test("week shift moves seven days", () => {
const out = shift(new Date(2026, 4, 8), "week", 1);
expect(isoDate(out)).toBe("2026-05-15");
});
test("day shift moves one day", () => {
const out = shift(new Date(2026, 4, 8), "day", -1);
expect(isoDate(out)).toBe("2026-05-07");
});
});
describe("bucketByDate", () => {
test("groups items by ISO date and skips items outside the filter", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
// outside the May 2026 filter:
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
// malformed:
item({ id: "bad", event_date: "not-a-date" }),
];
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
expect(out.size).toBe(2);
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
expect(out.has("2026-06-01")).toBe(false);
});
});
describe("filterByDay", () => {
test("returns only items whose calendar day equals the target", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
});
test("ignores malformed dates", () => {
const rows = [
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "bad", event_date: "not-a-date" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
});
});
describe("isToday", () => {
test("matches today's calendar day", () => {
expect(isToday(new Date())).toBe(true);
});
test("rejects yesterday + tomorrow", () => {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
expect(isToday(yesterday)).toBe(false);
expect(isToday(tomorrow)).toBe(false);
});
});

View File

@@ -0,0 +1,579 @@
import { t, tDyn, getLang, type I18nKey } from "../i18n";
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
// Lifted from the original shape-calendar.ts so both Custom Views
// (shape=calendar) and /events Kalender tab render through the same DOM.
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
//
// Surfaces wire in via mountCalendar(host, items, opts). The returned
// handle exposes update(items) for re-render after a filter change and
// destroy() for teardown when the host swaps to a different view.
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
/** ISO-8601 timestamp or date string. First 10 chars are read as the
* calendar bucket (yyyy-mm-dd). */
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
}
export type CalendarView = "month" | "week" | "day";
export interface CalendarOpts {
/** Initial view if URL has no override (or urlState is disabled). */
defaultView?: CalendarView;
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
* Surfaces that own their own URL contract pass urlState=false. */
urlState?: boolean;
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
* meaningful when urlState=true. Leave empty for the default
* ?cal_view / ?cal_date contract. */
urlPrefix?: string;
/** Override how a row's href is built. Default routes by kind. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Replace the item set and re-paint at the current view+anchor. */
update(items: CalendarItem[]): void;
/** Clear host + drop the keep-alive state. After destroy(), the handle
* is dead; create a fresh one with mountCalendar(). */
destroy(): void;
}
const MAX_PILLS_PER_MONTH_CELL = 3;
export function mountCalendar(
host: HTMLElement,
initialItems: CalendarItem[],
opts: CalendarOpts = {},
): CalendarHandle {
let items = initialItems;
let view: CalendarView;
let anchor: Date;
let destroyed = false;
const urlEnabled = opts.urlState ?? false;
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
view = urlEnabled
? readView(viewParam, opts.defaultView ?? "month")
: (opts.defaultView ?? "month");
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
paint();
return {
update(nextItems) {
if (destroyed) return;
items = nextItems;
paint();
},
destroy() {
destroyed = true;
host.innerHTML = "";
},
};
// --- paint -----------------------------------------------------------
function paint(): void {
if (destroyed) return;
host.innerHTML = "";
// Mobile fallback notice (<600px). Documented in design-calendar-
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
// notice just nudges users toward a friendlier view.
if (typeof window !== "undefined" && window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar());
if (view === "month") {
wrap.appendChild(renderMonth());
} else if (view === "week") {
wrap.appendChild(renderWeek());
} else {
wrap.appendChild(renderDay());
}
host.appendChild(wrap);
}
function setView(nextView: CalendarView, nextAnchor: Date): void {
view = nextView;
anchor = nextAnchor;
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
paint();
}
// --- Toolbar ---------------------------------------------------------
function renderToolbar(): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalendarView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
setView(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
nav.appendChild(next);
// "Heute" button — jump back to today in the current view. Adds a
// recognisable affordance for the /events Kalender users who relied
// on the old toolbar's "Heute" button.
const today = document.createElement("button");
today.type = "button";
today.className = "btn-secondary btn-small views-calendar-nav-btn";
today.textContent = t("cal.today");
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
nav.appendChild(today);
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => setView("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
// --- Month -----------------------------------------------------------
function renderMonth(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
const byDate = bucketByDate(items, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) ul.appendChild(renderPill(row));
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week ------------------------------------------------------------
function renderWeek(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
grid.appendChild(renderWeekColumn(day));
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
const dayRows = filterByDay(items, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day -------------------------------------------------------------
function renderDay(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(items, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering ---------------------------------------------------
function renderPill(row: CalendarItem): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = hrefFor(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = hrefFor(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function hrefFor(row: CalendarItem): string {
if (opts.hrefFor) return opts.hrefFor(row);
return defaultHrefFor(row);
}
}
// --- Pure helpers (shared, not closure-bound) ----------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
function defaultHrefFor(row: CalendarItem): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
export function bucketByDate(
rows: CalendarItem[], filter: (d: Date) => boolean,
): Map<string, CalendarItem[]> {
const out = new Map<string, CalendarItem[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
export function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
}
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function shift(d: Date, view: CalendarView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
export function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
export function isoDate(d: Date): string {
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 formatRangeLabel(view: CalendarView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
function firstAnchor(rows: CalendarItem[]): Date {
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return startOfDay(d);
}
return startOfDay(new Date());
}
function paramName(prefix: string | undefined, base: string): string {
if (!prefix) return base;
return `${prefix}_${base}`;
}
function readView(viewParam: string, fallback: CalendarView): CalendarView {
if (typeof window === "undefined") return fallback;
const params = new URLSearchParams(window.location.search);
const raw = params.get(viewParam);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return fallback;
}
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
if (typeof window === "undefined") return firstAnchor(rows);
const params = new URLSearchParams(window.location.search);
const raw = params.get(dateParam);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
return firstAnchor(rows);
}
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(viewParam, view);
url.searchParams.set(dateParam, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -0,0 +1,365 @@
// Authoring wizard for paliad.checklists. Serves both /checklists/new
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
// same; this client reads location.pathname to decide which mode to
// boot into.
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface Item {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface Group {
titleDE: string;
titleEN: string;
items: Item[];
}
interface Checklist {
id: string;
slug: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
body: { groups: Group[] };
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function detectMode(): { mode: "create" | "edit"; slug?: string } {
const path = window.location.pathname;
if (path === "/checklists/new") {
return { mode: "create" };
}
const m = path.match(/^\/checklists\/templates\/([^/]+)\/edit$/);
if (m) {
return { mode: "edit", slug: m[1] };
}
return { mode: "create" };
}
let groups: Group[] = [];
function renderGroups() {
const container = document.getElementById("groups-container")!;
if (groups.length === 0) {
// Seed with a single empty group + item so the user has something
// to fill out rather than a blank canvas.
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
container.innerHTML = groups.map((g, gi) => {
const itemsHTML = g.items.map((it, ii) => {
return `<div class="author-item" data-gi="${gi}" data-ii="${ii}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.label"))}</label>
<input class="form-input" data-field="label" value="${escAttr(it.labelDE || "")}" />
</div>
<div class="form-grid form-grid-2">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.note"))}</label>
<input class="form-input" data-field="note" value="${escAttr(it.noteDE || "")}" />
</div>
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.rule"))}</label>
<input class="form-input" data-field="rule" value="${escAttr(it.rule || "")}" />
</div>
</div>
<button type="button" class="btn btn-small btn-danger" data-action="remove-item">${esc(t("checklisten.author.item.remove"))}</button>
</div>`;
}).join("");
return `<div class="author-group" data-gi="${gi}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.group.title"))}</label>
<input class="form-input" data-field="group-title" value="${escAttr(g.titleDE || "")}" />
</div>
<div class="author-items">${itemsHTML}</div>
<div class="author-group-actions">
<button type="button" class="btn btn-small" data-action="add-item">${esc(t("checklisten.author.item.add"))}</button>
<button type="button" class="btn btn-small btn-danger" data-action="remove-group">${esc(t("checklisten.author.group.remove"))}</button>
</div>
</div>`;
}).join("");
// Wire input changes back into the data array.
container.querySelectorAll<HTMLInputElement>(".author-group > .form-row input[data-field=group-title]").forEach((input) => {
const groupDiv = input.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
input.addEventListener("input", () => {
groups[gi].titleDE = input.value;
groups[gi].titleEN = input.value; // single-language for Slice A
});
});
container.querySelectorAll<HTMLDivElement>(".author-item").forEach((itemDiv) => {
const gi = parseInt(itemDiv.dataset.gi!, 10);
const ii = parseInt(itemDiv.dataset.ii!, 10);
itemDiv.querySelectorAll<HTMLInputElement>("input[data-field]").forEach((input) => {
input.addEventListener("input", () => {
const field = input.dataset.field!;
if (field === "label") {
groups[gi].items[ii].labelDE = input.value;
groups[gi].items[ii].labelEN = input.value;
} else if (field === "note") {
groups[gi].items[ii].noteDE = input.value || undefined;
groups[gi].items[ii].noteEN = input.value || undefined;
} else if (field === "rule") {
groups[gi].items[ii].rule = input.value || undefined;
}
});
});
itemDiv.querySelector<HTMLButtonElement>("button[data-action=remove-item]")!.addEventListener("click", () => {
groups[gi].items.splice(ii, 1);
if (groups[gi].items.length === 0) {
groups[gi].items.push({ labelDE: "", labelEN: "" });
}
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=add-item]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups[gi].items.push({ labelDE: "", labelEN: "" });
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=remove-group]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups.splice(gi, 1);
renderGroups();
});
});
}
function showError(msg: string) {
const err = document.getElementById("author-error")!;
err.textContent = msg;
err.style.display = "";
err.scrollIntoView({ behavior: "smooth", block: "center" });
}
function clearError() {
const err = document.getElementById("author-error")!;
err.textContent = "";
err.style.display = "none";
}
function collectInput() {
const title = (document.getElementById("title") as HTMLInputElement).value.trim();
const description = (document.getElementById("description") as HTMLTextAreaElement).value.trim();
const regime = (document.getElementById("regime") as HTMLSelectElement).value;
const court = (document.getElementById("court") as HTMLInputElement).value.trim();
const reference = (document.getElementById("reference") as HTMLInputElement).value.trim();
const deadline = (document.getElementById("deadline") as HTMLInputElement).value.trim();
const lang = (document.getElementById("lang") as HTMLSelectElement).value;
const visibilityInput = document.querySelector<HTMLInputElement>("input[name=visibility]:checked");
const visibility = visibilityInput?.value || "private";
return { title, description, regime, court, reference, deadline, lang, visibility };
}
function validateGroups(): boolean {
if (groups.length === 0) return false;
let totalItems = 0;
for (const g of groups) {
if (!g.titleDE.trim()) return false;
for (const it of g.items) {
if (it.labelDE.trim()) totalItems += 1;
}
}
return totalItems > 0;
}
function trimmedGroups(): Group[] {
return groups
.filter((g) => g.titleDE.trim() && g.items.some((it) => it.labelDE.trim()))
.map((g) => ({
titleDE: g.titleDE.trim(),
titleEN: g.titleEN.trim(),
items: g.items
.filter((it) => it.labelDE.trim())
.map((it) => ({
labelDE: it.labelDE.trim(),
labelEN: it.labelEN.trim(),
noteDE: it.noteDE?.trim() || undefined,
noteEN: it.noteEN?.trim() || undefined,
rule: it.rule?.trim() || undefined,
})),
}));
}
async function loadEditTemplate(slug: string) {
// Use /api/checklists/{slug} (catalog Find with visibility check) +
// the mine list to ensure we have the editable fields. Templates the
// caller doesn't own/admin will trip the PATCH gate later.
const resp = await fetch(`/api/checklists/templates/mine`);
if (!resp.ok) {
showError(t("checklisten.author.error.notfound"));
return;
}
const rows: Checklist[] = (await resp.json()) ?? [];
const tpl = rows.find((r) => r.slug === slug);
if (!tpl) {
showError(t("checklisten.author.error.notfound"));
return;
}
(document.getElementById("author-heading")!).textContent = t("checklisten.author.heading.edit");
document.title = t("checklisten.author.title.edit");
(document.getElementById("title") as HTMLInputElement).value = tpl.title;
(document.getElementById("description") as HTMLTextAreaElement).value = tpl.description;
(document.getElementById("regime") as HTMLSelectElement).value = tpl.regime;
(document.getElementById("court") as HTMLInputElement).value = tpl.court;
(document.getElementById("reference") as HTMLInputElement).value = tpl.reference;
(document.getElementById("deadline") as HTMLInputElement).value = tpl.deadline;
(document.getElementById("lang") as HTMLSelectElement).value = tpl.lang || "de";
const visIn = document.querySelector<HTMLInputElement>(`input[name=visibility][value=${tpl.visibility}]`);
if (visIn) visIn.checked = true;
groups = (tpl.body?.groups || []).map((g) => ({
titleDE: g.titleDE || "",
titleEN: g.titleEN || g.titleDE || "",
items: g.items.map((it) => ({
labelDE: it.labelDE || "",
labelEN: it.labelEN || it.labelDE || "",
noteDE: it.noteDE,
noteEN: it.noteEN,
rule: it.rule,
})),
}));
if (groups.length === 0) {
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
renderGroups();
}
async function submitCreate() {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const body = JSON.stringify({ ...input, body: { groups: trimmedGroups() } });
const resp = await fetch("/api/checklists/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
const created: Checklist = await resp.json();
window.location.href = `/checklists/${encodeURIComponent(created.slug)}`;
}
async function submitEdit(slug: string) {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const patch = {
title: input.title,
description: input.description,
regime: input.regime,
court: input.court,
reference: input.reference,
deadline: input.deadline,
body: { groups: trimmedGroups() },
};
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
// Visibility lives on its own endpoint so the audit row reflects the
// distinct transition. Only call if it actually changed.
if (resp.ok && input.visibility) {
await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}/visibility`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visibility: input.visibility }),
});
}
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
window.location.href = `/checklists/${encodeURIComponent(slug)}`;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
renderGroups();
document.getElementById("add-group")!.addEventListener("click", () => {
groups.push({ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] });
renderGroups();
});
const { mode, slug } = detectMode();
if (mode === "edit" && slug) {
void loadEditTemplate(slug);
}
document.getElementById("author-form")!.addEventListener("submit", (e) => {
e.preventDefault();
if (mode === "edit" && slug) {
void submitEdit(slug);
} else {
void submitCreate();
}
});
});

View File

@@ -30,6 +30,37 @@ interface Checklist {
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
// Slice B fields — present on authored entries via the merged
// catalog response. 'static' templates don't carry these.
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface Me {
id: string;
email: string;
display_name: string;
global_role?: string;
}
interface UserSummary {
id: string;
email: string;
display_name: string;
}
interface PartnerUnit {
id: string;
name: string;
}
interface Share {
id: string;
checklist_id: string;
recipient_kind: "user" | "office" | "partner_unit" | "project";
recipient_label: string;
}
interface ChecklistInstance {
@@ -371,13 +402,320 @@ function rerenderAll() {
renderInstances();
}
// --- Slice B: owner actions + admin promote + share modal ----------------
let me: Me | null = null;
let isOwner = false;
let isAdmin = false;
let shareUsers: UserSummary[] = [];
let sharePartnerUnits: PartnerUnit[] = [];
let shareProjects: AkteSummary[] = [];
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
async function loadMe(): Promise<Me | null> {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function templateOriginInfo() {
return template as unknown as {
origin?: string;
visibility?: string;
owner_email?: string;
owner_display_name?: string;
} | null;
}
function applyOwnerControls() {
const info = templateOriginInfo();
const isAuthored = info?.origin === "authored";
const provenance = document.getElementById("checklist-provenance")!;
if (isAuthored && info?.owner_display_name) {
provenance.style.display = "";
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
} else {
provenance.style.display = "none";
}
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
isAdmin = !!(me && me.global_role === "global_admin");
const ownerOnly = (id: string, show: boolean) => {
const el = document.getElementById(id);
if (el) (el as HTMLElement).style.display = show ? "" : "none";
};
if (template) {
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
"href",
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
);
}
ownerOnly("btn-edit-template", isOwner);
ownerOnly("btn-share-template", isOwner);
ownerOnly("btn-delete-template", isOwner);
// Admin promote/demote — only when an authored template is visible to
// an admin, and only the appropriate one for the current visibility.
if (isAuthored && isAdmin) {
const isGlobal = info?.visibility === "global";
ownerOnly("btn-promote-template", !isGlobal);
ownerOnly("btn-demote-template", isGlobal);
} else {
ownerOnly("btn-promote-template", false);
ownerOnly("btn-demote-template", false);
}
}
function initOwnerActions() {
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.detail.delete.error"));
return;
}
window.location.href = "/checklists?tab=mine";
});
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: "firm" }),
});
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
}
async function loadSharePickerData() {
// Fire all three lookups in parallel — the share modal needs all of
// them but doesn't depend on their order.
try {
const [usersResp, unitsResp, projectsResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units"),
fetch("/api/projects"),
]);
shareUsers = usersResp.ok ? await usersResp.json() : [];
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
} catch {
/* leave whatever loaded */
}
populateSharePickerOptions();
}
function populateSharePickerOptions() {
const userSel = document.getElementById("share-user") as HTMLSelectElement;
if (userSel) {
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareUsers
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.forEach((u) => {
if (me && u.id === me.id) return; // can't share with self
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = `${u.display_name} (${u.email})`;
userSel.appendChild(opt);
});
}
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
if (officeSel) {
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
officeKeys.forEach((k) => {
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
officeSel.appendChild(opt);
});
}
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
if (puSel) {
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
sharePartnerUnits
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((u) => {
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = u.name;
puSel.appendChild(opt);
});
}
const prSel = document.getElementById("share-project") as HTMLSelectElement;
if (prSel) {
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareProjects
.slice()
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${p.reference || ""}${p.title}`;
prSel.appendChild(opt);
});
}
}
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
activeShareKind = kind;
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
p.classList.toggle("active", p.dataset.kind === kind);
});
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
s.style.display = s.dataset.kind === kind ? "" : "none";
});
}
function initShareModal() {
const modal = document.getElementById("share-modal")!;
const msg = document.getElementById("share-msg")!;
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
if (!template) return;
msg.textContent = "";
msg.className = "form-msg";
switchShareKind("user");
modal.style.display = "flex";
await loadSharePickerData();
await renderGrants();
});
document.getElementById("share-close")?.addEventListener("click", close);
document.getElementById("share-cancel")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
if (!btn) return;
switchShareKind(btn.dataset.kind as typeof activeShareKind);
});
document.getElementById("share-submit")?.addEventListener("click", async () => {
if (!template) return;
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
switch (activeShareKind) {
case "user": {
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_user_id"] = v;
break;
}
case "office": {
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_office"] = v;
break;
}
case "partner_unit": {
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_partner_unit_id"] = v;
break;
}
case "project": {
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_project_id"] = v;
break;
}
}
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!resp.ok) {
let errMsg = t("checklisten.share.error.generic");
try {
const j = await resp.json();
if (j?.error) errMsg = j.error;
} catch { /* keep generic */ }
msg.textContent = errMsg;
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.share.success");
msg.className = "form-msg form-msg-success";
await renderGrants();
});
}
async function renderGrants() {
if (!template) return;
const list = document.getElementById("share-grants-list")!;
const empty = document.getElementById("share-grants-empty")!;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
const rows: Share[] = resp.ok ? await resp.json() : [];
if (rows.length === 0) {
list.innerHTML = "";
list.appendChild(empty);
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = rows.map((s) => {
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
return `<li class="share-grant-row" data-id="${esc(s.id)}">
<span class="share-grant-kind">${kindLabel}</span>
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
</li>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
window.alert(t("checklisten.share.grants.revoke.error"));
return;
}
await renderGrants();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initNewInstance();
initFeedback();
initOwnerActions();
initShareModal();
onLangChange(rerenderAll);
void loadTemplate();
void (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadInstances();
void loadAkten();
});

View File

@@ -40,6 +40,16 @@ interface Instance {
created_by: string;
created_at: string;
updated_at: string;
// Slice C — snapshot of the template body + its version at create time.
template_snapshot?: { groups: ChecklistGroup[] } | null;
template_version?: number | null;
}
// Slice C — augmented Checklist with origin + version, returned by
// /api/checklists/{slug}.
interface ChecklistWithMeta extends Checklist {
origin?: "static" | "authored";
version?: number;
}
let template: Checklist | null = null;
@@ -155,6 +165,119 @@ function renderHeader() {
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
}
document.getElementById("instance-meta")!.innerHTML = parts.join("");
renderOutdatedBadge();
}
// Slice C — show an "outdated" badge when the live template has a
// version > the instance's snapshot version. Both values must be
// non-null for the comparison to be meaningful (pre-Slice-C instances
// have NULL template_version; static templates always have version=1
// and never bump).
function renderOutdatedBadge() {
const slot = document.getElementById("instance-outdated-slot");
if (!slot || !instance || !template) return;
const tplMeta = template as ChecklistWithMeta;
const instVersion = instance.template_version;
const tplVersion = tplMeta.version;
if (
instVersion == null ||
tplVersion == null ||
tplMeta.origin !== "authored" ||
tplVersion <= instVersion
) {
slot.innerHTML = "";
return;
}
const badge = esc(t("checklisten.instance.outdated.badge"));
const note = esc(
t("checklisten.instance.outdated.note")
.replace("{from}", String(instVersion))
.replace("{to}", String(tplVersion)),
);
const action = esc(t("checklisten.instance.outdated.diff"));
slot.innerHTML = `<div class="instance-outdated-banner">
<span class="instance-outdated-badge">${badge}</span>
<span class="instance-outdated-note">${note}</span>
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
</div>`;
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
}
// Shallow diff between two checklist bodies. Compares item label/note/
// rule pairs grouped by section title. Items with the same group title
// + same label are matched; differences in note/rule are flagged
// 'changed'. Items present only in snapshot are 'removed'; items only
// in current are 'added'.
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
{ added: string[]; removed: string[]; changed: string[] } {
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
const oldGroups = snapshot?.groups ?? [];
const oldMap: Record<string, ChecklistItem> = {};
for (const g of oldGroups) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
oldMap[key] = it;
}
}
const newMap: Record<string, ChecklistItem> = {};
for (const g of current) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
newMap[key] = it;
if (!(key in oldMap)) {
added.push(it.labelDE || it.labelEN);
} else {
const o = oldMap[key];
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
(o.rule || "") !== (it.rule || "")) {
changed.push(it.labelDE || it.labelEN);
}
}
}
}
for (const key in oldMap) {
if (!(key in newMap)) {
const labelParts = key.split("::");
removed.push(labelParts[1] || key);
}
}
return { added, removed, changed };
}
function openDiffModal() {
if (!template || !instance) return;
const modal = document.getElementById("instance-diff-modal")!;
const body = document.getElementById("instance-diff-body")!;
const diff = diffBodies(instance.template_snapshot, template.groups);
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
if (empty) {
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
} else {
const section = (label: string, klass: string, items: string[]) => {
if (items.length === 0) return "";
return `<section class="instance-diff-section ${klass}">
<h3>${esc(label)}</h3>
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
</section>`;
};
body.innerHTML = [
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
].join("");
}
modal.style.display = "flex";
}
function initDiffModal() {
const modal = document.getElementById("instance-diff-modal");
if (!modal) return;
const close = () => { modal.style.display = "none"; };
document.getElementById("instance-diff-close")?.addEventListener("click", close);
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
}
function renderGroups() {
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
initPrint();
initRename();
initFeedback();
initDiffModal();
onLangChange(renderAll);
void bootstrap();
});

View File

@@ -11,6 +11,26 @@ interface ChecklistSummary {
courtDE: string;
courtEN: string;
itemCount: number;
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface MyChecklist {
id: string;
slug: string;
owner_id: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
created_at: string;
updated_at: string;
}
interface ChecklistInstance {
@@ -26,15 +46,20 @@ interface ChecklistInstance {
project_title?: string | null;
}
type TabId = "templates" | "instances";
type TabId = "templates" | "mine" | "gallery" | "instances";
const VALID_TABS: TabId[] = ["templates", "instances"];
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let galleryRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let myTemplates: MyChecklist[] = [];
let myTemplatesLoaded = false;
let galleryLoaded = false;
let me: { id: string; email: string } | null = null;
let activeTab: TabId = "templates";
function esc(s: string): string {
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
let newURL = "/checklists";
if (tab === "instances") newURL = "/checklists?tab=instances";
if (tab === "mine") newURL = "/checklists?tab=mine";
if (tab === "gallery") newURL = "/checklists?tab=gallery";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
@@ -216,6 +244,155 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
if (tab === "instances") {
void loadInstances();
}
if (tab === "mine") {
void loadMyTemplates();
}
if (tab === "gallery") {
void loadGallery();
}
}
async function loadGallery(force = false) {
if (galleryLoaded && !force) return;
galleryLoaded = true;
// /api/checklists already returns the merged catalog; the gallery
// filter just narrows to non-static + non-owned + non-private.
if (allChecklists.length === 0) {
await loadTemplates();
}
renderGallery();
}
function renderGallery() {
const loading = document.getElementById("checklists-gallery-loading")!;
const empty = document.getElementById("checklists-gallery-empty")!;
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
loading.style.display = "none";
const visible = allChecklists.filter((c) => {
if (c.origin !== "authored") return false;
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
return true;
});
if (visible.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
const isEN = getLang() === "en";
grid.innerHTML = visible.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
const authorLine = c.owner_display_name
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
: "";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
${authorLine}
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
</a>`;
}).join("");
}
function initGalleryFilters() {
const container = document.getElementById("checklist-gallery-filters");
if (!container) return;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
galleryRegime = btn.dataset.regime ?? "all";
renderGallery();
});
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch { /* leave me=null */ }
}
async function loadMyTemplates(force = false) {
if (myTemplatesLoaded && !force) return;
myTemplatesLoaded = true;
const resp = await fetch("/api/checklists/templates/mine");
if (!resp.ok) {
myTemplates = [];
} else {
myTemplates = (await resp.json()) ?? [];
}
renderMyTemplates();
}
function renderMyTemplates() {
const loading = document.getElementById("checklists-mine-loading")!;
const empty = document.getElementById("checklists-mine-empty")!;
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
loading.style.display = "none";
if (myTemplates.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
grid.innerHTML = myTemplates.map((tpl) => {
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
const visLabel = esc(t(visKey as never) || tpl.visibility);
const titleSafe = esc(tpl.title);
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
</div>
<h2 class="checklist-card-title">
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
</h2>
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
<div class="checklist-card-actions">
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
</div>
</article>`;
}).join("");
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const slug = btn.dataset.slug!;
const title = btn.dataset.title || slug;
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.mine.delete.error"));
return;
}
await loadMyTemplates(true);
});
});
}
function initTabs() {
@@ -234,11 +411,15 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
initGalleryFilters();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
if (myTemplatesLoaded) renderMyTemplates();
if (galleryLoaded) renderGallery();
});
void loadMe();
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});

View File

@@ -0,0 +1,435 @@
// t-paliad-216 Slice B (initial) + t-paliad-217 Slice C (rewrite) —
// modal for the "Suggest changes" approval action.
//
// The approver authors a counter-proposal: edits any field on the
// underlying deadline / appointment AND/OR leaves a free-text note. On
// submit the caller POSTs to /api/approval-requests/{id}/suggest-changes,
// which closes the OLD row as `changes_requested` and spawns a NEW pending
// row authored by the approver carrying counter_payload as its payload.
//
// Scope (t-paliad-217 m's Q1 Reading A — 2026-05-20):
// - Every editable field on the entity is in the form, not just the
// date allowlist that triggers approval (t-paliad-138 §Q4). The
// backend's counter-allowlist (buildCounterSetClauses in
// approval_service.go) accepts the wider set:
// deadline: title, due_date, original_due_date, warning_date,
// description, notes, rule_code, event_type_ids
// appointment: title, start_at, end_at, description, location,
// appointment_type
// - Lifecycle restriction: update-only. shape-list.ts hides the
// suggest_changes button for create / complete / delete; this modal
// refuses to open on them as defence-in-depth.
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) —
// the primitive owns ESC, focus, backdrop, close button, browser
// back-button, mobile takeover. This module only constructs the body.
//
// API:
// const result = await openApprovalEditModal({
// entityType: "deadline",
// lifecycleEvent: "update",
// payload: {...}, // requester's proposed values (= current entity row)
// preImage: {...}, // pre-mutation values (for "vorher" diff hints)
// });
// if (result) {
// // result.counterPayload + result.note ready to POST
// } else {
// // user cancelled
// }
import { t } from "../i18n";
import {
attachEventTypePicker,
fetchEventTypes,
type PickerHandle,
} from "../event-types";
import { openModal } from "./modal";
export interface ApprovalEditModalArgs {
entityType: "deadline" | "appointment";
lifecycleEvent: string;
payload: Record<string, unknown> | null;
preImage: Record<string, unknown> | null;
// Optional context for the read-only context section. The caller can
// hydrate these from the row's API response (project_title,
// requester_name, requested_at) when available; the modal degrades
// gracefully when they're missing.
projectTitle?: string;
requesterName?: string;
requestedAt?: string;
}
export interface ApprovalEditModalResult {
counterPayload: Record<string, unknown>;
note: string;
}
// FieldSpec — one editable input row. The type determines the <input>
// (or <textarea>) shape; getValue / setValue normalise the form-element
// value to the server-friendly counter_payload shape.
interface FieldSpec {
key: string;
labelKey: string; // i18n key
inputType: "text" | "date" | "datetime-local" | "textarea";
// Required = title (NOT NULL on the column). Other fields are nullable;
// empty string clears (server's addText helper handles this).
required?: boolean;
}
// Deadline-only fields rendered in the editable section. `rule_code` and
// `event_type_ids` are intentionally NOT here — they're bundled into the
// dedicated "Verfahrenshandlung" section below the base fields so the
// event-type (parent concept) reads before the rule (m/paliad#56).
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
];
const APPOINTMENT_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "appointments.field.title", inputType: "text", required: true },
{ key: "start_at", labelKey: "appointments.field.start", inputType: "datetime-local" },
{ key: "end_at", labelKey: "appointments.field.end", inputType: "datetime-local" },
{ key: "location", labelKey: "appointments.field.location", inputType: "text" },
{ key: "appointment_type", labelKey: "appointments.field.type", inputType: "text" },
{ key: "description", labelKey: "appointments.field.description", inputType: "textarea" },
];
export async function openApprovalEditModal(
args: ApprovalEditModalArgs,
): Promise<ApprovalEditModalResult | null> {
if (args.lifecycleEvent !== "update") {
window.alert(t("approvals.suggest.unsupported_lifecycle"));
return null;
}
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
const original = (args.payload ?? {}) as Record<string, unknown>;
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
// Build the body element imperatively so we can wire input handlers
// before openModal mounts the dialog.
const body = document.createElement("div");
body.className = "approval-suggest-body";
body.appendChild(renderIntro());
body.appendChild(renderFieldsSection(fields, original, preImage));
// event_type_ids picker (deadline-only) — async because the picker
// needs to fetch the firm's event-type catalogue. We attach a host
// element synchronously and populate it once the fetch returns.
let eventTypePicker: PickerHandle | null = null;
let eventTypePickerLoaded = false;
if (args.entityType === "deadline") {
const pickerSection = renderEventTypePickerSection(original, preImage);
body.appendChild(pickerSection.section);
void (async () => {
try {
await fetchEventTypes();
eventTypePicker = attachEventTypePicker(pickerSection.host, {
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
});
eventTypePickerLoaded = true;
} catch (_e) {
// Fail-soft: leave the section empty; counter still works
// without event_type_ids in the payload.
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
}
})();
}
body.appendChild(renderContextSection(args, original));
const noteEl = renderNoteSection();
body.appendChild(noteEl.section);
// Read inputs back at submit time. The same list is what we listen to
// for the dirty-state gate.
const fieldInputs = Array.from(
body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[data-suggest-field]"),
);
return openModal<ApprovalEditModalResult>({
title: `${t("approvals.suggest.modal_title")}${t(("approvals.entity." + args.entityType) as never)}`,
body,
size: "lg",
primary: {
label: t("approvals.suggest.submit"),
handler: (close) => {
const result = buildResult(fieldInputs, noteEl.textarea, original, eventTypePicker, eventTypePickerLoaded);
if (!result.dirty && !result.note) {
// Server enforces too. Client-side guard avoids the 400 round-trip.
window.alert(t("approvals.suggest.submit_disabled_hint"));
return;
}
close({
counterPayload: result.counterPayload,
note: result.note,
});
},
},
secondary: { label: t("approvals.suggest.cancel") },
});
}
function renderIntro(): HTMLElement {
const p = document.createElement("p");
p.className = "approval-suggest-intro muted";
p.textContent = t("approvals.suggest.intro");
return p;
}
function renderFieldsSection(
fields: ReadonlyArray<FieldSpec>,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.editable");
section.appendChild(h);
for (const f of fields) {
section.appendChild(renderSingleField(f, original, preImage));
}
return section;
}
// Verfahrenshandlung section — bundles the event-type picker and the
// rule_code input so the editor reads "what procedural step? which rule
// cites it?" instead of two disconnected fields with rule above type
// (m/paliad#56). The hint underneath spells out the parent/child
// relationship so first-time editors don't read them as peers.
function renderEventTypePickerSection(
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): { section: HTMLElement; host: HTMLElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.event_type_rule");
section.appendChild(h);
const host = document.createElement("div");
host.className = "approval-suggest-event-type-picker";
section.appendChild(host);
// Rule citation — rendered as a sub-field directly beneath the picker so
// the visual hierarchy matches the conceptual one (rule is meta on the
// event type, not a peer).
const ruleField: FieldSpec = {
key: "rule_code",
labelKey: "approvals.suggest.field.rule_code",
inputType: "text",
};
section.appendChild(renderSingleField(ruleField, original, preImage));
return { section, host };
}
// renderSingleField builds one labelled input in the same shape as the
// fields-section loop. Extracted so the Verfahrenshandlung section can
// host the rule_code input next to the picker without duplicating the
// wiring (dirty-tracking, pre_image hint, label/for binding).
function renderSingleField(
f: FieldSpec,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
return wrap;
}
function renderContextSection(
args: ApprovalEditModalArgs,
original: Record<string, unknown>,
): HTMLElement {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--context";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.context");
section.appendChild(h);
const rows: Array<[string, string]> = [];
if (args.projectTitle) {
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
}
if (args.requesterName) {
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
}
if (args.requestedAt) {
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
}
// Approval status — entity row's current approval_status (typically
// "pending" while the modal is open, but display the requester's
// perspective for completeness).
const approvalStatus = original.approval_status as string | undefined;
if (approvalStatus) {
rows.push([
t("approvals.suggest.context.approval_status"),
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
]);
}
if (rows.length === 0) {
section.style.display = "none";
return section;
}
const dl = document.createElement("dl");
dl.className = "approval-suggest-context-grid";
for (const [label, value] of rows) {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = value;
dl.appendChild(dt);
dl.appendChild(dd);
}
section.appendChild(dl);
return section;
}
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--note";
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-note";
const label = document.createElement("label");
label.textContent = t("approvals.suggest.note_label");
label.setAttribute("for", "suggest-note");
wrap.appendChild(label);
const textarea = document.createElement("textarea");
textarea.id = "suggest-note";
textarea.rows = 3;
textarea.placeholder = t("approvals.suggest.note_placeholder");
textarea.dataset.suggestNote = "true";
wrap.appendChild(textarea);
section.appendChild(wrap);
return { section, textarea };
}
interface BuildResult {
counterPayload: Record<string, unknown>;
note: string;
dirty: boolean;
}
function buildResult(
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
noteEl: HTMLTextAreaElement,
original: Record<string, unknown>,
eventTypePicker: PickerHandle | null,
eventTypePickerLoaded: boolean,
): BuildResult {
const counterPayload: Record<string, unknown> = {};
let dirty = false;
for (const el of fieldInputs) {
const key = el.dataset.suggestField || "";
const orig = el.dataset.suggestOriginal || "";
const inputType = el.dataset.suggestInputType || "text";
if (el.value === orig) continue;
counterPayload[key] = formatFieldForServer(el.value, inputType);
dirty = true;
}
if (eventTypePicker && eventTypePickerLoaded) {
const currentIDs = eventTypePicker.getIDs().slice().sort();
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
if (currentIDs.length !== originalIDs.length
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
counterPayload.event_type_ids = currentIDs;
dirty = true;
}
}
return {
counterPayload,
note: noteEl.value.trim(),
dirty,
};
}
// formatFieldForInput — convert a server-side payload value to the format
// the <input> wants. Dates round-trip as YYYY-MM-DD; datetime-local wants
// YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps; we
// trim to the local-input shape. Text passes through verbatim.
function formatFieldForInput(v: unknown, inputType: string): string {
if (v == null) return "";
const s = String(v);
if (inputType === "date") {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : s;
}
if (inputType === "datetime-local") {
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
return m ? `${m[1]}T${m[2]}` : s;
}
return s;
}
// formatFieldForServer — convert input value back to server-friendly
// shape. Empty string means "clear this nullable field"; the server's
// addText helper writes NULL for "". Required fields (title) reach the
// server's non-empty CHECK on the column, which surfaces as a 400.
function formatFieldForServer(value: string, inputType: string): unknown {
if (inputType === "date" || inputType === "datetime-local") {
return value || null;
}
return value;
}
function formatDateForDisplay(iso: string): string {
const d = Date.parse(iso);
if (isNaN(d)) return iso;
return new Date(d).toLocaleString();
}

View File

@@ -0,0 +1,200 @@
// Unified modal primitive — t-paliad-217.
//
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
// ARIA, and focus trap. We layer back-button integration and focus
// restoration on top so the modal behaves consistently on desktop and on
// the iPhone PWA (m's checking surface).
//
// API:
// const result = await openModal<MyResult>({
// title: "…",
// body: htmlStringOrElement,
// primary: { label: "Speichern", handler: (close) => { close(result); } },
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
// onClose: () => { /* … */ },
// classNames: "extra css classes on the <dialog>",
// });
// // result is the value passed to close(), or null if the user
// // dismissed via ESC / backdrop / secondary / browser back-button.
//
// All dismiss paths are unified: ESC, backdrop click, secondary button,
// the always-rendered close (×) button, and the browser back-button all
// resolve the promise with null. Programmatic close from the primary
// handler resolves with whatever was passed.
//
// Migration target: call sites that currently roll their own
// modal-overlay + ESC handler + focus management replace all of it with
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
// modals migrate in follow-up PRs.
import { t } from "../i18n";
export interface ModalConfig<T> {
title: string;
// body can be either a pre-built HTMLElement (the caller assembled the
// DOM and may have local references for read-back) or an HTML string
// (caller is responsible for escaping). Element is preferred when the
// caller needs to read form state on submit.
body: HTMLElement | string;
primary: {
label: string;
handler: (close: (result: T) => void) => void | Promise<void>;
};
// secondary defaults to a Cancel button that just dismisses. Pass null
// explicitly to suppress (rare — primary-only modals like a confirmation
// toast).
secondary?: { label: string } | null;
size?: "sm" | "md" | "lg" | "full";
// onClose fires on EVERY dismiss path (including primary handler
// resolution). Use for analytics / dirty-state warnings.
onClose?: () => void;
classNames?: string;
}
// openModal returns a promise that resolves with the value passed to
// close() inside the primary handler, or null if the user dismissed via
// any other path. Always non-throwing — the primary handler decides
// whether to surface errors via its own UI (e.g. inline form errors)
// rather than rejecting the promise.
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
return new Promise((resolve) => {
// Record + restore focus to whatever was focused before the modal
// opened. Native <dialog> does NOT do this automatically.
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = document.createElement("dialog");
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
dialog.dataset.size = config.size ?? "md";
const header = document.createElement("header");
header.className = "modal__header";
const titleEl = document.createElement("h2");
titleEl.className = "modal__title";
titleEl.textContent = config.title;
header.appendChild(titleEl);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "modal__close";
closeBtn.setAttribute("aria-label", t("modal.close.label"));
closeBtn.textContent = "×"; // ×
header.appendChild(closeBtn);
dialog.appendChild(header);
const body = document.createElement("div");
body.className = "modal__body";
if (typeof config.body === "string") {
body.innerHTML = config.body;
} else {
body.appendChild(config.body);
}
dialog.appendChild(body);
const footer = document.createElement("footer");
footer.className = "modal__footer";
const secondaryCfg = config.secondary === null
? null
: config.secondary ?? { label: t("common.cancel") };
let secondaryBtn: HTMLButtonElement | null = null;
if (secondaryCfg) {
secondaryBtn = document.createElement("button");
secondaryBtn.type = "button";
secondaryBtn.className = "btn btn-ghost modal__secondary";
secondaryBtn.textContent = secondaryCfg.label;
footer.appendChild(secondaryBtn);
}
const primaryBtn = document.createElement("button");
primaryBtn.type = "button";
primaryBtn.className = "btn btn-primary modal__primary";
primaryBtn.textContent = config.primary.label;
footer.appendChild(primaryBtn);
dialog.appendChild(footer);
document.body.appendChild(dialog);
// History integration (Q5): push a synthetic history state so the
// browser back-button closes the modal instead of leaving the page.
// We pop the state in finish() unless popstate already fired it.
let historyEntryActive = false;
try {
history.pushState({ paliadModalOpen: true }, "");
historyEntryActive = true;
} catch (_e) {
// pushState may throw in obscure embedded contexts; degrade gracefully.
}
// resolved guards against double-resolution (e.g. ESC fires + then a
// microtask-deferred primary handler also calls close).
let resolved = false;
const finish = (value: T | null) => {
if (resolved) return;
resolved = true;
window.removeEventListener("popstate", onPopState);
// Pop our history entry if it's still on the stack. Skip when the
// popstate listener already fired (otherwise we'd go back twice).
if (historyEntryActive) {
historyEntryActive = false;
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
}
// Native dialog close. Use the close event's default rather than
// the cancel event so we don't fight the browser's own dismissal.
if (dialog.open) dialog.close();
dialog.remove();
// Restore focus to whatever the user was on before. The dialog
// teardown happens synchronously so the focus call lands on a
// live element.
if (previouslyFocused && document.body.contains(previouslyFocused)) {
previouslyFocused.focus();
}
config.onClose?.();
resolve(value);
};
const close = (result: T) => finish(result);
// Dismiss paths.
closeBtn.addEventListener("click", () => finish(null));
secondaryBtn?.addEventListener("click", () => finish(null));
dialog.addEventListener("click", (e) => {
// Backdrop click — only when the click landed on the dialog element
// itself (not on a child). Browsers report dialog.click events
// through the backdrop too because the backdrop is conceptually
// part of the dialog's box.
if (e.target === dialog) finish(null);
});
// <dialog>'s cancel event fires on ESC. preventDefault stops the
// browser's default close so we can run our finish() (history pop,
// focus restore, onClose, resolve).
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
finish(null);
});
const onPopState = () => {
// Browser back-button. Our history entry is gone by the time this
// fires, so skip the history.back() in finish().
historyEntryActive = false;
finish(null);
};
window.addEventListener("popstate", onPopState);
// Primary action.
primaryBtn.addEventListener("click", () => {
const result = config.primary.handler(close);
// Allow async primary handlers (handler returns a promise) — we
// don't wait for it explicitly; the handler is responsible for
// calling close() when ready.
void result;
});
// Open the dialog in the top layer. showModal activates ARIA
// role="dialog" + aria-modal=true + focus trap + backdrop.
dialog.showModal();
});
}

View File

@@ -0,0 +1,149 @@
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
//
// Before t-paliad-252 the deadline + appointment detail pages did a
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
// For pending CREATE lifecycles that endpoint silently DELETES the
// underlying entity row — m's "withdrawing the approval deletes the event"
// surprise.
//
// This modal replaces the confirm() with three explicit paths:
//
// 1. Cancel — does nothing
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
// through POST /approval-requests/{id}/
// edit-entity which keeps the request
// pending and merges the new fields
// into approval_request.payload
// 3. Endgültig zurückziehen + — destructive; current /revoke
// löschen behaviour (delete for CREATE, revert
// for UPDATE/COMPLETE, cancel for
// DELETE-lifecycle requests)
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
// three-button row sits cleanly inside the body — the primitive only
// supports one secondary action, but we paint the destructive button as a
// separate row above the footer.
import { t } from "../i18n";
import { openModal } from "./modal";
export type WithdrawAction = "edit" | "withdraw";
export interface WithdrawWarningArgs {
// entityType drives the copy ("event" vs "appointment" labels).
entityType: "deadline" | "appointment";
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
// cancelling the deletion request).
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
}
// openWithdrawWarningModal resolves with the chosen action, or null if the
// user dismissed via Cancel / Esc / backdrop / browser back-button.
export async function openWithdrawWarningModal(
args: WithdrawWarningArgs,
): Promise<WithdrawAction | null> {
const body = document.createElement("div");
body.className = "withdraw-warning-body";
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
// knows what the destructive button will actually do. The /revoke
// backend behaviour:
// - create → DELETE the entity (the "surprise" m flagged)
// - update → revert to pre_image
// - complete → revert to pre-complete state
// - delete → cancel the delete request (entity stays alive)
const intro = document.createElement("p");
intro.className = "withdraw-warning-intro";
intro.textContent = leadCopyFor(args);
body.appendChild(intro);
const sub = document.createElement("p");
sub.className = "withdraw-warning-sub muted";
sub.textContent = subCopyFor(args);
body.appendChild(sub);
// The destructive button lives inside the body — the openModal primitive
// only exposes one secondary button slot, and we want the safe "Edit"
// path to be the primary CTA. Painting it in red here, separated from
// the footer, signals "this is the dangerous option" without competing
// visually with the primary CTA.
const destructiveRow = document.createElement("div");
destructiveRow.className = "withdraw-warning-destructive-row";
const destructiveBtn = document.createElement("button");
destructiveBtn.type = "button";
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
destructiveRow.appendChild(destructiveBtn);
body.appendChild(destructiveRow);
return new Promise<WithdrawAction | null>((resolve) => {
let chosen: WithdrawAction | null = null;
// The destructive button has to close the modal and return "withdraw".
// We need access to the modal's internal close() — fortunately openModal
// exposes it via the primary handler's first arg. We pass through the
// outer resolve and let the primary handler (Edit) own the close-fn
// route. For the destructive button we resolve the outer promise
// directly and then synthesise an ESC keypress so the modal dismisses
// — or, simpler, set chosen and use the secondary "Cancel" path that
// the modal already supports. (openModal's onClose fires on every
// dismiss path including the primary handler resolution.)
destructiveBtn.addEventListener("click", () => {
chosen = "withdraw";
// The unified openModal primitive (modal.ts) wires its dismiss path
// through the native <dialog>'s `cancel` event. Dispatching it on
// the parent <dialog> runs the same finish() → onClose → resolve
// sequence as ESC / backdrop. We then map the resolved `null` back
// to "withdraw" via the captured `chosen` in onClose below.
const dialogEl = body.closest("dialog");
dialogEl?.dispatchEvent(new Event("cancel"));
});
void openModal<WithdrawAction>({
title: t("approvals.withdraw.modal.title"),
body,
size: "md",
classNames: "withdraw-warning-modal",
primary: {
label: t("approvals.withdraw.primary.label"),
handler: (close) => {
chosen = "edit";
close("edit");
},
},
secondary: { label: t("approvals.withdraw.cancel") },
onClose: () => {
// Resolves whatever was chosen via the destructive button OR the
// primary handler. ESC / backdrop / secondary clear `chosen` to
// null which is the right "cancel" semantics.
resolve(chosen);
},
});
});
}
function leadCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return args.entityType === "appointment"
? t("approvals.withdraw.lead.create.appointment")
: t("approvals.withdraw.lead.create.deadline");
case "delete":
return t("approvals.withdraw.lead.delete");
default:
// update / complete / unknown → revert semantics
return t("approvals.withdraw.lead.update");
}
}
function subCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return t("approvals.withdraw.sub.create");
case "delete":
return t("approvals.withdraw.sub.delete");
default:
return t("approvals.withdraw.sub.update");
}
}

View File

@@ -0,0 +1,285 @@
import { describe, expect, test } from "bun:test";
import {
GRID_COLUMNS,
clampH,
clampW,
placeWidgets,
type WidgetPlacementInput,
} from "./dashboard-grid";
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
// mode produced overlapping widgets when a 2-col widget sat next to a
// 1-col widget on the same row, when a drag swapped widgets of
// different widths, and when a resize grew a widget into a sibling. The
// fix moved the placement math into ./dashboard-grid + made it
// collision-aware. These tests pin the no-overlap invariant.
function spec(
key: string,
x: number | undefined,
y: number | undefined,
w: number,
h = 1,
visible = true,
): WidgetPlacementInput {
return { key, visible, x, y, w, h };
}
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
// fine — layouts cap at 32 widgets and the tests stay tiny.
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
const list = Array.from(rects.entries());
for (let i = 0; i < list.length; i++) {
const [ka, a] = list[i];
for (let j = i + 1; j < list.length; j++) {
const [kb, b] = list[j];
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
if (xOverlap && yOverlap) return `${ka}${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
}
}
return null;
}
describe("placeWidgets — basic auto-flow", () => {
test("places two 6-wide widgets side by side on row 0", () => {
const out = placeWidgets([
spec("a", undefined, undefined, 6),
spec("b", undefined, undefined, 6),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(hasOverlap(out)).toBeNull();
});
test("wraps when row doesn't fit", () => {
const out = placeWidgets([
spec("a", undefined, undefined, 8),
spec("b", undefined, undefined, 8),
]);
expect(out.get("a")!.y).toBe(0);
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
test("hidden widgets are skipped and reserve no cells", () => {
const out = placeWidgets([
spec("hidden", 0, 0, 12, 1, false),
spec("visible", undefined, undefined, 6),
]);
expect(out.has("hidden")).toBe(false);
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
});
});
describe("placeWidgets — explicit positions, no collision", () => {
test("trusts non-colliding explicit positions exactly", () => {
const out = placeWidgets([
spec("a", 0, 0, 6),
spec("b", 6, 0, 6),
spec("c", 0, 1, 12),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
test("1-col + 2-col on same row do not overlap when both explicit", () => {
// Half-width left + half-width right is the canonical 'two widgets per
// row' layout; pre-fix this was fine but the next regression below
// exercises the actual bug.
const out = placeWidgets([
spec("left", 0, 0, 6),
spec("right", 6, 0, 6),
]);
expect(hasOverlap(out)).toBeNull();
});
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
// a buggy reset path or a stale spec from before #70). Placer must
// honour the first one's position and fit the second somewhere
// free — landing it on the same row at x=4 is acceptable (better
// density) as long as nothing overlaps.
const out = placeWidgets([
spec("first", 0, 0, 4),
spec("colliding", 0, 0, 8),
]);
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
expect(out.get("colliding")!.w).toBe(8);
expect(hasOverlap(out)).toBeNull();
});
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
// Setup before swap:
// A at (0, 0, w=12) — full width row 0
// B at (0, 1, w=6) — half row 1 left
// C at (6, 1, w=6) — half row 1 right
// User drags A onto B. reorderViaDnd swaps (x, y):
// A.x=0, A.y=1
// B.x=0, B.y=0
// Result must not overlap C.
const out = placeWidgets([
spec("a", 0, 1, 12),
spec("b", 0, 0, 6),
spec("c", 6, 1, 6),
]);
expect(hasOverlap(out)).toBeNull();
});
test("auto-flow widget steps past explicit blocker on same row", () => {
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
// would want (6, 0) which is taken. Placer must wrap it.
const out = placeWidgets([
spec("flow-a", undefined, undefined, 6),
spec("anchored", 6, 0, 6),
spec("flow-b", undefined, undefined, 6),
]);
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — resize-grow shifts siblings", () => {
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
// Pre-resize state:
// A at (0, 0, w=6)
// B at (6, 0, w=6)
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
// at (6, 0). Placer must shift B down.
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("b", 6, 0, 6),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
test("growing widget pushes only the first colliding sibling", () => {
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
// move; their relative order on row 0 is preserved (B at x=0, C at
// x=6) on row 1.
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("b", 0, 0, 4),
spec("c", 4, 0, 4),
]);
expect(hasOverlap(out)).toBeNull();
expect(out.get("a")!.y).toBe(0);
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(out.get("c")!.y).toBeGreaterThan(0);
});
});
describe("placeWidgets — explicit position overflow clamp", () => {
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
// A 12-wide widget with x=6 would extend past col 11. Placer must
// clamp x to 0 (or wherever fits) so the widget renders inside the
// grid.
const out = placeWidgets([
spec("wide", 6, 0, 12),
]);
const r = out.get("wide")!;
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
expect(r.w).toBe(12);
});
});
describe("placeWidgets — vertical (multi-row) widgets", () => {
test("a 2-row-tall widget reserves both rows", () => {
const out = placeWidgets([
spec("tall", 0, 0, 6, 2),
spec("collides-on-row-1", 0, 1, 6, 1),
]);
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
// The colliding widget must move because tall covers cols 0..5
// on both row 0 and row 1. The placer may shift it to the right
// half of row 1 (cols 6..11) or to a later row — either is fine
// as long as nothing overlaps.
const other = out.get("collides-on-row-1")!;
expect(other.x >= 6 || other.y >= 2).toBe(true);
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — includeHidden (edit mode)", () => {
test("hidden widgets are skipped by default", () => {
const out = placeWidgets([
spec("visible", 0, 0, 6),
spec("hidden", 0, 0, 6, 1, false),
]);
expect(out.has("visible")).toBe(true);
expect(out.has("hidden")).toBe(false);
});
test("includeHidden:true places hidden widgets after visible ones", () => {
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
// widgets MUST receive a placement, otherwise applyLayout leaves
// their inline grid-column empty and CSS Grid auto-flows them as
// 1×1 slivers ("super slim greyed-out column").
const out = placeWidgets([
spec("active", 0, 0, 12),
spec("hidden", 0, 0, 6, 1, false),
], { includeHidden: true });
expect(out.has("hidden")).toBe(true);
const h = out.get("hidden")!;
// Must keep its requested width (6), not collapse to 1.
expect(h.w).toBe(6);
// Must land below the visible widget — never overlap or steal cells.
expect(h.y).toBeGreaterThanOrEqual(1);
expect(hasOverlap(out)).toBeNull();
});
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
// Hidden widget stored at (0, 0) shouldn't displace a visible
// widget that wants (0, 0). The visible pass runs first, claims
// (0, 0); the hidden widget is then placed wherever free — the
// placer happily fits it next to the visible widget on the same
// row if there's room. The hard invariant is just no-overlap.
const out = placeWidgets([
spec("active", 0, 0, 6),
spec("hidden-at-origin", 0, 0, 6, 1, false),
], { includeHidden: true });
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.has("hidden-at-origin")).toBe(true);
expect(hasOverlap(out)).toBeNull();
});
test("multiple hidden widgets all receive valid placements", () => {
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("h1", undefined, undefined, 6, 1, false),
spec("h2", undefined, undefined, 6, 1, false),
spec("h3", undefined, undefined, 12, 1, false),
], { includeHidden: true });
expect(out.size).toBe(4);
for (const r of out.values()) {
expect(r.w).toBeGreaterThanOrEqual(1);
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
}
expect(hasOverlap(out)).toBeNull();
});
});
describe("clamp helpers", () => {
test("clampW respects min/max bounds", () => {
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
expect(clampW(0, { default_w: 6 })).toBe(6);
expect(clampW(NaN, { default_w: 8 })).toBe(8);
});
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
expect(clampH(0, { default_h: 2 })).toBe(2);
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
expect(clampH(1, { min_h: 3 })).toBe(3);
});
});

View File

@@ -0,0 +1,264 @@
// dashboard-grid — pure layout math for the dashboard widget grid.
//
// Lives outside dashboard.ts so the placement logic is importable from
// tests without dragging in the DOM-side rendering code. The grid is a
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
// rows grow vertically as widgets are placed.
//
// The core invariant is no-overlap: after placeWidgets() returns, every
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
// computePlacements() to trust explicit (x, y) without checking — that
// produced visual overlap whenever a drag or resize landed a widget on
// cells another widget already covered (m/paliad#70). The collision-
// aware placer below shifts colliding widgets to the next free row so
// the rendered grid never overlaps regardless of the input spec.
export const GRID_COLUMNS = 12;
export const MAX_ROW_SPAN = 5;
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
// at least one widget, so 256 rows is an order-of-magnitude buffer
// against runaway loops on pathological inputs.
const MAX_SCAN_ROWS = 256;
export interface PlacedRect {
x: number;
y: number;
w: number;
h: number;
}
// WidgetSizeBound captures the per-widget min/max/default clamps the
// catalog publishes. Optional fields keep callers from having to
// synthesize zeroes when the catalog entry is missing.
export interface WidgetSizeBound {
default_w?: number;
default_h?: number;
min_w?: number;
max_w?: number;
min_h?: number;
max_h?: number;
}
// WidgetPlacementInput is the per-widget data the placer consumes. The
// catalog bound is optional — when missing, defaults fall back to a
// full-width 1-row widget.
export interface WidgetPlacementInput {
key: string;
visible: boolean;
x?: number;
y?: number;
w?: number;
h?: number;
bound?: WidgetSizeBound;
}
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
let v = Math.round(w);
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
v = Math.max(1, Math.min(GRID_COLUMNS, v));
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
return v;
}
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
let v = Math.round(h);
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
return v;
}
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
// created lazily so the map only stores rows the layout actually
// reaches. Cell value 1 = occupied.
class Occupancy {
private rows = new Map<number, Uint8Array>();
row(y: number): Uint8Array {
let r = this.rows.get(y);
if (!r) {
r = new Uint8Array(GRID_COLUMNS);
this.rows.set(y, r);
}
return r;
}
free(x: number, y: number, w: number, h: number): boolean {
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
for (let yy = y; yy < y + h; yy++) {
const row = this.row(yy);
for (let xx = x; xx < x + w; xx++) {
if (row[xx]) return false;
}
}
return true;
}
mark(x: number, y: number, w: number, h: number): void {
for (let yy = y; yy < y + h; yy++) {
const row = this.row(yy);
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
}
}
}
// findFreeSlot scans for the first (x, y) where a w×h block fits without
// collision, starting at row startY. At each row preferX is tried first
// — that keeps a widget close to its requested column when only the row
// is blocked. Falls back to left-to-right scan within the row, then to
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
function findFreeSlot(
occ: Occupancy,
startY: number,
w: number,
h: number,
preferX: number,
): { x: number; y: number } {
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
return { x: preferX, y };
}
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
if (x === preferX) continue;
if (occ.free(x, y, w, h)) return { x, y };
}
}
// Pathological fallback — caller's widget cap (32) makes this
// unreachable in practice. Snap to the bottom-left so the widget at
// least renders somewhere visible instead of vanishing.
return { x: 0, y: startY + MAX_SCAN_ROWS };
}
// PlaceOptions tunes the placer for the caller's render-vs-persist
// needs.
export interface PlaceOptions {
// When true, hidden widgets are placed too — for edit-mode rendering
// where the user can see + un-hide them inline. The two-pass order
// (visible first, then hidden) guarantees hidden widgets never
// displace visible ones: they get whatever cells are left below the
// active layout. Default false matches view-mode behaviour and the
// persistence path (materializePositions) where hidden widgets
// retain their stored coordinates instead of being repacked.
//
// Without this option, hidden widgets in edit mode were left without
// an explicit grid-column inline style by applyLayout(), so CSS Grid
// auto-flowed them into the next free cell at 1×1 — the "super slim
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
includeHidden?: boolean;
}
// placeWidgets assigns no-overlap grid coordinates to widgets. By
// default only visible widgets receive placements; pass
// {includeHidden:true} to also place hidden widgets after the visible
// pass (used by applyLayout in edit mode).
//
// Algorithm — per pass:
// 1. Clamp w/h against catalog bounds.
// 2. If the spec carries explicit x and y, try that slot. On a
// collision, search downward starting at the requested y for the
// first free w×h block (preferring the requested x).
// 3. If only x is explicit, search from y=0 at that x.
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
// when the row doesn't fit or is blocked by an explicitly-placed
// widget, wrap to the next free row.
//
// The mixed-spec case (some widgets explicit, others auto-flow) is the
// real-world layout — placing the explicit widgets first would change
// the visual order, so we keep input order and let auto-flow widgets
// step around any explicit blockers via the same collision search.
//
// Two-pass behaviour for hidden widgets: the visible pass owns its
// own auto-flow cursor; the hidden pass continues from where the
// visible pass left off so the hidden widgets stack right under the
// active layout. The shared Occupancy bitmap guarantees the second
// pass can never overlap a placed visible widget.
export function placeWidgets(
widgets: WidgetPlacementInput[],
options: PlaceOptions = {},
): Map<string, PlacedRect> {
const out = new Map<string, PlacedRect>();
const occ = new Occupancy();
// Auto-flow cursor — advances as we place flowed widgets. cursorY
// tracks the row currently being filled; rowMaxH is the tallest
// widget in that row so wrapping advances past it (not just past the
// new widget's height — that would let taller previous neighbours
// overlap into the wrap row).
let cursorX = 0;
let cursorY = 0;
let rowMaxH = 0;
const placeOne = (w: WidgetPlacementInput): void => {
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
const hasX = typeof w.x === "number";
const hasY = typeof w.y === "number";
let placed: { x: number; y: number };
if (hasX && hasY) {
// Clamp x so the widget never overflows the right edge — drag/
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
const prefY = Math.max(0, w.y as number);
if (occ.free(prefX, prefY, dw, dh)) {
placed = { x: prefX, y: prefY };
} else {
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
}
} else if (hasX) {
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
placed = findFreeSlot(occ, 0, dw, dh, prefX);
} else {
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
// remaining columns of the current row, then ask findFreeSlot to
// honour the cursor's preferred (x, y) — that lets it step past
// any explicit widget that already claimed cells under the
// cursor.
if (cursorX + dw > GRID_COLUMNS) {
cursorY += rowMaxH || 1;
cursorX = 0;
rowMaxH = 0;
}
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
if (placed.y > cursorY) {
// Wrap was forced by a collision deeper than the current row.
cursorY = placed.y;
rowMaxH = 0;
}
cursorX = placed.x + dw;
if (dh > rowMaxH) rowMaxH = dh;
}
occ.mark(placed.x, placed.y, dw, dh);
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
};
// Pass 1: visible widgets. They own the active layout.
for (const w of widgets) {
if (!w.visible) continue;
placeOne(w);
}
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
// start of the next row before the second pass so the hidden tray
// visually separates from the active layout — even if the last
// visible widget left half a row open.
if (options.includeHidden) {
if (cursorX > 0) {
cursorY += rowMaxH || 1;
cursorX = 0;
rowMaxH = 0;
}
for (const w of widgets) {
if (w.visible) continue;
placeOne(w);
}
}
return out;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,181 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Deadline {
id: string;
project_id: string;
title: string;
due_date: string;
status: string;
project_reference: string;
project_title: string;
}
let allDeadlines: Deadline[] = [];
let viewYear = 0;
let viewMonth = 0; // 0-11
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadDeadlines() {
try {
const resp = await fetch("/api/deadlines?status=all");
if (resp.ok) allDeadlines = await resp.json();
} catch {
/* non-fatal */
}
}
function deadlinesForDate(iso: string): Deadline[] {
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = deadlinesForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("deadline-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allDeadlines.some((f) => {
const iso = f.due_date.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("deadline-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = deadlinesForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((f) => {
const cls = urgencyClass(f.due_date, f.status);
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadDeadlines();
render();
});

View File

@@ -9,6 +9,8 @@ import {
type EventType,
type PickerHandle,
} from "./event-types";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Deadline {
id: string;
@@ -20,6 +22,9 @@ interface Deadline {
source: string;
rule_id?: string;
rule_code?: string;
// t-paliad-258 — lawyer's free-text rule label when the deadline was
// saved in Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
notes?: string;
created_at: string;
completed_at?: string;
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -54,7 +62,21 @@ interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en?: string;
rule_code?: string;
legal_source?: string | null;
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
// when the user flips to Auto on the edit form.
concept_default_event_type_id?: string | null;
proceeding_type_id?: number | null;
}
interface ProceedingType {
id: number;
jurisdiction: string;
name: string;
name_en?: string;
sort_order?: number;
}
interface Me {
@@ -70,6 +92,30 @@ let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
// On enterEdit we initialise the mode from the persisted deadline:
// rule_id set → "auto"
// custom_rule_text set, no rule_id → "custom"
// neither set → "auto" (so the Type-driven
// resolver fills in immediately).
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let allRules: DeadlineRule[] = [];
let rulesByID = new Map<string, DeadlineRule>();
let proceedingTypesByID = new Map<number, ProceedingType>();
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
// modal, the entity is still in approval_status='pending'. Save must POST
// to /api/approval-requests/{id}/edit-entity (which keeps the request
// pending + merges the new fields into payload) instead of the regular
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
// from edit mode + after a successful save.
let pendingEditMode = false;
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
// modal handler (initWithdraw) can route into pending-edit mode without
// duplicating the edit-mode toggle logic.
let pendingEnterEdit: (() => void) | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "deadlines" || !parts[1]) return null;
@@ -165,17 +211,66 @@ function populateProjectPicker() {
sel.value = deadline.project_id;
}
async function loadRule(ruleID: string) {
async function loadAllRules() {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal */
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
function lookupRule(ruleID: string): DeadlineRule | null {
return rulesByID.get(ruleID) || null;
}
// resolveAutoRuleForType mirrors the create-form resolver: pick the
// canonical rule for the chosen event_type, prioritising the project's
// proceeding then jurisdiction match.
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const projID = deadline?.project_id;
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
if (proj && proj.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypeByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
return resolveAutoRuleForType(picked[0]);
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -227,9 +322,15 @@ function render() {
}
const ruleEl = document.getElementById("deadline-rule-display")!;
// t-paliad-258 — display priority:
// 1. catalog rule (canonical Name · Citation pattern)
// 2. custom_rule_text + Custom badge
// 3. legacy rule_code-only (Fristenrechner saves)
// 4. "—"
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code}${rule.name}` : rule.name;
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
} else if (deadline.rule_code) {
// Fristenrechner-saved deadlines carry rule_code directly without
// a rule_id (no rule UUID round-trips through the public API).
@@ -353,6 +454,48 @@ function render() {
}
}
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const r = currentAutoRule();
if (r) {
text.textContent = formatRuleLabel(r);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function initEdit() {
const titleDisplay = document.getElementById("deadline-title-display")!;
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
@@ -366,6 +509,11 @@ function initEdit() {
const etEdit = document.getElementById("deadline-event-types-edit");
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
const ruleDisplay = document.getElementById("deadline-rule-display");
const ruleEdit = document.getElementById("deadline-rule-edit");
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -381,6 +529,20 @@ function initEdit() {
projectEdit.style.display = "";
projectEdit.value = deadline.project_id;
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
// from the persisted deadline. Display element stays visible so the
// user keeps "before / after" context while editing.
if (ruleEdit) ruleEdit.style.display = "";
if (ruleDisplay) ruleDisplay.style.display = "none";
if (deadline?.custom_rule_text && !deadline.rule_id) {
ruleMode = "custom";
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
} else {
ruleMode = "auto";
if (ruleCustomInput) ruleCustomInput.value = "";
}
applyRuleModeUI();
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -399,12 +561,71 @@ function initEdit() {
projectEdit.style.display = "none";
projectLink.style.display = "";
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
if (ruleEdit) ruleEdit.style.display = "none";
if (ruleDisplay) ruleDisplay.style.display = "";
saveBtn.style.display = "none";
editBtn.style.display = "";
pendingEditMode = false;
}
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
// time the Type picker changes, so just-toggling-to-Auto immediately
// surfaces a fresh resolution.
ruleToggleBtn?.addEventListener("click", () => {
ruleMode = ruleMode === "auto" ? "custom" : "auto";
applyRuleModeUI();
if (ruleMode === "custom") ruleCustomInput?.focus();
});
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
// route into pending-edit mode without re-running the edit-button
// visibility gate (which hides the button during pending).
pendingEnterEdit = () => {
pendingEditMode = true;
enterEdit();
};
editBtn.addEventListener("click", enterEdit);
// t-paliad-251 Part 4 — Standardtitel button.
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
// head = event_type label (if exactly one Typ chip in edit)
// || Auto-resolved rule's canonical label (Name · Citation)
// || saved rule's canonical label
// || custom_rule_text (when in Custom mode + non-empty)
// || rule_code-only legacy fallback
// || "Neue Frist" fallback
// suffix = " — <project.reference>" when not already in head
titleDefaultBtn?.addEventListener("click", () => {
if (!deadline) return;
let head = "";
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
if (ids.length === 1) {
const et = eventTypeByID.get(ids[0]);
if (et) head = eventTypeLabel(et);
}
if (!head) {
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
if (r) head = formatRuleLabel(r);
}
if (!head && ruleMode === "custom") {
const txt = ruleCustomInput?.value.trim() || "";
if (txt) head = txt;
}
if (!head && rule) {
head = formatRuleLabel(rule);
}
if (!head && deadline.rule_code) {
head = deadline.rule_code;
}
if (!head) head = t("deadlines.field.title.default_fallback");
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) head = `${head}${ref}`;
titleEdit.value = head;
titleEdit.focus();
});
saveBtn.addEventListener("click", async () => {
if (!deadline) return;
const newTitle = titleEdit.value.trim();
@@ -424,6 +645,48 @@ function initEdit() {
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
// t-paliad-258 — rule_set discriminator tells the service this
// PATCH carries an Auto/Custom rule change. Both columns are
// mutually exclusive at the persistence boundary.
payload.rule_set = true;
if (ruleMode === "auto") {
const r = currentAutoRule();
payload.rule_id = r ? r.id : null;
payload.custom_rule_text = null;
} else {
const txt = ruleCustomInput?.value.trim() || "";
payload.rule_id = null;
payload.custom_rule_text = txt || null;
}
// t-paliad-252 — pending-edit mode routes through the new endpoint
// that updates the entity + merges payload into the still-pending
// approval_request. Outside pending-edit mode the regular PATCH
// path remains the authoritative one (with its existing 409-on-
// pending guard).
if (pendingEditMode && pendingRequest) {
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: payload }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else {
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error))
|| (t("approvals.withdraw.error") || "Fehler");
window.alert(msg);
}
return;
}
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -501,19 +764,39 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
//
// Click flow: open the withdraw warning modal (replaces the old
// confirm()). The modal returns one of:
//
// "edit" — open the edit form in pending-edit mode; Save calls
// /api/approval-requests/{id}/edit-entity which keeps the
// request pending + merges the new fields into payload
// "withdraw" — destructive: call the existing /revoke endpoint
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
// cancel-delete for DELETE lifecycle)
// null — user cancelled; nothing happens
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "deadline",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
btn.disabled = false;
return;
}
if (action === "edit") {
btn.disabled = false;
pendingEnterEdit?.();
return;
}
// action === "withdraw" → existing destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -521,14 +804,16 @@ function initWithdraw() {
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
// and the badge / buttons rerender accordingly. For CREATE
// lifecycle the entity is gone, so the 404 surfaces as a reload.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
// CREATE lifecycle deleted the entity — bounce to the list.
window.location.href = "/events?type=deadline";
}
} else {
btn.disabled = false;
@@ -592,8 +877,14 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
await Promise.all([
loadProject(deadline.project_id),
loadAllProjects(),
loadPendingRequest(),
loadAllRules(),
loadProceedingTypes(),
]);
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
// chips off the cached map, and the display element re-renders on the
@@ -614,6 +905,11 @@ async function main() {
eventTypePicker = attachEventTypePicker(pickerHost, {
initialIDs: deadline.event_type_ids ?? [],
currentUserAdmin: me?.global_role === "global_admin",
onChange: () => {
// Type change shifts the Auto-resolved rule. Refresh the
// read-only display panel (no-op outside edit mode / Custom).
refreshRuleAutoDisplay();
},
});
}

View File

@@ -1,4 +1,4 @@
import { initI18n, t, tDyn } from "./i18n";
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypePicker,
@@ -8,22 +8,21 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel } from "./rule-label";
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;
reference?: string | null;
title: string;
path: string;
// Used by the Type→Rule resolver to narrow rule candidates to the
// project's own proceeding when one applies. Optional because clients
// and matter-level projects don't carry a proceeding type.
proceeding_type_id?: number | null;
}
interface DeadlineRule {
@@ -32,23 +31,37 @@ 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.
legal_source?: string | null;
proceeding_type_id?: number | null;
sequence_order?: number;
// t-paliad-165 — canonical event_type for the rule's concept. The
// catalog is indexed by it so we can resolve Type → canonical 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>();
interface ProceedingType {
id: number;
code: string;
name: string;
name_en?: string;
jurisdiction: string;
sort_order?: number;
}
// 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;
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
// auto — rule_id resolved from the chosen event_type, rendered
// read-only as "Auto: Name · Citation".
// custom — free-text input; submits as custom_rule_text on the API.
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let rulesByID = new Map<string, DeadlineRule>();
let allRules: DeadlineRule[] = [];
let proceedingTypesByID = new Map<number, ProceedingType>();
let projectsByID = new Map<string, Project>();
let preselectedProjectID = "";
let preselectedProjectIDLocal = "";
function esc(s: string): string {
const d = document.createElement("div");
@@ -62,6 +75,13 @@ function showError(msg: string) {
el.className = "form-msg form-msg-error";
}
function proceedingLabel(pt: ProceedingType | undefined): string {
if (!pt) return "";
const lang = getLang();
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
return `${pt.jurisdiction}${name}`;
}
async function loadProjects() {
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
const hint = document.getElementById("deadline-project-empty-hint")!;
@@ -69,6 +89,7 @@ async function loadProjects() {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const projects: Project[] = await resp.json();
projectsByID = new Map(projects.map((p) => [p.id, p]));
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
@@ -82,7 +103,7 @@ async function loadProjects() {
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");
@@ -91,122 +112,166 @@ async function loadProjects() {
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
try {
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>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal — rule select stays at "no rule" */
/* non-fatal — rule display falls back to "—" */
}
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
// resolveAutoRuleForType picks the best-match catalog rule for the
// chosen event type, scoring by:
// 1. project's proceeding_type_id (if known) — exact match wins,
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
// jurisdiction (EPA→EPO canonicalised),
// 3. otherwise the first candidate in canonical sequence_order.
//
// 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;
// Returns null when no rule maps. Callers render that as "no Auto rule
// available" so the user can flip to Custom or pick a different Type.
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
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 project = projectID ? projectsByID.get(projectID) : undefined;
if (project?.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypesByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
// currentAutoRule returns the catalog rule the Auto mode would resolve
// to for the current form state, or null when no Type is picked or no
// rule maps. Centralised so the Auto display, submitForm, and the
// Standardtitel button all agree on the same resolution.
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
return resolveAutoRuleForType(picked[0], projectID);
}
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";
// refreshRuleAutoDisplay updates the read-only Auto display panel to
// reflect the rule that would be saved in Auto mode. Hides itself when
// the user is in Custom mode (the input takes its place).
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
text.textContent = formatRuleLabel(rule);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
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 = "";
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
warn.style.display = "none";
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function setRuleMode(mode: RuleMode): void {
ruleMode = mode;
applyRuleModeUI();
if (mode === "custom") {
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
input?.focus();
}
}
// 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();
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
// 1. event_type label (when exactly one Typ chip is set)
// 2. canonical rule name (when Auto resolves to a rule)
// 3. custom rule text (when in Custom mode)
// 4. proceeding type name (when project carries one)
// 5. fallback i18n key
// Suffix: " — <project-reference>" when not already in head.
function computeDefaultTitle(): string {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const project = projectID ? projectsByID.get(projectID) : undefined;
const picked = eventTypePicker?.getIDs() ?? [];
// Reset the override on transition to "Keine Regel" — fresh form
// session. Otherwise expandedOverride stays sticky.
if (ruleID === "") {
expandedOverride = false;
let head = "";
if (picked.length === 1) {
const et = eventTypesByID.get(picked[0]);
if (et) head = eventTypeLabel(et);
}
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;
if (!head) {
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) head = formatRuleLabel(rule);
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) head = txt;
}
} 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();
}
if (!head && project?.proceeding_type_id) {
const pt = proceedingTypesByID.get(project.proceeding_type_id);
if (pt) head = proceedingLabel(pt);
}
if (!head) {
head = t("deadlines.field.title.default_fallback");
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) {
return `${head}${ref}`;
}
return head;
}
async function submitForm(e: Event) {
@@ -217,7 +282,6 @@ async function submitForm(e: Event) {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
if (!projectID || !title || !due) {
@@ -234,7 +298,15 @@ async function submitForm(e: Event) {
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
// Rule field: Auto resolves to rule_id, Custom sends the free text.
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) payload.rule_id = rule.id;
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) payload.custom_rule_text = txt;
}
if (notes) payload.notes = notes;
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
@@ -252,8 +324,8 @@ async function submitForm(e: Event) {
return;
}
const created = await resp.json();
if (preselectedProjectID) {
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
if (preselectedProjectIDLocal) {
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
} else {
window.location.href = `/deadlines/${created.id}`;
}
@@ -275,6 +347,16 @@ function detectPreselect() {
if (fromQuery) preselectedProjectID = fromQuery;
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -288,8 +370,6 @@ async function loadMe() {
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
@@ -308,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar (with M1 legacy fallback).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
@@ -343,44 +422,51 @@ document.addEventListener("DOMContentLoaded", async () => {
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadProjects(), loadRules(), loadMe()]);
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleView(),
onChange: () => {
// Type change shifts which Auto rule resolves; re-render the
// read-only Auto display panel.
refreshRuleAutoDisplay();
},
});
}
// 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.
// Preload event_types for the Auto display + Standardtitel resolver.
fetchEventTypes()
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
refreshRuleAutoDisplay();
})
.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();
.catch(() => {/* non-fatal */});
// Rule mode toggle.
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
});
// "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.
applyRuleModeUI();
// Approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
// Project change can shift which Auto rule resolves (via the
// project's proceeding_type_id).
refreshRuleAutoDisplay();
});
// t-paliad-251 Part 4 — Standardtitel button.
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
if (!titleInput) return;
const derived = computeDefaultTitle();
if (derived) titleInput.value = derived;
titleInput.focus();
});
});

View File

@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
// jurisdiction). Any non-null value matches event_types.jurisdiction;
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
let activeJurisdiction: string | null = null;
// Surface every jurisdiction present in the data — "any" stays bucketed
// separately so users still have a "show generic-only" chip. EPA is
// canonicalised to EPO in event_types (see mig 074); the chip label
// shows EPA to match the legal vocabulary the lawyers use.
const jurisdictionsPresent = new Set<string>();
for (const et of opts.types) {
const j = (et.jurisdiction ?? "").trim();
if (j) jurisdictionsPresent.add(j);
}
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
// Any jurisdiction in the data that isn't in our ordered list lands at
// the end so the chip row never silently drops a court flavour.
for (const j of jurisdictionsPresent) {
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
}
function chipLabel(j: string): string {
if (j === "EPO") return "EPA";
if (j === "any") return t("event_types.browse.jurisdiction.none");
return j;
}
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
${chipJurisdictions
.map(
(j) =>
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
)
.join("")}
</div>
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
const groups = groupByCategory(opts.types);
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
return j;
}
function jurisdictionMatches(et: EventType): boolean {
if (activeJurisdiction === null) return true;
const j = (et.jurisdiction ?? "").trim();
return j === activeJurisdiction;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!jurisdictionMatches(et)) return false;
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
renderList();
});
chipButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const raw = btn.dataset.jurisdiction ?? "";
activeJurisdiction = raw === "" ? null : raw;
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
btn.classList.add("event-type-browse-chip--active");
renderList();
});
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();

View File

@@ -8,6 +8,8 @@ import {
type FilterHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -65,6 +67,9 @@ interface EventListItem {
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was created
// via the Custom rule path. Mutually exclusive with rule_id.
custom_rule_text?: string;
event_type_ids?: string[];
// appointment-only
@@ -125,12 +130,16 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
{ value: "completed", key: "deadlines.filter.completed" },
];
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
// "Ab heute" option was a UI lie (backend never narrowed past events for
// appointments) and is removed. 'today' is the sane default — matches the
// dashboard tile. 'all' stays as the explicit opt-in for past events.
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
{ value: "all", key: "events.filter.status.all" },
{ value: "today", key: "deadlines.filter.today" },
{ value: "this_week", key: "deadlines.filter.thisweek" },
{ value: "next_week", key: "deadlines.filter.nextweek" },
{ value: "later", key: "deadlines.filter.later" },
{ value: "all", key: "events.filter.status.all" },
];
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
@@ -139,7 +148,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "all" : "pending";
return type === "appointment" ? "today" : "pending";
}
let currentType: EventTypeChoice = "deadline";
@@ -153,8 +162,10 @@ let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
let calYear = 0;
let calMonth = 0;
// Calendar handle is created lazily when /events first switches into the
// Kalender view (t-paliad-224). The handle owns its own month/week/day
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
let calendar: CalendarHandle | null = null;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
@@ -257,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
function ruleDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
// Prefer the saved citation (RoP.023, R.151) over the rule name —
// REGEL is meant for the legal reference, not the rule's display
// name (which is the title column's job).
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
const lang = getLang();
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
if (localized && localized.trim()) return esc(localized);
// t-paliad-258 addendum — canonical display contract: Name primary,
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
// Custom rules render the lawyer's free text + a "Custom" badge.
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
// show the bare citation as last-resort fallback.
const hasName = (item.rule_name && item.rule_name.trim()) ||
(item.rule_name_en && item.rule_name_en.trim());
if (hasName || (item.rule_code && item.rule_code.trim())) {
return formatRuleLabelHTML(
{
name: item.rule_name || "",
name_en: item.rule_name_en,
rule_code: item.rule_code,
},
esc,
);
}
if (item.custom_rule_text && item.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
}
return "&mdash;";
}
@@ -425,12 +449,13 @@ function hideTableAndCalendar() {
const calWrap = document.getElementById("events-calendar-wrap");
if (tableWrap) tableWrap.style.display = "none";
if (calWrap) calWrap.hidden = true;
teardownCalendar();
}
function render() {
if (!loadedOK) return;
if (currentView === "calendar") {
renderCalendar();
renderCalendarView();
} else {
renderTable();
}
@@ -553,135 +578,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
</tr>`;
}
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
// plotting an event onto the calendar. Deadlines bucket on due_date;
// appointments on start_at's local-date component.
function itemDateISO(item: EventListItem): string {
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
// event_date); appointments bucket on start_at (fallback to event_date).
function toCalendarItem(item: EventListItem): CalendarItem {
let bucketDate: string;
if (item.type === "deadline") {
const src = item.due_date ?? item.event_date;
return src.slice(0, 10);
bucketDate = item.due_date ?? item.event_date;
} else if (item.start_at) {
bucketDate = item.start_at;
} else {
bucketDate = item.event_date;
}
if (!item.start_at) return item.event_date.slice(0, 10);
const d = new Date(item.start_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return {
kind: item.type,
id: item.id,
title: item.title,
event_date: bucketDate,
project_id: item.project_id,
project_title: item.project_title,
project_reference: item.project_reference,
};
}
function isoDate(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function fmtMonthYear(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function calDotClass(item: EventListItem): string {
// Per-item dot colour. Deadlines reuse the existing urgency palette;
// appointments get their own colour so they're visually distinct from
// deadlines on a mixed (Beides) calendar.
if (item.type === "appointment") return "events-cal-dot-appointment";
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
}
function renderCalendar() {
const wrap = document.getElementById("events-calendar-wrap")!;
const grid = document.getElementById("events-cal-grid")!;
const empty = document.getElementById("events-cal-empty") as HTMLElement;
const monthLabel = document.getElementById("events-cal-month-label")!;
function renderCalendarView() {
const host = document.getElementById("events-calendar-wrap");
if (!host) return;
const tableEmpty = document.getElementById("events-empty")!;
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
// Calendar always renders the visible month from allItems, regardless of
// pristine vs filtered state — empty calendar is allowed (the per-month
// empty hint communicates "no items in this month" without confusing it
// with the table-mode "no items at all" empty state).
tableEmpty.style.display = "none";
tableEmptyFiltered.style.display = "none";
wrap.hidden = false;
(host as HTMLElement).hidden = false;
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
const firstDay = new Date(calYear, calMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
const byDate = new Map<string, EventListItem[]>();
for (const item of allItems) {
const iso = itemDateISO(item);
const list = byDate.get(iso);
if (list) list.push(item);
else byDate.set(iso, [item]);
const items = allItems.map(toCalendarItem);
if (calendar) {
calendar.update(items);
return;
}
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(calYear, calMonth, day);
const items = byDate.get(iso) ?? [];
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
// urlState=true: the Kalender tab persists its month/week/day + anchor
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
// calendar state (per t-paliad-224 §11 Q3 head decision).
calendar = mountCalendar(host as HTMLElement, items, {
urlState: true,
defaultView: "month",
});
const monthStart = isoDate(calYear, calMonth, 1);
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
const hasInMonth = allItems.some((it) => {
const iso = itemDateISO(it);
return iso >= monthStart && iso <= monthEnd;
});
empty.hidden = hasInMonth;
}
function openCalPopup(iso: string, items: EventListItem[]) {
if (items.length === 0) return;
const popup = document.getElementById("events-cal-popup") as HTMLElement;
const dateEl = document.getElementById("events-cal-popup-date")!;
const list = document.getElementById("events-cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((it) => {
const cls = calDotClass(it);
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
const projectLabel = it.project_reference ?? "";
const projectCell = projectHref
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
: "";
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
${projectCell}
</li>`;
})
.join("");
popup.style.display = "flex";
function teardownCalendar() {
if (!calendar) return;
calendar.destroy();
calendar = null;
}
function applyView() {
@@ -702,12 +649,18 @@ function applyView() {
// Cards view = the original layout (5-card summary + table).
// List view = no summary cards, table only — gives more vertical space
// and matches users' mental model of a flat list.
// Calendar view = month grid; cards + table both hidden.
// Calendar view = mountCalendar() canon (month/week/day); cards + table
// both hidden. The handle is torn down when the user leaves Kalender
// so its URL state isn't reapplied to other shapes.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar" && loadedOK) renderCalendar();
if (currentView === "calendar") {
if (loadedOK) renderCalendarView();
} else {
teardownCalendar();
}
}
function wireRowHandlers(tbody: HTMLElement) {
@@ -728,6 +681,13 @@ function wireRowHandlers(tbody: HTMLElement) {
if (cb && !cb.disabled) {
cb.addEventListener("change", async () => {
if (!cb.checked) return;
const titleCell = row.querySelector<HTMLElement>(".events-title");
const title = (titleCell?.textContent || "").trim();
const msg = t("deadlines.complete.confirm").replace("{title}", title || "?");
if (!window.confirm(msg)) {
cb.checked = false;
return;
}
cb.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
@@ -1002,12 +962,10 @@ function initFilters() {
}
function initView() {
// Calendar always opens on the current month — month navigation is
// local to the view (cheap pagination, doesn't refetch).
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
// Kalender state (view + anchor) lives inside mountCalendar; no
// events-page-level wiring needed. The view chips below switch
// between Karten / Liste / Kalender; applyView() handles the
// mount + teardown.
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.dataset.eventView as EventView;
@@ -1017,31 +975,6 @@ function initView() {
syncURLParams();
});
});
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
calMonth -= 1;
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
renderCalendar();
});
document.getElementById("events-cal-next")?.addEventListener("click", () => {
calMonth += 1;
if (calMonth > 11) { calMonth = 0; calYear += 1; }
renderCalendar();
});
document.getElementById("events-cal-today")?.addEventListener("click", () => {
const t = new Date();
calYear = t.getFullYear();
calMonth = t.getMonth();
renderCalendar();
});
const popup = document.getElementById("events-cal-popup") as HTMLElement;
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
popup.style.display = "none";
});
popup?.addEventListener("click", (e) => {
if (e.target === popup) popup.style.display = "none";
});
}
function initSummaryCards() {

View File

@@ -162,10 +162,11 @@ function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
// ----------------------------------------------------------------------
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" },
{ 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" },
{ value: "changes_requested", key: "views.bar.approval_status.changes_requested" },
];
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ 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";
import { openApprovalEditModal } from "./components/approval-edit-modal";
// /inbox client — t-paliad-163 universal-filter migration.
//
@@ -123,11 +124,20 @@ function paint(
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
const action = btn.dataset.action as
| "approve"
| "reject"
| "revoke"
| "suggest_changes"
| undefined;
const li = btn.closest<HTMLLIElement>(".views-approval-row");
const id = li?.dataset.requestId;
if (!action || !id) return;
btn.addEventListener("click", async () => {
if (action === "suggest_changes") {
await handleSuggestChanges(btn, id, li!);
return;
}
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
@@ -141,8 +151,8 @@ function wireApprovalActions(host: HTMLElement): void {
body: JSON.stringify({ note }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
alert(mapApprovalError(body.error || "internal"));
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
alert(mapApprovalError(body.code || body.error || "internal"));
btn.disabled = false;
return;
}
@@ -156,14 +166,109 @@ function wireApprovalActions(host: HTMLElement): void {
});
}
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
// requester's original payload + pre_image pre-populated. If the user
// submits non-empty changes / note, POST to
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
// so the OLD row flips to changes_requested and the NEW pending row
// appears.
async function handleSuggestChanges(
btn: HTMLButtonElement,
requestID: string,
li: HTMLLIElement,
): Promise<void> {
// Read the row's detail blob off the data-attrs the shape-list stamped.
// shape-list serialises payload/pre_image inline; we fetch fresh via
// the per-row API to avoid relying on stale list data.
let payload: Record<string, unknown> | null = null;
let preImage: Record<string, unknown> | null = null;
let entityType: "deadline" | "appointment" = "deadline";
let lifecycleEvent = "update";
let projectTitle: string | undefined;
let requesterName: string | undefined;
let requestedAt: string | undefined;
try {
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
if (r.ok) {
const body = (await r.json()) as {
entity_type?: "deadline" | "appointment";
lifecycle_event?: string;
payload?: Record<string, unknown> | null;
pre_image?: Record<string, unknown> | null;
project_title?: string;
requester_name?: string;
requested_at?: string;
};
payload = body.payload ?? null;
preImage = body.pre_image ?? null;
if (body.entity_type === "appointment") entityType = "appointment";
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
projectTitle = body.project_title;
requesterName = body.requester_name;
requestedAt = body.requested_at;
}
} catch (_e) {
// Modal still opens with empty defaults if the fetch fails; the
// server-side schema validation catches a misshapen counter.
}
const result = await openApprovalEditModal({
entityType,
lifecycleEvent,
payload,
preImage,
projectTitle,
requesterName,
requestedAt,
});
if (!result) return; // cancel
btn.disabled = true;
try {
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
counter_payload: result.counterPayload,
note: result.note,
}),
});
const body = (await r.json().catch(() => ({}))) as {
error?: string;
code?: string;
new_request_id?: string;
};
if (!r.ok) {
alert(mapApprovalError(body.code || body.error || "internal"));
btn.disabled = false;
return;
}
await bar?.refresh();
await refreshInboxBadge();
btn.disabled = false;
// Surface the new row's id on the OLD row's <li> so callers (e.g.
// tests, future inspection) can find it without re-querying.
if (body.new_request_id) {
li.dataset.spawnedRequestId = body.new_request_id;
}
} 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;
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");
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
default: return key;
}
}

View File

@@ -93,12 +93,13 @@ export function routeNameFor(pathname: string): string {
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments/calendar") return "appointments.calendar";
if (pathname === "/appointments") return "appointments.list";
// /deadlines/calendar + /appointments/calendar are 301 redirects to
// /events?type=…&view=calendar since t-paliad-224 — the client never
// sees those pathnames any more.
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";

View File

@@ -1,15 +1,24 @@
// 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.
// Late-response polling (t-paliad-235 rewrite).
//
// 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.
// When the SSE stream closes mid-turn with an error event, the bubble
// can't tell from the wire whether (a) the upstream is still finishing
// the turn and we just lost transport, or (b) the upstream is truly
// dead.
//
// This module hits the dispatching recovery endpoint
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
//
// - aichat backend → asks aichat via its conversation API whether
// the turn actually completed upstream
// - legacy backend → reads the local row (paliad's filesystem
// janitor patches it when claude writes the
// response file late)
//
// The endpoint returns:
//
// recovery_state="recovered" → response is in the payload, render it
// recovery_state="pending" → keep polling
// recovery_state="lost" → upstream is truly gone, give up
export interface LateTurn {
turn_id: string;
@@ -28,6 +37,10 @@ export interface LatePollOptions {
intervalMs?: number; // default 3000
maxDurationMs?: number; // default 600000 (10 min)
onLateResponse: (turn: LateTurn) => void;
// onLost — backend confirmed the turn is unrecoverable. Caller should
// swap the bubble copy to the "verloren" string. Distinct from
// onGiveUp (which fires only on the local timeout).
onLost?: () => void;
onGiveUp?: () => void;
}
@@ -35,6 +48,20 @@ export interface LatePollHandle {
cancel: () => void;
}
interface RecoverResponse {
turn_id: string;
started_at: 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;
recovery_state: "recovered" | "pending" | "lost";
}
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
const interval = opts.intervalMs ?? 3000;
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
return;
}
try {
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
credentials: "same-origin",
});
if (r.ok) {
const turn = (await r.json()) as LateTurn;
if (turn.response && turn.response.length > 0) {
opts.onLateResponse(turn);
const body = (await r.json()) as RecoverResponse;
if (body.recovery_state === "recovered" && body.response) {
opts.onLateResponse(toLateTurn(body));
return;
}
}
// 404: row gone (very unlikely) — give up.
if (r.status === 404) {
if (body.recovery_state === "lost") {
opts.onLost?.();
return;
}
// pending — keep polling
} else if (r.status === 404) {
// Row gone — give up. Different signal from `lost`: a missing row
// is a paliad-side bookkeeping problem; aichat may still have the
// answer but we can't surface it without the row.
opts.onGiveUp?.();
return;
}
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
};
// First poll deliberately runs after one interval so we don't race
// the 60 s timeout on the very first tick.
// the dispatch endpoint on the very first tick (gives the upstream a
// moment to actually settle the row after the stream drop).
timer = window.setTimeout(tick, interval);
return {
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
},
};
}
function toLateTurn(body: RecoverResponse): LateTurn {
return {
turn_id: body.turn_id,
response: body.response,
error_code: body.error_code,
finished_at: body.finished_at,
duration_ms: body.duration_ms,
used_tools: body.used_tools ?? [],
rows_seen: body.rows_seen ?? [],
chip_count: body.chip_count ?? 0,
classifier_tag: body.classifier_tag,
};
}

View File

@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
const es = new EventSource(turnRes.sse_url);
activeStream = es;
startWidgetThinking(placeholder);
let fullText = "";
es.addEventListener("thinking", (ev) => {
let elapsed = 0;
try {
const data = JSON.parse((ev as MessageEvent).data || "{}");
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
} catch {
/* ignore */
}
updateWidgetThinking(placeholder, elapsed);
});
es.addEventListener("content", (ev) => {
try {
const data = JSON.parse((ev as MessageEvent).data);
if (typeof data.delta === "string" && data.delta) {
// Streamed delta (aichat backend) — append.
stopWidgetThinking(placeholder);
fullText += data.delta;
setBubbleText(placeholder, fullText);
return;
}
// Legacy one-shot full-text payload.
fullText = String(data.text || "");
stopWidgetThinking(placeholder);
setBubbleText(placeholder, fullText);
} catch {
/* ignore parse error */
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
});
es.addEventListener("end", () => {
placeholder.dataset.streaming = "false";
stopWidgetThinking(placeholder);
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
saveHistory();
cleanupStream();
});
es.addEventListener("error", () => {
stopWidgetThinking(placeholder);
const errText = t("paliadin.error.connection_lost");
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
placeholder.classList.add("paliadin-widget-bubble--error");
placeholder.classList.add("paliadin-widget-bubble--late-pending");
placeholder.dataset.streaming = "false";
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
});
}
function startWidgetThinking(bubble: HTMLElement): void {
if (bubble.querySelector(".paliadin-widget-thinking")) return;
// Clear the static placeholder text — the live pulse + counter is
// the canonical "denkt nach" signal.
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
if (textNode) textNode.textContent = "";
const node = document.createElement("div");
node.className = "paliadin-widget-thinking";
node.innerHTML = `
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
<span class="paliadin-widget-thinking-label"></span>
<span class="paliadin-widget-thinking-elapsed"></span>
`;
const label = node.querySelector(".paliadin-widget-thinking-label")!;
label.textContent = t("paliadin.thinking");
bubble.appendChild(node);
updateWidgetThinking(bubble, 0);
}
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
if (!node) return;
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
if (elapsed) {
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
}
}
function stopWidgetThinking(bubble: HTMLElement): void {
bubble.querySelector(".paliadin-widget-thinking")?.remove();
}
function cleanupStream(): void {
activeStream?.close();
activeStream = null;
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
lateWidgetPolls.delete(turnId);
applyWidgetLateResponse(bubble, turn);
},
onLost: () => {
lateWidgetPolls.delete(turnId);
applyWidgetLost(bubble);
},
onGiveUp: () => {
lateWidgetPolls.delete(turnId);
applyWidgetLost(bubble);
},
});
lateWidgetPolls.set(turnId, handle);
}
function applyWidgetLost(bubble: HTMLElement): void {
bubble.classList.remove("paliadin-widget-bubble--late-pending");
bubble.classList.add("paliadin-widget-bubble--lost");
setBubbleText(bubble, t("paliadin.late.lost"));
}
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove(

View File

@@ -3,16 +3,25 @@ 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).
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
// t-paliad-235).
//
// State machine: empty → typing → sending → streaming → done.
// State machine: empty → typing → sending → thinking → streaming → done.
// History lives in localStorage under "paliadin:history:<sessionId>"
// — design §0.5.4 session-only persistence.
//
// SSE consumer subscribes to `event: meta`, `event: content`,
// `event: end`, `event: error`, `event: ping`. Backend currently
// emits one `content` blob per turn (real chunked streaming is
// production-v1; PoC simulates with a typewriter effect).
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
//
// `content` events from the aichat backend arrive as incremental
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
// no typewriter simulation needed. Legacy backends still emit a single
// `{text: "..."}` payload and we fall back to the typewriter for that
// shape.
//
// `thinking` events fire while the upstream is alive but hasn't
// produced content yet (or stalled mid-stream); the bubble renders a
// pulse + counter so the user can SEE the chat is still working.
interface HistoryEntry {
role: "user" | "assistant";
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
const es = new EventSource(turnRes.sse_url);
currentEventSource = es;
// Show the thinking pulse immediately — the placeholder text already
// says "denkt nach", but the visible pulse + counter is the live
// proof-of-life signal m needs to trust that the chat is working.
startThinkingIndicator(placeholder);
// Reset the streamed accumulator for this turn.
placeholder.dataset.fullText = "";
es.addEventListener("meta", () => {
// Could surface a "thinking" indicator; placeholder text already does.
});
es.addEventListener("thinking", (ev) => {
let elapsed = 0;
try {
const data = JSON.parse((ev as MessageEvent).data || "{}");
if (typeof data.elapsed_seconds === "number") {
elapsed = data.elapsed_seconds;
}
} catch {
/* ignore */
}
updateThinkingIndicator(placeholder, elapsed);
});
es.addEventListener("content", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
const delta = typeof data.delta === "string" ? data.delta : "";
if (delta) {
// Aichat streaming path — accumulate the delta into the bubble.
stopThinkingIndicator(placeholder);
const current = placeholder.dataset.fullText ?? "";
const next = current + delta;
placeholder.dataset.fullText = next;
writeStreamedText(placeholder, next);
return;
}
// Legacy one-shot path — full body in `text`.
const text = String(data.text || "");
// Cache the full text on the bubble so finishBubble can render the
// complete response even when the typewriter is mid-flight when end
// arrives. textContent reflects only what's been typed so far and
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
// saw "## Proje" instead of the full 1408-byte body).
placeholder.dataset.fullText = text;
stopThinkingIndicator(placeholder);
typewriter(placeholder, text);
});
es.addEventListener("end", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
placeholder.dataset.streaming = "false";
stopThinkingIndicator(placeholder);
finishBubble(placeholder, data);
history.push({
role: "assistant",
@@ -210,12 +247,12 @@ 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.
stopThinkingIndicator(placeholder);
// Honest copy: we don't claim "nachgereicht" because the recovery
// path may report "lost". Frame it as "checking" while we ask the
// backend whether the turn actually completed upstream.
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
errText + " " + t("paliadin.late.waiting");
errText + " " + t("paliadin.late.checking");
placeholder.classList.add("paliadin-bubble--error");
placeholder.classList.add("paliadin-bubble--late-pending");
placeholder.dataset.streaming = "false";
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
});
}
// =============================================================================
// thinking indicator — proof-of-life pulse + elapsed counter
// =============================================================================
function startThinkingIndicator(bubble: HTMLElement): void {
// Append a thinking node next to the bubble text (sibling, so the
// typewriter rewriting text content doesn't clobber it). The node
// shows a pulse dot + the elapsed counter.
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
if (node) return; // already running
// Clear the static placeholder text — the live pulse + counter is
// now the canonical "denkt nach" signal. Leaving the text in place
// would render the same phrase twice.
const textNode = bubble.querySelector(".paliadin-bubble-text");
if (textNode) textNode.textContent = "";
node = document.createElement("div");
node.className = "paliadin-thinking";
node.innerHTML = `
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
<span class="paliadin-thinking-label"></span>
<span class="paliadin-thinking-elapsed"></span>
`;
const label = node.querySelector(".paliadin-thinking-label")!;
label.textContent = t("paliadin.thinking");
bubble.appendChild(node);
// Initial 0s — replaced as soon as a thinking event arrives or our
// local ticker fires.
updateThinkingIndicator(bubble, 0);
}
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
if (!node) return;
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
if (elapsed) {
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
}
}
function stopThinkingIndicator(bubble: HTMLElement): void {
bubble.querySelector(".paliadin-thinking")?.remove();
}
function formatThinkingSeconds(s: number): string {
if (s < 0) s = 0;
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
}
// writeStreamedText fills the bubble with raw text as it accumulates.
// Cheaper than the typewriter — we already have the real cadence from
// the wire, no need to simulate it.
function writeStreamedText(bubble: HTMLElement, text: string): void {
const node = bubble.querySelector(".paliadin-bubble-text");
if (!node) return;
node.textContent = text;
const stream = document.getElementById("paliadin-stream");
if (stream) stream.scrollTop = stream.scrollHeight;
}
// Server emits SSE error events as JSON `{code, message}`. Map known
// codes to localised, user-friendly text; fall through to a generic
// "connection lost" for anything we don't recognise (including raw
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
}
// 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).
// startLatePoll registers the recovery-endpoint 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). When the backend confirms the turn is
// "lost", we swap the bubble to the honest "verloren" copy.
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).
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
latePolls.delete(turnId);
applyLateResponse(bubble, turn);
},
onLost: () => {
latePolls.delete(turnId);
applyLostResponse(bubble);
},
onGiveUp: () => {
latePolls.delete(turnId);
applyLostResponse(bubble);
},
});
latePolls.set(turnId, handle);
}
function applyLostResponse(bubble: HTMLElement): void {
bubble.classList.remove("paliadin-bubble--late-pending");
bubble.classList.add("paliadin-bubble--lost");
const node = bubble.querySelector(".paliadin-bubble-text");
if (node) node.textContent = t("paliadin.late.lost");
}
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");

View File

@@ -1,13 +1,47 @@
import { t, tDyn } from "./i18n";
import { t, tDyn, getLang } from "./i18n";
// Shared logic for the Project form rendered by ProjectFormFields.tsx.
// Used by /projects/new and the edit modal on /projects/{id}.
export interface ProceedingTypeRow {
id: number;
code: string;
name: string;
name_en: string;
jurisdiction?: string;
is_active: boolean;
}
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
// loadProceedingTypes fetches active fristenrechner-category proceeding
// types — the only set a project may bind to (mig 087/088 + service
// validation guard `validateProceedingTypeCategory`). Cached at module
// level so the page only pays for one fetch even when both the new-
// project page and the edit modal exercise the picker.
export async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
if (proceedingTypesCache) return proceedingTypesCache;
try {
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return [];
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
proceedingTypesCache = rows.filter((r) => r.is_active);
return proceedingTypesCache;
} catch {
return [];
}
}
export interface ProjectMini {
id: string;
title: string;
type: string;
reference?: string | null;
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
// the ancestor tree. Populated by the service projection on every
// /api/projects response, so the picker can show the code without an
// extra fetch.
code?: string;
}
export interface ProjectFormState {
@@ -48,9 +82,11 @@ function tryGet(id: string): HTMLElement | null {
export function showFieldsForType(typeSel: string) {
const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null;
const clientFields = tryGet("fields-client") as HTMLDivElement | null;
const litigationFields = tryGet("fields-litigation") as HTMLDivElement | null;
const patentFields = tryGet("fields-patent") as HTMLDivElement | null;
const caseFields = tryGet("fields-case") as HTMLDivElement | null;
if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none";
if (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "block" : "none";
if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none";
if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none";
if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block";
@@ -88,18 +124,28 @@ export function initParentPicker() {
}
const matches = parentCandidates
.filter((p) => {
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
// Search across title + manual reference + auto-derived code
// so the user can type "EXMPL" or "INF.CFI" and find the row.
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
return hay.includes(q);
})
.slice(0, 8);
sugs.innerHTML = matches
.map(
(p) =>
`<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
.map((p) => {
// Render the auto-derived code (if any, and distinct from
// reference) as a small mono badge on the right so the user
// can disambiguate two same-titled projects by their tree
// position. Single template literal kept readable inline.
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
const codeBadge = code
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
: "";
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
<strong>${esc(p.title)}</strong>
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
</div>`,
)
${codeBadge}
</div>`;
})
.join("");
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
@@ -119,6 +165,34 @@ export function wireTypeChange() {
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
}
// populateProceedingTypeSelect fills #project-proceeding-type-id with one
// option per fristenrechner-category proceeding type, ordered by `code`
// (so the user scans `de.*`, `dpma.*`, `epa.*`, `upc.*` in stable
// jurisdiction-grouped order). The first option is the empty "unset"
// choice already in the markup; this helper only appends rows below it.
// Idempotent — clearing rows[1..] on re-call so a re-open of the edit
// modal doesn't double-render the list.
export async function populateProceedingTypeSelect(): Promise<void> {
const sel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
if (!sel) return;
const rows = await loadProceedingTypes();
rows.sort((a, b) => a.code.localeCompare(b.code));
while (sel.options.length > 1) sel.remove(1);
const isEN = getLang() === "en";
for (const row of rows) {
const opt = document.createElement("option");
opt.value = String(row.id);
const label = isEN && row.name_en ? row.name_en : row.name;
opt.textContent = `${label} (${row.code})`;
sel.appendChild(opt);
}
// Honour a pre-selection value that prefillForm wrote before the
// option set existed. dataset.preselect is set to "" or the saved id;
// restoring it here keeps the edit modal's saved value visible.
const preselect = sel.dataset.preselect;
if (preselect !== undefined) sel.value = preselect;
}
// readPayload collects the form's current values into a CreateProjectInput /
// UpdateProjectInput compatible JSON payload. Returns null + sets msg when
// title is missing.
@@ -174,20 +248,48 @@ export function readPayload(
const gd = ($("project-grant-date") as HTMLInputElement).value;
if (gd) payload.grant_date = gd + "T00:00:00Z";
}
if (type === "litigation") {
// opponent_code is the litigation-only short slug used as the
// middle segment when BuildProjectCode auto-derives a project
// code from the ancestor tree (t-paliad-222 / m/paliad#50).
// Uppercased on submit so the user can type lowercase comfortably
// — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern.
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
if (ocEl) {
const v = ocEl.value.trim().toUpperCase();
if (v) payload.opponent_code = v;
else if (!opts.omitEmpty) payload.opponent_code = "";
}
}
if (type === "case") {
stringField("project-court", "court");
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 = "";
// Proceeding type — optional picker. Per t-paliad-232, an empty
// pick simply omits the key from the payload (create: column stays
// NULL; edit: server's `omitempty` skips the SET). Clearing a
// previously-set value isn't supported in this slice; once bound,
// a project's proceeding type can be swapped but not unset from
// the form. The server's validateProceedingTypeCategory backs the
// selected id with a category check.
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
if (ptSel) {
const v = ptSel.value.trim();
if (v) {
const n = parseInt(v, 10);
if (!isNaN(n)) payload.proceeding_type_id = n;
}
}
// Client Role (DB column: our_side) — case-only after t-paliad-222.
// 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();
@@ -228,6 +330,18 @@ export function prefillForm(p: Record<string, unknown>) {
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 ?? "");
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
// Proceeding-type picker — populated lazily by populateProceedingTypeSelect.
// Set the value here even if the options haven't arrived yet; the post-
// populate render runs ApplyProceedingTypeValue to re-select the saved id
// once the option exists.
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
if (ptSel) {
const v = p.proceeding_type_id == null ? "" : String(p.proceeding_type_id);
ptSel.dataset.preselect = v;
ptSel.value = v;
}
getTA("project-description").value = String(p.description ?? "");
getSel("project-status").value = String(p.status ?? "active");
}

View File

@@ -8,10 +8,15 @@ import {
wireTypeChange,
prefillForm,
readPayload,
populateProceedingTypeSelect,
loadProceedingTypes as loadProceedingTypesShared,
} from "./project-form";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Project {
id: string;
@@ -20,6 +25,12 @@ interface Project {
path: string;
title: string;
reference?: string | null;
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
// service layer on every projection; equal to `reference` when the
// user typed an override.
code?: string;
opponent_code?: string | null;
description?: string | null;
status: string;
client_number?: string | null;
@@ -33,6 +44,12 @@ interface Project {
grant_date?: string | null;
court?: string | null;
case_number?: string | null;
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
// the team panel can render an inline <select> for callers who can
// change responsibilities (global_admin or effective_project_admin on
// this project / ancestor). Optional for back-compat with cached
// payloads.
effective_admin?: boolean;
updated_at: string;
created_at: string;
}
@@ -126,6 +143,11 @@ interface Deadline {
status: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was saved in
// Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
@@ -158,7 +180,9 @@ type TabId =
| "deadlines"
| "appointments"
| "notes"
| "checklists";
| "checklists"
| "submissions"
| "settings";
const VALID_TABS: TabId[] = [
"history",
@@ -169,6 +193,8 @@ const VALID_TABS: TabId[] = [
"appointments",
"notes",
"checklists",
"submissions",
"settings",
];
// Legacy German tab slugs that may appear in bookmarked URLs after the
@@ -196,6 +222,9 @@ interface ChecklistTemplateSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE?: string;
descriptionEN?: string;
regime?: string;
itemCount: number;
}
@@ -221,6 +250,12 @@ let attachedUnits: AttachedUnit[] = [];
let allUnits: { id: string; name: string; office: string }[] = [];
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
// mailto: button on the Team tab. Pure client state, wiped on page
// navigation. Pruned to currently-visible user_ids on every renderTeam
// so removed/filtered-out members don't ride along in the next mailto.
const selectedMailUserIDs: Set<string> = new Set();
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
@@ -776,6 +811,9 @@ interface UnionEvent {
status?: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
custom_rule_text?: string;
start_at?: string;
end_at?: string;
location?: string;
@@ -803,6 +841,9 @@ async function loadDeadlines(id: string) {
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
rule_name: it.rule_name,
rule_name_en: it.rule_name_en,
custom_rule_text: it.custom_rule_text,
project_title: it.project_title,
}));
} else {
@@ -972,6 +1013,27 @@ function fmtDateOnly(iso: string): string {
}
}
// formatDeadlineRuleCell renders the REGEL column for the project
// detail Fristen table using the canonical t-paliad-258 contract:
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
// 2. custom_rule_text → text + "Custom" badge
// 3. legacy rule_code-only saves → bare citation
// 4. otherwise "—"
function formatDeadlineRuleCell(f: Deadline): string {
const hasName = (f.rule_name && f.rule_name.trim()) ||
(f.rule_name_en && f.rule_name_en.trim());
if (hasName || (f.rule_code && f.rule_code.trim())) {
return formatRuleLabelHTML(
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
esc,
);
}
if (f.custom_rule_text && f.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
}
return "—";
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
@@ -1010,7 +1072,7 @@ function renderDeadlines() {
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
@@ -1086,6 +1148,24 @@ function renderHeader() {
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
// as a second badge whenever it's non-empty AND distinct from the
// manual reference. Hides when the derived value equals reference
// (avoids visual duplication when the user typed the same string)
// or when no derivation produced a value.
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
if (codeEl) {
const code = project.code ?? "";
const ref = project.reference ?? "";
if (code && code !== ref) {
codeEl.textContent = code;
codeEl.style.display = "";
} else {
codeEl.textContent = "";
codeEl.style.display = "none";
}
}
// t-paliad-177 — link from Verlauf header to standalone chart page.
// Wired here (not in the TSX shell) because we need the resolved
// project id, which only exists after the detail fetch settles.
@@ -1140,13 +1220,16 @@ function renderHeader() {
netdocs.style.display = "none";
}
// Delete visibility: partner/admin only
// Delete visibility: partner/admin only. The Verwaltung tab's archive
// sub-section mirrors the same gate (t-paliad-245) — it only points at
// the Edit-modal danger zone, so it's pointless to show when the danger
// zone itself is hidden.
const deleteWrap = document.getElementById("project-delete-wrap")!;
if (me && (me.global_role === "global_admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
const archiveSection = document.getElementById("project-settings-archive");
const canArchive = !!me && me.global_role === "global_admin";
deleteWrap.style.display = canArchive ? "" : "none";
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
updateSettingsTabVisibility();
}
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
@@ -1410,36 +1493,9 @@ function initSmartTimelineAddModal(id: string) {
initCounterclaimRoute(id, modal, choices, form);
}
interface ProceedingTypeRow {
id: number;
code: string;
name: string;
name_en: string;
jurisdiction?: string;
is_active: boolean;
}
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
// loadProceedingTypes fetches active proceeding types for the project
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
// picker only ever shows those — never the 7 legacy litigation codes
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
// server-side service validation + DB trigger (mig 088) are the
// defence-in-depth backstops for any non-UI writer.
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
if (proceedingTypesCache) return proceedingTypesCache;
try {
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return [];
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
proceedingTypesCache = rows.filter((r) => r.is_active);
return proceedingTypesCache;
} catch {
return [];
}
}
// loadProceedingTypes is shared from ./project-form so the counterclaim
// modal here and the project-edit picker hit the same cache.
const loadProceedingTypes = loadProceedingTypesShared;
function initCounterclaimRoute(
id: string,
@@ -1472,7 +1528,7 @@ function initCounterclaimRoute(
msg.className = "form-msg";
}
// Populate proceeding-type select on first open. Only UPC types
// make sense for a CCR (Nichtigkeit/CCI); pre-select UPC_REV.
// make sense for a CCR (Nichtigkeit/CCI); pre-select upc.rev.cfi.
if (procedureSel && procedureSel.options.length === 0) {
const types = await loadProceedingTypes();
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
@@ -1481,7 +1537,7 @@ function initCounterclaimRoute(
const opt = document.createElement("option");
opt.value = String(ty.id);
opt.textContent = `${ty.code}${langEN ? ty.name_en || ty.name : ty.name}`;
if (ty.code === "UPC_REV") opt.selected = true;
if (ty.code === "upc.rev.cfi") opt.selected = true;
procedureSel.appendChild(opt);
}
}
@@ -1610,21 +1666,40 @@ function showTab(tab: TabId) {
if (tab === "checklists" && project) {
void loadAndRenderChecklistInstances(project.id);
}
if (tab === "submissions" && project) {
void loadAndRenderSubmissions(project.id);
}
}
let checklistInstancesInited = false;
async function loadAndRenderChecklistInstances(projectID: string) {
if (checklistInstancesInited) return;
let checklistCatalogLoaded = false;
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
// the second open doesn't refetch the catalog (t-paliad-239).
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
try {
const resp = await fetch(`/api/checklists`);
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
checklistTemplates = {};
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
checklistCatalogLoaded = true;
return list;
} catch {
return [];
}
}
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
if (checklistInstancesInited && !force) return;
checklistInstancesInited = true;
try {
const [instResp, tplResp] = await Promise.all([
const [instResp] = await Promise.all([
fetch(`/api/projects/${projectID}/checklists`),
fetch(`/api/checklists`),
loadChecklistCatalog(),
]);
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
checklistTemplates = {};
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
} catch {
checklistInstances = [];
}
@@ -1684,6 +1759,143 @@ function renderChecklistInstances() {
});
}
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
// project-detail Checklists tab (t-paliad-239). Opens a template picker
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
// current project_id and the template title as the instance name.
function initAddChecklistModal(projectID: string) {
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
const close = () => {
modal.style.display = "none";
modalMsg.textContent = "";
modalMsg.className = "form-msg";
};
const renderPicker = () => {
const isEN = getLang() === "en";
const q = search.value.trim().toLowerCase();
const all = Object.values(checklistTemplates);
all.sort((a, b) => {
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
return at.localeCompare(bt, isEN ? "en" : "de");
});
const filtered = q
? all.filter((tpl) => {
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
return title.toLowerCase().includes(q)
|| desc.toLowerCase().includes(q)
|| (tpl.regime || "").toLowerCase().includes(q);
})
: all;
if (filtered.length === 0) {
list.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = filtered.map((tpl) => {
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
const regime = tpl.regime || "";
const regimeChip = regime
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
: "";
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
<div class="add-checklist-row-head">
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
${regimeChip}
</div>
${descLine}
</button>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
btn.addEventListener("click", () => {
const slug = btn.dataset.slug!;
void pickTemplate(slug, btn);
});
});
};
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
const tpl = checklistTemplates[slug];
if (!tpl) return;
const isEN = getLang() === "en";
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
b.disabled = true;
});
modalMsg.textContent = "";
modalMsg.className = "form-msg";
try {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, project_id: projectID }),
});
if (!resp.ok) {
modalMsg.textContent = t("projects.detail.checklisten.add.error");
modalMsg.className = "form-msg form-msg-error";
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
b.disabled = false;
});
return;
}
close();
flashTabMsg(t("projects.detail.checklisten.add.created"));
await loadAndRenderChecklistInstances(projectID, true);
} catch {
modalMsg.textContent = t("projects.detail.checklisten.add.error");
modalMsg.className = "form-msg form-msg-error";
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
b.disabled = false;
});
}
};
let flashTimer = 0;
const flashTabMsg = (text: string) => {
tabMsg.textContent = text;
tabMsg.className = "form-msg form-msg-success";
if (flashTimer) window.clearTimeout(flashTimer);
flashTimer = window.setTimeout(() => {
tabMsg.textContent = "";
tabMsg.className = "form-msg";
}, 3500);
};
addBtn.addEventListener("click", async () => {
await loadChecklistCatalog();
search.value = "";
modalMsg.textContent = "";
modalMsg.className = "form-msg";
renderPicker();
modal.style.display = "flex";
search.focus();
});
closeBtn.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
search.addEventListener("input", renderPicker);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display !== "none") close();
});
}
function escapeHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
@@ -1717,9 +1929,14 @@ async function prepareEditForm() {
// as the new parent (server would reject anyway).
await loadParentCandidates(project?.id);
initParentPicker();
await populateProceedingTypeSelect();
}
function openEditModal() {
// openEditModal opens the project-edit modal, optionally scrolling +
// focusing a specific field after the form is prefilled. Callers like
// the Schriftsätze empty-state CTA pass focusFieldID="project-proceeding-
// type-id" to land the user directly on the picker they came to set.
function openEditModal(focusFieldID?: string) {
if (!project) return;
const modal = document.getElementById("project-edit-modal");
const msg = document.getElementById("project-edit-msg");
@@ -1755,6 +1972,19 @@ function openEditModal() {
};
}
renderTypeChangeWarning();
if (focusFieldID) {
// Wait a tick so the modal has laid out before scrolling — the
// wrapping flex container is display:flex so the field's offset
// height is only reliable after the next animation frame.
requestAnimationFrame(() => {
const target = document.getElementById(focusFieldID);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
if (target instanceof HTMLSelectElement || target instanceof HTMLInputElement) {
target.focus();
}
});
}
});
msg.textContent = "";
msg.className = "form-msg";
@@ -1833,13 +2063,37 @@ function initEditModal() {
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
editBtn.addEventListener("click", openEditModal);
editBtn.addEventListener("click", () => openEditModal());
closeBtn.addEventListener("click", closeEditModal);
cancelBtn.addEventListener("click", closeEditModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
// Schriftsätze empty-state CTA — when the panel reports "no proceeding
// set", clicking the button opens the edit modal directly on the
// Verfahrenstyp picker so the lawyer can resolve the gap in one step
// (t-paliad-232).
const submissionsCTA = document.getElementById(
"project-submissions-edit-cta",
) as HTMLButtonElement | null;
if (submissionsCTA) {
submissionsCTA.addEventListener("click", () => {
openEditModal("project-proceeding-type-id");
});
}
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
// the danger-zone archive button (t-paliad-245).
const archiveLink = document.getElementById(
"project-settings-archive-link",
) as HTMLButtonElement | null;
if (archiveLink) {
archiveLink.addEventListener("click", () => {
openEditModal("project-delete-btn");
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
@@ -2056,8 +2310,10 @@ async function main() {
initSmartTimelineClientToggle(id);
initSmartTimelineAddModal(id);
initAttachUnitForm(id);
initAddChecklistModal(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
wireExportButton(id);
showTab(parseTab());
}
@@ -2470,6 +2726,7 @@ async function loadUserList() {
function renderTeam() {
const body = document.getElementById("team-body")!;
const empty = document.getElementById("team-empty")!;
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
// Existing team-body shows the direct + ancestor-inherited members
// returned by /api/projects/{id}/team. The derived + descendant
@@ -2480,12 +2737,26 @@ function renderTeam() {
if (totalRows === 0) {
body.innerHTML = "";
empty.style.display = "";
if (mailtoControls) mailtoControls.style.display = "none";
selectedMailUserIDs.clear();
syncMailtoButton();
syncMasterCheckbox();
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
return;
}
empty.style.display = "none";
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
// Prune the selection to whoever is actually rendered in team-body
// right now (e.g. a member just got removed). Invariant: selection ⊆
// currently-visible team-body rows.
pruneMailSelectionToVisible();
// t-paliad-223: callers with effective_project_admin authority see an
// inline <select> on the Rolle cell. Everyone else sees the read-only
// <span>. The bool comes from the GET /api/projects/{id} payload.
const canEditResponsibility = !!project?.effective_admin;
body.innerHTML = teamMembers
.map((m) => {
@@ -2512,17 +2783,51 @@ function renderTeam() {
: "";
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
// Inline-select only on direct rows where the caller can edit.
// Inherited rows stay read-only — the edit must happen at the
// ancestor where the row is direct.
const responsibilityCell =
canEditResponsibility && !m.inherited
? renderResponsibilitySelect(m.user_id, responsibility)
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
// rows with a real email participate in the mailto: build; rows
// without are still rendered with a disabled checkbox so the
// column geometry stays uniform.
const hasEmail = !!(m.user_email && m.user_email.trim());
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
const disabled = hasEmail ? "" : " disabled";
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
return `<tr>
${checkboxCell}
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
<td>${responsibilityCell}</td>
<td>${source}</td>
<td>${removeBtn}</td>
</tr>`;
})
.join("");
// t-paliad-231 — wire row checkboxes + master + mailto button.
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
cb.addEventListener("change", () => {
const userID = cb.dataset.userId!;
if (cb.checked) selectedMailUserIDs.add(userID);
else selectedMailUserIDs.delete(userID);
syncMailtoButton();
syncMasterCheckbox();
});
});
wireMailtoMaster();
wireMailtoButton();
syncMailtoButton();
syncMasterCheckbox();
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
@@ -2535,6 +2840,47 @@ function renderTeam() {
if (resp.ok) {
await loadTeam(project.id);
renderTeam();
} else {
await showTeamErrorToast(resp);
}
});
});
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
// Capture the pre-change value on focus so we can roll back the
// <select> if the PATCH fails (e.g. last-admin guard).
sel.dataset.previous = sel.value;
sel.addEventListener("focus", () => {
sel.dataset.previous = sel.value;
});
sel.addEventListener("change", async () => {
if (!project) return;
const userID = sel.dataset.userId!;
const previous = sel.dataset.previous || "member";
const next = sel.value;
if (next === previous) return;
sel.disabled = true;
try {
const resp = await fetch(
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responsibility: next }),
},
);
if (!resp.ok) {
sel.value = previous;
await showTeamErrorToast(resp);
return;
}
sel.dataset.previous = next;
// Refresh the team list so derived/descendant sections re-render
// with the new authority shape.
await loadTeam(project.id);
renderTeam();
} finally {
sel.disabled = false;
}
});
});
@@ -2680,10 +3026,214 @@ function canManagePartnerUnits(): boolean {
);
}
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
// reveal the export button — server still re-enforces on the request.
function canExportProject(): boolean {
if (!me || !project) return false;
if (me.global_role === "global_admin") return true;
return teamMembers.some(
(m) =>
m.user_id === me!.id &&
m.project_id === project!.id &&
(m.responsibility === "lead" || m.responsibility === "member"),
);
}
// wireExportButton reveals the Export sub-section of the Verwaltung tab
// (t-paliad-245) and hooks up the project-export button. Triggers a
// download via a transient <a download> — same pattern as the personal
// export in client/settings.ts.
function wireExportButton(projectID: string): void {
const section = document.getElementById("project-settings-export") as HTMLElement | null;
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!section || !btn) return;
if (!canExportProject()) {
section.style.display = "none";
updateSettingsTabVisibility();
return;
}
section.style.display = "";
updateSettingsTabVisibility();
btn.addEventListener("click", () => {
const a = document.createElement("a");
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
// sub-sections are visible to the current user — an empty tab is worse
// UX than no tab. Called whenever a sub-section's visibility flips.
function updateSettingsTabVisibility(): void {
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
if (!tab) return;
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
tab.style.display = exportShown || archiveShown ? "" : "none";
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;
return me.global_role === "global_admin";
if (me.global_role === "global_admin") return true;
// t-paliad-223: effective_project_admin (from the project payload)
// also covers remove. RLS makes the request fail anyway if the bit is
// stale; this just hides the affordance.
return !!project?.effective_admin;
}
// t-paliad-223: build the inline <select> for the responsibility cell.
// Options mirror the IsValidResponsibility set in approval_levels.go.
function renderResponsibilitySelect(userID: string, current: string): string {
const options = ["admin", "lead", "member", "observer", "external"]
.map((v) => {
const label = tDyn(`projects.team.responsibility.${v}`) || v;
const sel = v === current ? " selected" : "";
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
})
.join("");
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
}
// t-paliad-223: surface backend error responses (last-admin guard / 403
// from RLS / etc.) as a transient toast. We have no global toast service
// yet on this page, so write into #team-msg.
async function showTeamErrorToast(resp: Response): Promise<void> {
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
if (!msg) return;
let text = "";
try {
const data = (await resp.json()) as { error?: string };
text = data?.error || "";
} catch {
text = "";
}
if (!text) {
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
}
msg.textContent = text;
msg.classList.add("form-msg--error");
// Auto-clear after 5s so a stale error doesn't linger past the next
// successful action.
window.setTimeout(() => {
if (msg.textContent === text) {
msg.textContent = "";
msg.classList.remove("form-msg--error");
}
}, 5000);
}
// t-paliad-231 — mailto: selection helpers for the Team tab. The
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
// elsewhere; this is the non-admin / quick-CC variant that opens the
// user's local mail client. Pure client; no server call.
function pruneMailSelectionToVisible(): void {
const visible = new Set<string>();
for (const m of teamMembers) {
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
}
for (const id of Array.from(selectedMailUserIDs)) {
if (!visible.has(id)) selectedMailUserIDs.delete(id);
}
}
function selectedMailRecipients(): BroadcastRecipient[] {
const out: BroadcastRecipient[] = [];
for (const m of teamMembers) {
if (!selectedMailUserIDs.has(m.user_id)) continue;
if (!m.user_email || !m.user_email.trim()) continue;
out.push({
user_id: m.user_id,
email: m.user_email,
display_name: m.user_display_name || m.user_email,
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
role_on_project: m.responsibility || "member",
});
}
return out;
}
function syncMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
if (!btn || !label) return;
const n = selectedMailRecipients().length;
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
if (n === 0) {
btn.disabled = true;
label.textContent = baseLabel;
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
} else {
btn.disabled = false;
label.textContent = `${baseLabel} (${n})`;
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
btn.title = tooltip;
}
}
function syncMasterCheckbox(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
// Only count rows that actually rendered with an enabled checkbox —
// members without an email don't participate.
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"#team-body .team-mail-select:not(:disabled)",
);
const total = checkboxes.length;
let selected = 0;
checkboxes.forEach((cb) => {
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
});
master.disabled = total === 0;
if (total === 0 || selected === 0) {
master.checked = false;
master.indeterminate = false;
} else if (selected === total) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
// wireMailtoMaster is idempotent — registers once via a sentinel data
// attr so re-renders don't stack click handlers.
function wireMailtoMaster(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master || master.dataset.wired === "1") return;
master.dataset.wired = "1";
master.addEventListener("change", () => {
const turnOn = master.checked;
document
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
.forEach((cb) => {
const id = cb.dataset.userId!;
if (turnOn) selectedMailUserIDs.add(id);
else selectedMailUserIDs.delete(id);
cb.checked = turnOn;
});
syncMailtoButton();
syncMasterCheckbox();
});
}
function wireMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
if (!btn || btn.dataset.wired === "1") return;
btn.dataset.wired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const recipients = selectedMailRecipients();
if (recipients.length === 0) return;
window.location.href = buildMailtoHref(recipients);
});
}
function initTeamForm(id: string) {

View File

@@ -6,6 +6,7 @@ import {
wireTypeChange,
showFieldsForType,
readPayload,
populateProceedingTypeSelect,
} from "./project-form";
// /projects/new client. Posts v2 CreateProjectInput shape using the shared
@@ -106,5 +107,8 @@ document.addEventListener("DOMContentLoaded", async () => {
await loadParentCandidates();
initParentPicker();
await applyParentFromQueryString();
// Fire-and-forget — the picker is hidden until type=case, so no need
// to block initial render on the fetch.
void populateProceedingTypeSelect();
submitForm();
});

View File

@@ -0,0 +1,87 @@
// rule-label — canonical display contract for deadline rules.
//
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
// invented its own pattern: sometimes citation-only, sometimes name-only,
// sometimes "code — name". m flagged this on the first submissions in a
// proceeding sequence where the inconsistency was most visible.
//
// Canonical pattern: **Name primary, Citation muted secondary**.
// Text: "Notice of Appeal · UPC.RoP.220.1"
// HTML: <span class="rule-label-name">Notice of Appeal</span>
// <span class="rule-label-sep"> · </span>
// <span class="rule-label-cite">UPC.RoP.220.1</span>
//
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
// so list/detail surfaces can render both shapes uniformly.
import { getLang, t } from "./i18n";
export interface RuleLike {
name: string;
name_en?: string | null;
// The catalog carries multiple citation fields depending on which
// surface populated it. Order of preference: legal_source > rule_code
// > code. All three are accepted so callers don't have to normalise.
rule_code?: string | null;
code?: string | null;
legal_source?: string | null;
}
// formatRuleLabel returns the canonical plain-text label.
// Falls back gracefully when either side is missing.
export function formatRuleLabel(r: RuleLike): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) return `${name} · ${cite}`;
return name || cite || "";
}
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
// styling. The caller passes the HTML-escape helper so we don't pull a
// dependency on a specific esc() module — every surface already has one.
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) {
return (
`<span class="rule-label-name">${esc(name)}</span>` +
`<span class="rule-label-sep"> · </span>` +
`<span class="rule-label-cite">${esc(cite)}</span>`
);
}
return esc(name || cite || "");
}
// ruleCitation returns the best-available citation string for a rule.
// Exported so callers that need the bare code (e.g. CalDAV exports,
// inline data attributes) can pull it without going through the label
// formatter.
export function ruleCitation(r: RuleLike): string {
return r.legal_source || r.rule_code || r.code || "";
}
// formatCustomRuleLabelHTML — render a free-text custom rule label with
// a "Custom" badge slot. Used by surfaces that may display either a
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
// the text is empty so callers can fall through to "—".
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return (
`<span class="rule-label-name">${esc(trimmed)}</span>` +
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
);
}
// formatCustomRuleLabel — plain-text equivalent of the above.
export function formatCustomRuleLabel(text: string | null | undefined): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return `${trimmed} · ${badge}`;
}

View File

@@ -51,8 +51,8 @@ interface SyncLogEntry {
duration_ms?: number;
}
type TabName = "profil" | "benachrichtigungen" | "caldav";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
const DEFAULT_TAB: TabName = "profil";
let me: Me | null = null;
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
if (tab === "profil") void loadProfilTab();
else if (tab === "benachrichtigungen") void loadPrefsTab();
else if (tab === "caldav") void loadCalDAVTab();
else if (tab === "export") void loadExportTab();
}
}
@@ -411,6 +412,11 @@ async function loadCalDAVTab() {
fillCalDAVForm();
renderCalDAVStatus();
await loadCalDAVLog();
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
// project picker for scope=project; runs in parallel with the binding
// list fetch.
void loadBindingProjects();
await loadBindings();
}
async function loadCalDAVConfig(): Promise<boolean> {
@@ -596,6 +602,415 @@ async function deleteCalDAVConfig() {
}
}
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
interface UserCalendarBinding {
id: string;
user_id: string;
calendar_path: string;
display_name: string;
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
scope_id?: string | null;
include_personal: boolean;
enabled: boolean;
last_sync_at?: string | null;
last_sync_error?: string | null;
}
interface DiscoveredCalendar {
href: string;
display_name: string;
supported_components?: string[];
}
interface ProjectListItem {
id: string;
reference?: string;
title?: string;
type?: string;
}
let bindings: UserCalendarBinding[] = [];
let discoveredCalendars: DiscoveredCalendar[] = [];
let bindingProjects: ProjectListItem[] = [];
let editingBindingID: string | null = null;
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
// true = MKCALENDAR supported (show "Create new calendar" radio),
// false = degrade UX (hide radio, surface bilingual notice).
let supportsMKCalendar: boolean | null = null;
async function loadBindings(): Promise<void> {
const section = document.getElementById("caldav-bindings-section");
if (!section) return;
try {
const resp = await fetch("/api/caldav-bindings");
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
if (!resp.ok) return;
bindings = (await resp.json()) as UserCalendarBinding[];
section.style.display = "";
renderBindingsList();
} catch {
/* non-fatal */
}
}
function renderBindingsList(): void {
const list = document.getElementById("caldav-bindings-list")!;
const empty = document.getElementById("caldav-bindings-empty")!;
if (!bindings.length) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.innerHTML = bindings.map(renderBindingCard).join("");
// Wire per-card buttons.
for (const b of bindings) {
const card = document.getElementById(`caldav-binding-card-${b.id}`);
if (!card) continue;
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
}
}
function renderBindingCard(b: UserCalendarBinding): string {
const label = b.display_name || b.calendar_path;
const scope = scopeLabel(b);
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
<div class="caldav-binding-card-row">
<div class="caldav-binding-card-title">
<strong>${esc(label)}</strong>
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
</div>
<label class="caldav-toggle-label">
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
</label>
</div>
<div class="caldav-binding-card-row caldav-binding-card-meta">
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
</div>
<div class="caldav-binding-card-actions">
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
</div>
</div>`;
}
function scopeLabel(b: UserCalendarBinding): string {
switch (b.scope_kind) {
case "all_visible":
return t("caldav.bindings.scope.all_visible");
case "personal_only":
return t("caldav.bindings.scope.personal_only");
case "project": {
const p = bindingProjects.find((p) => p.id === b.scope_id);
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
return `${t("caldav.bindings.scope.project")}: ${name}`;
}
default:
return b.scope_kind;
}
}
async function loadBindingProjects(): Promise<void> {
if (bindingProjects.length) return;
try {
const resp = await fetch("/api/projects");
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
} catch {
/* ignore */
}
}
async function loadDiscoveredCalendars(): Promise<void> {
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
try {
const resp = await fetch("/api/caldav-discover");
if (!resp.ok) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
return;
}
const data = (await resp.json()) as {
calendars: DiscoveredCalendar[];
supports_mkcalendar?: boolean | null;
};
discoveredCalendars = data.calendars || [];
supportsMKCalendar = data.supports_mkcalendar ?? null;
if (!discoveredCalendars.length) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
} else {
sel.innerHTML = discoveredCalendars
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
.join("");
}
syncBindingSourceModeUI();
} catch {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
}
}
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
// radio + the Google-degrade notice based on the cached
// supports_mkcalendar capability. Also flips the visible input
// (dropdown vs URL text box) to match the currently selected mode.
function syncBindingSourceModeUI(): void {
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
const degrade = document.getElementById("caldav-binding-degrade-notice");
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
// If supports_mkcalendar flipped to false while "create" was selected,
// fall back to "existing" so the user isn't staring at a hidden radio.
if (supportsMKCalendar !== true) {
const createRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="create"]',
) as HTMLInputElement | null;
if (createRadio?.checked) {
const existing = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existing) existing.checked = true;
}
}
const mode = currentBindingSourceMode();
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
sel.style.display = mode === "existing" ? "" : "none";
customInput.style.display = mode === "custom" ? "" : "none";
}
function currentBindingSourceMode(): "existing" | "create" | "custom" {
const checked = document.querySelector(
'input[name="caldav-binding-source-mode"]:checked',
) as HTMLInputElement | null;
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
}
function openBindingModal(b: UserCalendarBinding | null) {
editingBindingID = b ? b.id : null;
const modal = document.getElementById("caldav-binding-modal")!;
const title = document.getElementById("caldav-binding-modal-title")!;
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
const sourceField = document.getElementById("caldav-binding-source-field")!;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
if (b) {
title.textContent = t("caldav.bindings.modal.edit_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
sourceField.style.display = "none";
nameInput.value = b.display_name;
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
if (radio) radio.checked = true;
} else {
title.textContent = t("caldav.bindings.modal.add_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
sourceField.style.display = "";
// Reset the 3-way source-mode radio to "existing" (most common path).
const existingRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existingRadio) existingRadio.checked = true;
customInput.value = "";
nameInput.value = "";
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
radio.checked = true;
void loadDiscoveredCalendars();
}
// Project picker — populate options when project scope is picked.
projectSel.innerHTML = bindingProjects
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
.join("");
if (b && b.scope_kind === "project" && b.scope_id) {
projectSel.value = b.scope_id;
projectSel.disabled = false;
}
syncBindingScopeUI();
syncBindingSourceModeUI();
modal.style.display = "flex";
}
function closeBindingModal() {
document.getElementById("caldav-binding-modal")!.style.display = "none";
editingBindingID = null;
}
function syncBindingScopeUI(): void {
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
projectSel.disabled = scope !== "project";
}
async function submitBindingModal(ev: Event): Promise<void> {
ev.preventDefault();
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
if (!scope) {
msg.textContent = t("caldav.bindings.error.scope");
msg.className = "form-msg form-msg-error";
return;
}
if (scope === "project" && !projectSel.value) {
msg.textContent = t("caldav.bindings.error.scope_project");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
if (editingBindingID) {
const patchPayload: Record<string, unknown> = {
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") patchPayload.scope_id = projectSel.value;
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
const mode = currentBindingSourceMode();
if (mode === "create") {
// Slice 2c MKCALENDAR path.
const displayName = nameInput.value.trim();
if (!displayName) {
msg.textContent = t("caldav.bindings.error.create_name_required");
msg.className = "form-msg form-msg-error";
return;
}
const createPayload: Record<string, unknown> = {
display_name: displayName,
scope_kind: scope,
};
if (scope === "project") createPayload.scope_id = projectSel.value;
const resp = await fetch("/api/caldav-mkcalendar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(createPayload),
});
if (resp.status === 501) {
// Race: probe flipped to false between modal-open and submit.
// Re-sync the UI and surface a helpful message.
supportsMKCalendar = false;
syncBindingSourceModeUI();
msg.textContent = t("caldav.bindings.error.create_unsupported");
msg.className = "form-msg form-msg-error";
return;
}
if (resp.status === 409) {
msg.textContent = t("caldav.bindings.error.create_name_taken");
msg.className = "form-msg form-msg-error";
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
// existing | custom — POST /api/caldav-bindings with the path.
const path = mode === "custom" ? customInput.value.trim() : sel.value;
if (!path) {
msg.textContent = t("caldav.bindings.error.path");
msg.className = "form-msg form-msg-error";
return;
}
const postPayload: Record<string, unknown> = {
calendar_path: path,
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") postPayload.scope_id = projectSel.value;
if (!postPayload.display_name && mode === "existing") {
const opt = sel.options[sel.selectedIndex];
postPayload.display_name = opt ? opt.text : "";
}
const resp = await fetch("/api/caldav-bindings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(postPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
}
}
closeBindingModal();
await loadBindings();
} catch {
msg.textContent = t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
try {
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
alert(t("caldav.bindings.delete.failed"));
return;
}
await loadBindings();
} catch {
alert(t("caldav.bindings.delete.failed"));
}
}
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
try {
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (resp.ok) {
b.enabled = enabled;
}
} catch {
/* non-fatal */
}
}
// --- "Meine Partner Units" card on the profile tab -------------------------
//
// Read-only summary of the current user's structural memberships. Membership
@@ -662,6 +1077,48 @@ async function renderMyPartnerUnits(): Promise<void> {
}
}
// --- Export tab (t-paliad-214 Slice 1) -------------------------------------
// Personal data export. One button; on click hits GET /api/me/export and the
// browser handles the download via Content-Disposition. We use an anchor +
// hidden iframe pattern so any non-200 response can surface inline instead
// of silently triggering a save dialog with an error-html body.
async function loadExportTab(): Promise<void> {
// Nothing to fetch on render; the tab is static text + button. Wired in
// the DOMContentLoaded handler.
}
function runExport(): void {
const msg = document.getElementById("export-msg");
const btn = document.getElementById("export-btn") as HTMLButtonElement | null;
if (msg) msg.textContent = "";
if (btn) btn.disabled = true;
// Trigger a navigation to the endpoint. The server sets
// Content-Disposition: attachment which the browser respects.
// We use a transient <a download> so the click goes through the
// normal download path even on browsers that try to render text/json.
const a = document.createElement("a");
a.href = "/api/me/export";
// download="" tells the browser to keep the server-provided filename
// when one is set via Content-Disposition.
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Re-enable after a short timeout so users can re-trigger if needed.
// We don't try to detect download completion — there's no portable
// browser API for it.
if (btn) {
setTimeout(() => {
btn.disabled = false;
if (msg)
msg.textContent =
t("einstellungen.export.started") ||
"Download gestartet. Falls nichts passiert, prüfen Sie Ihren Browser-Downloadordner.";
}, 500);
}
}
// --- Init -------------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
@@ -675,6 +1132,20 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
el.addEventListener("change", syncBindingSourceModeUI);
});
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
el.addEventListener("change", syncBindingScopeUI);
});
const exportBtn = document.getElementById("export-btn");
if (exportBtn) exportBtn.addEventListener("click", runExport);
onLangChange(() => {
if (loadedTabs.has("profil")) renderOfficeOptions();
if (loadedTabs.has("caldav")) {

View File

@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 480;
const SIDEBAR_WIDTH_DEFAULT = 240;
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
// on every scroll event, restored on initSidebar() so a full-page nav
// click doesn't bounce the user back to the top of a long sidebar
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
// starts that tab fresh at the top, which matches user expectation.
const SCROLL_KEY = "paliad.sidebar.scroll";
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
// BottomNav menu slot can call it without duplicating the open/close
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
}
// readStoredScroll returns the persisted scrollTop or 0 when missing /
// malformed. Bounds are checked at apply time against the actual
// scrollHeight, so a stale value pointing past the current scroll range
// is harmless (the browser clamps assignments to [0, max]).
function readStoredScroll(): number {
const raw = sessionStorage.getItem(SCROLL_KEY);
if (raw === null) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function applySidebarScroll(nav: HTMLElement, px: number): void {
if (px <= 0) return;
nav.scrollTop = px;
}
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
// first load and removes the stale entry. Drop this fallback once the rename
// grace period is over.
@@ -79,6 +103,7 @@ export function initSidebar() {
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
initSidebarScrollRestore(sidebar);
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
});
}
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
// sessionStorage so the user's scroll position survives a full-page
// navigation (every sidebar link click is a real reload — see m/paliad#85).
// Restore is synchronous on init so the first paint is already at the
// right offset; the passive scroll listener persists subsequent moves.
// reapplySidebarScroll() exists so callers that mutate sidebar content
// async (initUserViewsGroup appending /api/user-views into the Ansichten
// group) can nudge the scroll back to where it was after the layout shift.
function initSidebarScrollRestore(sidebar: HTMLElement): void {
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
nav.addEventListener("scroll", () => {
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
}, { passive: true });
}
function reapplySidebarScroll(): void {
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// The synchronous restore in initSidebarScrollRestore() happened
// before these views were appended, so a saved scrollTop that
// pointed below the Ansichten group would now sit on the wrong
// row. Re-apply once the layout has stabilised.
reapplySidebarScroll();
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-240 — global Schriftsätze drafts index. Loads
// /api/user/submission-drafts and renders one entity-table row per
// draft. Row click → editor at /projects/{project_id}/submissions/
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
// contract: a table whose rows look clickable must navigate on click;
// inner links / buttons keep their own affordance.
interface DraftRow {
id: string;
project_id: string | null;
project_title: string | null;
project_reference?: string | null;
submission_code: string;
name: string;
last_exported_at?: string | null;
updated_at: string;
created_at: string;
}
let drafts: DraftRow[] = [];
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const isEN = getLang() === "en";
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
async function load(): Promise<void> {
const loading = document.getElementById("submissions-index-loading")!;
const empty = document.getElementById("submissions-index-empty")!;
const error = document.getElementById("submissions-index-error")!;
const wrap = document.getElementById("submissions-index-tablewrap")!;
try {
const resp = await fetch("/api/user/submission-drafts");
if (!resp.ok) {
loading.style.display = "none";
error.style.display = "";
return;
}
const data = await resp.json();
drafts = (data.drafts ?? []) as DraftRow[];
} catch {
loading.style.display = "none";
error.style.display = "";
return;
}
loading.style.display = "none";
if (drafts.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
render();
}
function render(): void {
const body = document.getElementById("submissions-index-body")!;
const isEN = getLang() === "en";
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
body.innerHTML = drafts.map((d) => {
const projectCell = (() => {
if (!d.project_id) {
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
}
const title = esc(d.project_title ?? "");
if (d.project_reference) {
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
}
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
})();
const href = d.project_id
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
: `/submissions/draft/${esc(d.id)}`;
return `<tr class="submissions-index-row" data-href="${esc(href)}">
<td>${projectCell}</td>
<td>${esc(d.submission_code)}</td>
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
<td>${esc(fmtDate(d.updated_at))}</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
const href = row.dataset.href!;
row.addEventListener("click", (e) => {
// Inner <a> elements (project link, draft name) handle their own
// navigation — let the browser dispatch them.
if ((e.target as HTMLElement).closest("a, button")) return;
window.location.href = href;
});
});
// Keep tsc happy for the imported `t` (used only via data-i18n on
// static markup — keep the import so future dynamic strings can hook
// in without re-importing).
void t;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => {
if (drafts.length > 0) render();
});
void load();
});

View File

@@ -0,0 +1,368 @@
import { initI18n, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-243 — client for /submissions/new. Fetches the
// cross-proceeding submission catalog, groups it by proceeding, filters
// by text + chip, and offers two start paths per row: with project
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
interface CatalogEntry {
submission_code: string;
name: string;
name_en: string;
event_type?: string;
primary_party?: string;
legal_source?: string;
has_template: boolean;
proceeding_code: string;
proceeding_name: string;
proceeding_name_en: string;
}
interface CatalogResponse {
entries: CatalogEntry[];
}
interface ProjectRow {
id: string;
title: string;
reference?: string | null;
}
interface State {
entries: CatalogEntry[];
activeProceeding: string | null; // null = all
searchTerm: string;
pickerForCode: string | null;
}
const state: State = {
entries: [],
activeProceeding: null,
searchTerm: "",
pickerForCode: null,
};
function isEN(): boolean {
return getLang() === "en";
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function partyLabel(role: string | undefined): string {
switch ((role ?? "").toLowerCase()) {
case "claimant": return isEN() ? "Claimant" : "Klägerin";
case "defendant": return isEN() ? "Defendant" : "Beklagte";
case "both": return isEN() ? "Both" : "Beide";
case "court": return isEN() ? "Court" : "Gericht";
default: return "";
}
}
async function loadCatalog(): Promise<void> {
const loading = document.getElementById("submissions-new-loading")!;
const error = document.getElementById("submissions-new-error")!;
const wrap = document.getElementById("submissions-new-tablewrap")!;
try {
const resp = await fetch("/api/submissions/catalog");
if (!resp.ok) {
loading.style.display = "none";
error.style.display = "";
return;
}
const data = (await resp.json()) as CatalogResponse;
state.entries = data.entries ?? [];
} catch {
loading.style.display = "none";
error.style.display = "";
return;
}
loading.style.display = "none";
wrap.style.display = "";
renderChips();
renderTable();
}
function renderChips(): void {
const host = document.getElementById("submissions-new-proceeding-chips");
if (!host) return;
const seen = new Map<string, string>();
for (const e of state.entries) {
if (!seen.has(e.proceeding_code)) {
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
}
}
const chips: string[] = [];
const allLabel = isEN() ? "All" : "Alle";
const allActive = state.activeProceeding === null;
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
for (const [code, name] of seen) {
const active = state.activeProceeding === code;
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
}
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code ?? "";
state.activeProceeding = code === "" ? null : code;
renderChips();
renderTable();
});
});
}
function filtered(): CatalogEntry[] {
const term = state.searchTerm.trim().toLowerCase();
return state.entries.filter((e) => {
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
return false;
}
if (term === "") return true;
const name = isEN() && e.name_en ? e.name_en : e.name;
const hay = [
name,
e.submission_code,
e.legal_source ?? "",
e.proceeding_code,
e.proceeding_name,
e.proceeding_name_en,
].join(" ").toLowerCase();
return hay.includes(term);
});
}
function renderTable(): void {
const body = document.getElementById("submissions-new-body");
const empty = document.getElementById("submissions-new-empty");
const wrap = document.getElementById("submissions-new-tablewrap");
if (!body || !empty || !wrap) return;
const rows = filtered();
if (rows.length === 0) {
wrap.style.display = "none";
empty.style.display = "";
return;
}
wrap.style.display = "";
empty.style.display = "none";
// Group by proceeding.
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
for (const e of rows) {
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
const bucket = groups.get(e.proceeding_code);
if (bucket) {
bucket.entries.push(e);
} else {
groups.set(e.proceeding_code, { name: gname, entries: [e] });
}
}
const colspan = 4;
const html: string[] = [];
for (const [code, group] of groups) {
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
for (const entry of group.entries) {
html.push(renderRow(entry));
}
}
body.innerHTML = html.join("");
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code;
if (code) void startDraft(code, null);
});
});
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code;
if (code) openProjectPicker(code);
});
});
}
function renderRow(entry: CatalogEntry): string {
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
const source = entry.legal_source ?? "";
const templateBadge = entry.has_template
? ""
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
return `<tr class="submission-row">
<td>
<span class="submission-name">${esc(name)}</span>
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
</td>
<td>${esc(partyLabel(entry.primary_party))}</td>
<td>${esc(source)}</td>
<td class="submission-action-cell">
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
</td>
</tr>`;
}
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
try {
const resp = await fetch("/api/submission-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
});
if (!resp.ok) {
let detail = "";
try {
const data = (await resp.json()) as { error?: string };
detail = data.error ?? "";
} catch { /* ignore */ }
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
return;
}
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
const id = view.draft.id;
const pid = view.draft.project_id;
const code = view.draft.submission_code;
if (pid) {
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
} else {
window.location.href = `/submissions/draft/${id}`;
}
} catch (err) {
console.error("submissions-new createDraft:", err);
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
}
}
// ─────────────────────────────────────────────────────────────────────
// Project picker modal
// ─────────────────────────────────────────────────────────────────────
let pickerProjects: ProjectRow[] = [];
let pickerLoaded = false;
function openProjectPicker(submissionCode: string): void {
state.pickerForCode = submissionCode;
const modal = document.getElementById("submissions-new-project-modal");
if (modal) modal.style.display = "";
if (!pickerLoaded) {
void loadPickerProjects();
} else {
renderPickerList();
}
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
if (searchInput) {
searchInput.value = "";
setTimeout(() => searchInput.focus(), 50);
}
}
function closeProjectPicker(): void {
state.pickerForCode = null;
const modal = document.getElementById("submissions-new-project-modal");
if (modal) modal.style.display = "none";
}
async function loadPickerProjects(): Promise<void> {
const loadingEl = document.getElementById("submissions-new-project-loading");
if (loadingEl) loadingEl.style.display = "";
try {
const resp = await fetch("/api/projects?status=active");
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
const rows = (await resp.json()) as ProjectRow[];
pickerProjects = rows ?? [];
pickerLoaded = true;
} catch (err) {
console.error("submissions-new loadPickerProjects:", err);
pickerProjects = [];
} finally {
if (loadingEl) loadingEl.style.display = "none";
}
renderPickerList();
}
function renderPickerList(): void {
const list = document.getElementById("submissions-new-project-list");
const empty = document.getElementById("submissions-new-project-empty");
if (!list || !empty) return;
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
const term = (searchInput?.value ?? "").trim().toLowerCase();
const matches = pickerProjects.filter((p) => {
if (term === "") return true;
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
return hay.includes(term);
}).slice(0, 50);
if (matches.length === 0) {
list.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = matches.map((p) => {
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
li.addEventListener("click", () => {
const pid = li.dataset.id;
const code = state.pickerForCode;
if (pid && code) {
closeProjectPicker();
void startDraft(code, pid);
}
});
});
}
// ─────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────
function wireToolbar(): void {
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
if (search) {
search.addEventListener("input", () => {
state.searchTerm = search.value;
renderTable();
});
}
const closeBtn = document.getElementById("submissions-new-project-modal-close");
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
const modal = document.getElementById("submissions-new-project-modal");
if (modal) {
modal.addEventListener("click", (e) => {
if (e.target === modal) closeProjectPicker();
});
}
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
if (pickerSearch) {
pickerSearch.addEventListener("input", () => renderPickerList());
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireToolbar();
void loadCatalog();
});

View File

@@ -0,0 +1,269 @@
// Submissions panel — fetches the full submission catalog across every
// proceeding and renders it grouped by proceeding, with the project's
// own proceeding pinned at the top.
//
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
// broadened it to the catalog: from any project a lawyer can pick a
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
// handles missing variables gracefully via the [KEIN WERT: …] marker,
// so cross-proceeding picks still render cleanly.
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
interface SubmissionEntry {
submission_code: string;
name: string;
name_en: string;
event_type?: string;
primary_party?: string;
legal_source?: string;
has_template: boolean;
proceeding_code: string;
proceeding_name: string;
proceeding_name_en: string;
}
interface SubmissionListResponse {
project_id: string;
proceeding_type_id?: number;
project_proceeding_code?: string;
entries: SubmissionEntry[];
}
// Module state — set once per page load when the user first opens the
// tab. Subsequent activations re-use the cached result so the lawyer
// doesn't pay for repeat list calls flipping between tabs.
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
let loading = false;
/**
* Load + render the submissions panel for the given project.
*
* Idempotent: safe to call on every tab activation. The second call
* paints from cache instantly; the first call shows a loading state
* until the list response arrives.
*/
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
if (loading) return;
if (cached && cached.projectID === projectID) {
render(cached.data);
return;
}
loading = true;
try {
const resp = await fetch(`/api/projects/${projectID}/submissions`);
if (!resp.ok) {
renderError();
return;
}
const data = (await resp.json()) as SubmissionListResponse;
cached = { projectID, data };
render(data);
} catch {
renderError();
} finally {
loading = false;
}
}
function render(data: SubmissionListResponse): void {
const empty = document.getElementById("project-submissions-empty");
const noProc = document.getElementById("project-submissions-no-proceeding");
const wrap = document.getElementById("project-submissions-tablewrap");
const body = document.getElementById("project-submissions-body");
if (!empty || !noProc || !wrap || !body) return;
// t-paliad-242: the catalog is shown to every project regardless of
// whether a proceeding is bound — the no-proceeding hint stays as a
// soft nudge above the table, but no longer hides the catalog.
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
? ""
: "none";
if (data.entries.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = document.documentElement.lang === "en";
// Group entries by proceeding_code. Build a stable group order:
// project's own proceeding first (when present), then alphabetical
// by proceeding_code for the rest.
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
for (const entry of data.entries) {
const key = entry.proceeding_code || "";
const groupName = isEN && entry.proceeding_name_en
? entry.proceeding_name_en
: entry.proceeding_name;
const bucket = groups.get(key);
if (bucket) {
bucket.entries.push(entry);
} else {
groups.set(key, { name: groupName, entries: [entry] });
}
}
const ownCode = data.project_proceeding_code ?? "";
const orderedCodes: string[] = [];
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
for (const code of Array.from(groups.keys()).sort()) {
if (code !== ownCode) orderedCodes.push(code);
}
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
const colspan = 4;
const html: string[] = [];
for (const code of orderedCodes) {
const group = groups.get(code);
if (!group) continue;
const isOwn = code === ownCode;
const label = group.name + (isOwn ? ownSuffix : "");
const headerClass = isOwn
? "entity-table-group-header entity-table-group-header--own"
: "entity-table-group-header";
html.push(`<tr class="${headerClass}">`
+ `<th colspan="${colspan}" scope="colgroup">`
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
+ `</th></tr>`);
for (const entry of group.entries) {
html.push(renderRow(entry, data.project_id, isEN));
}
}
body.innerHTML = html.join("");
// Wire button clicks. One handler per render to avoid stale closures
// from the previous render's data.
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
void onGenerateClick(btn);
});
});
}
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
const name = isEN && entry.name_en ? entry.name_en : entry.name;
const party = formatParty(entry.primary_party, isEN);
const source = entry.legal_source ?? "";
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
const templateBadge = entry.has_template
? ""
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(projectID)}"
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
const action = `${editBtn} ${generateBtn}`;
return `<tr class="submission-row">
<td>
<span class="submission-name">${escapeHtml(name)}</span>
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
</td>
<td>${escapeHtml(party)}</td>
<td>${escapeHtml(source)}</td>
<td class="submission-action-cell">${action}</td>
</tr>`;
}
function renderError(): void {
const empty = document.getElementById("project-submissions-empty");
const noProc = document.getElementById("project-submissions-no-proceeding");
const wrap = document.getElementById("project-submissions-tablewrap");
if (!empty || !noProc || !wrap) return;
noProc.style.display = "none";
wrap.style.display = "none";
empty.style.display = "";
empty.textContent = document.documentElement.lang === "en"
? "Failed to load submissions list."
: "Schriftsatzliste konnte nicht geladen werden.";
}
function formatParty(role: string | undefined, isEN: boolean): string {
switch ((role ?? "").toLowerCase()) {
case "claimant": return isEN ? "Claimant" : "Klägerin";
case "defendant": return isEN ? "Defendant" : "Beklagte";
case "both": return isEN ? "Both" : "Beide";
case "court": return isEN ? "Court" : "Gericht";
default: return "";
}
}
// onGenerateClick triggers a download. Disables the button while the
// request is in flight to prevent double-submits and surfaces an
// inline error on failure.
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
const code = btn.dataset.code;
const projectID = btn.dataset.project;
if (!code || !projectID) return;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
try {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
const resp = await fetch(url, { method: "POST" });
if (!resp.ok) {
let detail = "";
try {
const data = await resp.json() as { error?: string };
detail = data.error ?? "";
} catch {
// fallthrough
}
alert(
(document.documentElement.lang === "en"
? "Generation failed."
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
);
return;
}
const blob = await resp.blob();
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
?? `${code}.docx`;
triggerDownload(blob, filename);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
// parseFilename pulls the filename out of a Content-Disposition
// header. Supports both unquoted and quoted forms.
function parseFilename(header: string): string | null {
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
return m ? m[1] : null;
}
// triggerDownload creates an <a> with an object URL, clicks it, and
// revokes the URL. Standard browser-side download pattern.
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke on next tick so the click actually triggers the download
// before the URL is gone.
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View File

@@ -1,6 +1,6 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface User {
id: string;
@@ -77,6 +77,25 @@ let activeRole = "all";
let activeProjectIDs: Set<string> = new Set();
let searchQuery = "";
// t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing
// filter pills. When selection.size > 0 the sticky footer takes over the
// broadcast action and targets only the explicit subset; with empty
// selection the existing top-bar broadcast button still targets the whole
// filter result (purely additive).
//
// Invariant: selection only ever holds user_ids that match the current
// filter set — render() prunes drop-outs every cycle. This keeps the
// counter honest and avoids "hidden-but-selected" debug nightmares.
const selectedUserIDs: Set<string> = new Set();
// For Shift-click range select — the user_id of the most recent toggle
// in the currently-rendered list order. Reset to null on any filter
// change so the range never spans an invisible row.
let lastToggledUserID: string | null = null;
// Snapshot of the rendered user-IDs in DOM order, refreshed on each render.
// Drives Shift-click range expansion and the master-checkbox "select all
// visible" action.
let renderedUserIDs: string[] = [];
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
const ICON_PIN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
@@ -322,28 +341,64 @@ function buildProjectFilter() {
function buildBroadcastButton() {
const wrap = document.getElementById("team-broadcast-wrap");
if (!wrap) return;
if (!canBroadcast()) {
// Wait for /api/me so the affordance never flickers between admin (form)
// and non-admin (mailto) on initial paint. canBroadcast() already returns
// false when me is null but we'd briefly render the mailto anchor before
// the admin form, which is visually jarring.
if (!me) {
wrap.innerHTML = "";
wrap.style.display = "none";
return;
}
wrap.style.display = "";
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
if (canBroadcast()) {
// Admin path (global_admin or project-lead-of-selected): opens the
// in-app compose modal that POSTs to /api/team/broadcast.
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${label} ${counter}
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
} else {
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
// the current filter set. href is refreshed in updateBroadcastButton()
// whenever filters change so the link always reflects what's visible.
wrap.innerHTML = `
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
${label} ${counter}
</a>
`;
}
}
function updateBroadcastButton() {
buildBroadcastButton();
const recipients = displayedRecipients();
const countEl = document.getElementById("team-broadcast-count");
if (countEl) {
const n = displayedRecipients().length;
countEl.textContent = String(n);
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = n === 0;
if (countEl) countEl.textContent = String(recipients.length);
const btn = document.getElementById("team-broadcast-btn");
if (!btn) return;
if (btn.tagName === "BUTTON") {
(btn as HTMLButtonElement).disabled = recipients.length === 0;
} else {
// Anchor (non-admin): regenerate the mailto: href against the current
// visible recipients, and disable the affordance when empty so a click
// doesn't open an empty mail composer.
const a = btn as HTMLAnchorElement;
if (recipients.length === 0) {
a.setAttribute("href", "mailto:");
a.setAttribute("aria-disabled", "true");
a.style.pointerEvents = "none";
a.style.opacity = "0.5";
} else {
a.setAttribute("href", buildMailtoHref(recipients));
a.removeAttribute("aria-disabled");
a.style.pointerEvents = "";
a.style.opacity = "";
}
}
}
@@ -403,8 +458,17 @@ function memberAsUser(m: DepartmentMember): User | undefined {
function renderUserCard(u: User): string {
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
const jobTitle = (u.job_title ?? "").trim();
// t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a
// click on the checkbox cell triggers the toggle; the rest of the card
// (links, email, etc.) keeps its native behaviour. Selection state
// mirrored to data-selected so the CSS can highlight the card.
const selected = selectedUserIDs.has(u.id);
const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen";
return `
<article class="team-card">
<article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
<label class="team-card-select" title="${escAttr(selectAria)}">
<input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
</label>
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
<div class="team-card-body">
<div class="team-card-name">${esc(u.display_name)}</div>
@@ -418,6 +482,13 @@ function renderUserCard(u: User): string {
</article>`;
}
// escAttr is the attribute-context counterpart of esc. Used in title=""
// + aria-label="" where esc()'s div-textContent trick is fine but
// double-quote-escaping is the bit we actually need.
function escAttr(s: string): string {
return esc(s).replace(/"/g, "&quot;");
}
function renderGroupByOffice(filtered: User[]): string {
const present = presentOffices();
const sections = present
@@ -505,12 +576,22 @@ function render() {
const filtered = users.filter(
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
);
// t-paliad-223 (#53): prune drop-outs from the explicit selection. The
// invariant is "selection ⊆ visible"; carrying invisible IDs forward
// would create stale "12 selected" counters that don't match what the
// user sees on screen.
pruneSelectionToVisible(new Set(filtered.map((u) => u.id)));
count.textContent = `${filtered.length} / ${users.length}`;
updateBroadcastButton();
if (filtered.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
renderedUserIDs = [];
syncMasterCheckbox();
renderSelectionFooter();
return;
}
empty.style.display = "none";
@@ -518,6 +599,233 @@ function render() {
list.innerHTML = groupBy === "office"
? renderGroupByOffice(filtered)
: renderGroupByDepartment(filtered);
// Refresh the DOM-order snapshot Shift-click + master-checkbox rely on.
renderedUserIDs = Array.from(
list.querySelectorAll<HTMLElement>(".team-card"),
).map((el) => el.dataset.userId || "");
wireSelectionCheckboxes(list);
syncMasterCheckbox();
renderSelectionFooter();
}
// pruneSelectionToVisible drops user_ids from selection that no longer
// match the visible set. Always called from render() before painting so
// the per-row "checked" state and the footer counter stay in sync.
function pruneSelectionToVisible(visible: Set<string>): void {
const removed: string[] = [];
for (const id of selectedUserIDs) {
if (!visible.has(id)) removed.push(id);
}
for (const id of removed) selectedUserIDs.delete(id);
if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) {
lastToggledUserID = null;
}
}
// wireSelectionCheckboxes attaches click handlers to every per-row
// checkbox in the freshly-rendered list. Each click toggles the
// underlying selection Set + the data-selected attribute on the card.
// Shift-click extends a contiguous range from the previous toggle to
// the current row using renderedUserIDs as the order reference.
function wireSelectionCheckboxes(list: HTMLElement): void {
list.querySelectorAll<HTMLInputElement>(".team-card-select-input").forEach((cb) => {
cb.addEventListener("click", (ev) => {
const id = cb.dataset.userId || "";
if (!id) return;
const checked = cb.checked;
if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) {
applyRangeSelection(lastToggledUserID, id, checked);
} else {
if (checked) selectedUserIDs.add(id);
else selectedUserIDs.delete(id);
}
lastToggledUserID = id;
// Visual + footer refresh without a full re-render (selection
// changes don't affect the filter set; render() is reserved for
// filter/data changes to keep typing in the search box fast).
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
});
});
}
// applyRangeSelection sets selection state for every user between
// (inclusive) startID and endID in renderedUserIDs order. Mode = the
// final state — checked => add to selection, unchecked => remove.
function applyRangeSelection(startID: string, endID: string, mode: boolean): void {
const a = renderedUserIDs.indexOf(startID);
const b = renderedUserIDs.indexOf(endID);
if (a === -1 || b === -1) {
// One of the anchors dropped out of the current visible set; fall
// back to a single-row toggle of the end-id.
if (mode) selectedUserIDs.add(endID);
else selectedUserIDs.delete(endID);
return;
}
const [lo, hi] = a <= b ? [a, b] : [b, a];
for (let i = lo; i <= hi; i++) {
const id = renderedUserIDs[i];
if (mode) selectedUserIDs.add(id);
else selectedUserIDs.delete(id);
}
}
// refreshCardSelectedAttribute syncs every visible card's data-selected
// + checkbox.checked to the canonical Set, without a full re-render.
function refreshCardSelectedAttribute(): void {
const list = document.getElementById("team-list");
if (!list) return;
list.querySelectorAll<HTMLElement>(".team-card").forEach((card) => {
const id = card.dataset.userId || "";
const selected = selectedUserIDs.has(id);
card.dataset.selected = selected ? "true" : "false";
const cb = card.querySelector<HTMLInputElement>(".team-card-select-input");
if (cb) cb.checked = selected;
});
}
// renderSelectionFooter mounts (or hides) the sticky footer that takes
// over the broadcast action when ≥ 1 row is checked. The footer lives
// outside the main content tree so it can be position: fixed without
// fighting any of the existing layout rules.
function renderSelectionFooter(): void {
let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null;
const n = selectedUserIDs.size;
if (n === 0) {
if (footer) footer.style.display = "none";
document.body.classList.remove("team-has-selection");
return;
}
if (!footer) {
footer = document.createElement("div");
footer.id = "team-selection-footer";
footer.className = "team-selection-footer";
document.body.appendChild(footer);
}
const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace(
"{n}",
String(n),
);
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
// compose modal; non-admin gets a native mailto: anchor pre-filled with
// the explicit selection.
const adminPath = canBroadcast();
const sendAction = adminPath
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
footer.innerHTML = `
<span class="team-selection-count">${esc(countLabel)}</span>
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
${esc(t("team.selection.clear") || "Auswahl aufheben")}
</button>
${sendAction}
`;
footer.style.display = "";
document.body.classList.add("team-has-selection");
document.getElementById("team-selection-clear")?.addEventListener("click", () => {
selectedUserIDs.clear();
lastToggledUserID = null;
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
});
if (adminPath) {
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
}
// Anchor path has no click handler — native href open is the action.
}
// selectedRecipients maps the explicit selection Set into the
// BroadcastRecipient shape openBroadcastModal expects. Mirrors the
// role-resolution rules of displayedRecipients() (active project
// filter wins; falls back to first available role).
function selectedRecipients(): BroadcastRecipient[] {
const out: BroadcastRecipient[] = [];
for (const id of selectedUserIDs) {
const u = users.find((u) => u.id === id);
if (!u) continue;
const m = memberships.find((m) => m.user_id === u.id);
let role = "";
if (m) {
if (activeProjectIDs.size > 0) {
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
if (idx >= 0) role = m.roles[idx];
} else if (m.roles.length > 0) {
role = m.roles[0];
}
}
out.push({
user_id: u.id,
email: u.email,
display_name: u.display_name,
first_name: firstName(u.display_name),
role_on_project: role,
});
}
return out;
}
function onBroadcastFromSelection(): void {
const recipients = selectedRecipients();
if (recipients.length === 0) return;
const selectedProjectIDs = Array.from(activeProjectIDs);
// Same scope-resolution as displayedRecipients/onBroadcastClick: pass
// project_id only when exactly one is selected so the server can
// verify lead-ship; multi-project relies on global_admin.
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
const offices = activeOffice === "all" ? [] : [activeOffice];
const roles = activeRole === "all" ? [] : [activeRole];
openBroadcastModal({
recipients,
projectID,
projectIDs: selectedProjectIDs,
offices,
roles,
});
}
// syncMasterCheckbox refreshes the master "select all visible" checkbox
// to one of three states: empty / partial / full. The HTML element lives
// in team.tsx (#team-select-master); when missing (older shells) the
// helper no-ops so the page still works.
function syncMasterCheckbox(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
const visible = renderedUserIDs.length;
if (visible === 0) {
master.checked = false;
master.indeterminate = false;
master.disabled = true;
return;
}
master.disabled = false;
let selectedHere = 0;
for (const id of renderedUserIDs) {
if (selectedUserIDs.has(id)) selectedHere++;
}
master.checked = selectedHere === visible;
master.indeterminate = selectedHere > 0 && selectedHere < visible;
}
function onMasterToggle(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
const checked = master.checked;
for (const id of renderedUserIDs) {
if (checked) selectedUserIDs.add(id);
else selectedUserIDs.delete(id);
}
lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null;
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
}
function initToggle() {
@@ -547,6 +855,8 @@ document.addEventListener("DOMContentLoaded", () => {
initSidebar();
initSearch();
initToggle();
// t-paliad-223 (#53): master checkbox toggles every visible row.
document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle);
onLangChange(() => {
buildOfficeFilters();
buildRoleFilters();

View File

@@ -12,19 +12,137 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
type Side,
calculateDeadlines,
escHtml,
formatDate,
populateCourtPicker,
renderColumnsBody,
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
// view is shareable and survives reload:
// ?side=claimant|defendant → swaps which column owns the user's
// side (proactive vs reactive label).
// Default null = claimant-on-the-left.
// ?appellant=claimant|defendant → collapses party=both rows into the
// appellant's column (no mirror).
// Only meaningful for role-swap
// proceedings (Appeal etc.). Default
// null = legacy mirror behaviour.
let currentSide: Side = null;
let currentAppellant: Side = null;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
// — when set, "both" rows collapse to a single row in the appellant's
// column. For first-instance proceedings (Inf, Rev, …) the selector is
// hidden because there's no appellant axis.
//
// Today: every upc.apl.* family member plus dpma.appeal.* and
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
// Conservative — false negatives just hide a control; false positives
// would show an irrelevant control.
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.merits",
"upc.apl.cost",
"upc.apl.order",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
"dpma.appeal.bpatg",
"dpma.appeal.bgh",
"epa.opp.boa",
]);
function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}
function readSideFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("side");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function readAppellantFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("appellant");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writeSideToURL(s: Side) {
const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side");
else url.searchParams.set("side", s);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
function writeAppellantToURL(a: Side) {
const url = new URL(window.location.href);
if (a === null) url.searchParams.delete("appellant");
else url.searchParams.set("appellant", a);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
// user's chosen date. Cleared whenever the trigger changes (proceeding,
// trigger date, flag toggle) so a fresh calc starts unanchored — same
// semantic as /tools/fristenrechner.
const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// Notes toggle — when off (default), per-rule descriptive notes render
// as a compact ⓘ icon next to the meta line (hover for full text). When
// on, the full notes block expands under each card. Choice persists in
// localStorage so a reload or recalc keeps the user's preference.
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
// Verletzungsklage etc.) once the picker collapses.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
function jurisdictionFor(btn: HTMLButtonElement): string {
const group = btn.closest<HTMLElement>(".proceeding-group");
const forum = group?.dataset.forum || "";
return FORUM_LABEL[forum] || "";
}
function proceedingDisplayName(btn: HTMLButtonElement): string {
const name = btn.querySelector("strong")?.textContent || "";
const jur = jurisdictionFor(btn);
return jur ? `${jur} ${name}` : name;
}
function activeProceedingButton(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
}
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
@@ -46,6 +164,31 @@ function showStep(n: number) {
}
}
// Read the proceeding-specific flag checkboxes and assemble the
// payload the calculator expects. Mirrors fristenrechner.ts so the
// gating semantics stay identical: with_amend on upc.inf.cfi is
// nested under with_ccr (R.30 is only available with a CCR);
// upc.rev.cfi exposes with_amend + with_cci as two independent
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
// call): it's just an always-available optional submission, so it
// has no checkbox.
function readFlags(): string[] {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "upc.inf.cfi") {
if (ccr?.checked) flags.push("with_ccr");
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
}
if (selectedType === "upc.rev.cfi") {
if (revAmend?.checked) flags.push("with_amend");
if (revCci?.checked) flags.push("with_cci");
}
return flags;
}
async function doCalc() {
const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
@@ -58,9 +201,14 @@ async function doCalc() {
? courtPicker.value
: "";
const overrides: Record<string, string> = {};
for (const [code, date] of anchorOverrides) overrides[code] = date;
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
flags: readFlags(),
anchorOverrides: overrides,
courtId,
});
if (seq !== calcSeq) return;
@@ -70,25 +218,90 @@ async function doCalc() {
showStep(3);
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. Precedence:
//
// 1. Server-supplied triggerEventLabel from proceeding_types
// (mig 121, m/paliad#81). UPC Appeal sets this to
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
// all carry a non-zero duration off the trigger date so none is
// the root, and the proceedingName fallback ("Berufungsverfahren")
// misnamed the input as the proceeding itself.
// 2. Root rule (isRootEvent=true) — the first event in the
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
// Nichtigkeitsklage for upc.rev.cfi.
// 3. Active proceeding name — last-resort fallback. Language-aware
// (m/paliad#58: prior code rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel)
: (data.triggerEventLabel || data.triggerEventLabelEN);
if (curated) return curated;
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
if (lang === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
}
function syncTriggerEventLabel() {
const triggerEventEl = document.getElementById("trigger-event");
if (!triggerEventEl) return;
if (lastResponse) {
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
} else {
triggerEventEl.textContent = "—";
}
}
function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container");
if (!container) return;
const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
// Header shows the picked proceeding with its jurisdiction prefix
// so the user can tell UPC Verletzungsverfahren apart from DE
// Verletzungsklage once the picker collapses.
const activeBtn = activeProceedingButton();
const procName = activeBtn ? proceedingDisplayName(activeBtn)
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data)
: renderTimelineBody(data);
// Sub-track contextual note (m/paliad#58). Surfaces above the
// timeline body when the server routed the user-picked proceeding
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
// Plain-text banner — server-side copy is plain text per the
// SubTrackRouting contract.
const noteText = getLang() === "en"
? (data.contextualNoteEN || data.contextualNote || "")
: (data.contextualNote || data.contextualNoteEN || "");
const noteHtml = noteText
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
: "";
container.innerHTML = headerHtml + bodyHtml;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, {
editable: true,
showNotes,
side: currentSide,
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -100,35 +313,105 @@ function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string)
if (summaryName && displayName) summaryName.textContent = displayName;
}
// syncFlagRows shows/hides the proceeding-specific checkbox rows
// based on selectedType. Same disposition as fristenrechner.ts —
// the with_amend nested-under-ccr semantic is enforced via
// syncInfAmendEnabled().
function syncFlagRows() {
const show = (id: string, when: boolean) => {
const el = document.getElementById(id);
if (el) el.style.display = when ? "" : "none";
};
show("ccr-flag-row", selectedType === "upc.inf.cfi");
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
syncInfAmendEnabled();
}
// R.30 amendment-application is only available with a CCR — disable
// (and clear) the nested inf-amend checkbox while ccr is off so the
// calc payload stays coherent. Mirrors fristenrechner.ts.
function syncInfAmendEnabled() {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (!ccr || !infAmend) return;
infAmend.disabled = !ccr.checked;
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code || "";
const nextType = btn.dataset.code || "";
// Different proceeding tree → previously-set overrides reference
// rule codes that don't exist in the new tree. Clear before the
// next calc so the fresh proceeding starts unanchored.
if (selectedType !== nextType) clearAnchorOverrides();
selectedType = nextType;
const name = btn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
lastResponse = null;
syncTriggerEventLabel();
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppellantRowVisibility();
setProceedingPickerCollapsed(true, name);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2);
scheduleCalc(0);
}
// syncAppellantRowVisibility hides the appellant selector for
// proceedings that have no appellant axis (first-instance Inf, Rev,
// …). Clears the in-memory state and the URL param when hidden so a
// shared link with ?appellant= doesn't leak into an unrelated
// proceeding's render.
function syncAppellantRowVisibility() {
const row = document.getElementById("appellant-row");
if (!row) return;
const visible = hasAppellantAxis(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppellant !== null) {
currentAppellant = null;
writeAppellantToURL(null);
syncRadioGroup("appellant", "");
}
}
function syncRadioGroup(name: string, value: string) {
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
input.checked = input.value === value;
});
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
// `@page paliad-landscape`, so flipping this class is what lets a
// user print the horizontal timeline in landscape without affecting
// the columns view (which stays portrait).
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "timeline") procedureView = "timeline";
applyVerfahrensablaufViewBodyClass(procedureView);
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
input.checked = input.value === procedureView;
input.addEventListener("change", () => {
if (!input.checked) return;
procedureView = input.value === "columns" ? "columns" : "timeline";
applyVerfahrensablaufViewBodyClass(procedureView);
const url = new URL(window.location.href);
if (procedureView === "columns") {
url.searchParams.delete("view");
@@ -143,6 +426,38 @@ function initViewToggle() {
toggle.style.display = "none";
}
// initPerspectiveControls hydrates side+appellant from the URL,
// reflects state into the radio inputs, and wires onchange handlers
// that update state + URL + re-render. Re-render path skips the
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
if (lastResponse) renderResults(lastResponse);
});
});
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
writeAppellantToURL(currentAppellant);
if (lastResponse) renderResults(lastResponse);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
@@ -169,18 +484,63 @@ document.addEventListener("DOMContentLoaded", () => {
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
// Flag-checkbox listeners — each flip triggers a fresh calc so the
// timeline re-projects with the new gating. ccr-flag additionally
// enables/disables the nested inf-amend row.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
syncInfAmendEnabled();
scheduleCalc(0);
});
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
// Click-to-edit on timeline / column date cells — same delegated
// pattern as /tools/fristenrechner. Survives renderResults()'s
// innerHTML rewrites because the listener lives on the container.
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) {
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
scheduleCalc(0);
});
}
// Notes toggle — restores last preference on load + re-renders when
// the user flips it. Lives in the same toggle bar as the view picker.
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
if (notesShowCb) {
notesShowCb.checked = showNotes;
notesShowCb.addEventListener("change", () => {
showNotes = notesShowCb.checked;
writeNotesPref(showNotes);
if (lastResponse) renderResults(lastResponse);
});
}
initViewToggle();
initPerspectiveControls();
onLangChange(() => {
if (lastResponse) renderResults(lastResponse);
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
// chip and re-derive the trigger event label from the lang-current
// calc response.
const activeBtn = activeProceedingButton();
if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});
// Pre-select the first proceeding tile so users see a timeline

View File

@@ -1,16 +1,25 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape, DataSource } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
import { renderTimelineShape } from "./views/shape-timeline-cv";
import type { ChartHandle } from "./views/shape-timeline-chart";
import { mountFilterBar, type BarHandle, type AxisKey } from "./filter-bar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
//
// t-paliad-211 — the per-view filter bar (`mountFilterBar`) lives between
// the shape chips and the render hosts. The saved view's filter_spec is
// the baseline; the bar overlays the user's per-session tweaks and POSTs
// `/api/views/{slug}/run` with the effective spec as override (the
// substrate accepts `{filter: ...}` per views.go:283). Axes are picked
// from the spec's data sources so a deadline-only view doesn't expose
// the appointment-type chip cluster and vice versa.
initI18n();
initSidebar();
@@ -30,6 +39,8 @@ interface ViewMeta {
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
let currentRender: RenderSpec | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
@@ -54,9 +65,10 @@ async function hydrate(): Promise<void> {
return;
}
currentMeta = meta;
currentRender = meta.render;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
mountBar(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
@@ -97,61 +109,107 @@ async function resolveMeta(slug: string): Promise<ViewMeta | null> {
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
// mountBar wires the filter-bar to the view's saved spec. The bar runs
// the spec through `/api/views/{slug}/run` whenever the user tweaks an
// axis, and the onResult callback re-paints into the active shape host.
function mountBar(meta: ViewMeta): void {
const host = document.getElementById("views-filter-bar");
const toolbar = document.getElementById("views-toolbar");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = false;
if (toolbar) toolbar.hidden = false;
if (host) host.hidden = false;
if (!host) return;
// Tear down any prior bar (re-mount on lang change isn't supported
// here, but a future Phase-2 axis switch may need this).
if (bar) {
bar.destroy();
bar = null;
}
const axes = axesForSources(meta.filter.sources);
// surfaceKey scoped per-view-slug so two views remember their own
// density/sort prefs independently.
const surfaceKey = `views.${meta.slug}`;
bar = mountFilterBar(host, {
baseFilter: meta.filter,
baseRender: meta.render,
axes,
surfaceKey,
systemViewSlug: meta.slug,
// The saved view IS the baseline; "Speichern als Sicht" remains
// available for users who want to fork.
showSaveAsView: !meta.is_system,
userViewId: meta.user_view_id,
onResult: (result, effective) => {
if (loading) loading.hidden = true;
currentRows = result;
currentRender = effective.render;
paintRows(result, effective.render);
},
});
}
// axesForSources picks the filter-bar axes a saved view's data sources
// support. Universal axes (time / personal_only / sort) always render;
// per-source predicates only render when the view's spec actually
// queries that source — otherwise the chip would be a no-op.
function axesForSources(sources: DataSource[]): AxisKey[] {
const set = new Set(sources);
const out: AxisKey[] = ["time"];
if (set.has("deadline")) out.push("deadline_status");
if (set.has("appointment")) out.push("appointment_type");
if (set.has("approval_request")) {
out.push("approval_viewer_role");
out.push("approval_status");
out.push("approval_entity_type");
}
if (set.has("project_event")) out.push("project_event_kind");
out.push("personal_only");
out.push("sort");
return out;
}
function paintRows(result: ViewRunResult, render: RenderSpec): void {
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
setActiveShape(null);
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
}
return;
}
if (empty) empty.hidden = true;
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
setActiveShape(render.shape);
renderShape(render.shape, render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
function setActiveShape(shape: RenderShape | null): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
if (el) el.hidden = shape === null ? true : !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
// Mirror the active shape on <body> so the print stylesheet can opt
// calendar / timeline into landscape (`@page paliad-landscape`) while
// list / cards stay portrait — t-paliad-233.
for (const s of ["list", "cards", "calendar", "timeline"]) {
document.body.classList.toggle(`views-shape-active-${s}`, shape === s);
}
}
let timelineHandle: ChartHandle | null = null;
@@ -223,9 +281,10 @@ function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
if (!currentRows || !currentRender) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
const overrideRender = { ...currentRender, shape };
currentRender = overrideRender;
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});

View File

@@ -1,129 +1,28 @@
import { t, type I18nKey, getLang } from "../i18n";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month grid. Toggleable to week-view via per-shape
// config. Mirrors the look of /events?view=calendar but generic across
// sources.
// shape-calendar — Custom Views calendar shape. Since t-paliad-224 this
// is a thin adapter on top of the canonical mountCalendar() in
// frontend/src/client/calendar/mount-calendar.ts. /events Kalender tab
// uses the same module so both surfaces render identical DOM.
// See docs/design-calendar-view-align-2026-05-20.md.
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
const view = cfg.default_view ?? "month";
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
if (window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
const monthRef = pickMonthAnchor(rows);
wrap.appendChild(renderMonth(monthRef, rows));
host.appendChild(wrap);
const items: CalendarItem[] = rows.map(toCalendarItem);
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Weekday headers (Mon-Sun, ISO week).
const weekdayBar = document.createElement("div");
weekdayBar.className = "views-calendar-weekdays";
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
weekdayBar.appendChild(cell);
}
wrap.appendChild(weekdayBar);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
// Pad start with prev-month spillover.
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd).
const byDate = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
const key = isoDate(d);
const arr = byDate.get(key);
if (arr) arr.push(row);
else byDate.set(key, [row]);
}
for (let day = 1; day <= daysInMonth; day++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
const dayLabel = document.createElement("div");
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(day);
cell.appendChild(dayLabel);
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
const dayRows = byDate.get(dateKey) ?? [];
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, 3);
for (const row of visible) {
const li = document.createElement("li");
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
li.textContent = row.title;
li.title = row.title + (row.project_title ? `${row.project_title}` : "");
ul.appendChild(li);
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
more.className = "views-calendar-pill views-calendar-pill--more";
more.textContent = `+${dayRows.length - visible.length}`;
ul.appendChild(more);
}
cell.appendChild(ul);
}
grid.appendChild(cell);
}
wrap.appendChild(grid);
return wrap;
}
function pickMonthAnchor(rows: ViewRow[]): Date {
// Anchor on the first row's month, or "this month" if empty.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return d;
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
}
function isoDate(d: Date): string {
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 toCalendarItem(row: ViewRow): CalendarItem {
return {
kind: row.kind,
id: row.id,
title: row.title,
event_date: row.event_date,
project_id: row.project_id,
project_title: row.project_title,
project_reference: row.project_reference,
};
}

View File

@@ -147,8 +147,22 @@ function formatColumn(row: ViewRow, col: string): string {
const s = (row.detail.status as string | undefined) ?? "";
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
}
case "rule":
return (row.detail.rule_code as string | undefined) ?? "—";
case "rule": {
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
const lang = getLang();
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
const name = (row.detail[nameKey] as string | undefined)
|| (row.detail.rule_name as string | undefined)
|| "";
const cite = (row.detail.rule_code as string | undefined) ?? "";
if (name && cite) return `${name} · ${cite}`;
if (name) return name;
if (cite) return cite;
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
if (custom.trim()) return `${custom} · Custom`;
return "—";
}
case "event_type":
return (row.detail.event_type as string | undefined) ?? "—";
case "location":
@@ -196,8 +210,25 @@ interface ApprovalDetail {
requester_kind?: "user" | "agent";
decider_name?: string;
decision_note?: string;
// counter_payload + next_request_id — populated on the OLD row of a
// suggest-changes pair (t-paliad-216). The new row's id lets us
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
// unset on any non-changes_requested status.
counter_payload?: Record<string, unknown> | null;
next_request_id?: string;
// Per-viewer eligibility flags resolved server-side against the caller
// (t-paliad-202). Used to grey out actions the server would reject.
// Optional so an older payload still renders — falsy means "treat as
// disabled" for the safety side (no false enables).
viewer_can_approve?: boolean;
viewer_is_requester?: boolean;
}
// Pending-row action set. suggest_changes was added in t-paliad-216 as
// the fourth action — the approver authors a counter-proposal which
// becomes a NEW pending row authored by them.
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
function renderApprovalList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list views-approval-list";
@@ -256,13 +287,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
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"));
// All four actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
//
// suggest_changes is hidden for non-update lifecycles (the backend
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
// so we don't even render the button for them).
actions.appendChild(approvalActionBtn("approve", detail));
if (detail.lifecycle_event === "update") {
actions.appendChild(approvalActionBtn("suggest_changes", detail));
}
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
@@ -277,6 +317,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
}
li.appendChild(actions);
// Back-link from the OLD changes_requested row to the NEW pending
// counter row (t-paliad-216). Hydrated server-side as
// detail.next_request_id; the surface renders a link that scrolls
// / filters to the new row. Falsy next_request_id = no link (e.g.
// older rows pre-mig-103, or rows where the server hasn't joined the
// back-pointer).
if (detail.status === "changes_requested" && detail.next_request_id) {
const link = document.createElement("a");
link.className = "inbox-row-next-request";
link.href = `#request-${detail.next_request_id}`;
link.dataset.nextRequestId = detail.next_request_id;
const deciderName = detail.decider_name || "";
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
li.appendChild(link);
}
ul.appendChild(li);
}
return ul;
@@ -312,16 +368,46 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
return wrap;
}
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
function approvalActionBtn(
action: ApprovalAction,
detail: ApprovalDetail,
): 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";
// suggest_changes shares the secondary style with revoke; reject is
// danger (terminal "no"); approve is primary.
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);
// approve / reject / suggest_changes share the canApprove eligibility
// gate; revoke is requester-only.
const reason = disabledReasonFor(action, detail);
if (reason) {
btn.disabled = true;
btn.title = t(reason);
}
return btn;
}
function disabledReasonFor(
action: ApprovalAction,
detail: ApprovalDetail,
): I18nKey | null {
if (action === "revoke") {
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
}
// approve / reject / suggest_changes — same gate as the server's canApprove.
if (detail.viewer_can_approve) return null;
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
return "approvals.disabled.not_authorized";
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;

View File

@@ -467,6 +467,11 @@ export function paint(
}
// Lane separators — horizontal lines between rows + labels in the gutter.
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
// would bleed into the chart canvas (t-paliad-211).
const labelPadding = 8;
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
@@ -479,13 +484,19 @@ export function paint(
}));
}
if (row.label) {
const labelEl = svg("text", {
class: "chart-lane-label",
x: 8,
y: row.y + row.height / 2 + 4,
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
labelEl.textContent = row.label;
gGrid.appendChild(labelEl);
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}

View File

@@ -7,6 +7,7 @@ import {
} from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
import type { RenderSpec, ViewRow } from "./types";
import { t } from "../i18n";
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
// host for the chart renderer.
@@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types";
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
// existing range presets already provide three meaningful zoom levels.
// Stored in URL as ?tl_zoom=1y|2y|all.
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
const ZOOM_PARAM = "tl_zoom";
export function renderTimelineShape(
host: HTMLElement,
rows: ReadonlyArray<ViewRow>,
@@ -35,21 +42,127 @@ export function renderTimelineShape(
const { events, lanes } = adapt(rows);
const cfg = render.timeline ?? {};
// Resolve the initial zoom: URL > render spec > "1y" default.
const initialZoom = resolveInitialZoom(cfg.range_preset);
// Toolbar lives above the chart in its own row so it doesn't compete
// with the date-axis / lane labels for space.
const toolbar = document.createElement("div");
toolbar.className = "views-timeline-toolbar";
host.appendChild(toolbar);
const chartHost = document.createElement("div");
chartHost.className = "views-timeline-chart-host-inner";
host.appendChild(chartHost);
// The CV adapter has no per-project "id" to fetch live timeline data
// for — we hand mount() a placeholder projectId and the staticData
// pre-loaded array so it skips the project endpoint entirely. If the
// user clicks a mark, the renderer's default click handler still
// resolves /deadlines/{id} / /appointments/{id} from the adapted
// event's id field, so deep-links land on the correct entity page.
return mount(host, {
const handle = mount(chartHost, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
rangePreset: initialZoom,
rangeFrom: cfg.range_from,
rangeTo: cfg.range_to,
});
let currentZoom = initialZoom;
const setZoom = (next: RangePreset) => {
if (next === currentZoom) return;
currentZoom = next;
handle.setRange(next);
writeZoomURL(next);
paintToolbar();
};
const paintToolbar = () => {
toolbar.innerHTML = "";
const zoomGroup = document.createElement("div");
zoomGroup.className = "views-timeline-zoom-group";
const zoomLabel = document.createElement("span");
zoomLabel.className = "views-timeline-zoom-label";
zoomLabel.textContent = t("views.timeline.zoom.label");
zoomGroup.appendChild(zoomLabel);
const zoomOut = document.createElement("button");
zoomOut.type = "button";
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
zoomOut.title = t("views.timeline.zoom.out");
zoomOut.textContent = "";
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
zoomOut.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
});
zoomGroup.appendChild(zoomOut);
// Active-level chips (1y / 2y / all). Clicking jumps directly.
const chips = document.createElement("div");
chips.className = "views-timeline-zoom-chips agenda-chip-row";
for (const level of ZOOM_LEVELS) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-timeline-zoom-chip"
+ (level === currentZoom ? " agenda-chip-active" : "");
chip.dataset.zoom = level;
chip.textContent = t(zoomLevelKey(level));
chip.addEventListener("click", () => setZoom(level));
chips.appendChild(chip);
}
zoomGroup.appendChild(chips);
const zoomIn = document.createElement("button");
zoomIn.type = "button";
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
zoomIn.title = t("views.timeline.zoom.in");
zoomIn.textContent = "+";
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
zoomIn.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
});
zoomGroup.appendChild(zoomIn);
toolbar.appendChild(zoomGroup);
};
paintToolbar();
// Apply the URL zoom if it differed from the spec — mount() already
// used initialZoom so this is a no-op when URL was empty. But when URL
// disagreed with the spec, mount() honoured the URL and the toolbar
// already reflects that, so nothing extra to do here.
return handle;
}
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
if (level === "1y") return "views.timeline.zoom.1y";
if (level === "2y") return "views.timeline.zoom.2y";
return "views.timeline.zoom.all";
}
function resolveInitialZoom(spec: string | undefined): RangePreset {
const params = new URLSearchParams(window.location.search);
const raw = params.get(ZOOM_PARAM);
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
return "1y";
}
function writeZoomURL(zoom: RangePreset): void {
const url = new URL(window.location.href);
url.searchParams.set(ZOOM_PARAM, zoom);
history.replaceState(null, "", url.toString());
}
export interface AdapterResult {

View File

@@ -99,6 +99,21 @@ export interface PredecessorMissingPayload {
message_en: string;
}
// t-paliad-237 — server tells us the anchored rule belongs to the
// parent infringement project, not this CCR. Frontend renders the
// message with a clickable link to the parent project.
export interface CrossProceedingAnchorPayload {
error: "cross_proceeding_anchor";
requested_rule_code: string;
requested_rule_name_de: string;
requested_rule_name_en: string;
parent_project_id: string;
parent_project_title: string;
parent_project_url: 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;
@@ -822,7 +837,13 @@ function buildAnchorEditor(
return;
}
if (resp.status === 409) {
const payload = (await resp.json()) as PredecessorMissingPayload;
const payload = (await resp.json()) as
| PredecessorMissingPayload
| CrossProceedingAnchorPayload;
if (payload.error === "cross_proceeding_anchor") {
renderCrossProceedingError(msg, payload, opts);
return;
}
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
return;
}
@@ -886,6 +907,34 @@ function renderPredecessorError(
msg.appendChild(link);
}
// t-paliad-237 — rule belongs to the parent inf project, not this CCR.
// Render the bilingual message + a link to the parent project so the
// user can navigate over and anchor the rule there. We deliberately do
// NOT auto-route the write across projects (out of scope per brief).
function renderCrossProceedingError(
msg: HTMLElement,
payload: CrossProceedingAnchorPayload,
opts: RenderOptions,
): void {
msg.innerHTML = "";
msg.classList.add("smart-timeline-anchor-msg--error");
msg.classList.add("smart-timeline-anchor-msg--cross-proceeding");
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
const main = document.createElement("p");
main.textContent = lang === "en" ? payload.message_en : payload.message_de;
msg.appendChild(main);
const link = document.createElement("a");
link.className = "smart-timeline-anchor-parent-link";
link.href = payload.parent_project_url;
link.textContent =
lang === "en"
? `Open „${payload.parent_project_title}`
: `${payload.parent_project_title}“ öffnen`;
msg.appendChild(link);
}
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
for (const r of Array.from(rows)) {

View File

@@ -0,0 +1,206 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
} from "./verfahrensablauf-core";
// Regression tests for the editable→click-to-edit wiring on timeline date
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
// date` on the date span. Pages then attach a delegated click handler that
// resolves that selector to swap in an inline `<input type="date">`. If a
// future refactor drops the attrs, /tools/verfahrensablauf and
// /tools/fristenrechner both silently lose click-to-edit (no script error,
// nothing happens on click). These tests pin the contract.
//
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
// in plain Node without jsdom).
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
code: "upc-rop-12",
name: "Klageerwiderung",
nameEN: "Statement of Defence",
party: "defendant",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-07-15",
originalDate: "2026-07-15",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
...overrides,
});
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
expect(html).toContain('class="timeline-date frist-date-edit"');
expect(html).toContain('data-rule-code="upc-rop-12"');
expect(html).toContain('data-current-date="2026-07-15"');
expect(html).toContain('role="button"');
expect(html).toContain('tabindex="0"');
});
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).toContain("timeline-date");
expect(html).not.toContain("data-rule-code=");
expect(html).not.toContain('role="button"');
});
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
expect(html).not.toContain("data-rule-code=");
});
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
expect(html).toContain("timeline-court-set frist-date-edit");
expect(html).toContain('data-rule-code="upc-rop-12"');
});
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
expect(html).not.toContain("data-rule-code=");
});
});
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
// left") instead of the misleading Proaktiv/Reaktiv pair.
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
// in pure-Node territory (renderColumnsBody goes through escHtml ->
// document.createElement which isn't available in plain bun test).
//
// Scenario fixture mirrors the UPC Appeal "both parties" case m
// pasted into #81: every filing rule carries party='both' so the
// legacy mirror path duplicates every row across both columns.
// With ?appellant= set, the duplicate must collapse to a single
// row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
const both = (name: string, due: string): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
});
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
...both(name, due),
party,
});
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
expect(rows).toHaveLength(1);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].court).toHaveLength(0);
});
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
]);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
});
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
{ appellant: "claimant" },
);
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
["Notice of Appeal"],
["Statement of Grounds"],
]);
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
});
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ appellant: "defendant" },
);
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
// User is on the defendant side: defendant filings land in 'ours'
// (left), claimant filings land in 'opponent' (right). Court rules
// stay in court regardless of side.
const rows = bucketDeadlinesIntoColumns(
[
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
partySpecific("court", "Urteil", "2026-10-01"),
],
{ side: "defendant" },
);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
});
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
// The user is the defendant AND the appellant, so the appellant's
// column == the user's own column == ours after the swap.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "defendant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
// Side flip + appellant axis combined: the claimant is the appellant
// but NOT us, so the collapsed 'both' row lands in the opponent
// column (right). This is the UPC Appeal "they appealed, we
// respond" scenario.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "claimant" },
);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].ours).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "A", sameDate),
partySpecific("defendant", "B", sameDate),
partySpecific("court", "C", sameDate),
]);
expect(rows).toHaveLength(1);
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
partySpecific("court", "Decision", ""),
]);
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
["Statement of Claim"],
["Oral Hearing"],
["Decision"],
]);
});
});

View File

@@ -32,16 +32,20 @@ export interface CalculatedDeadline {
name: string;
nameEN: string;
party: string;
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority is now the
// authoritative 4-way enum. The legacy isMandatory / isOptional pair
// is still emitted by the backend (derived via wireFlagsFromPriority)
// for one release so this code path stays buildable across the
// cutover. Slice 9 will drop the legacy fields server-side; this
// interface keeps them optional so the cutover lands cleanly.
priority?: "mandatory" | "recommended" | "optional" | "informational";
isMandatory: boolean;
// 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;
// legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
// by FormatLegalSourceDisplay on the backend. Renderer prefers this
// over ruleRef when set; falls back to ruleRef otherwise.
legalSourceDisplay?: string;
// legalSourceURL is the youpc.org/laws permalink when the cited body
// is hosted there (UPCRoP / UPCA / UPCS today). Empty for DE/EPA/EU
// bodies — the renderer shows display text without a link.
legalSourceURL?: string;
notes?: string;
notesEN?: string;
dueDate: string;
@@ -51,28 +55,30 @@ export interface CalculatedDeadline {
isRootEvent: boolean;
isCourtSet: boolean;
isCourtSetIndirect?: boolean;
isOptional?: 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 in Slice 8; the rule
// editor (Slice 11) will. Unknown shape on this side — pass-through.
// 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 post-Slice-8. Maps the unified Priority enum to:
// 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)
// Unknown priority (or missing on legacy responses) falls back to
// the legacy (isMandatory, isOptional) pair semantics.
//
// 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":
return { preChecked: true, hideSave: false };
case "recommended":
return { preChecked: true, hideSave: false };
case "optional":
@@ -80,24 +86,40 @@ export function priorityRendering(
case "informational":
return { preChecked: false, hideSave: true };
}
// Legacy fallback: pre-Slice-8 backend responses without `priority`.
// The wireFlagsFromPriority reverse is: T/F → mandatory, T/T → optional,
// F/* → recommended. We surface the legacy pair semantic so existing
// callers don't regress before the backend ships the new field.
if (d.isMandatory && !d.isOptional) {
return { preChecked: true, hideSave: false };
}
if (d.isMandatory && d.isOptional) {
return { preChecked: false, hideSave: false };
}
// 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;
// proceedingNameEN: English label of the picked proceeding. Empty
// when not populated server-side; frontend falls back to
// proceedingName. Used for the "Trigger event" fallback when the
// timeline has no root rule. (m/paliad#58)
proceedingNameEN?: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
// contextualNote / contextualNoteEN render as a banner above the
// timeline. Populated when the picked proceeding is a sub-track of
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr) — the server routes to the parent's rules but keeps the
// picked proceeding's identity in the response, and the note
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
// triggerEventLabel / triggerEventLabelEN: optional caption for the
// "Auslösendes Ereignis" / "Triggering event" field on
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
// when set (mig 121). The page prefers this over the proceedingName
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
// uses this so the field reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
}
export interface CourtRow {
@@ -220,6 +242,13 @@ export interface CardOpts {
// verfahrensablauf abstract-browse surface keeps editable=false because
// there's no anchor-override state on that page in Slice 1.
editable?: boolean;
// showNotes controls how the per-rule descriptive notes render:
// true → expanded `<div class="timeline-notes">…</div>` below the card
// false → compact ⓘ icon next to the meta line, full text on hover
// (browser-native `title` attribute) and screen-reader-readable
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
// re-renders. Default false — notes are noisy on long timelines.
showNotes?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
@@ -236,9 +265,12 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<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>';
// 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;
@@ -246,19 +278,35 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
// Prefer the structured legalSource (pretty display + youpc.org link
// when hosted there) over the bare rule_code fallback. UPC.RoP rules
// link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
// yet so we render display text plain.
const legalDisplay = dl.legalSourceDisplay || "";
const legalURL = dl.legalSourceURL || "";
let ruleRef = "";
if (legalDisplay && legalURL) {
ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
} else if (legalDisplay) {
ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
} else if (dl.ruleRef) {
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
: "";
const noteHint = noteText && !showNotes
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
const meta = (opts.showParty || ruleRef)
const meta = (opts.showParty || ruleRef || noteHint)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
${noteHint}
</div>`
: "";
@@ -271,7 +319,88 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>
${meta}
${adjustedNote}
${notes}`;
${notesBlock}`;
}
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
//
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
// result container once, and the delegated click/keydown handlers swap a
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
// newValue means "revert" (clear the anchor override and let the calculator
// re-project). The actual recompute is the caller's job — they own the
// anchor-overrides map + the calc dispatch.
export function openInlineDateEditor(
span: HTMLElement,
onCommit: (ruleCode: string, newValue: string) => void,
): void {
const ruleCode = span.dataset.ruleCode || "";
if (!ruleCode) return;
const current = span.dataset.currentDate || "";
const editor = document.createElement("input");
editor.type = "date";
editor.className = "frist-date-edit-input";
editor.value = current;
let done = false;
const cancel = () => {
if (done) return;
done = true;
editor.replaceWith(span);
};
const commit = (newValue: string) => {
if (done) return;
done = true;
onCommit(ruleCode, newValue);
};
editor.addEventListener("blur", () => {
if (editor.value !== current) commit(editor.value);
else cancel();
});
editor.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key === "Enter") {
e.preventDefault();
editor.blur();
} else if (ke.key === "Escape") {
e.preventDefault();
cancel();
}
});
span.replaceWith(editor);
editor.focus();
if (editor.value) editor.select();
}
// wireDateEditClicks attaches delegated click + keyboard handlers to the
// timeline result container so click-to-edit survives every innerHTML
// rewrite the page does on recalc. Idempotent — re-calling on the same
// container does nothing (the dataset flag short-circuits).
export function wireDateEditClicks(
container: HTMLElement,
onCommit: (ruleCode: string, newValue: string) => void,
): void {
if (container.dataset.dateEditWired === "1") return;
container.dataset.dateEditWired = "1";
container.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
openInlineDateEditor(target, onCommit);
});
container.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key !== "Enter" && ke.key !== " ") return;
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
e.preventDefault();
openInlineDateEditor(target, onCommit);
});
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
@@ -293,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
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 };
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
//
// The columns are user-perspective ("WE are always on the left", per
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
// Klägerseite is sometimes proactive (filing the claim) and sometimes
// reactive (responding to a counterclaim), so the static "Proaktiv =
// Klägerseite" label-pair was wrong half the time. The new axis is
// "ours vs opponent" — the side toggle picks who WE are in this
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
// placement re-resolves around that pick.
//
// Column assignment per deadline (default opts.side === null keeps
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
//
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
// - party=court → court (independent of side)
// - party=both → BOTH ours AND opponent (mirror)
//
// When `opts.appellant` is set (claimant|defendant), "both" rows
// collapse to a single row in the appellant's column — the intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
// really means "either party files, depending on who initiated".
// Appellant axis is independent of `side`: in an Appeal CoA, the
// appellant selector pins which party appealed; the side toggle
// still picks which of those is us.
export type Side = "claimant" | "defendant" | null;
// Internal column-position alias. "ours" is always rendered in the
// left grid column ("Unsere Seite"); "opponent" is always the right
// column ("Gegnerseite"). Field names mirror the labels so the
// bucketing primitive reads as a direct mapping.
type ColumnPosition = "ours" | "opponent";
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// side: which side the user is on. Drives column placement;
// does NOT filter rows. Default null = claimant-on-the-left
// (i.e. "ours = claimant", legacy default).
side?: Side;
// appellant: which side initiated the appeal / counterclaim.
// When set, party=both rows go to the appellant's column ONLY
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
// so unit tests can hit the pure routing logic without going through
// document.createElement (no jsdom in this repo).
export interface ColumnsRow {
key: string;
ours: CalculatedDeadline[];
court: CalculatedDeadline[];
opponent: CalculatedDeadline[];
}
export interface BucketingOpts {
side?: Side;
appellant?: Side;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
// renderColumnsBody uses. Extracted as its own export so the per-row
// column placement (including the side-swap + appellant-collapse
// logic from m/paliad#81 and the user-perspective re-frame from
// m/paliad#88) is unit-testable without a DOM. The returned rows are
// sorted: dated rows ascending by dueDate, then unscheduled rows in
// declaration order (each keyed by sequence).
export function bucketDeadlinesIntoColumns(
deadlines: CalculatedDeadline[],
opts: BucketingOpts = {},
): ColumnsRow[] {
const userSide: Side = opts.side ?? null;
// Default (side=null) treats the user as claimant — keeps the
// legacy claimant-on-the-left layout when no perspective is picked.
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
const appellantColumn: ColumnPosition | null =
opts.appellant === "claimant" ? claimantColumn
: opts.appellant === "defendant" ? defendantColumn
: null;
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
const rowsMap = new Map<string, ColumnsRow>();
const ensureRow = (key: string): ColumnsRow => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
r = { key, ours: [], court: [], opponent: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
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);
row[claimantColumn].push(dl);
break;
case "defendant":
row.reactive.push(dl);
row[defendantColumn].push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else {
row.ours.push(dl);
row.opponent.push(dl);
}
break;
default:
row.court.push(dl);
@@ -343,9 +554,20 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
}
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = !appellantPinned;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
@@ -353,7 +575,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
const mirrorTag = showMirrorTag && 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" : ""}">
@@ -368,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
// Static labels — "Unsere Seite" is always the left column, regardless
// of which physical party (claimant vs defendant) occupies it. The
// bucketing primitive already routes the user's side into the `ours`
// bucket, so the header truth-fully describes the column contents.
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
for (const row of rows) {
html += renderCell(row.ours);
html += renderCell(row.court);
html += renderCell(row.reactive);
html += renderCell(row.opponent);
}
html += "</div>";
return html;
@@ -419,23 +644,23 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
const courtCache = new Map<string, CourtRow[]>();
export function courtTypesFor(proceedingType: string): string[] {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
return ["UPC-CoA"];
}
if (proceedingType === "UPC_REV") {
if (proceedingType === "upc.rev.cfi") {
return ["UPC-CD", "UPC-LD"];
}
if (proceedingType.startsWith("UPC_")) {
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") {
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
return "upc-coa-luxembourg";
}
if (proceedingType === "UPC_REV") {
if (proceedingType === "upc.rev.cfi") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";

View File

@@ -22,6 +22,7 @@ export function ProjectFormFields(): string {
<option value="patent" data-i18n="projects.type.patent">Patent</option>
<option value="case" data-i18n="projects.type.case">Verfahren</option>
<option value="project" data-i18n="projects.type.project">Projekt (generisch)</option>
<option value="other" data-i18n="projects.type.other">Sonstiges</option>
</select>
</div>
@@ -64,28 +65,28 @@ export function ProjectFormFields(): string {
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (7 Ziffern)</label>
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-client-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0001234"
pattern="[0-9]{6}"
maxLength={6}
placeholder="001234"
/>
</div>
<div className="form-field">
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (7 Ziffern)</label>
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-matter-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0000567"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000567"
/>
</div>
</div>
<p className="form-hint" data-i18n="projects.field.clientmatter.hint">
{`${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
{`${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt
(überschreibbar).`}
</p>
@@ -139,6 +140,24 @@ export function ProjectFormFields(): string {
</div>
</div>
{/* Litigation-specific */}
<div className="projekt-fields projekt-fields-litigation" id="fields-litigation" style="display:none">
<div className="form-field">
<label htmlFor="project-opponent-code" data-i18n="projects.field.opponent_code">Gegner-K&uuml;rzel</label>
<input
type="text"
id="project-opponent-code"
maxLength={16}
pattern="[A-Z0-9-]{1,16}"
placeholder="OPNT"
data-i18n-placeholder="projects.field.opponent_code.placeholder"
/>
<p className="form-hint" data-i18n="projects.field.opponent_code.hint">
Kurzes K&uuml;rzel der Gegenseite (Grossbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).
</p>
</div>
</div>
{/* Case-specific */}
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
<div className="form-field-row">
@@ -151,20 +170,39 @@ export function ProjectFormFields(): string {
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
</div>
</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&auml;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&auml;sst sich dort jederzeit &uuml;berschreiben.
</p>
<div className="form-field">
<label htmlFor="project-proceeding-type-id" data-i18n="projects.field.proceeding_type">Verfahrenstyp</label>
<select id="project-proceeding-type-id">
<option value="" data-i18n="projects.field.proceeding_type.unset">(nicht gesetzt)</option>
</select>
<p className="form-hint" data-i18n="projects.field.proceeding_type.hint">
Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.
</p>
</div>
<div className="form-field">
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
<select id="project-our-side">
<option value="" data-i18n="projects.field.client_role.unset">Unbekannt</option>
<optgroup data-i18n-label="projects.field.client_role.group.active" label="Aktiv (wir greifen an)">
<option value="claimant" data-i18n="projects.field.client_role.claimant">Kl&auml;gerseite</option>
<option value="applicant" data-i18n="projects.field.client_role.applicant">Antragsteller</option>
<option value="appellant" data-i18n="projects.field.client_role.appellant">Berufungsf&uuml;hrer</option>
</optgroup>
<optgroup data-i18n-label="projects.field.client_role.group.reactive" label="Reaktiv (wir verteidigen)">
<option value="defendant" data-i18n="projects.field.client_role.defendant">Beklagtenseite</option>
<option value="respondent" data-i18n="projects.field.client_role.respondent">Antragsgegner</option>
</optgroup>
<optgroup data-i18n-label="projects.field.client_role.group.other" label="Dritte / Sonstige">
<option value="third_party" data-i18n="projects.field.client_role.third_party">Streithelfer / Dritter</option>
<option value="other" data-i18n="projects.field.client_role.other">Sonstige Beteiligte</option>
</optgroup>
</select>
<p className="form-hint" data-i18n="projects.field.client_role.hint">
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv &rarr; Kl&auml;gerseite, Reaktiv &rarr; Beklagtenseite. L&auml;sst sich dort jederzeit &uuml;berschreiben.
</p>
</div>
</div>
<div className="form-field">

View File

@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
// 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>';
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
// affordance reads as "a draft document" at a glance.
const ICON_FILE_TEXT = '<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="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></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>';
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{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("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
@@ -199,6 +204,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/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"

View File

@@ -5,12 +5,14 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
// once in the output.
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
// request time by the Go handler (internal/handlers/dashboard_shell.go)
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
// Keep each token intact and exactly once in the output. The latter two
// power the per-user configurable layout (t-paliad-219).
const HYDRATION_SCRIPT =
"/*__PALIAD_DASHBOARD_DATA__*/";
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
// it 90deg clockwise when the section is open via the
@@ -23,12 +25,13 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
// renders all sections expanded so unstyled fallback is sensible.
function CollapsibleSection(props: {
id: string;
widgetKey: string;
headingI18n: string;
headingDe: string;
children: any;
}): string {
return (
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
<span className="dashboard-section-chevron" aria-hidden="true"
@@ -73,6 +76,17 @@ export function renderDashboard(): string {
<span className="dashboard-date" id="dashboard-date"></span>
</p>
</div>
{/* "Anpassen" toggle (t-paliad-219 Slice B). Off by
default — when on, body.dashboard-editing reveals
drag handles / ↑↓ / x / ⚙ chrome on each widget plus
the edit-footer below the widget stack. */}
<button
type="button"
id="dashboard-edit-toggle"
className="btn btn-ghost dashboard-edit-toggle"
aria-pressed="false"
data-i18n="dashboard.edit.toggle"
>Anpassen</button>
</div>
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
@@ -87,105 +101,184 @@ export function renderDashboard(): string {
</p>
</div>
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<div className="dashboard-summary-grid">
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">&Uuml;berf&auml;llig</div>
</a>
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">N&auml;chste Woche</div>
</a>
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
</CollapsibleSection>
{/* Matter summary card — single tappable card, kept outside the
collapsible scaffold because its h3 is internal to the card
and doubles as the navigation affordance. */}
<section className="dashboard-matters">
<a href="/projects" className="dashboard-matter-card">
<div className="dashboard-matter-header">
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
<span className="dashboard-matter-arrow" aria-hidden="true">&rarr;</span>
{/* Configurable widget grid (t-paliad-227 overhaul). All
widgets live as direct children of the single
.dashboard-grid container so applyLayout can place them
via grid-column/grid-row inline styles. Pre-overhaul
this stack had nested wrappers (.dashboard-columns,
standalone <section>s) that fought the layout engine
and made cross-row drags appear to fail. */}
<div className="dashboard-grid" id="dashboard-grid">
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<div className="dashboard-summary-grid">
<a href="/events?type=deadline&status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">&Uuml;berf&auml;llig</div>
</a>
<a href="/events?type=deadline&status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/events?type=deadline&status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/events?type=deadline&status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">N&auml;chste Woche</div>
</a>
<a href="/events?type=deadline&status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
<div className="dashboard-matter-stats">
<div>
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
</div>
</div>
</a>
</section>
</CollapsibleSection>
{/* Two-column lists — each column is its own collapsible section
so users can hide deadlines or appointments independently.
The .dashboard-columns wrapper is preserved so the grid
layout still applies; collapse hides the body of each col
but leaves the heading row in the grid. */}
<div className="dashboard-columns">
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
{/* Matter summary — uses CollapsibleSection now so it
participates in the grid like every other widget. The
inner card heading was redundant with the section
heading; we keep the stats grid + the projects link. */}
<CollapsibleSection id="matters" widgetKey="matter-summary" headingI18n="dashboard.matters.heading" headingDe="Meine Akten">
<a href="/projects" className="dashboard-matter-card">
<div className="dashboard-matter-stats">
<div>
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
</div>
</div>
</a>
</CollapsibleSection>
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
<div className="dashboard-calendar" id="dashboard-deadlines-calendar" style="display:none"></div>
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
Keine Fristen in den n&auml;chsten 7 Tagen.
</p>
</CollapsibleSection>
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
<div className="dashboard-calendar" id="dashboard-appointments-calendar" style="display:none"></div>
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
Keine Termine in den n&auml;chsten 7 Tagen.
</p>
</CollapsibleSection>
</div>
{/* Inline Agenda (t-paliad-162). Same item shape as the
standalone /agenda page, rendered via the shared
agenda-render module. The dashboard variant is read-only:
no chip filters, no URL state — a 30-day window of
upcoming items grouped by day. The standalone /agenda
route is unchanged for direct-link compatibility. */}
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<div className="dashboard-agenda">
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
Keine F&auml;lligkeiten in den n&auml;chsten 30 Tagen.
{/* Inline Agenda (t-paliad-162). Same item shape as the
standalone /agenda page, rendered via the shared
agenda-render module. The dashboard variant is read-only:
no chip filters, no URL state — a 30-day window of
upcoming items grouped by day. The standalone /agenda
route is unchanged for direct-link compatibility. */}
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<div className="dashboard-agenda">
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
<ul className="dashboard-list" id="dashboard-agenda-list" style="display:none"></ul>
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
Keine F&auml;lligkeiten in den n&auml;chsten 30 Tagen.
</p>
<p className="dashboard-agenda-link">
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollst&auml;ndige Agenda &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
list mirrors /inbox's "Approver" axis but capped at the
widget's count setting. Renders the empty state when
the user has no open approvals to review. */}
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
<div className="dashboard-inbox">
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
Keine offenen Freigaben.
</p>
<p className="dashboard-agenda-link">
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollst&auml;ndigen Posteingang &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Activity feed — moved under Agenda per m's design call
(t-paliad-162). */}
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
Noch keine Aktivit&auml;t erfasst.
</p>
</CollapsibleSection>
{/* Pinned-projects widget (t-paliad-219 Slice C). Reads
PinService via DashboardData.pinned_projects (server-
joined to titles + refs). Default-hidden — users opt
in via the picker. */}
<CollapsibleSection id="pinned-projects" widgetKey="pinned-projects" headingI18n="dashboard.pinned.heading" headingDe="Angepinnte Akten">
<ul className="dashboard-list" id="dashboard-pinned-list"></ul>
<p className="dashboard-empty" id="dashboard-pinned-empty" style="display:none" data-i18n="dashboard.pinned.empty">
Noch keine Akten angepinnt.
</p>
<p className="dashboard-agenda-link">
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollst&auml;ndige Agenda &ouml;ffnen &rarr;</a>
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
</CollapsibleSection>
{/* Activity feed — moved under Agenda per m's design call
(t-paliad-162). */}
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
Noch keine Aktivit&auml;t erfasst.
</p>
</CollapsibleSection>
{/* Quick-actions widget (t-paliad-219 Slice C). Pure UI;
no backend data path. Default-hidden — surfaced via the
picker. */}
<CollapsibleSection id="quick-actions" widgetKey="quick-actions" headingI18n="dashboard.quick.heading" headingDe="Schnellzugriff">
<div className="dashboard-quick-actions">
<a href="/projects/new" className="btn btn-primary dashboard-quick-btn" data-i18n="dashboard.quick.new_project">+ Akte</a>
<a href="/deadlines/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_deadline">+ Frist</a>
<a href="/appointments/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_appointment">+ Termin</a>
</div>
</CollapsibleSection>
</div>
{/* Edit-mode footer (t-paliad-219 Slice B). Hidden via CSS
unless body.dashboard-editing — see dashboard.ts.
Slice C added the admin "Promote to firm default"
button — it stays hidden unless data.user.global_role
is 'global_admin'; dashboard.ts toggles it. */}
<div id="dashboard-edit-footer" className="dashboard-edit-footer">
<button
type="button"
id="dashboard-edit-add"
className="btn btn-secondary dashboard-edit-add"
data-i18n="dashboard.edit.add_widget"
>Widget hinzuf&uuml;gen</button>
<button
type="button"
id="dashboard-edit-promote"
className="btn btn-ghost dashboard-edit-promote"
style="display:none"
data-i18n="dashboard.edit.promote"
>Als Firmen-Standard speichern</button>
<button
type="button"
id="dashboard-edit-reset"
className="dashboard-edit-reset-link"
data-i18n="dashboard.edit.reset"
>Auf Standard zur&uuml;cksetzen</button>
</div>
{/* Save toast slot — managed by dashboard.ts. */}
<div
id="dashboard-save-toast"
className="dashboard-save-toast"
role="status"
aria-live="polite"
></div>
</div>
</section>
</main>

View File

@@ -1,84 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderDeadlinesCalendar(): 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="deadlines.kalender.title">Fristenkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=deadline" />
<BottomNav currentPath="/events?type=deadline" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="deadlines.kalender.heading">Fristenkalender</h1>
<p className="tool-subtitle" data-i18n="deadlines.kalender.subtitle">
Monats&uuml;bersicht aller Fristen Ihrer Akten.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=deadline" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar" id="deadline-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="deadline-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="deadline-cal-empty" style="display:none" data-i18n="deadlines.kalender.empty">
Keine Fristen im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/deadlines-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
<div className="entity-detail-title-col">
<h1 id="deadline-title-display" />
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
{/* t-paliad-251 Part 4 — Standardtitel button only
visible in edit mode; clicking replaces the
title with a default derived from the project
and the deadline's event types / rule. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
style="display:none"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
<div className="entity-detail-meta">
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />
@@ -82,15 +95,50 @@ export function renderDeadlinesDetail(): string {
<input type="date" id="deadline-due-edit" style="display:none" />
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
{/* m/paliad#56 — Verfahrenshandlung block.
Event type (parent concept) renders first; rule
sits beneath as the citation under that event
type. Editor splits them back into separate
pickers but the read-only stack reads as one
compound "Typ — Regel" surface. */}
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
<dd>
<span id="deadline-event-types-display">&mdash;</span>
<div id="deadline-event-types-edit" className="event-type-picker-host" style="display:none" />
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd>
<span id="deadline-rule-display">&mdash;</span>
{/* t-paliad-258 — Auto / Custom rule editor.
Mirrors /deadlines/new: read-only Auto display
(resolved from Type) or free-text Custom input,
with a toggle link. Hidden outside edit mode. */}
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
</dd>
<dt data-i18n="deadlines.detail.source">Quelle</dt>
<dd id="deadline-source-display" />

View File

@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
</div>
<div className="form-field">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
<div className="form-field-label-row">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
currently-known context (event type → rule → proceeding
type → fallback) with the project reference as suffix.
Always replaces the title; no destructive confirmation
because the user invoked it explicitly. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
</div>
<input
type="text"
id="deadline-title"
@@ -57,62 +72,47 @@ export function renderDeadlinesNew(): string {
<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"
>
&nbsp;(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&auml;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 &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">F&auml;lligkeitsdatum</label>
<input type="date" id="deadline-due" required />
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
Auto (default): rule_id derived from the chosen
Type, displayed read-only with a canonical
"Name · Citation" label. Custom: free-text input,
no catalog FK. Toggle switches modes. */}
<div className="form-field">
<div className="form-field-label-row">
<label data-i18n="deadlines.field.rule">Regel</label>
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
</div>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span
className="form-hint-badge"
data-i18n="deadlines.field.rule.auto_badge"
>Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
<div className="form-field">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
</div>
<div className="form-field">
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">F&auml;lligkeitsdatum</label>
<input type="date" id="deadline-due" required />
</div>
<div className="form-field">

View File

@@ -236,37 +236,10 @@ export function renderEvents(): string {
</table>
</div>
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
<div className="frist-calendar-controls">
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="events-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
Keine Eintr&auml;ge im ausgew&auml;hlten Zeitraum.
</p>
</div>
<div className="modal-overlay" id="events-cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="events-cal-popup-date" />
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
</div>
</div>
{/* Calendar host — mountCalendar() (t-paliad-224) builds the
month/week/day grid + toolbar into this container when
the Kalender view chip is active. Empty until then. */}
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden />
<div className="entity-empty" id="events-empty" style="display:none">
<h2 data-i18n="events.empty.title">Keine Eintr&auml;ge vorhanden</h2>

View File

@@ -54,34 +54,44 @@ function quickChip(c: QuickChip): string {
}
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\u00dfnahmen" },
{ 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" },
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
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.)" },
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "EPA_OPP", 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" },
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "DPMA_OPP", 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" },
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderFristenrechner(): string {
@@ -151,19 +161,19 @@ export function renderFristenrechner(): string {
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
data-i18n="deadlines.step1.adhoc.upc">
Custom UPC proceeding
UPC proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
data-i18n="deadlines.step1.adhoc.de">
Custom DE proceeding
DE proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
data-i18n="deadlines.step1.adhoc.epa">
Custom EPA proceeding
EPA proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
data-i18n="deadlines.step1.adhoc.dpma">
Custom DPMA proceeding
DPMA proceeding
</button>
</div>
</div>
@@ -234,78 +244,74 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
</h2>
<div className="fristen-mode-toggle" role="radiogroup" aria-label="B1/B2 mode">
<label className="fristen-mode-toggle-option">
<input type="radio" name="fristen-b-mode" value="tree" id="fristen-b-mode-tree" />
<span data-i18n="deadlines.pathway.b.mode.tree">Schritt-f&uuml;r-Schritt (Entscheidungsbaum)</span>
</label>
<label className="fristen-mode-toggle-option">
<input type="radio" name="fristen-b-mode" value="filter" id="fristen-b-mode-filter" />
<span data-i18n="deadlines.pathway.b.mode.filter">Filter / Suche</span>
</label>
</div>
{/* B1 panel — decision tree above + concept-card results below.
fristen-b1-cascade hosts the breadcrumb / question / button row.
fristen-b1-results hosts the narrowing concept-card list,
populated by runB1Search() in fristenrechner.ts. The cards
reuse renderConceptCard() (B2's card shape).
m/paliad#15 follow-up: the inbox-channel chip lives at the
top of THIS panel (not page-level) — m's call: "inside the
decision tree because it helps us to determine what to do
next". The chip narrows the cascade entry-points + B2 fine
forum filter; Pathway A's Verlauf doesn't see it. */}
{/* B1 panel — row-stack cascade.
`#fristen-row-stack` hosts the perspective / inbox /
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
added project-driven prefills + auto-walk). The
stack-header above carries the inline-search trigger
(t-paliad-198 Slice 3 — clicking expands
`#fristen-row-search-panel` over the row stack instead
of routing to the legacy B2 surface) and the reset link.
`#fristen-b1-results` is unchanged — it renders concept
cards for both cascade-narrowing AND inline-search
results, so users see the same card layout regardless
of how they reached a deadline rule. */}
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
{/* Slice 3c — perspective chip strip. Klägerseite vs
Beklagtenseite hides cascade leaves whose party tag
contradicts the user's side. "Beide" / no chip
leaves the cascade unfiltered. */}
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
data-i18n-title="deadlines.perspective.claimant.title" title="Kl&auml;gerseite (Proactive)">
<span data-i18n="deadlines.perspective.claimant.short">Kl&auml;ger</span>
</button>
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
<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 className="fristen-row-stack-header" id="fristen-row-stack-header">
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
data-i18n-title="deadlines.row.search.link.title"
aria-expanded="false"
aria-controls="fristen-row-search-panel"
title="Direkt nach einer Frist suchen">
<span aria-hidden="true">&#128269;</span>{" "}
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
</button>
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
data-i18n-title="deadlines.row.reset.title"
title="Pfad zur&uuml;cksetzen — alle Cascade-Antworten verwerfen">
<span aria-hidden="true">&#8634;</span>{" "}
<span data-i18n="deadlines.row.reset">Pfad zur&uuml;cksetzen</span>
</button>
</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>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
data-i18n-title="deadlines.inbox.cms.title" title="UPC &mdash; &uuml;ber CMS">
CMS
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren &mdash; &uuml;ber beA">
beA
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren &mdash; Postzustellung">
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
<span data-i18n="deadlines.inbox.all">Alle</span>
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
default; the search icon-button in the stack header
toggles it open / closed. While open, the row stack is
hidden and the search input drives `#fristen-b1-results`
directly — same surface the cascade leaf populates so
the user sees one consistent concept-card list. */}
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
data-i18n-title="deadlines.row.search.panel.back.title"
title="Zur&uuml;ck zum Entscheidungsbaum">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.row.search.panel.back">Zur&uuml;ck zum Entscheidungsbaum</span>
</button>
<div className="fristen-row-search-panel-input-wrap">
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="fristen-row-search-panel-input"
className="fristen-row-search-panel-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
placeholder="Frist suchen&hellip;"
aria-label="Frist suchen"
/>
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
</div>
@@ -428,8 +434,17 @@ export function renderFristenrechner(): string {
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
@@ -470,7 +485,10 @@ export function renderFristenrechner(): string {
<div className="date-input-group">
<div className="date-field-row">
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
{/* Read-only caption labelling the value <span>. Not a
<label htmlFor> — m/paliad#60: <label for=…> must
point at a labelable form control, never a span. */}
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
@@ -531,6 +549,10 @@ export function renderFristenrechner(): string {
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_GLOSSAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
@@ -108,19 +107,6 @@ export function renderIndex(): string {
</div>
</section>
<section className="sections">
<div className="container">
<h3 className="section-heading" data-i18n="index.downloads">Downloads</h3>
<div className="grid grid-2">
<a href="/files/hl-patents-style.dotm" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_DOWNLOAD }} />
<h2 data-i18n="index.style.title">{`${FIRM} Patents Style`}</h2>
<p data-i18n="index.style.desc">{`Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`}</p>
</a>
</div>
</div>
</section>
<section className="offices">
<div className="container">
<h3 data-i18n="index.offices">Standorte</h3>

View File

@@ -104,7 +104,7 @@ export function renderKostenrechner(): string {
<title data-i18n="kosten.title">Prozesskostenrechner &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<body className="has-sidebar page-kostenrechner">
<Sidebar currentPath="/tools/kostenrechner" />
<BottomNav currentPath="/tools/kostenrechner" />

View File

@@ -27,7 +27,7 @@ export function renderProjectsChart(): string {
<title data-i18n="projects.chart.title">Projekt-Chart &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<body className="has-sidebar page-projects-chart">
<Sidebar currentPath="/projects" />
<BottomNav currentPath="/projects" />

View File

@@ -50,6 +50,14 @@ export function renderProjectsDetail(): string {
<div className="entity-detail-meta">
<span id="project-type-chip" className="entity-type-chip" />
<span className="entity-ref" id="project-ref-display" />
{/* Auto-derived project code (t-paliad-222 / m/paliad#50).
Rendered as a separate badge so the user can still
distinguish a custom reference (left badge) from a
tree-derived code (right badge); when reference is
blank, the derived code IS reference and only this
badge shows. Hidden via inline style until the
client populates it. */}
<span className="entity-ref entity-ref-code" id="project-code-display" style="display:none" title="Auto-derived project code" />
<span id="project-clientmatter" className="entity-ref" />
<span id="project-status-chip" className="entity-status-chip" />
<a id="project-netdocs" className="netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments &nearr;</a>
@@ -80,6 +88,10 @@ export function renderProjectsDetail(): string {
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
{/* Verwaltung — rare admin actions (export, archive). Sits
last in the tab list per t-paliad-245. */}
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
</nav>
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
@@ -188,7 +200,7 @@ export function renderProjectsDetail(): string {
<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 */}
{/* Options injected from client; defaults to upc.rev.cfi */}
</select>
</div>
<div className="form-field">
@@ -247,6 +259,7 @@ export function renderProjectsDetail(): string {
<div className="form-field">
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
<select id="team-responsibility">
<option value="admin" data-i18n="projects.team.responsibility.admin">Admin</option>
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
@@ -262,9 +275,33 @@ export function renderProjectsDetail(): string {
<p className="form-msg" id="team-msg" />
</form>
{/* t-paliad-231 — pure-client mailto: for non-admins.
Button stays disabled until at least one row is
selected; click opens a mailto: with every selected
member in the To: line. No server call. */}
<div className="party-controls team-mailto-controls" id="team-mailto-controls" style="display:none">
<button
type="button"
id="team-mailto-btn"
className="btn-secondary btn-small"
disabled
data-i18n-title="projects.team.mailto.empty"
title="Mindestens ein Mitglied ausw&auml;hlen"
>
<span id="team-mailto-label" data-i18n="projects.team.mailto.label">Mail an Auswahl</span>
</button>
</div>
<table className="party-table">
<thead>
<tr>
<th className="team-col-select">
<input
type="checkbox"
id="team-select-master"
aria-label="Alle sichtbaren ausw&auml;hlen"
/>
</th>
<th data-i18n="projects.detail.team.col.name">Name</th>
<th data-i18n="projects.detail.team.col.profession">Profession</th>
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
@@ -548,6 +585,12 @@ export function renderProjectsDetail(): string {
{/* Checklists (Checklisten) */}
<section className="entity-tab-panel" id="tab-checklists" style="display:none">
<div className="party-controls">
<button id="checklist-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projects.detail.checklisten.add">
Checkliste hinzuf&uuml;gen
</button>
</div>
<p className="form-msg" id="project-checklists-msg" />
<p id="project-checklists-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.checklisten.empty">
F&uuml;r dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
</p>
@@ -565,17 +608,86 @@ export function renderProjectsDetail(): string {
</table>
</div>
<p className="tool-subtitle checklists-hint">
<span data-i18n="projects.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
<span data-i18n="projects.detail.checklisten.hint.prefix">Vorlagen werden auf der </span>
<a href="/checklists" data-i18n="projects.detail.checklisten.hint.link">Checklisten</a>
<span data-i18n="projects.detail.checklisten.hint.suffix"> angelegt.</span>
<span data-i18n="projects.detail.checklisten.hint.suffix">-Seite angelegt und bearbeitet.</span>
</p>
</section>
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
Projekt archivieren
</button>
</div>
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
the original t-paliad-215 list to the full
cross-proceeding catalog. The table shows every
active filing rule across every proceeding, grouped
by proceeding; the project's own proceeding is
pinned to the top. The no-proceeding hint stays as
a soft nudge above the catalog (the table renders
regardless). */}
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt der Katalog unten zeigt trotzdem alle Vorlagen.
</p>
<button
type="button"
id="project-submissions-edit-cta"
className="btn-primary btn-small"
data-i18n="projects.detail.submissions.empty.no_proceeding.cta">
Projekt bearbeiten
</button>
</div>
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
</p>
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="projects.detail.submissions.col.name">Schriftsatz</th>
<th data-i18n="projects.detail.submissions.col.party">Partei</th>
<th data-i18n="projects.detail.submissions.col.source">Rechtsgrundlage</th>
<th data-i18n="projects.detail.submissions.col.action" />
</tr>
</thead>
<tbody id="project-submissions-body" />
</table>
</div>
<p className="tool-subtitle submissions-hint" data-i18n="projects.detail.submissions.hint">
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
</p>
</section>
{/* Verwaltung — rare admin actions (export, archive). Each
sub-section hides itself if the caller is not entitled
(export: §4 gate; archive: global_admin). */}
<section className="entity-tab-panel" id="tab-settings" style="display:none">
<div className="settings-section" id="project-settings-export" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
</p>
<button
type="button"
id="project-export-btn"
className="btn-secondary"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
</div>
<div className="settings-section" id="project-settings-archive" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
</p>
<button
type="button"
id="project-settings-archive-link"
className="btn-secondary"
data-i18n="projects.detail.settings.archive.cta">
Bearbeiten öffnen
</button>
</div>
</section>
</div>
{/* Full edit modal — same form as /projects/new, pre-filled. */}
@@ -607,6 +719,33 @@ export function renderProjectsDetail(): string {
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.save">Speichern</button>
</div>
</form>
{/* Danger zone — destructive action, visually separated from Save/Cancel. */}
<div className="modal-danger-zone" id="project-delete-wrap" style="display:none">
<button id="project-delete-btn" className="btn-link-danger" type="button" data-i18n="projects.detail.delete">
Projekt archivieren
</button>
</div>
</div>
</div>
{/* Add-checklist-instance modal (t-paliad-239) — picks a
template and POSTs to /api/checklists/{slug}/instances
with the current project_id; the row appears in the
Checklists tab list on success. */}
<div className="modal-overlay" id="add-checklist-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="projects.detail.checklisten.add">Checkliste hinzuf&uuml;gen</h2>
<button className="modal-close" id="add-checklist-close" type="button">&times;</button>
</div>
<div className="form-field">
<input type="text" id="add-checklist-search" autocomplete="off" data-i18n-placeholder="projects.detail.checklisten.add.search" placeholder="Vorlage suchen&hellip;" />
</div>
<div className="add-checklist-list" id="add-checklist-list" />
<p className="entity-events-empty" id="add-checklist-empty" style="display:none" data-i18n="projects.detail.checklisten.add.empty_pick">
Keine passenden Vorlagen gefunden.
</p>
<p className="form-msg" id="add-checklist-msg" />
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More