Compare commits

...

125 Commits

Author SHA1 Message Date
mAi
81742a88c9 docs(inbox): t-paliad-249 — inbox overhaul inventor design (m/paliad#80)
LOCKED design with head decisions (Q1=A) folded in §12. Slice plan
A/B/C reuses existing FilterSpec + RunSpec engine; no new aggregation
service. Slice A adds inbox_seen_at cursor + project_event source on
InboxSystemView + RowActionInbox dispatch in shape-list; Slice B adds
shape toggle (list/cards/calendar) + member_role_changed narrowing;
Slice C upgrades the badge + per-item dismiss.
2026-05-25 13:35:04 +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
196 changed files with 33149 additions and 4188 deletions

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

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,
@@ -162,7 +193,11 @@ func main() {
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),
@@ -175,27 +210,38 @@ 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),
}
// t-paliad-215 Slice 1 — submission generator. Three services
// stitched together by handlers/submissions.go: registry pulls
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
// the placeholder map from project + parties + rule, renderer
// merges {{placeholder}} tokens into the .docx.
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
pool,
svcBundle.Project,
svcBundle.Party,
svcBundle.Users,
)
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
// 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.
//

View File

@@ -3,20 +3,23 @@
// Three checks against TEST_DATABASE_URL:
//
// 1. db.ApplyMigrations does not panic and returns nil.
// 2. The migration tracker (public.paliad_schema_migrations) advances to
// the highest *.up.sql version on disk — no migrations were silently
// skipped, no "dirty=true" stragglers left behind.
// 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.
// 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.
// 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
@@ -51,19 +54,23 @@ func TestBootSmoke(t *testing.T) {
t.Fatalf("db.ApplyMigrations: %v", err)
}
// (2) Assert the tracker advanced to the highest *.up.sql version we
// embed. If a migration was silently skipped or the tracker is dirty,
// the prod container would crash-loop — this turns that into a test
// failure with a precise reason.
expected := highestEmbeddedMigrationVersion(t)
got, dirty := readTrackerVersion(t, url)
if dirty {
t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got)
// (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 got != expected {
t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+
"A migration was skipped or applied out of order.",
got, expected)
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,
@@ -93,11 +100,16 @@ func TestBootSmoke(t *testing.T) {
}
}
// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql
// file in internal/db/migrations/ on disk. Used as the expected tracker
// version after a clean apply. We read from disk (not the embed.FS in
// the db package — it's unexported) since the test runs from the repo.
func highestEmbeddedMigrationVersion(t *testing.T) int {
// 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 {
@@ -129,24 +141,52 @@ func highestEmbeddedMigrationVersion(t *testing.T) int {
t.Fatalf("no *.up.sql files found in %s", dir)
}
sort.Ints(versions)
return versions[len(versions)-1]
return versions
}
// readTrackerVersion fetches the lone row from the tracker. golang-migrate
// keeps exactly one row; if we ever see zero or more, that's the dirty-state
// the test is designed to flag.
func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) {
// 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()
row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`)
if err := row.Scan(&version, &dirty); err != nil {
t.Fatalf("read tracker: %v", err)
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
if err != nil {
t.Fatalf("read applied_migrations: %v", err)
}
return version, dirty
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

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}

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,848 @@
# Design: /inbox overhaul — project-events feed + filtering + list/cards/calendar toggles
**Task:** t-paliad-249
**Gitea:** m/paliad#80
**Author:** icarus (inventor)
**Date:** 2026-05-25
**Status:** LOCKED — head confirmed Q1=A with two refinements (2026-05-25), see §12.
**Branch:** `mai/icarus/inventor-inbox-overhaul`
---
## 0. TL;DR
`/inbox` today is approval-requests only. m wants it to become the actual
"what's new on my projects" surface — approval requests **plus** recent
project_events on visible projects — with the same view-toggle paradigm
as `/events` (list / cards / calendar) and a meaningful filter row.
The good news: the substrate already exists.
- `view_service.RunSpec` unions four sources (deadline, appointment,
**project_event**, **approval_request**) into one ranked `[]ViewRow`.
- `FilterSpec` has predicates for every axis we need
(`ProjectEventPredicates.EventTypes`, `ApprovalRequestPredicates`).
- `filter-bar` knows the axes we need: `time`, `project`,
`approval_viewer_role`, `approval_status`, `approval_entity_type`,
`project_event_kind`, plus `shape` / `sort` / `density`.
- Shape renderers exist: `shape-list` (table + compact + approval), `shape-cards`
(day-grouped), `shape-calendar` (thin adapter on `mountCalendar`).
So the work is **mostly re-mix**:
1. Extend `InboxSystemView` from `Sources=[ApprovalRequest]` to
`Sources=[ApprovalRequest, ProjectEvent]`, default
`Time.Horizon=Past30d`, and add a curated `project_event.event_types`
default that filters out noise (approvals duplicate-suppression,
checklist mutations, status churn).
2. Extend `shape-list.ts` so `row_action="approve"` no longer assumes
every row is an approval — rename it `"inbox"`, dispatch per
`row.kind` (approval → existing approve-card layout; project_event →
navigate-style stream row).
3. Wire the existing view-axis selector (the chip cluster on `/events`)
onto `/inbox`'s host, persisting selection via the filter-bar URL
codec (axis `shape` already in `AxisKey`).
4. Add a high-watermark read cursor (`paliad.users.inbox_seen_at`) +
`POST /api/inbox/mark-all-seen` + extend `/api/inbox/count` to count
unseen project_events too. Adds one new axis `unread_only` to the bar.
That's Slice A. Slice B layers cards + calendar toggles cleanly. Slice C
is per-item dismissal — keep out of v1 unless the cursor proves not
enough (m's pick Q3 is the cursor).
No new aggregation service, no new endpoint family — the inbox runs on
`/api/views/inbox/run` like every other system view does today.
---
## 1. Current `/inbox` state
**Routes (`internal/handlers/approvals.go`):**
| Path | Behaviour |
|---------------------------------------|--------------------------------------------------------------|
| `GET /inbox` | Serves `dist/inbox.html`, a thin shell. No SSR data. |
| `GET /api/inbox/pending-mine` | Approval requests I can approve. |
| `GET /api/inbox/mine` | Approval requests I submitted (all statuses by default). |
| `GET /api/inbox/count` | `{count: N}` for the sidebar bell badge — `PendingCountForUser`. |
| `GET /api/approval-requests/{id}` | Hydrate one request (used by suggest-changes modal). |
| `POST /api/approval-requests/{id}/{action}` | `approve` / `reject` / `revoke` / `suggest-changes`. |
**Data path:** `frontend/src/client/inbox.ts` mounts the universal
`FilterBar` over the inbox `SystemView` (slug `"inbox"`, sources
`[approval_request]`, viewer_role `any_visible`, status `[pending]`).
The bar fetches `/api/views/system`, hands the spec to itself, calls
`/api/views/inbox/run?…`, and stamps rows via `shape-list.ts`'s
`renderApprovalList(rows)` path (gated by `row_action="approve"`).
**Action wiring:** `wireApprovalActions(host)` listens on
`.views-approval-action` clicks; on success it triggers
`bar.refresh()` and `refreshInboxBadge()` (which pokes
`/api/inbox/count`).
**Empty state + admin nudge:** when the result list is empty AND the
caller is `global_admin` AND no `approval_policies` row exists firm-wide,
the page shows a "configure policies" CTA. Otherwise the localized
"no items" empty-state text.
**Sidebar bell:** `Sidebar.tsx:143` `navItem("/inbox", BELL_ICON, …)`
plus `client/sidebar.ts:320345`'s `initInboxBadge` which polls
`/api/inbox/count` every 60s. Badge clamps to `"9+"`.
### What aggregates cleanly
The whole approval flow already plugs into `RunSpec`'s union pipeline.
That's the win — extending sources from `[ApprovalRequest]` to
`[ApprovalRequest, ProjectEvent]` is a `[]DataSource` literal edit in
`InboxSystemView()` and the engine fans out per source, sorts, returns
one `[]ViewRow`. The hard work (`runProjectEvents` + the
visibility predicate + project metadata join) is already in
`view_service.go:344430`.
### What doesn't aggregate (yet)
- **Read state.** There is no `inbox_seen_at` on `paliad.users` (verified
via information_schema). The bell badge counts pending **approval
requests for the caller** only — it has no notion of "new project
events since last visit". We have to add it.
- **Mixed `row_action`.** `shape-list.ts`'s `renderApprovalList` assumes
every row is an approval and unconditionally parses
`row.detail` as an `ApprovalDetail`. Project_event rows in the same
list would crash the parse. We need to branch per `row.kind` inside
the inbox row stamper.
- **`/inbox` shape toggle.** `client/inbox.ts` hardcodes `shape-list`;
the `shape` axis is wired into `filter-bar/axes.ts` but `/inbox`'s
`INBOX_AXES` deliberately omits it (because today the only meaningful
shape was list). Adding it onto INBOX_AXES + a small dispatcher in
`onResult` gives us cards + calendar for free.
Everything else (sidebar entry, /api/views machinery, FilterBar URL
codec, RowAction validation) carries through unchanged.
---
## 2. Event-type catalogue for inbox v1 (Q1)
This is the only design pick that requires a head/m signal. **Open
question Q1 in §9 — defaulting to (A) until head answers.**
### (R) Recommendation (A): curated subset
Sources: `[approval_request, project_event]`.
**Approval requests:** all rows whose `viewer_role=any_visible` AND
status ∈ {pending} by default; the existing chip cluster
(approver_eligible / self_requested / any_visible) stays. Decided
requests are filtered by the chip, not hidden by source-removal — so a
user who wants to see "what got approved this week" toggles the status
chip rather than the source.
**Project events:** filter by `event_type ∈ InboxProjectEventKinds`
where InboxProjectEventKinds is a new sub-list of KnownProjectEventKinds:
| event_type | In inbox v1? | Reason |
|-------------------------|--------------|---------------------------------------------------------------------|
| `project_created` | no | The author already saw the page; not news to the team yet (the team grows post-creation). |
| `project_archived` | **yes** | High-signal lifecycle event ("Akte XY wurde archiviert"). |
| `project_reparented` | **yes** | Hierarchy moves matter to everyone with access. |
| `project_type_changed` | **yes** | Same reason. |
| `status_changed` | no | Currently too granular; surface in Verlauf, revisit if m disagrees. |
| `deadline_created` | **yes** | New deadline on a project I can see — exactly the kind of event m named ("we should also display new events"). |
| `deadline_completed` | **yes** | Likewise. |
| `deadline_reopened` | **yes** | Likewise. |
| `deadline_updated` | **yes** | Currently in DB (11 rows live) but not in KnownProjectEventKinds — add it. |
| `deadline_deleted` | **yes** | Likewise — add to KnownProjectEventKinds. |
| `deadlines_imported` | **yes** | Bulk-import event surfaces what got added. |
| `appointment_created` | **yes** | |
| `appointment_updated` | **yes** | |
| `appointment_deleted` | **yes** | |
| `note_created` | **yes** | A note is "someone said something about this project". High-signal; add to KnownProjectEventKinds. |
| `our_side_changed` | **yes** | Party-side flip; high-signal, add to KnownProjectEventKinds. |
| `member_role_changed` | no | Admin churn; would dominate active users' inbox. Revisit slice B. |
| `*_approval_requested` | **no — de-duped** | The approval_request row itself carries the signal; the audit event is the same fact in a different table. Filtering it out avoids duplicate inbox entries. |
| `*_approval_approved/rejected/revoked` | **no — de-duped** | Same reason. The approval_request row's status flip is what the user sees. |
| `*_approval_changes_suggested` | **no — de-duped** | Same. |
| `approval_decided` | no | This is the umbrella audit-only kind; superseded by the approval_request row. |
| `checklist_*` | no | Low signal; checklists are surfaced on the project's checklist page. |
The de-dup pattern means: if a row exists in `approval_requests` for an
entity, the corresponding `*_approval_*` project_event is **not** shown
in the inbox — we trust the approval_request row.
### Alternative (B): everything in KnownProjectEventKinds + approvals
Simpler — no curated sub-list, no de-dup. Two drawbacks:
1. `*_approval_*` duplicates would render twice per request.
2. `status_changed` and `member_role_changed` are admin churn; in firm
tests both would dominate.
If head picks B, we need at minimum the `*_approval_*` de-dup; otherwise
the inbox renders the same fact twice.
### Alternative (C): minimal — approvals + appointment_* + deadline_*
Tightest set. Drops notes + our_side_changed + project_*. Risk: m's
brief literally says "new events that relate to one's projects" — notes
and side changes ARE such events. C feels too narrow.
---
## 3. Read/unread model (Q3 → R: high-watermark cursor)
### (R) Decision: per-user high-watermark `inbox_seen_at`
**Schema:**
```sql
ALTER TABLE paliad.users
ADD COLUMN inbox_seen_at timestamptz NULL;
```
NULL means "never visited" → everything counts as unread. The high-water
cursor advances exactly when the user POSTs to
`/api/inbox/mark-all-seen` (UI affordance: a button in the inbox header
+ implicit advance on page-mount, see Slice A wiring below).
### Why cursor, not per-item
m's recommendation: cursor. Mine matches: single column, no fan-out
table, covers the common case ("I checked my inbox, mark everything
read"). Per-item dismiss is Slice C — opt-in only if the cursor proves
inadequate. The risk we're guarding against: a single high-value pending
approval that's a week old gets buried by 80 fresh deadline_updated
events; the user clears the badge and may now never look at the
approval. Mitigation: **approval_requests with status=pending never
fall behind the cursor** — they count toward the badge regardless of
seen_at. This is a tiny conditional in the count query (Slice A).
### Cursor advance behaviour
- **Explicit:** "Alles als gelesen markieren" button in the inbox
header. POSTs `/api/inbox/mark-all-seen`; server sets
`inbox_seen_at = now()`.
- **Implicit:** when the page mounts AND the bar surfaces at least one
row that's newer than the current cursor, the *new* cursor is
remembered locally as the timestamp of the **newest visible row**.
We do **not** auto-advance the server cursor on mount — too easy to
lose items behind a stray pageview. The "neu" highlight on rows
newer than the saved cursor is the silent UX. Explicit click is the
one and only path to clearing the badge.
### `unread_only` axis
New filter-bar axis (Slice A):
```ts
// types.ts
unread_only?: boolean;
```
When `true`, the bar overlays a FilterSpec predicate:
`row.event_date > inbox_seen_at` (substrate-side filter; for project_events
that's `pe.created_at > $cursor`, for approval_requests that's
`requested_at > $cursor` OR `status='pending'` per the carve-out above).
Default: **unread_only=true** for first paint (per Slice A — landing on
the inbox shows you what's new). The "Alle" chip flips it off so the
user can see history.
---
## 4. Filter contract
The bar surfaces these axes on `/inbox` (`INBOX_AXES` constant in
`client/inbox.ts`):
| Axis | Why on /inbox | New? |
|--------------------------|----------------------------------------------------------------------|------|
| `time` | "Last 30 days" (default) with chip cluster + "Älter anzeigen" . | already |
| `project` | Single-select autocomplete from visible projects. | already |
| `approval_viewer_role` | "Zur Genehmigung" / "Eigene Anfragen" / "Alle sichtbaren". | already |
| `approval_status` | pending / approved / rejected / revoked / changes_requested. | already |
| `approval_entity_type` | Frist / Termin (chip pair). | already |
| `project_event_kind` | Chip cluster over InboxProjectEventKinds. | already |
| **`unread_only`** | Boolean toggle ("Nur ungelesen" / "Alle"); defaults to ungelesen. | **Slice A new axis** |
| `shape` | list / cards / calendar. | already in `AxisKey`, not yet on `/inbox` |
| `sort` | Newest first (default) / oldest first. | already |
| `density` | comfortable / compact. | already |
**Default landing state** for a brand-new pageview:
`?time=past_30d&unread_only=true&a_status=pending&shape=list&sort=date_desc`.
Bookmarks from older clients (e.g. the legacy `?tab=pending-mine`)
still work because `client/inbox.ts:4658` already applies the legacy
tab → `a_role` redirect at hydration.
### Source-removal not exposed as an axis
Users do **not** see a "show approvals only / show events only" chip.
The signal we want is "what's new across my projects"; splitting the
two via the filter row is busywork. If they want approvals-only they
chip-pick `project_event_kind` empty + status=any (or future axis pick
`source=approval_request`). If feedback shows otherwise after Slice A
ships, we add the axis in Slice B trivially (`Sources` is a
spec.Sources literal flip).
---
## 5. View toggle implementation plan (Q5 → R: list / cards / calendar)
The pattern `/events` uses today (see `frontend/src/events.tsx:107141`
for the `<div className="events-view-selector">` block and
`client/events.ts:617650` for the `applyView` function):
- One chip cluster `data-event-view="cards|list|calendar"`.
- Active class toggle.
- Per-shape `display: none` on the table-wrap / cards-wrap / cal-wrap
hosts.
- For calendar, `mountCalendar()` constructs a month/week/day grid
into a dedicated `events-calendar-wrap` host; the handle is destroyed
on shape-leave so its URL state doesn't leak into the other shapes.
### Mapping onto /inbox
The cleanest path: **use `filter-bar`'s built-in `shape` axis instead of
a per-page selector.** The axis already round-trips into the URL via
`url-codec.ts` and serialises into `RenderSpec.Shape`. `client/inbox.ts`
just needs:
1. Add `"shape"` to `INBOX_AXES`.
2. Dispatch in the `onResult` callback by `effective.render.shape`:
```ts
onResult: (result, effective) => {
switch (effective.render.shape) {
case "cards": return paintCards(result.rows, effective.render, ...);
case "calendar": return paintCalendar(result.rows, ...);
case "list":
default: return paintList(result.rows, effective.render, ...);
}
}
```
3. The renderers exist already: `renderCardsShape` in
`views/shape-cards.ts`, `renderCalendarShape` in
`views/shape-calendar.ts`, `renderListShape` in `views/shape-list.ts`.
The only piece of new code is the per-shape host-clearing on switch
(so we don't leak a stale shape's DOM into the new host).
### Calendar shape — items without dates
Calendar can only render rows with a calendar-mappable date. Today:
- **approval_request:** `requested_at` (timestamp). Maps fine, but
shows up as a single point — rendering an approval-request on a month
grid is semantically "you got asked on this day". OK for v1.
- **project_event:** `created_at`. Same shape.
- **deadline:** `due_date`. Already supported.
- **appointment:** `start_at`. Already supported.
So every row in the inbox v1 has a calendar position. No
need to filter rows on calendar-mount. **One caveat:** the calendar
shape currently doesn't render action affordances (approve/reject) — it
opens a detail dialog on click. Slice B accepts that: clicking an
approval row on the calendar opens the inbox-list-style detail in a
modal (re-using the existing per-row /api/approval-requests/{id}
fetch). Out of scope for Slice A.
### Cards shape — day-grouped chronological cards
`shape-cards.ts` groups by day and renders one card per row, with
title + meta + actor. The approval-card layout there is the standard
card (no approve buttons — same caveat as calendar). For Slice B, we
extend `shape-cards.ts` to detect `row.kind === "approval_request"
&& row.detail.status === "pending"` and stamp the approve/reject button
strip inline. The DOM template is the same as
`shape-list.ts:renderApprovalRow`, so most of the work is hoisting that
template into a shared util.
---
## 6. Backend aggregation service (Q6 → R: reuse RunSpec)
**Decision: do not build a new aggregation service.** The
substrate-level work is exactly two edits:
### 6.1 InboxSystemView (system_views.go:103144)
```go
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{
SourceApprovalRequest,
SourceProjectEvent,
},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"}, // default; bar can override
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds, // curated subset
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateDesc, // newest first — different from today's date_asc
RowAction: RowActionInbox, // new — see §6.3
},
},
}
}
```
Curated sub-list lives in `filter_spec.go` next to KnownProjectEventKinds:
```go
var InboxProjectEventKinds = []string{
"project_archived", "project_reparented", "project_type_changed",
"deadline_created", "deadline_completed", "deadline_reopened",
"deadline_updated", "deadline_deleted", "deadlines_imported",
"appointment_created", "appointment_updated", "appointment_deleted",
"note_created", "our_side_changed",
}
```
(With Q1 pick A locked. If head picks B, drop the InboxProjectEventKinds
list and remove the `EventTypes` predicate. If head picks C, narrow the
list to deadline_* + appointment_* only.)
KnownProjectEventKinds in `filter_spec.go:186` needs **additions** so
`note_created`, `our_side_changed`, `deadline_updated`, `deadline_deleted`,
`deadlines_imported` are valid filter values — without this the
validator rejects the InboxSystemView spec. Migrate this list at the
same time. (`event_categories` and similar grouping infra are already
covered by `event_category_service.go` and won't move.)
### 6.2 Approval-duplicate suppression
In `view_service.runProjectEvents` (or in a tiny new predicate helper),
skip `event_type LIKE '%_approval_%'` when source-set includes
ApprovalRequest. This avoids the double-count described in Q1 §2.
Implementation: extend `allowedProjectEventKinds` (view_service.go:649) to
auto-drop the `*_approval_*` strings when the same RunSpec already
fans out the approval_request source. One conditional, six lines.
### 6.3 Mixed-row row_action
`shape-list.ts` today: `row_action="approve"` → calls
`renderApprovalList(rows)` which assumes every row is an approval.
Need a new value:
```go
// render_spec.go
const RowActionInbox ListRowAction = "inbox"
```
And register it in `KnownRowActions`.
Frontend (`shape-list.ts`):
```ts
if (rowAction === "inbox") {
host.appendChild(renderInboxList(sorted));
return;
}
```
Where `renderInboxList(rows)`:
- approval_request rows → existing `renderApprovalRow(row)` template (the
per-row factor-out from `renderApprovalList`).
- project_event rows → a new `renderProjectEventRow(row)` template:
timestamp + actor + title + project chip + optional "Öffnen" link
to the underlying entity (deadline / appointment / note / project
detail). Modelled on the Verlauf row in
`client/projects-detail.ts:651700` (`.entity-event` markup).
This makes the inbox stamping kind-aware. The
existing `wireApprovalActions` continues to find buttons via class
`.views-approval-action` and works unchanged.
### 6.4 Endpoints — what's new vs reused
| Path | Behaviour | Slice |
|-------------------------------------|----------------------------------------------------------|-------|
| `GET /api/views/inbox/run` | **Already exists** — fans the InboxSystemView spec. | A reuse |
| `GET /api/inbox/count` | **Behaviour change:** count includes unread project_events on visible projects + pending approval_requests (the latter regardless of cursor). | A |
| `POST /api/inbox/mark-all-seen` | New. Sets `users.inbox_seen_at = now()` for the caller. | A |
| `GET /api/inbox/pending-mine` | **Keep** — backwards-compat for clients (sidebar bell may still use it). | unchanged |
| `GET /api/inbox/mine` | **Keep** — used by the saved view `inbox-mine`. | unchanged |
The two `/api/inbox/{pending-mine,mine}` endpoints stay because they're
narrower-than-RunSpec optimisations and used by the dashboard's
`loadInboxSummary`. No reason to remove them.
### 6.5 InboxSummary on the dashboard (out of scope, but flag)
`DashboardData.InboxSummary` (dashboard_service.go:89) currently counts
only pending approvals. If Slice C extends the badge count to include
unread project_events, the dashboard widget also needs to swap
`PendingCountForUser` for the new unified count — keep this as a small
follow-up after Slice A ships and the cursor semantics are proven.
---
## 7. Slice plan
### Slice A — Project-event aggregation + read cursor + list view
**Goal:** /inbox shows pending approvals + curated project_events for
visible projects in the last 30 days, with the new "Nur ungelesen"
toggle. List view only.
Tasks:
1. **Migration `NNN_inbox_seen_at.up.sql`:**
`ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL;`
2. **`filter_spec.go`:** extend `KnownProjectEventKinds` (add
`note_created`, `our_side_changed`, `deadline_updated`,
`deadline_deleted`, `deadlines_imported`). Add
`InboxProjectEventKinds` (curated subset, Q1=A).
3. **`system_views.go`:** rewrite `InboxSystemView` per §6.1 with
both sources, `HorizonPast30d`, `SortDateDesc`,
`RowAction=RowActionInbox`.
4. **`render_spec.go`:** add `RowActionInbox`, register in
`KnownRowActions`.
5. **`view_service.go`:** in `runProjectEvents`, auto-drop
`*_approval_*` event_types when ApprovalRequest is in
`spec.Sources` (§6.2).
6. **`approvals.go`:**
- New handler `handleInboxMarkAllSeen` →
`UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1`.
- Modify `handleInboxCount` to return
`pending_approvals_count + unread_project_events_count`. SQL
in approval_service.go: one new method
`UnseenInboxCountForUser(userID)` returning that union. Keep
`PendingCountForUser` (dashboard still uses it).
7. **`shape-list.ts`:** factor `renderApprovalRow(row)` out of
`renderApprovalList`. Add `renderInboxList(rows)` that dispatches
per `row.kind`. Wire `row_action="inbox"` to it.
8. **`client/inbox.ts`:**
- Add the `unread_only` axis to `INBOX_AXES` and wire to a FilterSpec
overlay (sub-spec `Time.Horizon=Past30d` AND
filter predicate "newer than cursor OR pending-approval").
- Render "Alles als gelesen markieren" button in the page header
(in `inbox.tsx`); on click POST `/api/inbox/mark-all-seen`,
refresh bar + badge.
- Listen for cursor update (server response) and refresh.
9. **Sidebar badge (`client/sidebar.ts:initInboxBadge`):** unchanged code
path, but the new server count includes project_events. Add no client
changes for v1 — server returns the wider count.
10. **i18n:** new keys —
- `inbox.title.feed` ("Inbox") replaces "Genehmigungen" in the page
header (since the page is now more than approvals).
- `inbox.subtitle.feed` ("Neuigkeiten zu Ihren Projekten und offene
Genehmigungen.").
- `inbox.action.mark_all_seen` ("Alles als gelesen markieren").
- `inbox.axis.unread_only.on/off`.
- `inbox.empty.feed` ("Keine Neuigkeiten in den letzten 30 Tagen.").
- `views.col.event_kind` (for the kind column in
table-density list).
- DE primary, EN secondary, both in `i18n.ts`.
11. **Tests:** `system_views_test.go` covers the
InboxSystemView spec shape; new test for the de-dup helper in
view_service. `approval_service_test.go` adds tests for the new
`UnseenInboxCountForUser` method. New
`inbox_seen_at_test.go` covers the cursor migration + the POST
handler.
12. **Verify** the page renders for a sample user with both event types
visible, "Nur ungelesen" toggles correctly, mark-all-seen clears the
badge, the project-events deduplicate against approval requests.
### Slice B — Cards + calendar shape toggles
**Goal:** `?shape=cards` and `?shape=calendar` work on /inbox; users can
switch via the bar's shape chip. Approval rows on cards/calendar are
*read-only* (open detail modal on click; no inline approve/reject).
Tasks:
1. **`client/inbox.ts`:** add `"shape"` to `INBOX_AXES`. Add the
per-shape host divs to `inbox.tsx` (one for cards, one for calendar)
matching the `/events` pattern. Implement `onResult` dispatch.
2. **`shape-cards.ts`:** when `row.kind==="approval_request"` AND
`row.detail.status==="pending"`, stamp the approval row template
inline. Hoist the template out of `shape-list.ts` if reuse pays.
3. **`shape-calendar.ts`:** approval_request rows render as date-point
chips; click opens a detail modal. The modal reuses the existing
`approval-edit-modal` for suggest-changes when the user is the
approver; otherwise a read-only summary.
4. **CSS:** ensure `.entity-event` and `.views-approval-row` markup
coexist on the cards view without z-index clashes; lightweight
targeting via `.views-cards-list[data-surface="inbox"]`.
5. **Tests:** shape toggle persistence via URL codec (already covered
in `url-codec.test.ts`; add one inbox-surface case).
### Slice C — Badge upgrade + per-item dismiss (deferred)
**Goal:** sidebar badge reflects unified count; per-item dismiss for
power-users.
Tasks:
1. **`paliad.inbox_dismissals` table** —
`(user_id, source, row_id, dismissed_at)` PK `(user_id, source, row_id)`.
"source" is `approval_request` / `project_event`; "row_id" is the
row's UUID. New endpoint `POST /api/inbox/dismiss` body
`{source, row_id}`. RunSpec for inbox subtracts dismissed rows.
2. **`/api/inbox/count`:** subtract dismissed rows from the count.
3. **Dashboard widget:** `DashboardData.InboxSummary` swaps to a new
`UnifiedInboxSummary` that mirrors the page count. Backwards-compat
JSON: keep old fields, add `total_count` and `top_unified`.
4. **Empty-state:** "Alle Einträge gelesen — gut gemacht."
5. **Optional `member_role_changed` etc.:** if Slice A surfaces that
one of the excluded event_types is actually wanted, this slice opens
up `InboxProjectEventKinds` accordingly.
### Why Slice A alone is shippable
Slice A delivers m's full ask except the cards/calendar views — which
are aesthetic shape toggles, not data changes. Slice A gives:
- Inbox feed across approvals + project_events for visible projects
- Project / type / time / read-state filters
- Newest-first list with mark-all-seen
- Sidebar badge reflects unified unread count (server-side)
Slice B + C are layer cake on top with no schema or substrate changes.
---
## 8. Out of scope
- **Push notifications.** Telegram / WhatsApp / email — different
channel concerns, separate design.
- **Cross-user inbox views.** No "admin sees others' inboxes" in v1.
- **Pinning / starring items.** Not in m's ask. If feedback after Slice
A wants it, opens its own design.
- **Paliadin chat unread.** Not part of project_events; paliadin lives
in its own pane. Slice C could surface a banner if asked.
- **Replacement of the existing /api/inbox/{pending-mine,mine} endpoints.**
They stay because the dashboard's `loadInboxSummary` uses them and
no benefit to consolidating.
- **Detail-page changes.** Clicking a project_event row in the inbox
navigates to the existing entity detail page (deadline, appointment,
note); we don't build a new "event detail" view.
- **InboxSummary on the dashboard.** Out of Slice A. Slice C upgrades
it; for now the widget keeps showing approval-only.
---
## 9. Open questions for m
Defaulted to (R) per the inventor protocol — only **Q1** is escalated
to head for explicit confirmation because it changes the
inbox's surface area. Everything else falls to the recommended pick
unless head/m flag otherwise.
**Q1 — Event-type catalogue (material pick, head answered):**
**LOCKED = A** (curated subset with `*_approval_*` de-dup). Head added
`member_role_changed` to the curated list with a Slice B narrowing
follow-up + a coarser `inbox_focus` chip cluster on the bar. Full
decision recorded in §12.
**Q2 — Time window:** (R) Past30d default + chip cluster
(today / past_7d / past_30d / past_90d / any) + custom range via the
existing time picker. Locked unless head overrides.
**Q3 — Read/unread model:** (R) High-watermark cursor
(`users.inbox_seen_at`). Pending approval_requests carry forward even
when older than the cursor — guards against burying a high-value
approval. Per-item dismiss is Slice C, opt-in. Locked.
**Q4 — Filters surfaced on the bar:** (R) time / project /
approval_viewer_role / approval_status / approval_entity_type /
project_event_kind / unread_only / shape / sort / density. Locked
unless head wants `source` (approvals-only vs events-only chip)
added — defaulting to "not in v1".
**Q5 — View toggle parity with /events:** (R) list (default — newest
first) / cards (day-grouped) / calendar (date-point). Wired via the
filter-bar's existing `shape` axis, not a per-page selector. Locked.
**Q6 — Architecture:** (R) Reuse `view_service.RunSpec` with both
sources in the InboxSystemView spec; no new aggregation service.
Approval-event de-dup applied in `runProjectEvents`. Locked.
**Q7 — Notification badge:** (R) Yes — Slice A makes the existing
`/api/inbox/count` return the unified unread count; sidebar badge
client unchanged. Locked.
**Q8 — Acknowledgement flow:** (R) Approval rows keep
approve/reject/revoke buttons inline (list shape only). project_event
rows have no inline action — click row → navigate to the underlying
entity. Cursor advance is via "Alles als gelesen markieren" only —
no per-row mark-read in v1. Locked.
**Q9 — Empty-state copy:** (R) "Keine Neuigkeiten in den letzten 30
Tagen." (DE primary) / "No updates in the last 30 days." (EN). The
existing admin nudge for unseeded approval_policies stays untouched.
Locked.
---
## 10. Risks + mitigations
- **Performance.** `runProjectEvents` reads up to LIMIT 500 rows per
user-call; with two sources unioned + 30-day window + visibility
predicate this should stay under 50ms on the live shape (project
count ~100, events/day low double digits). If
it doesn't, partial index hint: `paliad.project_events (created_at DESC)
WHERE event_type IN (curated list)` — Slice A optional, add if
EXPLAIN shows a seq scan in dev.
- **De-dup correctness.** Suppressing `*_approval_*` events in the
project_event source relies on the approval_request row being the
authoritative signal. **Edge case:** a request gets revoked, then
re-requested — both audit events exist. Both correspond to a single
approval_request row at any moment (the latter via the partial-index
upsert). De-dup stays valid.
- **Cursor advance race.** If two browser tabs both POST mark-all-seen,
the second wins (now() wins). Acceptable. If a user reads in tab A
then clicks an item in tab B that was created between the two reads,
tab A's "Alles als gelesen" advances past that newer item without
the user seeing it. Mitigation: server-side, `mark-all-seen` accepts
an optional `?up_to=<iso>` so the client can pin to the timestamp of
the newest visible row. Slice A wires this.
- **shape-list factor-out.** Pulling `renderApprovalRow` out of
`renderApprovalList` risks regressions on the *current* /inbox. Cover
with a snapshot/golden test on the approval row markup in Slice A
before the dispatch change.
- **Sidebar bell badge cap.** Current code clamps at "9+". Once we add
project_events, the count can easily exceed 100. Keep the "9+" clamp
for visual reasons — but make the page header show the *exact* count
("123 neu") so the user knows what's behind it.
- **Q1 fallback.** If head doesn't reply before Slice A coder shift
starts, the (R) pick A locks. If head later picks B or C, the only
change is the `InboxProjectEventKinds` list literal in
`filter_spec.go` — no schema impact, no migration change. Cheap to
flip.
---
## 11. Build/test verify list (Slice A done-when)
1. `make build` clean.
2. `go test ./...` passes; new tests cover:
- InboxSystemView spec shape includes both sources + curated kinds.
- `runProjectEvents` drops `*_approval_*` when ApprovalRequest is in spec.
- `UnseenInboxCountForUser` returns expected count for cursor and pending-approval combinations.
- POST `/api/inbox/mark-all-seen` updates the column.
- URL codec round-trip for `unread_only` axis.
3. Inbox loads at `/inbox` with project-event rows interleaved with
approval rows in date-desc order.
4. "Nur ungelesen" chip toggles between unread (with pending-approval
carve-out) and full feed.
5. "Alles als gelesen markieren" advances cursor; bar refreshes;
badge clears (except for any still-pending approvals).
6. Sidebar bell badge count is the unified number (approval + unread events).
7. Existing approve/reject/revoke + suggest-changes flows on inbox
rows still work unchanged.
8. `?tab=mine` legacy redirect still hits the right state.
9. Bilingual labels render (DE/EN toggle).
That's the doneness bar for Slice A.
---
## §12 — m's decisions (head 2026-05-25 11:30)
Head replied to the `mai instruct head` escalation; folded in below.
**Q1 (Event-type catalogue): A — locked.** Curated subset with
`*_approval_*` de-dup. Tracks Verlauf, matches m's framing ("new events
that relate to one's projects"), avoids double-counting approval audit
events against the approval_request row.
Locked InboxProjectEventKinds:
- IN: `project_archived`, `project_reparented`, `project_type_changed`,
`deadline_created`, `deadline_completed`, `deadline_reopened`,
`deadline_updated`, `deadline_deleted`, `deadlines_imported`,
`appointment_created`, `appointment_updated`, `appointment_deleted`,
`note_created`, `our_side_changed`, **`member_role_changed`**
(added by head — see refinement #1).
- OUT (audit duplicates of approval_requests): every `*_approval_*` event.
- OUT (too granular / authoring noise): `status_changed`,
`project_created`, `checklist_*`.
**Refinement 1 — `member_role_changed` visibility predicate.**
Head wants this kind included but narrowed: surface the row only when
the role change applies to the **viewer themselves** or someone above
them in the project tree (i.e. impacts the viewer's permissions / chain
of command), not when it's a peer's role changing on a project the
viewer happens to see.
- Slice A: include `member_role_changed` in
`InboxProjectEventKinds` without the narrowing predicate. The row
will appear for everyone who can see the project — over-surfacing but
not wrong. This keeps Slice A's MVP scope tight.
- Slice B: add a per-row narrowing filter on top of the inbox source
(likely a small extension to `runProjectEvents` that, when
`event_type='member_role_changed'`, inspects `metadata.affects_user_id`
+ walks the project-membership predicate before emitting). The
metadata shape is already written by the responsible handler; verify
+ lock the filter in B.
Q2-Q9 all default to (R) per the inventor protocol.
**Refinement 2 — Filter chip copy.**
For the visible chip cluster in the bar, head wants user-readable groupings,
not raw event-kind names. The bar today exposes `project_event_kind`
as one chip per kind (rendered via the
`event.title.<kind>` i18n key). For the inbox surface, surface a
**coarser grouping chip cluster** ahead of that:
- "Genehmigungen" — narrows to `Sources=[approval_request]` only.
- "Genehmigungen + Termine" — adds appointment_* event_kinds + the
approval_entity_type=appointment slice of approvals.
- "Genehmigungen + Fristen" — adds deadline_* event_kinds + the
approval_entity_type=deadline slice of approvals.
- "Alles" — default; both sources, full curated kinds list.
Implementation: a new axis `inbox_focus` (Slice A, additive — replaces
the lower-level `project_event_kind` chip's *default visibility* in the
inbox UI; advanced users still see `project_event_kind` if they expand
the bar). The four values map to FilterSpec overlays that tweak
`Sources` + per-source `EventTypes`. Coder owns the exact chip-text
final copy and the placement (probably first axis in `INBOX_AXES`).
The lower-level `project_event_kind` chip stays in `INBOX_AXES` as an
advanced override for power users — when active, it overrides the
`inbox_focus` chip's per-kind defaults.
---
### What changes for Slice A as a result
Doc deltas vs the draft text above:
1. **§2 / §6.1:** add `member_role_changed` to InboxProjectEventKinds.
Note Slice B narrowing follow-up.
2. **§4 / §5:** front of the bar gets a new `inbox_focus` axis
(4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default
"Alles". `project_event_kind` stays available as an advanced chip,
visible after the user expands the bar's overflow section.
3. **§7 Slice A task list:** add task —
"**12a.** New `inbox_focus` axis (`filter-bar/types.ts`,
`axes.ts`). FilterSpec overlay translates the chip value to a
`(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)`
triple. URL codec round-trips."
4. **§11 Slice B done-when:** add — "`member_role_changed` narrowing
predicate is in place; rows surface only when the change affects
the viewer's permissions chain."
No schema changes from the head's adjustments. The `inbox_focus` axis
is a pure UI/overlay primitive; nothing about the InboxSystemView spec
schema moves.

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,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,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";
@@ -245,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"),
@@ -252,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"),
@@ -370,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());
@@ -377,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
@@ -384,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());

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

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

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

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

@@ -1,27 +1,35 @@
// t-paliad-216 Slice B — modal for the "Suggest changes" approval action.
// 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 of the date-allowlist
// fields (per entity_type) 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.
// 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 (v1):
// - update-lifecycle only — the suggest_changes button is hidden for
// create / complete / delete lifecycles in shape-list.ts, so the modal
// never opens on them. If callers somehow trigger it on an unsupported
// lifecycle, openApprovalEditModal() resolves with null (cancel) after
// surfacing the unsupported-lifecycle copy.
// - Hard-coded fields per entity_type. We deliberately don't build a
// generic field-editor framework — only two entity_types exist and
// both have small fixed allowlists.
// 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 original proposed values
// preImage: {...}, // pre-mutation values (for diff display)
// 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
@@ -30,12 +38,25 @@
// }
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 {
@@ -43,213 +64,372 @@ export interface ApprovalEditModalResult {
note: string;
}
// Per-entity-type editable field allowlist. Matches buildRevertSetClauses
// in internal/services/approval_service.go — the server side rejects any
// key outside this set anyway. Keeping the UI list in sync is a
// safety-vs-confusion trade-off: a stray key here would be silently
// dropped server-side, so it's harmless but misleading.
const DEADLINE_FIELDS: ReadonlyArray<{ key: string; type: "date" }> = [
{ key: "due_date", type: "date" },
{ key: "original_due_date", type: "date" },
{ key: "warning_date", type: "date" },
// 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<{ key: string; type: "datetime-local" }> = [
{ key: "start_at", type: "datetime-local" },
{ key: "end_at", type: "datetime-local" },
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 function openApprovalEditModal(
export async function openApprovalEditModal(
args: ApprovalEditModalArgs,
): Promise<ApprovalEditModalResult | null> {
return new Promise((resolve) => {
if (args.lifecycleEvent !== "update") {
// Defence-in-depth: shape-list.ts hides the button for non-update
// lifecycles, but if some caller bypasses that gate, fail cleanly.
window.alert(t("approvals.suggest.unsupported_lifecycle"));
resolve(null);
return;
}
if (args.lifecycleEvent !== "update") {
window.alert(t("approvals.suggest.unsupported_lifecycle"));
return null;
}
document.getElementById("approval-edit-modal")?.remove();
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>;
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";
const overlay = document.createElement("div");
overlay.id = "approval-edit-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args, fields, original, preImage);
document.body.appendChild(overlay);
body.appendChild(renderIntro());
body.appendChild(renderFieldsSection(fields, original, preImage));
const close = (result: ApprovalEditModalResult | null) => {
overlay.remove();
document.removeEventListener("keydown", onKey);
resolve(result);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close(null);
};
document.addEventListener("keydown", onKey);
overlay.querySelectorAll("[data-suggest-cancel]").forEach((el) =>
el.addEventListener("click", () => close(null)),
);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-suggest-submit]");
const noteEl = overlay.querySelector<HTMLTextAreaElement>("[data-suggest-note]");
const inputs = Array.from(
overlay.querySelectorAll<HTMLInputElement>("[data-suggest-field]"),
);
const refreshSubmit = () => {
if (!submitBtn) return;
const dirty = inputs.some((el) => {
const orig = formatFieldForInput(original[el.dataset.suggestField || ""]);
return el.value !== orig;
});
const hasNote = !!(noteEl && noteEl.value.trim());
submitBtn.disabled = !(dirty || hasNote);
submitBtn.title = submitBtn.disabled
? t("approvals.suggest.submit_disabled_hint")
: "";
};
inputs.forEach((el) => el.addEventListener("input", refreshSubmit));
noteEl?.addEventListener("input", refreshSubmit);
refreshSubmit();
const form = overlay.querySelector<HTMLFormElement>("[data-suggest-form]");
form?.addEventListener("submit", (e) => {
e.preventDefault();
if (submitBtn?.disabled) return;
// Build counter_payload from inputs that differ from original.
// Fields unchanged stay out of the payload — the server's
// buildRevertSetClauses only writes the keys it sees, so we don't
// need to send untouched fields.
const counterPayload: Record<string, unknown> = {};
for (const el of inputs) {
const key = el.dataset.suggestField || "";
const orig = formatFieldForInput(original[key]);
if (el.value !== orig) {
counterPayload[key] = formatFieldForServer(el.value, el.type);
}
// 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");
}
close({
counterPayload,
note: (noteEl?.value ?? "").trim(),
});
});
})();
}
// Focus first input (or note if no fields).
(inputs[0] ?? noteEl)?.focus();
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 renderShell(
args: ApprovalEditModalArgs,
fields: ReadonlyArray<{ key: string; type: string }>,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): string {
const entityLabel = esc(t(("approvals.entity." + args.entityType) as never));
const fieldRows = fields
.map((f) => {
const label = fieldLabel(args.entityType, f.key);
const value = esc(formatFieldForInput(original[f.key]));
const preVal = formatFieldForInput(preImage[f.key]);
const preHint = preVal
? `<span class="suggest-field-prehint">${esc(t("approvals.diff.before"))}: ${esc(preVal)}</span>`
: "";
return `
<label class="suggest-field">
<span class="suggest-field-label">${esc(label)}</span>
<input type="${esc(f.type)}" data-suggest-field="${esc(f.key)}" value="${value}" />
${preHint}
</label>
`;
})
.join("");
return `
<div class="modal modal-approval-suggest" role="dialog" aria-modal="true" aria-labelledby="approval-suggest-title">
<header class="modal-header">
<h2 id="approval-suggest-title">${esc(t("approvals.suggest.modal_title"))}${entityLabel}</h2>
<button type="button" class="modal-close" data-suggest-cancel aria-label="${esc(t("approvals.suggest.cancel"))}">&times;</button>
</header>
<form data-suggest-form>
<div class="modal-body">
<p class="suggest-intro muted">${esc(t("approvals.suggest.intro"))}</p>
<div class="suggest-fields">${fieldRows}</div>
<label class="suggest-note">
<span class="suggest-field-label">${esc(t("approvals.suggest.note_label"))}</span>
<textarea data-suggest-note rows="3" placeholder="${esc(t("approvals.suggest.note_placeholder"))}"></textarea>
</label>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-suggest-cancel>${esc(t("approvals.suggest.cancel"))}</button>
<button type="submit" class="btn btn-primary" data-suggest-submit disabled>${esc(t("approvals.suggest.submit"))}</button>
</footer>
</form>
</div>
`;
function renderIntro(): HTMLElement {
const p = document.createElement("p");
p.className = "approval-suggest-intro muted";
p.textContent = t("approvals.suggest.intro");
return p;
}
// fieldLabel — pick the user-facing label for a given (entity_type, key)
// tuple. Reuses existing entity-field i18n where it exists so the same
// label that's used on the deadline / appointment edit forms also shows
// in this modal.
function fieldLabel(entityType: string, key: string): string {
const lookups: Record<string, string> = {
"deadline.due_date": t("deadlines.field.due" as never) || "Fälligkeitsdatum",
"deadline.original_due_date": "Ursprüngliches Fälligkeitsdatum",
"deadline.warning_date": "Warndatum",
"appointment.start_at": t("appointments.field.start" as never) || "Beginn",
"appointment.end_at": t("appointments.field.end" as never) || "Ende",
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,
};
return lookups[`${entityType}.${key}`] || key;
}
// formatFieldForInput — convert a server-side payload value to the format
// the <input> wants. Dates round-trip cleanly 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.
function formatFieldForInput(v: unknown): string {
// 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);
// Pure date: keep first 10 chars.
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// ISO timestamp: keep YYYY-MM-DDTHH:MM (drop seconds + tz).
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
if (m) return `${m[1]}T${m[2]}`;
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 the input element's string value back to
// a server-friendly shape. Date inputs send YYYY-MM-DD; datetime-local
// sends YYYY-MM-DDTHH:MM (we let the server interpret as local time, same
// as the existing entity-edit forms — there's no tz-shift specific to
// suggest-changes).
// 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 (!value) return null;
if (inputType === "date") return value; // YYYY-MM-DD
if (inputType === "datetime-local") return value; // YYYY-MM-DDTHH:MM
if (inputType === "date" || inputType === "datetime-local") {
return value || null;
}
return value;
}
// HTML-escape helper. Local to this module so the modal doesn't bring in a
// utility from elsewhere.
function esc(s: string): string {
return s.replace(/[&<>"]/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
default: return c;
}
});
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,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

@@ -8,6 +8,7 @@ import {
type FilterHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
// 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
@@ -125,8 +126,11 @@ 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: "upcoming", key: "events.filter.status.upcoming" },
{ value: "today", key: "deadlines.filter.today" },
{ value: "this_week", key: "deadlines.filter.thisweek" },
{ value: "next_week", key: "deadlines.filter.nextweek" },
@@ -140,7 +144,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "upcoming" : "pending";
return type === "appointment" ? "today" : "pending";
}
let currentType: EventTypeChoice = "deadline";
@@ -154,8 +158,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);
@@ -426,12 +432,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();
}
@@ -554,135 +561,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() {
@@ -703,12 +632,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) {
@@ -1010,12 +945,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;
@@ -1025,31 +958,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

@@ -2,11 +2,11 @@
// 3-step wizard: select proceeding -> enter date -> view timeline
//
// Rendering primitives (renderTimelineBody / renderColumnsBody /
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
// `./views/verfahrensablauf-core` and are shared with the
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
// click-to-edit — none of which Verfahrensablauf wants.
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
// date editor) live in `./views/verfahrensablauf-core` and are shared
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
// wants.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
@@ -22,6 +22,7 @@ import {
priorityRendering,
renderColumnsBody,
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
let lastResponse: DeadlineResponse | null = null;
@@ -186,12 +187,21 @@ interface ProjectOption {
// (Slice 3b) can scope the cascade by the project's jurisdiction
// without an extra fetch.
proceeding_type_id?: number | null;
// our_side carries which side the firm represents on this project
// (t-paliad-164). When a user selects an Akte, the perspective chip
// pre-locks to this value; a small hint above the strip flags the
// our_side carries which side the firm represents on this case
// project (Client Role; t-paliad-164, widened in t-paliad-222).
// When a user selects an Akte, the perspective chip pre-locks via
// ourSideToPerspective(); a small hint above the strip flags the
// pre-selection and the user can still click another chip to
// override. NULL/undefined leaves the chip unset (free-pick).
our_side?: "claimant" | "defendant" | "court" | "both" | null;
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
}
async function fetchProjects(): Promise<ProjectOption[]> {
@@ -250,6 +260,19 @@ function closeSaveModal() {
if (modal) modal.style.display = "none";
}
// preselectedProjectId returns the project the user picked in Step 1
// (if any) so the various save/add flows can default their project
// pickers to it. Carries through anywhere a "save to Akte" pop-out
// renders \u2014 preselection is *only* a default; the picker still
// renders every available project and the user can override.
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
// project should be pre-selected" on Add.
function preselectedProjectId(): string {
return currentStep1Context.kind === "project" && currentStep1Context.projectId
? currentStep1Context.projectId
: "";
}
async function openSaveModal() {
if (!lastResponse) return;
ensureSaveModal();
@@ -266,6 +289,7 @@ async function openSaveModal() {
sel.style.display = "";
noProjects.style.display = "none";
submit.disabled = false;
const preselected = preselectedProjectId();
sel.innerHTML = projects
.map((p) => {
const ref = (p.reference || "").trim();
@@ -273,9 +297,11 @@ async function openSaveModal() {
const label = ref
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: `${indent}${escHtml(p.title)}`;
return `<option value="${escAttr(p.id)}">${label}</option>`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
})
.join("");
if (preselected) sel.value = preselected;
}
const list = document.getElementById("frist-save-list")!;
@@ -430,54 +456,21 @@ function renderProcedureResults(data: DeadlineResponse) {
applyPendingFocus();
}
// openInlineDateEditor swaps the date span for a date input. On commit
// (blur or Enter), the override is recorded and the timeline re-fetched.
// On Escape, the editor closes without changing anything. An empty
// commit clears the override (lets the user revert to the calculated
// date or to the IsCourtSet placeholder).
function openInlineDateEditor(span: HTMLElement) {
const ruleCode = span.dataset.ruleCode!;
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
const editor = document.createElement("input");
editor.type = "date";
editor.className = "frist-date-edit-input";
editor.value = current;
const commit = (newValue: string) => {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
void calculate();
};
const cancel = () => {
editor.replaceWith(span);
};
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();
// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
function onDateEditCommit(ruleCode: string, newValue: string) {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
void calculate();
}
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
// openInlineDateEditor / wireDateEditClicks moved to
// ./views/verfahrensablauf-core.
function reset() {
selectedType = "";
@@ -648,21 +641,7 @@ document.addEventListener("DOMContentLoaded", () => {
// rules re-anchor on the user's date. Delegated on the container so
// it survives renderProcedureResults() innerHTML rewrites.
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) {
timelineContainer.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
openInlineDateEditor(target);
});
timelineContainer.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);
});
}
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);
@@ -1306,19 +1285,27 @@ function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
card.classList.add("is-expanded");
card.setAttribute("aria-expanded", "true");
const panel = buildCalcPanel(cardData, rulePills);
card.appendChild(panel);
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
// context is already known — the calc panel renders with that pill
// locked in and no "Which context?" picker. The card's pill list is
// hidden via CSS while is-expanded so the rules aren't listed twice.
// When the user clicked the card body (no autoSelectPill), the picker
// is the primary surface — still no duplicate pill list above it.
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
? rulePills.find((p) =>
p.proceeding?.code === autoSelectPill.dataset.proc
&& (autoSelectPill.dataset.focus
? p.rule_local_code === autoSelectPill.dataset.focus
: true))
: undefined;
// Auto-select the clicked pill if it's a rule pill; otherwise the
// first pill is preselected by buildCalcPanel.
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
}
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
card.appendChild(panel);
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
const panel = document.createElement("div");
panel.className = "fristen-card-calc";
// stopPropagation so clicks inside the panel don't bubble to the
@@ -1329,10 +1316,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
const lang = getLang();
const today = new Date().toISOString().split("T")[0];
// Pill picker (only when >1 rule pill).
const pickerHtml = rulePills.length <= 1
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
// Picker semantics (m/paliad#57 part 4):
// - lockedPill set → context known (user clicked a specific
// rule pill on the card). Render as a
// hidden input only; the calc panel shows
// no "Which context?" question. A small
// "ändern" link reopens the picker fieldset.
// - rulePills.length <= 1 → only one possible context, never a
// picker (hidden input carries the data).
// - otherwise → show the picker as primary surface; the
// card's pill list is hidden via CSS while
// the panel is open, so the user isn't
// asked the same thing twice.
let pickerHtml: string;
if (lockedPill) {
const procName = lockedPill.proceeding
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
: "";
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
pickerHtml = `<div class="fristen-card-calc-pill-locked">
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
</div>`;
} else if (rulePills.length <= 1) {
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
} else {
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
@@ -1346,6 +1361,7 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
</label>`;
}).join("")}
</fieldset>`;
}
panel.innerHTML = `
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
@@ -1398,6 +1414,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
void addCalcToProject(card, last);
});
// "ändern" — swap the locked-context caption for the full radio
// picker so the user can change context without collapsing the panel.
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
if (!card || !locked) return;
const fieldset = document.createElement("fieldset");
fieldset.className = "fristen-card-calc-pill-picker";
fieldset.setAttribute("role", "radiogroup");
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
fieldset.innerHTML = `
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
const src = p.legal_source_display || p.legal_source || "";
const isChecked = (p.proceeding?.code || "") === lockedProc
&& (p.rule_local_code || "") === lockedFocus;
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
</label>`;
}).join("")}`;
locked.replaceWith(fieldset);
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => scheduleCardCalc(card, 0));
});
});
return panel;
}
@@ -1601,6 +1649,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const dueLabel = formatDate(calc.dueDate);
const preselected = preselectedProjectId();
msgEl.innerHTML = `
<div class="fristen-card-calc-add-picker">
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
@@ -1609,7 +1658,8 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref ? `${indent}${ref}${p.title}` : `${indent}${p.title}`;
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
}).join("")}
</select>
</label>
@@ -1619,6 +1669,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
`;
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
if (preselected) sel.value = preselected;
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
msgEl.innerHTML = "";
addBtn.disabled = false;
@@ -1688,12 +1739,12 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
const ruleSection = rulePills.length === 0 ? "" : `
<div class="fristen-card-pills-section">
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
const triggerSection = triggerPills.length === 0 ? "" : `
<div class="fristen-card-pills-section">
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
@@ -2469,6 +2520,17 @@ interface EventCategoryNode {
let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
// Top-level cascade roots that represent forward-looking workflows ("I
// want to file X, what deadlines does my action trigger?") rather than
// the backward-looking calc the Fristenrechner is built for ("event Y
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
// remove these from the "Was ist passiert?" picker — they belong in a
// future forward-workflow tool, not here. The DB rows stay so that
// future tool can pick them back up; we just hide them at the UI layer.
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
"ich-moechte-einreichen",
]);
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
if (eventCategoryTree) return eventCategoryTree;
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
@@ -2477,7 +2539,8 @@ async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
const r = await fetch("/api/tools/fristenrechner/event-categories");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
const raw = (data.tree || []) as EventCategoryNode[];
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
return eventCategoryTree;
} finally {
eventCategoryFetchInflight = null;
@@ -3747,14 +3810,30 @@ function applyPerspective(p: Perspective) {
triggerCascadeRefresh();
}
// ourSideToPerspective maps the project-level "Wir vertreten" enum
// onto the chip-strip Perspective. 'court' / 'both' map to null
// (chip cleared) — court actions are neutral to the user's side and
// "both" is explicit no-filter intent.
// ourSideToPerspective maps the project-level "Client Role" enum
// (DB column: our_side) onto the chip-strip Perspective.
//
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
// sub-role values grouped at display time:
// Active (we initiate) : claimant, applicant, appellant → "claimant"
// Reactive (we defend) : defendant, respondent → "defendant"
// Other : third_party, other, NULL → null
//
// Legacy 'court' / 'both' values no longer exist in the column
// (mig 110 backfilled them to NULL); both fall through to the null
// default arm if a stale value sneaks in.
function ourSideToPerspective(os: string | null | undefined): Perspective {
if (os === "claimant") return "claimant";
if (os === "defendant") return "defendant";
return null;
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
// applyOurSidePredefine locks the perspective from project.our_side

File diff suppressed because it is too large Load Diff

View File

@@ -184,6 +184,9 @@ async function handleSuggestChanges(
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) {
@@ -192,11 +195,17 @@ async function handleSuggestChanges(
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
@@ -208,6 +217,9 @@ async function handleSuggestChanges(
lifecycleEvent,
payload,
preImage,
projectTitle,
requesterName,
requestedAt,
});
if (!result) return; // cancel

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,11 +8,14 @@ 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";
interface Project {
id: string;
@@ -21,6 +24,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;
@@ -34,6 +43,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;
}
@@ -199,6 +214,9 @@ interface ChecklistTemplateSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE?: string;
descriptionEN?: string;
regime?: string;
itemCount: number;
}
@@ -224,6 +242,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;
@@ -1089,6 +1113,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.
@@ -1413,36 +1455,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,
@@ -1619,18 +1634,34 @@ function showTab(tab: TabId) {
}
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 = [];
}
@@ -1690,6 +1721,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;
@@ -1723,9 +1891,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");
@@ -1761,6 +1934,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";
@@ -1839,13 +2025,26 @@ 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");
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
@@ -2062,8 +2261,10 @@ async function main() {
initSmartTimelineClientToggle(id);
initSmartTimelineAddModal(id);
initAttachUnitForm(id);
initAddChecklistModal(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
wireExportButton(id);
showTab(parseTab());
}
@@ -2476,6 +2677,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
@@ -2486,12 +2688,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) => {
@@ -2518,17 +2734,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;
@@ -2541,6 +2791,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;
}
});
});
@@ -2686,10 +2977,199 @@ 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 + hooks up the project-export button on the
// tabs nav. Triggers a download via a transient <a download> — same
// pattern as the personal export in client/settings.ts.
function wireExportButton(projectID: string): void {
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!btn) return;
if (!canExportProject()) {
btn.style.display = "none";
return;
}
btn.style.display = "";
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);
});
}
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

@@ -412,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> {
@@ -597,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
@@ -717,6 +1131,18 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV);
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);

View File

@@ -0,0 +1,955 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-238 Slice A — client bundle for the dedicated
// Submissions/Schriftsätze editor at
// /projects/{id}/submissions/{code}/draft
// /projects/{id}/submissions/{code}/draft/{draft_id}
//
// Reads (project_id, submission_code, optional draft_id) from the URL,
// loads the editor payload (draft row + resolved bag + merged bag +
// HTML preview), and wires the sidebar / preview / autosave / export.
//
// Autosave is debounced 500ms after the lawyer stops typing in any
// variable input. Each PATCH returns a fresh editor payload, so the
// preview pane stays in lockstep with the variable overrides.
interface SubmissionDraftJSON {
id: string;
project_id: string | null;
submission_code: string;
user_id: string;
name: string;
variables: Record<string, string>;
last_exported_at?: string | null;
last_exported_sha?: string | null;
created_at: string;
updated_at: string;
}
interface SubmissionRuleSummary {
name: string;
name_en: string;
submission_code: string;
primary_party?: string;
event_type?: string;
legal_source?: string;
legal_source_pretty?: string;
}
interface SubmissionDraftView {
draft: SubmissionDraftJSON;
rule?: SubmissionRuleSummary;
resolved_bag: Record<string, string>;
merged_bag: Record<string, string>;
preview_html: string;
lang: string;
has_template: boolean;
template_missing?: boolean;
}
interface SubmissionDraftListResponse {
project_id: string;
submission_code: string;
drafts: SubmissionDraftJSON[];
}
interface ParsedPath {
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
// from the loaded draft row after fetch.
projectID: string | null;
submissionCode: string | null;
draftID?: string;
// mode tracks the URL shape we were entered from. Affects redirect
// semantics when we create a new draft or navigate away.
mode: "project" | "global";
}
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
function parsePath(): ParsedPath | null {
let m = PROJECT_PATH_RE.exec(window.location.pathname);
if (m) {
return {
projectID: m[1],
submissionCode: decodeURIComponent(m[2]),
draftID: m[3],
mode: "project",
};
}
m = GLOBAL_PATH_RE.exec(window.location.pathname);
if (m) {
return {
projectID: null,
submissionCode: null,
draftID: m[1],
mode: "global",
};
}
return null;
}
function isEN(): boolean {
return document.documentElement.lang === "en";
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ─────────────────────────────────────────────────────────────────────
// Variable contract — DE/EN labels per dotted-path placeholder.
// Mirrors the same shape the email-template variables sidebar uses;
// keeps the lawyer's mental model anchored on the same vocabulary.
// ─────────────────────────────────────────────────────────────────────
interface VariableLabel {
de: string;
en: string;
}
interface VariableGroup {
id: string;
label: VariableLabel;
keys: string[];
}
const VARIABLE_LABELS: Record<string, VariableLabel> = {
"firm.name": { de: "Kanzlei", en: "Firm" },
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
"today": { de: "Heute", en: "Today" },
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
"user.display_name": { de: "Bearbeiter", en: "Author" },
"user.email": { de: "E-Mail", en: "Email" },
"user.office": { de: "Büro", en: "Office" },
"project.title": { de: "Projekttitel", en: "Project title" },
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
"project.court": { de: "Gericht", en: "Court" },
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
"project.our_side": { de: "Unsere Seite", en: "Our side" },
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
"project.instance_level": { de: "Instanz", en: "Instance" },
"project.client_number": { de: "Mandantennummer", en: "Client number" },
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
"rule.name": { de: "Schriftsatz", en: "Submission" },
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "rule",
label: { de: "Schriftsatz", en: "Submission" },
keys: [
"rule.name",
"rule.legal_source_pretty",
"rule.primary_party",
"rule.event_type",
"rule.submission_code",
],
},
{
id: "parties",
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "project",
label: { de: "Verfahren", en: "Proceeding" },
keys: [
"project.title",
"project.case_number",
"project.court",
"project.patent_number",
"project.patent_number_upc",
"project.filing_date",
"project.grant_date",
"project.our_side",
"project.proceeding.name",
"project.client_number",
"project.matter_number",
"project.reference",
"project.instance_level",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
"deadline.due_date_long_en",
"deadline.computed_from",
"deadline.title",
"deadline.original_due_date",
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
keys: [
"firm.name",
"user.display_name",
"user.email",
"user.office",
"today.long_de",
"today.long_en",
],
},
];
function labelFor(key: string): string {
const entry = VARIABLE_LABELS[key];
if (!entry) return key;
return isEN() ? entry.en : entry.de;
}
// ─────────────────────────────────────────────────────────────────────
// Module state
// ─────────────────────────────────────────────────────────────────────
interface State {
parsed: ParsedPath;
view: SubmissionDraftView | null;
drafts: SubmissionDraftJSON[];
saveTimer: number | null;
pendingOverrides: Record<string, string> | null;
inFlight: AbortController | null;
}
const state: State = {
parsed: null as unknown as ParsedPath,
view: null,
drafts: [],
saveTimer: null,
pendingOverrides: null,
inFlight: null,
};
// ─────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────
async function boot(): Promise<void> {
initI18n();
initSidebar();
const parsed = parsePath();
if (!parsed) {
showNotFound();
return;
}
state.parsed = parsed;
try {
if (parsed.mode === "global") {
// Global path: we have a draft_id, fetch by id alone. Drafts
// list (the sidebar switcher) is scoped to the same project +
// submission_code AFTER we've loaded the draft.
if (!parsed.draftID) {
showNotFound();
return;
}
const view = await fetchGlobalView(parsed.draftID);
state.view = view;
// Backfill parsed.* from the loaded draft so the sidebar
// switcher can list peers; project-less drafts get no peer list
// beyond themselves (no useful (project, code) cross-section).
state.parsed = {
...parsed,
projectID: view.draft.project_id,
submissionCode: view.draft.submission_code,
};
if (view.draft.project_id) {
try {
const list = await fetchDrafts(state.parsed);
state.drafts = list.drafts;
} catch { state.drafts = [view.draft]; }
} else {
state.drafts = [view.draft];
}
paint();
return;
}
// Project-scoped path: same logic as before.
if (!parsed.projectID || !parsed.submissionCode) {
showNotFound();
return;
}
const list = await fetchDrafts(parsed);
state.drafts = list.drafts;
let draft: SubmissionDraftJSON | null = null;
if (parsed.draftID) {
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
if (!draft) {
showNotFound();
return;
}
} else if (state.drafts.length > 0) {
draft = state.drafts[0];
// Redirect to the canonical /draft/{id} URL so refresh + share
// both land on the same draft.
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
window.history.replaceState({}, "", url);
state.parsed = { ...parsed, draftID: draft.id };
} else {
draft = await createProjectDraft(parsed);
state.drafts = [draft];
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
window.history.replaceState({}, "", url);
state.parsed = { ...parsed, draftID: draft.id };
}
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
state.view = view;
paint();
} catch (err) {
console.error("submission-draft boot:", err);
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
}
}
// ─────────────────────────────────────────────────────────────────────
// API
// ─────────────────────────────────────────────────────────────────────
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
return resp.json();
}
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
const view = (await resp.json()) as SubmissionDraftView;
return view.draft;
}
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
return resp.json();
}
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
const resp = await fetch(`/api/submission-drafts/${draftID}`);
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
state.inFlight.abort();
state.inFlight = null;
}
const ctl = new AbortController();
state.inFlight = ctl;
// The global PATCH endpoint accepts both project-scoped and
// project-less drafts — route everything through it so attach (set
// project_id) works from both URL shapes.
const url = `/api/submission-drafts/${p.draftID}`;
try {
const resp = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: ctl.signal,
});
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
return resp.json();
} finally {
if (state.inFlight === ctl) state.inFlight = null;
}
}
async function deleteDraft(): Promise<void> {
const p = state.parsed;
if (!p.draftID) return;
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
}
// ─────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────
function paint(): void {
if (!state.view) return;
hide("submission-draft-loading");
hide("submission-draft-notfound");
hide("submission-draft-error");
show("submission-draft-body");
paintHeader();
paintBackLink();
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintVariables();
paintPreview();
}
function paintHeader(): void {
const view = state.view!;
const title = document.getElementById("submission-draft-title");
if (title) {
const ruleName = view.rule?.name ?? view.draft.submission_code;
title.textContent = ruleName;
}
const subtitle = document.getElementById("submission-draft-subtitle");
if (subtitle) {
const code = view.draft.submission_code;
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
const parts: string[] = [code];
if (source) parts.push(source);
subtitle.textContent = parts.join(" · ");
}
}
function paintBackLink(): void {
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
if (!back || !state.view) return;
if (state.view.draft.project_id) {
back.href = `/projects/${state.view.draft.project_id}/submissions`;
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
} else {
back.href = "/submissions";
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
}
}
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
// banner above the editor body. The banner offers a "Projekt zuweisen"
// button that opens an inline project picker — same modal pattern the
// /submissions/new page uses. Removed once the draft has a project_id.
function paintNoProjectBanner(): void {
const body = document.getElementById("submission-draft-body");
if (!body || !state.view) return;
let banner = document.getElementById("submission-draft-noproject-banner");
if (state.view.draft.project_id) {
if (banner) banner.remove();
return;
}
const msg = isEN()
? "No project assigned — all variables are filled manually."
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
<button type="button" id="submission-draft-noproject-assign"
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
if (banner) {
banner.innerHTML = html;
} else {
banner = document.createElement("aside");
banner.id = "submission-draft-noproject-banner";
banner.className = "submission-draft-noproject-banner";
banner.innerHTML = html;
// Insert before the header.
const header = body.querySelector(".submission-draft-header");
if (header && header.parentElement) {
header.parentElement.insertBefore(banner, header);
} else {
body.prepend(banner);
}
}
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
if (btn) btn.onclick = () => openProjectAssignPicker();
}
function paintSwitcher(): void {
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
if (!sel || !state.view) return;
sel.innerHTML = state.drafts
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
.join("");
sel.onchange = () => {
const id = sel.value;
if (!id || !state.view) return;
if (id === state.view.draft.id) return;
const p = state.parsed;
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
window.location.href = url;
};
}
function paintNameRow(): void {
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
if (input && state.view) {
input.value = state.view.draft.name;
input.onchange = () => {
const newName = input.value.trim();
if (!newName || newName === state.view!.draft.name) return;
void renameDraft(newName);
};
}
if (del) del.onclick = () => onDelete();
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
if (newBtn) newBtn.onclick = () => onCreateNew();
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;
const overrides = state.view.draft.variables ?? {};
const resolved = state.view.resolved_bag ?? {};
const merged = state.view.merged_bag ?? {};
let html = "";
for (const group of VARIABLE_GROUPS) {
const groupLabel = isEN() ? group.label.en : group.label.de;
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
for (const key of group.keys) {
const label = labelFor(key);
const override = overrides[key];
const resolvedVal = resolved[key] ?? "";
const mergedVal = merged[key] ?? "";
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
html += ` data-var="${escapeHtml(key)}"`;
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
const hintParts: string[] = [];
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
if (overridden) {
if (override === "") {
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
} else if (override !== resolvedVal) {
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
}
}
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
if (overridden && override !== resolvedVal) {
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
}
html += `</label>`;
// Visual hint: marker text appears in preview when override is "".
void mergedVal;
}
html += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
}
function paintPreview(): void {
const host = document.getElementById("submission-draft-preview");
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
}
// ─────────────────────────────────────────────────────────────────────
// Event handlers
// ─────────────────────────────────────────────────────────────────────
function onVarChange(input: HTMLInputElement): void {
const key = input.dataset.var;
if (!key || !state.view) return;
// Stage the override on the draft view so paintPreview reflects.
const overrides = { ...state.view.draft.variables };
overrides[key] = input.value;
state.view.draft.variables = overrides;
state.pendingOverrides = overrides;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
if (state.saveTimer) window.clearTimeout(state.saveTimer);
state.saveTimer = window.setTimeout(() => {
void flushAutosave();
}, 500);
}
function onVarReset(key: string): void {
if (!state.view) return;
const overrides = { ...state.view.draft.variables };
delete overrides[key];
state.view.draft.variables = overrides;
state.pendingOverrides = overrides;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
if (state.saveTimer) window.clearTimeout(state.saveTimer);
state.saveTimer = window.setTimeout(() => {
void flushAutosave();
}, 500);
}
async function flushAutosave(): Promise<void> {
if (!state.pendingOverrides) return;
const payload = { variables: state.pendingOverrides };
state.pendingOverrides = null;
try {
const view = await patchDraft(payload);
state.view = view;
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft autosave:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
}
}
async function renameDraft(newName: string): Promise<void> {
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ name: newName });
state.view = view;
// Refresh the draft list cache.
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
if (idx >= 0) state.drafts[idx].name = view.draft.name;
paintSwitcher();
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft rename:", err);
const msg = (err as Error).message?.includes("409")
? (isEN() ? "Name already in use" : "Name bereits vergeben")
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
setSaveStatus(msg, true);
}
}
async function onCreateNew(): Promise<void> {
const p = state.parsed;
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
// (project, code) cross-section — kick the user out to the global
// picker instead.
if (!p.projectID || !p.submissionCode) {
window.location.href = "/submissions/new";
return;
}
try {
const fresh = await createProjectDraft(p);
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
window.location.href = url;
} catch (err) {
console.error("submission-draft new:", err);
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
}
}
async function onDelete(): Promise<void> {
if (!state.view) return;
const msg = isEN()
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
if (!window.confirm(msg)) return;
try {
await deleteDraft();
const p = state.parsed;
const url = p.projectID && p.submissionCode
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
: "/submissions";
window.location.href = url;
} catch (err) {
console.error("submission-draft delete:", err);
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
}
}
async function onExport(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const p = state.parsed;
if (!p.draftID) return;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
try {
// Use the global export endpoint for both project-scoped and
// project-less drafts; the handler routes audit + project_events
// writes based on the draft row's project_id.
const url = `/api/submission-drafts/${p.draftID}/export`;
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((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
return;
}
const blob = await resp.blob();
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
triggerDownload(blob, filename);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
// ─────────────────────────────────────────────────────────────────────
// Project assign picker (project-less → project-scoped)
// ─────────────────────────────────────────────────────────────────────
interface PickerProjectRow {
id: string;
title: string;
reference?: string | null;
}
let assignPickerProjects: PickerProjectRow[] = [];
let assignPickerLoaded = false;
function openProjectAssignPicker(): void {
ensureAssignPickerDOM();
const modal = document.getElementById("submission-draft-assign-modal");
if (modal) modal.style.display = "";
if (!assignPickerLoaded) {
void loadAssignPickerProjects();
} else {
renderAssignPickerList();
}
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
if (searchInput) {
searchInput.value = "";
setTimeout(() => searchInput.focus(), 50);
}
}
function closeProjectAssignPicker(): void {
const modal = document.getElementById("submission-draft-assign-modal");
if (modal) modal.style.display = "none";
}
function ensureAssignPickerDOM(): void {
if (document.getElementById("submission-draft-assign-modal")) return;
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
const placeholder = isEN()
? "Search project (title or reference)…"
: "Projekt suchen (Titel oder Aktenzeichen)…";
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
const modal = document.createElement("div");
modal.id = "submission-draft-assign-modal";
modal.className = "modal-overlay";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.style.display = "none";
modal.innerHTML = `
<div class="modal-card">
<header class="modal-header">
<h2>${escapeHtml(titleTxt)}</h2>
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
</header>
<div class="modal-body">
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeProjectAssignPicker();
});
const closeBtn = document.getElementById("submission-draft-assign-close");
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
});
}
async function loadAssignPickerProjects(): Promise<void> {
const loadingEl = document.getElementById("submission-draft-assign-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 PickerProjectRow[];
assignPickerProjects = rows ?? [];
assignPickerLoaded = true;
} catch (err) {
console.error("submission-draft assignPicker:", err);
assignPickerProjects = [];
} finally {
if (loadingEl) loadingEl.style.display = "none";
}
renderAssignPickerList();
}
function renderAssignPickerList(): void {
const list = document.getElementById("submission-draft-assign-list");
const empty = document.getElementById("submission-draft-assign-empty");
if (!list || !empty) return;
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
const term = (searchInput?.value ?? "").trim().toLowerCase();
const matches = assignPickerProjects.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">${escapeHtml(p.reference)}</span> ` : "";
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
li.addEventListener("click", () => {
const pid = li.dataset.id;
if (pid) void onAssignProject(pid);
});
});
}
async function onAssignProject(projectID: string): Promise<void> {
closeProjectAssignPicker();
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
try {
const view = await patchDraft({ project_id: projectID });
state.view = view;
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
// Redirect to the project-scoped URL so the editor's URL matches the
// attached project and the project-scoped draft list (sidebar
// switcher) loads on refresh.
const code = view.draft.submission_code;
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
} catch (err) {
console.error("submission-draft assign:", err);
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
}
}
// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────
function show(id: string): void {
const el = document.getElementById(id);
if (el) el.style.display = "";
}
function hide(id: string): void {
const el = document.getElementById(id);
if (el) el.style.display = "none";
}
function showNotFound(): void {
hide("submission-draft-loading");
hide("submission-draft-body");
show("submission-draft-notfound");
}
function showError(msg: string): void {
hide("submission-draft-loading");
hide("submission-draft-body");
const el = document.getElementById("submission-draft-error");
if (el) {
el.textContent = msg;
el.style.display = "";
}
}
function setSaveStatus(msg: string, errorState: boolean = false): void {
const el = document.getElementById("submission-draft-savestatus");
if (!el) return;
el.textContent = msg;
el.classList.toggle("submission-draft-savestatus--error", errorState);
}
function parseFilename(header: string): string | null {
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
return m ? m[1] : null;
}
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);
setTimeout(() => URL.revokeObjectURL(url), 0);
}
// Keep t() referenced so the bundler doesn't tree-shake it; future
// affordances will use the per-page i18n keys.
void t;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => { void boot(); });
} else {
void boot();
}

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

@@ -1,10 +1,13 @@
// Submissions panel — fetches the project's submission catalog and
// renders one row per filing-type rule, with a [Generieren] action
// when a .docx template resolves server-side.
// 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. Loaded lazily by the projects-detail tab
// switcher so projects without the Schriftsätze tab open don't pay
// for the per-row template-availability probes.
// 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
@@ -23,11 +26,15 @@ interface SubmissionEntry {
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[];
}
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
const body = document.getElementById("project-submissions-body");
if (!empty || !noProc || !wrap || !body) return;
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
noProc.style.display = "";
empty.style.display = "none";
wrap.style.display = "none";
return;
}
noProc.style.display = "none";
// 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";
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
wrap.style.display = "";
const isEN = document.documentElement.lang === "en";
body.innerHTML = data.entries.map((entry) => {
const name = isEN && entry.name_en ? entry.name_en : entry.name;
const party = formatParty(entry.primary_party, isEN);
const source = entry.legal_source ?? "";
const action = entry.has_template
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(data.project_id)}"
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
return `<tr class="submission-row">
<td>
<span class="submission-name">${escapeHtml(name)}</span>
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
</td>
<td>${escapeHtml(party)}</td>
<td>${escapeHtml(source)}</td>
<td class="submission-action-cell">${action}</td>
</tr>`;
}).join("");
// Wire button clicks. One click handler per render to avoid stale
// closures from the previous render's data.
// 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();
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
});
}
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");
@@ -159,7 +220,7 @@ async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
try {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
const resp = await fetch(url, { method: "GET" });
const resp = await fetch(url, { method: "POST" });
if (!resp.ok) {
let detail = "";
try {

View File

@@ -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>';
@@ -403,8 +422,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 +446,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 +540,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 +563,223 @@ 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),
);
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>
<button type="button" class="btn-primary" id="team-selection-send">
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
</button>
`;
footer.style.display = "";
document.body.classList.add("team-has-selection");
document.getElementById("team-selection-clear")?.addEventListener("click", () => {
selectedUserIDs.clear();
lastToggledUserID = null;
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
});
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
}
// 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 +809,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

@@ -13,15 +13,26 @@ import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
escHtml,
formatDate,
populateCourtPicker,
renderColumnsBody,
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// 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";
@@ -125,10 +136,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;
@@ -143,13 +158,19 @@ async function doCalc() {
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank).
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
return data.proceedingName || "";
if (getLang() === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
}
function syncTriggerEventLabel() {
@@ -179,11 +200,23 @@ function renderResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { showNotes })
: renderTimelineBody(data, { showParty: true, showNotes });
// 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 })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
@@ -229,7 +262,12 @@ function syncInfAmendEnabled() {
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;
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
@@ -245,18 +283,30 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0);
}
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");
@@ -312,6 +362,21 @@ document.addEventListener("DOMContentLoaded", () => {
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;

View File

@@ -204,6 +204,12 @@ function setActiveShape(shape: RenderShape | null): void {
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;

View File

@@ -1,525 +1,28 @@
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month / week / day views. The view switcher is rendered
// inline above the grid; the active view persists in the URL via
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
// shareable deep-link. Each view buckets the same flat ViewRow[] by
// ISO-date — only the rendering differs.
type CalView = "month" | "week" | "day";
const VIEW_PARAM = "cal_view";
const DATE_PARAM = "cal_date";
const MAX_PILLS_PER_MONTH_CELL = 3;
// 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 ?? {};
// 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 initialView = readView(cfg.default_view);
const anchor = readAnchor(rows);
paint(host, rows, anchor, initialView);
}
// paint redraws the calendar in the supplied view + anchor. Called from
// the view switcher and from the day/week navigation buttons. Each paint
// clears the host so we don't leak prior DOM.
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
// Keep the mobile-notice (first child) if present; everything else is
// re-rendered each time.
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
host.innerHTML = "";
if (notice) host.appendChild(notice);
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
writeURL(nextView, nextAnchor);
paint(host, rows, nextAnchor, nextView);
}));
if (view === "month") {
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
writeURL("day", clickedDate);
paint(host, rows, clickedDate, "day");
}));
} else if (view === "week") {
wrap.appendChild(renderWeek(anchor, rows));
} else {
wrap.appendChild(renderDay(anchor, rows));
}
host.appendChild(wrap);
}
// --- Toolbar -------------------------------------------------------------
function renderToolbar(
view: CalView,
anchor: Date,
onNav: (view: CalView, anchor: Date) => void,
): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
// View switcher: month / week / day chips.
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
onNav(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
// Prev / current-label / next. Step size depends on the view.
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
nav.appendChild(next);
// Day/week view: provide a "Zurück zum Monat" link so users can climb
// back without hunting for the switcher chip.
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => onNav("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
// --- Month view ----------------------------------------------------------
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Single grid with one column-template that the weekday row and the day
// cells share. The header row is added with `grid-column: span 7` so
// it spans the full width above the day grid (laid out below).
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
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();
// 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) within the visible month.
const byDate = bucketByDate(rows, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(
dayDate: Date,
dayNum: number,
dayRows: ViewRow[],
onDayDrill: (d: Date) => void,
): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
// Day-number is a click-target that switches to the day view. We render
// it as a button to keep keyboard semantics; the surrounding cell stays
// a div so it doesn't compete with the inner row anchors.
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) {
ul.appendChild(renderPill(row));
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week view -----------------------------------------------------------
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
const col = renderWeekColumn(day, rows);
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
// No 3-row cap on week / day views — show everything for that day.
const dayRows = filterByDay(rows, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day view ------------------------------------------------------------
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(rows, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering -------------------------------------------------------
function renderPill(row: ViewRow): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = rowHref(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
// Pills are anchors — month-cell day-button click ignores them via
// stopPropagation on the button; cell-level handlers would intercept
// them otherwise.
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = rowHref(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function rowHref(row: ViewRow): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event":
// project_events surface on the project's Verlauf — best we can do
// is link to the project. If no project, leave as a non-link target.
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
// --- Bucketing / date helpers --------------------------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
const out = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
const items: CalendarItem[] = rows.map(toCalendarItem);
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7; // Mon=0
out.setDate(out.getDate() - offset);
return out;
}
function shift(d: Date, view: CalView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
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: CalView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
// --- URL state -----------------------------------------------------------
function readView(defaultView: CalView | undefined): CalView {
const params = new URLSearchParams(window.location.search);
const raw = params.get(VIEW_PARAM);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return defaultView ?? "month";
}
function readAnchor(rows: ViewRow[]): Date {
const params = new URLSearchParams(window.location.search);
const raw = params.get(DATE_PARAM);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
// No URL anchor — pick the first row's date, or today.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function writeURL(view: CalView, anchor: Date): void {
const url = new URL(window.location.href);
url.searchParams.set(VIEW_PARAM, view);
url.searchParams.set(DATE_PARAM, isoDate(anchor));
history.replaceState(null, "", url.toString());
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

@@ -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,67 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
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=");
});
});

View File

@@ -95,8 +95,21 @@ export function priorityRendering(
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;
}
export interface CourtRow {
@@ -299,6 +312,87 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
${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 {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {

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>
@@ -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) +

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

@@ -82,15 +82,21 @@ 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 id="deadline-rule-display">&mdash;</dd>
<dt data-i18n="deadlines.detail.source">Quelle</dt>
<dd id="deadline-source-display" />

View File

@@ -101,18 +101,19 @@ export function renderDeadlinesNew(): string {
</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 />
</div>
{/* m/paliad#56 — Regel sits directly beneath the Typ
picker so the parent/child relationship reads at a
glance. Due date is its own row below. */}
<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-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

@@ -161,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>
@@ -485,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">

View File

@@ -440,7 +440,23 @@ export type I18nKey =
| "admin.section.planned"
| "admin.subtitle"
| "admin.team.add.direct"
| "admin.team.add.full"
| "admin.team.add.invite"
| "admin.team.add_full.body"
| "admin.team.add_full.cancel"
| "admin.team.add_full.email"
| "admin.team.add_full.error.email_exists"
| "admin.team.add_full.error.generic"
| "admin.team.add_full.error.unavailable"
| "admin.team.add_full.feedback.added"
| "admin.team.add_full.job_title"
| "admin.team.add_full.lang"
| "admin.team.add_full.name"
| "admin.team.add_full.office"
| "admin.team.add_full.profession"
| "admin.team.add_full.send_welcome"
| "admin.team.add_full.submit"
| "admin.team.add_full.title"
| "admin.team.col.actions"
| "admin.team.col.additional"
| "admin.team.col.created"
@@ -486,6 +502,13 @@ export type I18nKey =
| "agenda.appointment_type.deadline_hearing"
| "agenda.appointment_type.hearing"
| "agenda.appointment_type.meeting"
| "agenda.day.di"
| "agenda.day.do"
| "agenda.day.fr"
| "agenda.day.mi"
| "agenda.day.mo"
| "agenda.day.sa"
| "agenda.day.so"
| "agenda.day.today"
| "agenda.day.tomorrow"
| "agenda.empty.hint"
@@ -555,12 +578,6 @@ export type I18nKey =
| "appointments.filter.type"
| "appointments.filter.type.all"
| "appointments.form.approval_hint"
| "appointments.kalender.empty"
| "appointments.kalender.heading"
| "appointments.kalender.list"
| "appointments.kalender.subtitle"
| "appointments.kalender.title"
| "appointments.list.calendar"
| "appointments.list.heading"
| "appointments.list.new"
| "appointments.list.subtitle"
@@ -642,11 +659,23 @@ export type I18nKey =
| "approvals.status.superseded"
| "approvals.subtitle"
| "approvals.suggest.cancel"
| "approvals.suggest.context.approval_status"
| "approvals.suggest.context.project"
| "approvals.suggest.context.requested_at"
| "approvals.suggest.context.requester"
| "approvals.suggest.event_type_picker_unavailable"
| "approvals.suggest.field.description"
| "approvals.suggest.field.original_due_date"
| "approvals.suggest.field.rule_code"
| "approvals.suggest.field.warning_date"
| "approvals.suggest.intro"
| "approvals.suggest.modal_title"
| "approvals.suggest.next_request_link"
| "approvals.suggest.note_label"
| "approvals.suggest.note_placeholder"
| "approvals.suggest.section.context"
| "approvals.suggest.section.editable"
| "approvals.suggest.section.event_type_rule"
| "approvals.suggest.submit"
| "approvals.suggest.submit_disabled_hint"
| "approvals.suggest.unsupported_lifecycle"
@@ -693,11 +722,49 @@ export type I18nKey =
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.today"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
| "cal.week.next"
| "cal.week.prev"
| "caldav.bindings.add"
| "caldav.bindings.card.edit"
| "caldav.bindings.card.enabled"
| "caldav.bindings.card.remove"
| "caldav.bindings.delete.confirm"
| "caldav.bindings.delete.failed"
| "caldav.bindings.empty"
| "caldav.bindings.error.create_name_required"
| "caldav.bindings.error.create_name_taken"
| "caldav.bindings.error.create_unsupported"
| "caldav.bindings.error.path"
| "caldav.bindings.error.scope"
| "caldav.bindings.error.scope_project"
| "caldav.bindings.heading"
| "caldav.bindings.hint"
| "caldav.bindings.modal.add_title"
| "caldav.bindings.modal.display_name"
| "caldav.bindings.modal.display_name.placeholder"
| "caldav.bindings.modal.edit_title"
| "caldav.bindings.modal.scope"
| "caldav.bindings.modal.scope.all_visible"
| "caldav.bindings.modal.scope.personal_only"
| "caldav.bindings.modal.scope.project"
| "caldav.bindings.modal.scope.project.loading"
| "caldav.bindings.modal.source"
| "caldav.bindings.modal.source.create"
| "caldav.bindings.modal.source.custom"
| "caldav.bindings.modal.source.degrade"
| "caldav.bindings.modal.source.discover_empty"
| "caldav.bindings.modal.source.discover_failed"
| "caldav.bindings.modal.source.existing"
| "caldav.bindings.modal.source.loading"
| "caldav.bindings.modal.submit_add"
| "caldav.bindings.modal.submit_edit"
| "caldav.bindings.scope.all_visible"
| "caldav.bindings.scope.personal_only"
| "caldav.bindings.scope.project"
| "caldav.delete"
| "caldav.delete.confirm"
| "caldav.delete.done"
@@ -740,7 +807,54 @@ export type I18nKey =
| "changelog.tag.feature"
| "changelog.tag.fix"
| "changelog.title"
| "checklisten.author.cancel"
| "checklisten.author.error.generic"
| "checklisten.author.error.no_groups"
| "checklisten.author.error.notfound"
| "checklisten.author.error.title"
| "checklisten.author.field.court"
| "checklisten.author.field.deadline"
| "checklisten.author.field.description"
| "checklisten.author.field.lang"
| "checklisten.author.field.reference"
| "checklisten.author.field.regime"
| "checklisten.author.field.title"
| "checklisten.author.field.title.hint"
| "checklisten.author.field.visibility"
| "checklisten.author.group.remove"
| "checklisten.author.group.title"
| "checklisten.author.groups.add"
| "checklisten.author.groups.heading"
| "checklisten.author.heading.edit"
| "checklisten.author.heading.new"
| "checklisten.author.item.add"
| "checklisten.author.item.label"
| "checklisten.author.item.note"
| "checklisten.author.item.remove"
| "checklisten.author.item.rule"
| "checklisten.author.save"
| "checklisten.author.saving"
| "checklisten.author.subtitle"
| "checklisten.author.title"
| "checklisten.author.title.edit"
| "checklisten.author.visibility.firm.hint"
| "checklisten.author.visibility.private.hint"
| "checklisten.back"
| "checklisten.detail.authored.by"
| "checklisten.detail.delete"
| "checklisten.detail.delete.confirm"
| "checklisten.detail.delete.error"
| "checklisten.detail.demote"
| "checklisten.detail.demote.confirm"
| "checklisten.detail.edit"
| "checklisten.detail.promote"
| "checklisten.detail.promote.confirm"
| "checklisten.detail.promote.error"
| "checklisten.detail.share"
| "checklisten.detail.visibility"
| "checklisten.detail.visibility.error"
| "checklisten.detail.visibility.set.firm"
| "checklisten.detail.visibility.set.private"
| "checklisten.disclaimer"
| "checklisten.empty"
| "checklisten.feedback.btn"
@@ -758,11 +872,23 @@ export type I18nKey =
| "checklisten.feedback.type"
| "checklisten.filter.all"
| "checklisten.filter.de"
| "checklisten.filter.other"
| "checklisten.gallery.empty"
| "checklisten.heading"
| "checklisten.instance.akte.open"
| "checklisten.instance.back"
| "checklisten.instance.diff.added"
| "checklisten.instance.diff.changed"
| "checklisten.instance.diff.close"
| "checklisten.instance.diff.empty"
| "checklisten.instance.diff.error"
| "checklisten.instance.diff.removed"
| "checklisten.instance.diff.title"
| "checklisten.instance.loading"
| "checklisten.instance.notfound"
| "checklisten.instance.outdated.badge"
| "checklisten.instance.outdated.diff"
| "checklisten.instance.outdated.note"
| "checklisten.instance.rename"
| "checklisten.instance.rename.error"
| "checklisten.instance.rename.save"
@@ -785,6 +911,18 @@ export type I18nKey =
| "checklisten.instances.heading"
| "checklisten.instances.loading"
| "checklisten.instances.sub"
| "checklisten.mine.delete"
| "checklisten.mine.delete.confirm"
| "checklisten.mine.delete.error"
| "checklisten.mine.edit"
| "checklisten.mine.empty"
| "checklisten.mine.loading"
| "checklisten.mine.new"
| "checklisten.mine.origin.authored"
| "checklisten.mine.visibility.firm"
| "checklisten.mine.visibility.global"
| "checklisten.mine.visibility.private"
| "checklisten.mine.visibility.shared"
| "checklisten.newInstance"
| "checklisten.newInstance.akte"
| "checklisten.newInstance.akte.hint"
@@ -801,8 +939,31 @@ export type I18nKey =
| "checklisten.reset"
| "checklisten.reset.confirm"
| "checklisten.reset.error"
| "checklisten.share.cancel"
| "checklisten.share.error.generic"
| "checklisten.share.error.pick"
| "checklisten.share.grants.empty"
| "checklisten.share.grants.heading"
| "checklisten.share.grants.recipient.office"
| "checklisten.share.grants.recipient.partner_unit"
| "checklisten.share.grants.recipient.project"
| "checklisten.share.grants.recipient.user"
| "checklisten.share.grants.revoke"
| "checklisten.share.grants.revoke.confirm"
| "checklisten.share.grants.revoke.error"
| "checklisten.share.kind"
| "checklisten.share.kind.office"
| "checklisten.share.kind.partner_unit"
| "checklisten.share.kind.project"
| "checklisten.share.kind.user"
| "checklisten.share.pick"
| "checklisten.share.submit"
| "checklisten.share.success"
| "checklisten.share.title"
| "checklisten.subtitle"
| "checklisten.tab.gallery"
| "checklisten.tab.instances"
| "checklisten.tab.mine"
| "checklisten.tab.templates"
| "checklisten.title"
| "common.cancel"
@@ -878,12 +1039,54 @@ export type I18nKey =
| "dashboard.appointments.heading"
| "dashboard.deadlines.empty"
| "dashboard.deadlines.heading"
| "dashboard.edit.add_widget"
| "dashboard.edit.drag"
| "dashboard.edit.exit"
| "dashboard.edit.hide"
| "dashboard.edit.move_down"
| "dashboard.edit.move_up"
| "dashboard.edit.promote"
| "dashboard.edit.promote_confirm"
| "dashboard.edit.promoted"
| "dashboard.edit.reset"
| "dashboard.edit.reset_confirm"
| "dashboard.edit.resize"
| "dashboard.edit.save_failed"
| "dashboard.edit.saved"
| "dashboard.edit.setting.count"
| "dashboard.edit.setting.count.custom"
| "dashboard.edit.setting.horizon"
| "dashboard.edit.setting.horizon.custom"
| "dashboard.edit.setting.horizon.days"
| "dashboard.edit.setting.position"
| "dashboard.edit.setting.size"
| "dashboard.edit.setting.view"
| "dashboard.edit.settings"
| "dashboard.edit.toggle"
| "dashboard.greeting.prefix"
| "dashboard.inbox.empty"
| "dashboard.inbox.entity.appointment"
| "dashboard.inbox.entity.deadline"
| "dashboard.inbox.full_link"
| "dashboard.inbox.heading"
| "dashboard.matters.active"
| "dashboard.matters.archived"
| "dashboard.matters.heading"
| "dashboard.matters.total"
| "dashboard.onboarding"
| "dashboard.picker.close"
| "dashboard.picker.empty"
| "dashboard.picker.status.absent"
| "dashboard.picker.status.active"
| "dashboard.picker.status.hidden"
| "dashboard.picker.title"
| "dashboard.pinned.empty"
| "dashboard.pinned.full_link"
| "dashboard.pinned.heading"
| "dashboard.quick.heading"
| "dashboard.quick.new_appointment"
| "dashboard.quick.new_deadline"
| "dashboard.quick.new_project"
| "dashboard.section.collapse"
| "dashboard.section.expand"
| "dashboard.summary.completed"
@@ -918,7 +1121,9 @@ export type I18nKey =
| "deadlines.card.calc.flag.with_cci"
| "deadlines.card.calc.flag.with_ccr"
| "deadlines.card.calc.flags.label"
| "deadlines.card.calc.pill_picker.change"
| "deadlines.card.calc.pill_picker.label"
| "deadlines.card.calc.pill_picker.locked_label"
| "deadlines.card.calc.result.calculating"
| "deadlines.card.calc.result.court_set"
| "deadlines.card.calc.result.due"
@@ -1064,13 +1269,6 @@ export type I18nKey =
| "deadlines.inbox.label"
| "deadlines.inbox.posteingang"
| "deadlines.inbox.posteingang.title"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
| "deadlines.kalender.list"
| "deadlines.kalender.subtitle"
| "deadlines.kalender.title"
| "deadlines.kalender.today"
| "deadlines.list.calendar"
| "deadlines.list.heading"
| "deadlines.list.new"
| "deadlines.list.subtitle"
@@ -1398,7 +1596,6 @@ export type I18nKey =
| "event_types.picker.no_match"
| "event_types.picker.remove"
| "event_types.picker.search"
| "events.calendar.empty"
| "events.col.appointment_type"
| "events.col.date"
| "events.col.location"
@@ -1670,6 +1867,7 @@ export type I18nKey =
| "login.tab.login"
| "login.tab.register"
| "login.title"
| "modal.close.label"
| "nav.admin.audit"
| "nav.admin.bereich"
| "nav.admin.event_types"
@@ -1706,6 +1904,7 @@ export type I18nKey =
| "nav.paliadin"
| "nav.projekte"
| "nav.soon.tooltip"
| "nav.submissions"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
@@ -1787,8 +1986,11 @@ export type I18nKey =
| "paliadin.error.shim_error"
| "paliadin.error.timeout"
| "paliadin.error.upstream"
| "paliadin.error.upstream_silence"
| "paliadin.heading"
| "paliadin.input.placeholder"
| "paliadin.late.checking"
| "paliadin.late.lost"
| "paliadin.late.marker"
| "paliadin.late.waiting"
| "paliadin.reset"
@@ -1798,6 +2000,8 @@ export type I18nKey =
| "paliadin.starter.week"
| "paliadin.stop"
| "paliadin.tagline"
| "paliadin.thinking"
| "paliadin.thinking.seconds"
| "paliadin.title"
| "paliadin.widget.close"
| "paliadin.widget.context.on_page"
@@ -1918,6 +2122,7 @@ export type I18nKey =
| "projects.chip.type.case"
| "projects.chip.type.client"
| "projects.chip.type.litigation"
| "projects.chip.type.other"
| "projects.chip.type.patent"
| "projects.chip.type.project"
| "projects.col.clientmatter"
@@ -1933,6 +2138,11 @@ export type I18nKey =
| "projects.detail.appointments.form.cancel"
| "projects.detail.appointments.form.submit"
| "projects.detail.back"
| "projects.detail.checklisten.add"
| "projects.detail.checklisten.add.created"
| "projects.detail.checklisten.add.empty_pick"
| "projects.detail.checklisten.add.error"
| "projects.detail.checklisten.add.search"
| "projects.detail.checklisten.col.created"
| "projects.detail.checklisten.col.name"
| "projects.detail.checklisten.col.progress"
@@ -1954,6 +2164,8 @@ export type I18nKey =
| "projects.detail.edit"
| "projects.detail.edit.modal.title"
| "projects.detail.edit.type_change_warning.title"
| "projects.detail.export.button"
| "projects.detail.export.tooltip"
| "projects.detail.firmwide.off"
| "projects.detail.firmwide.on"
| "projects.detail.kinder.add"
@@ -2049,6 +2261,7 @@ export type I18nKey =
| "projects.detail.smarttimeline.track.only.counterclaim"
| "projects.detail.smarttimeline.track.only.parent"
| "projects.detail.smarttimeline.track.only.parent_context"
| "projects.detail.submissions.action.edit"
| "projects.detail.submissions.action.generate"
| "projects.detail.submissions.action.no_template"
| "projects.detail.submissions.col.action"
@@ -2057,6 +2270,7 @@ export type I18nKey =
| "projects.detail.submissions.col.source"
| "projects.detail.submissions.empty"
| "projects.detail.submissions.empty.no_proceeding"
| "projects.detail.submissions.empty.no_proceeding.cta"
| "projects.detail.submissions.hint"
| "projects.detail.tab.checklisten"
| "projects.detail.tab.fristen"
@@ -2103,6 +2317,19 @@ export type I18nKey =
| "projects.field.billing_reference"
| "projects.field.case_number"
| "projects.field.client_number"
| "projects.field.client_role"
| "projects.field.client_role.appellant"
| "projects.field.client_role.applicant"
| "projects.field.client_role.claimant"
| "projects.field.client_role.defendant"
| "projects.field.client_role.group.active"
| "projects.field.client_role.group.other"
| "projects.field.client_role.group.reactive"
| "projects.field.client_role.hint"
| "projects.field.client_role.other"
| "projects.field.client_role.respondent"
| "projects.field.client_role.third_party"
| "projects.field.client_role.unset"
| "projects.field.clientmatter.hint"
| "projects.field.collaborators"
| "projects.field.collaborators.hint"
@@ -2120,18 +2347,29 @@ export type I18nKey =
| "projects.field.matter_number"
| "projects.field.netdocuments_url"
| "projects.field.office"
| "projects.field.opponent_code"
| "projects.field.opponent_code.hint"
| "projects.field.opponent_code.placeholder"
| "projects.field.our_side"
| "projects.field.our_side.appellant"
| "projects.field.our_side.applicant"
| "projects.field.our_side.both"
| "projects.field.our_side.claimant"
| "projects.field.our_side.court"
| "projects.field.our_side.defendant"
| "projects.field.our_side.hint"
| "projects.field.our_side.none"
| "projects.field.our_side.other"
| "projects.field.our_side.respondent"
| "projects.field.our_side.third_party"
| "projects.field.our_side.unset"
| "projects.field.parent"
| "projects.field.parent.hint"
| "projects.field.parent.placeholder"
| "projects.field.patent_number"
| "projects.field.proceeding_type"
| "projects.field.proceeding_type.hint"
| "projects.field.proceeding_type.unset"
| "projects.field.proceeding_type_id"
| "projects.field.ref"
| "projects.field.ref.placeholder"
@@ -2173,7 +2411,15 @@ export type I18nKey =
| "projects.team.derived.from"
| "projects.team.derived.visibility"
| "projects.team.direct"
| "projects.team.error.forbidden"
| "projects.team.error.generic"
| "projects.team.error.last_admin"
| "projects.team.inherited.hint"
| "projects.team.mailto.count"
| "projects.team.mailto.empty"
| "projects.team.mailto.label"
| "projects.team.mailto.select_all"
| "projects.team.mailto.select_row"
| "projects.team.profession.associate"
| "projects.team.profession.hint"
| "projects.team.profession.none"
@@ -2183,6 +2429,8 @@ export type I18nKey =
| "projects.team.profession.paralegal"
| "projects.team.profession.partner"
| "projects.team.profession.senior_pa"
| "projects.team.responsibility.admin"
| "projects.team.responsibility.admin.hint"
| "projects.team.responsibility.external"
| "projects.team.responsibility.lead"
| "projects.team.responsibility.member"
@@ -2231,6 +2479,7 @@ export type I18nKey =
| "projects.type.case"
| "projects.type.client"
| "projects.type.litigation"
| "projects.type.other"
| "projects.type.patent"
| "projects.type.project"
| "projects.unavailable"
@@ -2248,6 +2497,45 @@ export type I18nKey =
| "search.no_results"
| "search.placeholder"
| "sidebar.resize.title"
| "submissions.draft.action.delete"
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"
| "submissions.draft.preview.hint"
| "submissions.draft.preview.title"
| "submissions.draft.switcher.label"
| "submissions.draft.title"
| "submissions.index.action.new"
| "submissions.index.col.draft"
| "submissions.index.col.project"
| "submissions.index.col.submission"
| "submissions.index.col.updated"
| "submissions.index.empty"
| "submissions.index.empty.cta"
| "submissions.index.error"
| "submissions.index.heading"
| "submissions.index.loading"
| "submissions.index.subtitle"
| "submissions.index.title"
| "submissions.new.back"
| "submissions.new.col.actions"
| "submissions.new.col.name"
| "submissions.new.col.party"
| "submissions.new.col.source"
| "submissions.new.empty.filtered"
| "submissions.new.error"
| "submissions.new.heading"
| "submissions.new.loading"
| "submissions.new.picker.empty"
| "submissions.new.picker.loading"
| "submissions.new.picker.placeholder"
| "submissions.new.picker.title"
| "submissions.new.search.placeholder"
| "submissions.new.subtitle"
| "submissions.new.title"
| "team.broadcast.body"
| "team.broadcast.body_placeholder"
| "team.broadcast.button"
@@ -2297,6 +2585,11 @@ export type I18nKey =
| "team.role.senior_associate"
| "team.role.trainee"
| "team.search.placeholder"
| "team.selection.clear"
| "team.selection.count"
| "team.selection.select_all"
| "team.selection.send"
| "team.selection.toggle_card"
| "team.subtitle"
| "team.title"
| "theme.toggle.auto"

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>
@@ -81,6 +89,20 @@ export function renderProjectsDetail(): string {
<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>
{/* t-paliad-214 Slice 2 — project-subtree export button.
Sits at the end of the tab nav. Hidden by default; the
client unhides it after /api/me confirms the caller can
extract (responsibility ∈ {lead, member} OR global_admin). */}
<button
type="button"
id="project-export-btn"
className="entity-tab entity-tab-action"
style="display:none"
title=""
data-i18n-title="projects.detail.export.tooltip"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
</nav>
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
@@ -248,6 +270,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>
@@ -263,9 +286,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>
@@ -549,6 +596,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>
@@ -566,25 +619,35 @@ 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>
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
Lists the project's filing-type rules with a per-row
[Generieren] button when a .docx template resolves
in the registry's fallback chain (firm → base/code →
base/family → skeleton). Empty for projects with no
proceeding bound; otherwise enumerates every active
filing rule for the proceeding. */}
{/* 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">
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
Bitte zuerst einen Verfahrenstyp setzen.
</p>
<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">
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
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">
@@ -603,12 +666,6 @@ export function renderProjectsDetail(): string {
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
</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>
</div>
{/* Full edit modal — same form as /projects/new, pre-filled. */}
@@ -640,6 +697,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>

View File

@@ -127,7 +127,8 @@ export function renderProjects(): string {
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
<label><input type="checkbox" value="project" /><span data-i18n="projects.chip.type.project">Projekt</span></label>
<label><input type="checkbox" value="other" /><span data-i18n="projects.chip.type.other">Sonstiges</span></label>
</div>
</details>
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>

View File

@@ -323,6 +323,25 @@ export function renderSettings(): string {
</div>
</form>
{/* t-paliad-212 Slice 2b — multi-calendar bindings.
Each card is one (calendar, scope) binding layered on the
single CalDAV server connection above. */}
<div className="caldav-bindings-section" id="caldav-bindings-section" style="display:none">
<div className="caldav-bindings-header">
<h2 data-i18n="caldav.bindings.heading">Kalender</h2>
<button type="button" id="caldav-bindings-add-btn" className="btn-secondary" data-i18n="caldav.bindings.add">
+ Kalender hinzuf&uuml;gen
</button>
</div>
<p className="form-hint" data-i18n="caldav.bindings.hint">
Verbinde mehrere Kalender mit Paliad &mdash; einen Master f&uuml;r alles oder eigene Kalender pro Projekt.
</p>
<div id="caldav-bindings-list" className="caldav-bindings-list" />
<p className="entity-events-empty" id="caldav-bindings-empty" data-i18n="caldav.bindings.empty" style="display:none">
Noch keine Kalender konfiguriert.
</p>
</div>
<div className="caldav-log-card">
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
<table className="entity-table entity-table--readonly caldav-log-table">
@@ -392,6 +411,89 @@ export function renderSettings(): string {
<Footer />
<PaliadinWidget />
{/* t-paliad-212 Slice 2b — single-step Add/Edit modal for
calendar bindings. Source picker (existing dropdown or
custom URL) + scope radio + display name. Edit mode hides
the source picker (path is fixed). */}
<div id="caldav-binding-modal" className="modal-backdrop" style="display:none">
<div className="modal-dialog">
<div className="modal-header">
<h2 id="caldav-binding-modal-title" data-i18n="caldav.bindings.modal.add_title">Kalender hinzuf&uuml;gen</h2>
<button type="button" className="modal-close" id="caldav-binding-modal-close" aria-label="Schlie&szlig;en">&times;</button>
</div>
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
<div className="form-field" id="caldav-binding-source-field">
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender w&auml;hlen</span>
</label>
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
<input type="radio" name="caldav-binding-source-mode" value="create" />
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-source-mode" value="custom" />
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
</label>
</div>
<select id="caldav-binding-discover-select">
<option value="" data-i18n="caldav.bindings.modal.source.loading">L&auml;dt&hellip;</option>
</select>
<input
type="text"
id="caldav-binding-custom-path"
placeholder="https://..."
style="display:none"
/>
{/* Slice 2c — Google-degrade notice. Shown when
supports_mkcalendar=false; the create-new radio is
hidden in that state, so users are nudged to the
custom-URL path. */}
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
Erstelle den Kalender direkt in der Anbieter-Oberfl&auml;che und f&uuml;ge ihn hier per URL hinzu.
</p>
</div>
<div className="form-field">
<label htmlFor="caldav-binding-display-name" data-i18n="caldav.bindings.modal.display_name">Anzeigename (optional)</label>
<input type="text" id="caldav-binding-display-name" data-i18n-placeholder="caldav.bindings.modal.display_name.placeholder" placeholder="z.B. Projekt Acme v Bosch" />
</div>
<div className="form-field">
<label data-i18n="caldav.bindings.modal.scope">Inhalt</label>
<div className="caldav-binding-scope-radios">
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-scope" value="all_visible" checked />
<span data-i18n="caldav.bindings.modal.scope.all_visible">Alles, was ich sehe</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-scope" value="personal_only" />
<span data-i18n="caldav.bindings.modal.scope.personal_only">Nur pers&ouml;nliche Termine</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-scope" value="project" />
<span data-i18n="caldav.bindings.modal.scope.project">Ein Projekt:</span>
<select id="caldav-binding-project-select" disabled>
<option value="" data-i18n="caldav.bindings.modal.scope.project.loading">L&auml;dt&hellip;</option>
</select>
</label>
</div>
</div>
<p className="form-msg" id="caldav-binding-msg" />
<div className="form-actions">
<button type="button" className="btn-secondary" id="caldav-binding-cancel-btn" data-i18n="common.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" id="caldav-binding-submit-btn" data-i18n="caldav.bindings.modal.submit_add">Hinzuf&uuml;gen</button>
</div>
</form>
</div>
</div>
<script src="/assets/settings.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-238 Slice A — dedicated Submissions/Schriftsätze editor page.
//
// Lawyer picks (or creates) a draft for one (project, submission_code),
// edits placeholder variables in a sticky sidebar, sees a read-only
// HTML preview of the merged document, and exports the result as
// .docx. Drafts persist server-side per paliad.submission_drafts.
//
// Pure shell: client/submission-draft.ts hydrates draft list + variable
// form + preview pane after page load. The same dist/submission-draft.html
// serves every (project_id, submission_code, [draft_id]) URL.
//
// Design ref: docs/design-submission-page-2026-05-22.md §6.
export function renderSubmissionDraft(): 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="submissions.draft.title">Schriftsatz bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-submission-draft">
<Sidebar currentPath="/projects" />
<BottomNav currentPath="/projects" />
<main>
<section className="tool-page submission-draft-page">
<div className="container">
<a
id="submission-draft-back-link"
href="/projects"
className="back-link"
data-i18n="submissions.draft.back">
&larr; Zur&uuml;ck zum Projekt
</a>
<div id="submission-draft-loading" className="entity-loading">
<p data-i18n="submissions.draft.loading">L&auml;dt&hellip;</p>
</div>
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
<p data-i18n="submissions.draft.notfound">
Schriftsatz nicht gefunden oder keine Berechtigung.
</p>
</div>
<div id="submission-draft-error" className="entity-empty" style="display:none" />
<div id="submission-draft-body" style="display:none">
<header className="submission-draft-header">
<div className="submission-draft-header-text">
<h1 id="submission-draft-title" />
<p id="submission-draft-subtitle" className="tool-subtitle" />
</div>
<div className="submission-draft-header-actions">
<button
id="submission-draft-export-btn"
type="button"
className="btn-primary btn-cta-lime"
data-i18n="submissions.draft.action.export">
Als .docx exportieren
</button>
</div>
</header>
<div className="submission-draft-grid">
{/* Sidebar — draft switcher + variable groups. */}
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
<div className="submission-draft-switcher">
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
Entwurf
</label>
<select id="submission-draft-pick" />
<button
type="button"
id="submission-draft-new-btn"
className="btn-small btn-secondary"
data-i18n="submissions.draft.action.new">
+ Neuer Entwurf
</button>
</div>
<div className="submission-draft-name-row">
<input
type="text"
id="submission-draft-name"
className="entity-form-input"
data-i18n-placeholder="submissions.draft.name.placeholder"
placeholder="Name dieses Entwurfs"
/>
<button
type="button"
id="submission-draft-delete-btn"
className="btn-small btn-link-danger"
data-i18n="submissions.draft.action.delete">
L&ouml;schen
</button>
</div>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
<div className="submission-draft-variables" id="submission-draft-variables" />
</aside>
{/* Preview pane — read-only HTML render of the merged
document body. Re-renders on autosave round-trip. */}
<section className="submission-draft-preview-wrap">
<header className="submission-draft-preview-header">
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
<span
className="submission-draft-preview-hint"
data-i18n="submissions.draft.preview.hint">
Read-only Vorschau &mdash; finale Bearbeitung in Word.
</span>
</header>
<div className="submission-draft-preview" id="submission-draft-preview" />
</section>
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submission-draft.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,84 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
// entry that lists every draft the caller owns across visible projects.
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
// this page only adds a discovery surface and click-through to it.
export function renderSubmissionsIndex(): 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="submissions.index.title">Schriftsätze &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/submissions" />
<BottomNav currentPath="/submissions" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="submissions-index-headline">
<div>
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
Ihre Schriftsatz-Entw&uuml;rfe &uuml;ber alle sichtbaren Projekte.
</p>
</div>
<a href="/submissions/new" className="btn-primary btn-cta-lime"
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
</div>
</div>
<p className="entity-events-empty" id="submissions-index-loading"
data-i18n="submissions.index.loading">L&auml;dt&hellip;</p>
<div className="entity-empty" id="submissions-index-empty" style="display:none">
<p data-i18n="submissions.index.empty">
Noch keine Entw&uuml;rfe. Beginnen Sie mit einem neuen Entwurf &mdash; mit oder ohne Projekt.
</p>
<a href="/submissions/new" className="btn-primary btn-cta-lime"
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
</div>
<div className="entity-empty" id="submissions-index-error" style="display:none">
<p data-i18n="submissions.index.error">Schrifts&auml;tze konnten nicht geladen werden.</p>
</div>
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
<table className="entity-table">
<thead>
<tr>
<th data-i18n="submissions.index.col.project">Projekt</th>
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
<th data-i18n="submissions.index.col.draft">Entwurf</th>
<th data-i18n="submissions.index.col.updated">Zuletzt ge&auml;ndert</th>
</tr>
</thead>
<tbody id="submissions-index-body" />
</table>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submissions-index.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,118 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-243 — global Schriftsatz picker. Lists the full
// cross-proceeding submission catalog (grouped by proceeding,
// filterable) and lets the lawyer start a draft with or without
// binding a project. Picking "Ohne Projekt" jumps straight to
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
// autocomplete project picker, then redirects to the project-scoped
// editor.
export function renderSubmissionsNew(): 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="submissions.new.title">Neuer Schriftsatz &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/submissions" />
<BottomNav currentPath="/submissions" />
<main>
<section className="tool-page submissions-new-page">
<div className="container">
<a href="/submissions" className="back-link"
data-i18n="submissions.new.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<div className="tool-header">
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
W&auml;hlen Sie eine Vorlage. Optional verkn&uuml;pfen Sie den
Entwurf mit einem Projekt &mdash; sonst f&uuml;llen Sie alle
Variablen manuell.
</p>
</div>
<div className="submissions-new-toolbar">
<input
type="search"
id="submissions-new-search"
className="entity-form-input"
data-i18n-placeholder="submissions.new.search.placeholder"
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
</div>
<p className="entity-events-empty" id="submissions-new-loading"
data-i18n="submissions.new.loading">L&auml;dt&hellip;</p>
<div className="entity-empty" id="submissions-new-error" style="display:none">
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
</div>
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
<th data-i18n="submissions.new.col.party">Partei</th>
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
</tr>
</thead>
<tbody id="submissions-new-body" />
</table>
</div>
<p className="entity-empty" id="submissions-new-empty" style="display:none">
<span data-i18n="submissions.new.empty.filtered">
Keine passenden Schrifts&auml;tze. Filter zur&uuml;cksetzen.
</span>
</p>
</div>
</section>
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
<div className="modal-card">
<header className="modal-header">
<h2 data-i18n="submissions.new.picker.title">Projekt w&auml;hlen</h2>
<button type="button" id="submissions-new-project-modal-close"
className="modal-close" aria-label="Close">&times;</button>
</header>
<div className="modal-body">
<input
type="search"
id="submissions-new-project-search"
className="entity-form-input"
data-i18n-placeholder="submissions.new.picker.placeholder"
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
data-i18n="submissions.new.picker.loading">L&auml;dt Projekte&hellip;</p>
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
</div>
</div>
</div>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submissions-new.js"></script>
</body>
</html>
);
}

View File

@@ -75,6 +75,14 @@ export function renderTeam(): string {
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
</div>
{/* t-paliad-223 (#53) — master "select all visible" checkbox. */}
<div className="team-select-master-row">
<label className="team-select-master-label">
<input type="checkbox" id="team-select-master" />
<span data-i18n="team.selection.select_all">Alle sichtbaren ausw&auml;hlen</span>
</label>
</div>
<div className="team-list" id="team-list" />
<div className="glossar-empty" id="team-empty" style="display:none">

View File

@@ -84,7 +84,7 @@ export function renderVerfahrensablauf(): string {
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<body className="has-sidebar page-verfahrensablauf">
<Sidebar currentPath="/tools/verfahrensablauf" />
<BottomNav currentPath="/tools/verfahrensablauf" />
@@ -163,7 +163,10 @@ export function renderVerfahrensablauf(): 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">

7
go.mod
View File

@@ -4,20 +4,21 @@ go 1.24.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/text v0.34.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

58
go.sum
View File

@@ -1,39 +1,11 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
@@ -43,26 +15,14 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
@@ -71,22 +31,12 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -30,6 +30,78 @@ type Entry struct {
// Entries lists everything shipped so far, newest first. Append new rows
// at the top.
var Entries = []Entry{
{
Date: "2026-05-21",
Tag: TagFeature,
TitleDE: "Konfigurierbares Dashboard",
TitleEN: "Configurable dashboard",
BodyDE: "Das Dashboard lässt sich jetzt frei zusammenstellen: Widgets per Drag-and-drop verschieben, in der Größe ändern und einzeln konfigurieren. Der Katalog umfasst Fristen-Ampel, Termine, Agenda, Inbox-Übersicht, angepinnte Projekte und Schnellaktionen. Admins können eine kanzleiweite Standardanordnung festlegen, von der jeder Nutzer startet und sie nach Wunsch anpasst.",
BodyEN: "The dashboard can now be assembled freely: drag-and-drop widgets, resize them and configure each one individually. The catalog covers the deadline traffic-light, appointments, agenda, inbox summary, pinned projects and quick actions. Admins can set a firm-wide default layout that every user starts from and then tweaks to taste.",
},
{
Date: "2026-05-20",
Tag: TagFeature,
TitleDE: "Eigene Einreichungs-Checklisten",
TitleEN: "User-authored checklists",
BodyDE: "Eigene Checklisten lassen sich per Wizard anlegen und gezielt mit einzelnen Kolleg:innen, einem Büro, einer Partnereinheit oder einem Projekt teilen. Admins können besonders gute Vorlagen kanzleiweit unter „Geteilte Vorlagen\" freigeben. Wird eine Vorlage später überarbeitet, erscheint an laufenden Instanzen ein Hinweis-Badge auf die neuere Version.",
BodyEN: "Build your own filing checklists through a wizard and share them explicitly with individual colleagues, an office, a partner unit or a project. Admins can promote the best templates firm-wide under „Shared templates\". When a template is later revised, running instances surface a notice badge pointing at the newer version.",
},
{
Date: "2026-05-20",
Tag: TagFeature,
TitleDE: "Genehmigungen: Änderungen vorschlagen",
TitleEN: "Approvals: suggest changes",
BodyDE: "Im Inbox gibt es eine dritte Aktion neben „Genehmigen\" und „Ablehnen\": „Änderungen vorschlagen\". Ein Modal zeigt den ursprünglichen Wert, der Gegenvorschlag wandert mit einem Kommentar zurück an die Antragsteller:in. Der gesamte Austausch erscheint im Verlauf des Eintrags.",
BodyEN: "Inbox now offers a third action alongside „Approve\" and „Reject\": „Suggest changes\". A modal shows the original value, the counter-proposal travels back to the requester together with a comment. The full exchange shows up in the entry's Verlauf.",
},
{
Date: "2026-05-20",
Tag: TagFeature,
TitleDE: "Mandant:innen-Rolle und automatische Projekt-Codes",
TitleEN: "Client role and auto-derived project codes",
BodyDE: "Mandant:innen lassen sich jetzt als eigene Rolle in das Team eines Projekts aufnehmen — separat von HLC-Mitgliedern und mit eigenem Sichtbarkeitsumfang. Außerdem leitet Paliad pro Projekt einen kompakten Code aus dem Baum ab (etwa /9999-1-EP123-CFI) und zeigt ihn als zweites Badge im Header und in jedem Projekt-Picker.",
BodyEN: "Clients can now be added to a project's team as their own role — separate from HLC members and with their own visibility scope. In addition, Paliad derives a compact code per project from the ancestor tree (e.g. /9999-1-EP123-CFI) and shows it as a second badge in the header and in every project picker.",
},
{
Date: "2026-05-19",
Tag: TagFeature,
TitleDE: "Datenexport — Excel, CSV, JSON",
TitleEN: "Data export — Excel, CSV, JSON",
BodyDE: "Unter Einstellungen → Datenexport lassen sich alle sichtbaren Projekte, Fristen, Termine, Notizen und Checklisten als Excel-, CSV- oder JSON-Datei herunterladen. Auf jeder Projekt-Seite gibt es zusätzlich einen „Daten exportieren\"-Button, der nur den jeweiligen Teilbaum mitnimmt.",
BodyEN: "Settings → Data export lets you download every project, deadline, appointment, note and checklist you can see as an Excel, CSV or JSON file. Each project page additionally offers a „Daten exportieren\" button that exports just that subtree.",
},
{
Date: "2026-05-15",
Tag: TagFeature,
TitleDE: "Eigene Sichten — Liste, Karten, Kalender, Timeline",
TitleEN: "Custom views — list, cards, calendar, timeline",
BodyDE: "Eigene Filter über Fristen, Termine und Projekte lassen sich speichern und als Liste, Karten, Kalender oder Timeline rendern. Jede Sicht erhält einen permanenten Link, lässt sich als SVG, PNG, CSV, JSON oder iCal exportieren und erscheint in der Seitenleiste unter „Meine Sichten\".",
BodyEN: "Custom filters over deadlines, appointments and projects can be saved and rendered as list, cards, calendar or timeline. Each view gets a permalink, can be exported as SVG, PNG, CSV, JSON or iCal and shows up in the sidebar under „Meine Sichten\".",
},
{
Date: "2026-05-07",
Tag: TagFeature,
TitleDE: "Projekte-Seite mit Baum, Pinnungen und Karten-Ansicht",
TitleEN: "Projects page with tree, pins and cards view",
BodyDE: "Die Projekte-Seite öffnet jetzt mit einem zusammenklappbaren Baum, Volltextsuche und Chips für Mandant, Ort und Status. Häufig genutzte Projekte lassen sich oben anpinnen; die alternative Karten-Ansicht erlaubt frei per Drag-and-drop sortierbare Layouts pro Nutzer.",
BodyEN: "The Projects page now opens with a collapsible tree, full-text search and chips for client, location and status. Frequently used projects can be pinned to the top; the alternative cards view supports per-user drag-and-drop layouts.",
},
{
Date: "2026-05-06",
Tag: TagFeature,
TitleDE: "Vier-Augen-Prinzip für Fristen und Termine",
TitleEN: "Four-eyes principle for deadlines and appointments",
BodyDE: "Pro Projekt lässt sich festlegen, dass Anlegen, Ändern, Abhaken und Löschen von Fristen oder Terminen durch eine zweite Person freigegeben werden müssen. Anfragen erscheinen im Inbox, am Eintrag selbst und mit „PENDING\"-Vermerk im CalDAV-Kalender. Admins pflegen die Regeln zentral unter /admin/approval-policies.",
BodyEN: "Per project you can require that creating, editing, completing or deleting a deadline or appointment must be cleared by a second person. Requests show up in the inbox, on the entry itself and as a „PENDING\" marker in the CalDAV calendar. Admins maintain the rules centrally under /admin/approval-policies.",
},
{
Date: "2026-05-05",
Tag: TagFeature,
TitleDE: "Fristenrechner v3 — Entscheidungsbaum, Begriffe, DE/EPA/DPMA",
TitleEN: "Deadline calculator v3 — decision tree, concepts, DE/EPA/DPMA",
BodyDE: "Der Fristenrechner wurde grundlegend überarbeitet: ein Entscheidungsbaum führt durch Verfahren und Fristart, eine neue Begriffsebene fasst Wiedereinsetzung, Säumnis, Schriftsatznachreichung und Weiterbehandlung als wiederverwendbare Konzepte zusammen. Der Regelbestand wurde um deutsche Verfahren (PatG, BPatG, BGH), EPA- und DPMA-Strecken erweitert, mit aktuellen Werten und Querverweisen.",
BodyEN: "The deadline calculator has been overhauled from the ground up: a decision tree walks you through proceeding and deadline type, and a new concept layer treats Wiedereinsetzung, default, post-filing and further processing as reusable cross-cutting building blocks. The rule corpus has been extended with German proceedings (PatG, BPatG, BGH), EPO and DPMA tracks, with current values and cross-references.",
},
{
Date: "2026-04-30",
Tag: TagFeature,

View File

@@ -1,46 +1,78 @@
// Package db owns the Paliad Postgres connection and embedded schema migrations.
//
// Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and
// live in the migrations/ subdirectory, embedded into the binary so a single
// artifact ships with its schema. The server applies pending migrations at
// startup before binding the HTTP listener.
// Migrations are NNN_description.up.sql / .down.sql files in the migrations/
// subdirectory, embedded into the binary so a single artifact ships with its
// schema. The server applies pending migrations at startup before binding
// the HTTP listener.
//
// The runner tracks applied state as a set, not a counter: every applied
// migration gets its own row in paliad.applied_migrations(version PK, name,
// applied_at, checksum). On every deploy, pending = on_disk \ applied, in
// ascending version order. Gaps in the version space are first-class — a
// version that's missing from applied_migrations runs on the next deploy,
// regardless of which higher versions are already applied.
//
// This is what closes the parallel-merge skip-hole that the single-counter
// tracker (golang-migrate) silently fell into on 2026-05-20 (m/paliad#44).
// Background and design: docs/design-migration-runner-applied-set-2026-05-20.md.
//
// .down.sql files ship in the embedded FS as reference material but are not
// auto-applied — there are no call sites for rolling back, and operator
// recovery (psql .down.sql + DELETE FROM paliad.applied_migrations WHERE
// version=N) is the documented path. If a real call site for auto-rollback
// materializes later, add it as a focused follow-up.
package db
import (
"crypto/sha256"
"database/sql"
"embed"
"errors"
"fmt"
"hash/fnv"
"sort"
"strconv"
"strings"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/lib/pq"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// migrationsTable is the name of the golang-migrate tracking table. We use a
// uniquely-named table (not the default "schema_migrations") because the
// production Supabase instance hosts multiple apps in the `public` schema,
// and a differently-shaped `public.schema_migrations` already exists there.
// Using "paliad_schema_migrations" prevents collision at startup.
// advisoryLockID is the Postgres advisory-lock id the runner takes around
// the apply loop. Derived once from the table name so the value is stable
// across processes — two concurrent deploys (rolling Dokploy update, dev
// laptop hitting the same scratch DB as CI) serialize on this id rather
// than racing on the pending set.
//
// The table lives in the `public` schema (golang-migrate's default) rather
// than `paliad`. Rationale: migration 001's down-step is
// DROP SCHEMA IF EXISTS paliad CASCADE
// which would take the tracking table with it — breaking any subsequent
// migrate.Up() call. Keeping the tracker in `public` makes the down-path
// safe and idempotent.
const migrationsTable = "paliad_schema_migrations"
// FNV-1a-64 is good enough: the id only has to be a stable int64, not
// cryptographically uniform. Process-wide constant.
var advisoryLockID = func() int64 {
h := fnv.New64a()
_, _ = h.Write([]byte("paliad.applied_migrations"))
return int64(h.Sum64())
}()
// ApplyMigrations runs all pending up-migrations against the given database
// URL. Returns nil if no migrations were pending. Safe to call repeatedly.
// migration is one *.up.sql file from the embedded FS.
type migration struct {
version int
name string
filename string
}
// ApplyMigrations applies every pending up-migration to the given database.
//
// Pre-creates the `paliad` schema before invoking golang-migrate because the
// first migration creates it and golang-migrate's tracking table would
// otherwise be created in whatever `current_schema()` happens to be.
// Safe to call repeatedly; a fully-applied tree is a no-op. Returns the
// first error encountered (with the offending migration filename wrapped
// in the message) and leaves the rest of pending unapplied — same fail-fast
// posture as the previous golang-migrate runner.
//
// On first deploy of this code path against a database that still has the
// legacy paliad.paliad_schema_migrations counter at version N, the runner
// seeds paliad.applied_migrations with rows 1..N (checksum NULL) before
// applying anything new. The first deploy is therefore effectively a
// no-op against the schema — the bootstrap just relabels existing state.
func ApplyMigrations(databaseURL string) error {
if databaseURL == "" {
return errors.New("database URL is empty")
@@ -51,39 +83,250 @@ func ApplyMigrations(databaseURL string) error {
return fmt.Errorf("open database: %w", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
return fmt.Errorf("ping database: %w", err)
}
// Bootstrap the paliad schema so later migrations can target it cleanly.
// This duplicates migration 001, but is idempotent via IF NOT EXISTS and
// ensures the schema exists before golang-migrate touches the DB.
// Ensure the paliad schema exists. Mig 001 also creates it; the
// applied_migrations table lives in paliad.* and gets created before
// any migrations run, so the schema must exist first.
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
return fmt.Errorf("ensure paliad schema: %w", err)
}
source, err := iofs.New(migrationFS, "migrations")
if err != nil {
return fmt.Errorf("open migration source: %w", err)
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil {
return fmt.Errorf("acquire advisory lock: %w", err)
}
defer func() {
_, _ = conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
}()
if _, err := conn.Exec(`
CREATE TABLE IF NOT EXISTS paliad.applied_migrations (
version int NOT NULL PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
checksum text NULL
)
`); err != nil {
return fmt.Errorf("create applied_migrations: %w", err)
}
driver, err := postgres.WithInstance(conn, &postgres.Config{
// Unique tracking-table name avoids collision with pre-existing
// public.schema_migrations owned by other apps on this Postgres.
MigrationsTable: migrationsTable,
})
onDisk, err := scanEmbeddedMigrations()
if err != nil {
return fmt.Errorf("create migration driver: %w", err)
return fmt.Errorf("scan embedded migrations: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return fmt.Errorf("create migrator: %w", err)
if err := bootstrapFromLegacyTracker(conn, onDisk); err != nil {
return fmt.Errorf("bootstrap from legacy tracker: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("apply migrations: %w", err)
applied, err := readAppliedMigrations(conn)
if err != nil {
return fmt.Errorf("read applied_migrations: %w", err)
}
if err := checkNameAgreement(onDisk, applied); err != nil {
return err
}
for _, m := range onDisk {
if _, ok := applied[m.version]; ok {
continue
}
if err := applyOne(conn, m); err != nil {
return fmt.Errorf("apply %s: %w", m.filename, err)
}
}
return nil
}
// scanEmbeddedMigrations returns every NNN_*.up.sql in the embedded FS,
// sorted by version ascending. Hard-fails on two files sharing the same
// version prefix — that's the failure mode the parallel-merge incident
// exposed, and the runner refuses to start rather than silently picking one.
func scanEmbeddedMigrations() ([]migration, error) {
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("read migrations dir: %w", err)
}
seen := map[int]string{}
var out []migration
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".up.sql") {
continue
}
v, n, ok := parseMigrationFilename(name)
if !ok {
return nil, fmt.Errorf("unparseable migration filename %q "+
"(expected NNN_description.up.sql)", name)
}
if prior, dup := seen[v]; dup {
return nil, fmt.Errorf("two migrations at version %d: %q and %q — "+
"rename one and redeploy", v, prior, name)
}
seen[v] = name
out = append(out, migration{version: v, name: n, filename: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
return out, nil
}
// parseMigrationFilename splits "NNN_description.up.sql" into (NNN, description).
// Returns ok=false on any deviation from that shape.
func parseMigrationFilename(filename string) (version int, name string, ok bool) {
base := strings.TrimSuffix(filename, ".up.sql")
if base == filename {
return 0, "", false
}
underscore := strings.IndexByte(base, '_')
if underscore <= 0 {
return 0, "", false
}
v, err := strconv.Atoi(base[:underscore])
if err != nil {
return 0, "", false
}
return v, base[underscore+1:], true
}
// readAppliedMigrations returns a map version → name from
// paliad.applied_migrations. Returns an empty map (no error) if the table
// is missing — that's the fresh-DB path before the CREATE TABLE in
// ApplyMigrations runs against it.
func readAppliedMigrations(conn *sql.DB) (map[int]string, error) {
rows, err := conn.Query(`SELECT version, name FROM paliad.applied_migrations`)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
return map[int]string{}, nil
}
return nil, err
}
defer rows.Close()
out := map[int]string{}
for rows.Next() {
var v int
var n string
if err := rows.Scan(&v, &n); err != nil {
return nil, err
}
out[v] = n
}
return out, rows.Err()
}
// bootstrapFromLegacyTracker seeds paliad.applied_migrations from
// paliad.paliad_schema_migrations on the first deploy of the new runner
// against a DB that previously ran golang-migrate.
//
// Behavior:
// - applied_migrations already has rows → no-op (idempotent).
// - applied_migrations empty AND legacy tracker missing → no-op
// (virgin DB; the apply loop will run everything from scratch).
// - applied_migrations empty AND legacy tracker present, clean, version N
// → INSERT rows for every on-disk version ≤ N with checksum NULL.
// - applied_migrations empty AND legacy tracker dirty → hard-fail.
// The operator must recover the legacy tracker first (it being dirty
// means a prior golang-migrate run crashed mid-flight); we will not
// paper over an unknown state by guessing what landed.
//
// Backfilled rows have checksum NULL because the legacy runner didn't hash
// anything — we can't fabricate a provenance hash today without falsely
// claiming we know the byte-identity of what shipped historically.
func bootstrapFromLegacyTracker(conn *sql.DB, onDisk []migration) error {
var count int
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
return fmt.Errorf("count applied_migrations: %w", err)
}
if count > 0 {
return nil
}
var legacyVer int
var legacyDirty bool
err := conn.QueryRow(`SELECT version, dirty FROM paliad.paliad_schema_migrations LIMIT 1`).
Scan(&legacyVer, &legacyDirty)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
return nil
}
return fmt.Errorf("read legacy tracker: %w", err)
}
if legacyDirty {
return fmt.Errorf("legacy paliad.paliad_schema_migrations is dirty at version %d — "+
"recover manually before deploying", legacyVer)
}
for _, m := range onDisk {
if m.version > legacyVer {
continue
}
if _, err := conn.Exec(`
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
VALUES ($1, $2, now(), NULL)
ON CONFLICT (version) DO NOTHING
`, m.version, m.name); err != nil {
return fmt.Errorf("backfill version %d: %w", m.version, err)
}
}
return nil
}
// checkNameAgreement hard-fails if a version that's already applied has a
// different name on disk than in the DB. Catches the post-merge rename
// accident where someone renames `098_foo.up.sql` to `098_bar.up.sql` —
// the SQL has already run on prod with the old name, so the rename is a
// lie about history. Operator recovery: revert the rename, or update the
// DB row if the rename is intentional.
//
// Backfilled rows have a name pulled from the on-disk filename, so an
// out-of-the-box backfill never trips this check.
func checkNameAgreement(onDisk []migration, applied map[int]string) error {
for _, m := range onDisk {
dbName, ok := applied[m.version]
if !ok {
continue
}
if dbName != m.name {
return fmt.Errorf("migration %d: disk name %q != DB name %q "+
"(renamed after apply? revert the rename, or UPDATE paliad.applied_migrations "+
"SET name=%q WHERE version=%d if the rename is intentional)",
m.version, m.name, dbName, m.name, m.version)
}
}
return nil
}
// applyOne runs one migration's .up.sql plus its INSERT row in a single
// transaction. All-or-nothing per migration: if the SQL fails, the row
// isn't inserted and the next deploy re-tries from the same point. If
// the INSERT fails (e.g. PK violation because the lock wasn't held), the
// SQL rolls back too.
func applyOne(conn *sql.DB, m migration) error {
body, err := migrationFS.ReadFile("migrations/" + m.filename)
if err != nil {
return fmt.Errorf("read %s: %w", m.filename, err)
}
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
tx, err := conn.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(string(body)); err != nil {
return fmt.Errorf("exec sql: %w", err)
}
if _, err := tx.Exec(`
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
VALUES ($1, $2, now(), $3)
`, m.version, m.name, checksum); err != nil {
return fmt.Errorf("record applied: %w", err)
}
return tx.Commit()
}

View File

@@ -1,60 +1,49 @@
// Package db tests — migration dry-run gate.
//
// This is the test that catches mig-N crash-loops before they reach prod.
// The convention since t-paliad-098/099 is that paliad migrations land in
// numeric order on a single trunk; the next deploy runs whichever ones are
// pending against the live `public.paliad_schema_migrations` tracker. A
// migration that compiles cleanly but fails on apply (typo, missing column,
// wrong CHECK shape) crashes the Dokploy container loop before paliad.de
// finishes binding :8080, and the only way to learn about it today is to
// watch the deploy log.
// The new runner tracks applied state as a set in paliad.applied_migrations
// (one row per migration; see migrate.go). A migration that compiles cleanly
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
// Dokploy container loop before paliad.de finishes binding :8080, and the
// only way to learn about it today is to watch the deploy log.
//
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
// directory whose version is greater than the scratch DB's current tracker
// version, it opens a transaction, runs the SQL, and ROLLBACKs. Any error
// fails the test with the file name + Postgres error. Always non-destructive
// — the ROLLBACK runs even on success, so the scratch DB stays at its
// starting version.
// directory whose version is NOT present in paliad.applied_migrations on
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
// Any error fails the test with the file name + Postgres error. Always
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
// stays at its starting set.
//
// "Pending" means: a version that's on disk but not in applied_migrations.
// In CI against a fresh scratch DB (where applied_migrations either
// doesn't exist or is empty), every migration is pending and gets
// verified. On a developer laptop whose scratch DB is already at HEAD,
// no migrations are pending and the test logs and passes — the protection
// only kicks in the moment a new *.up.sql lands in the tree before the
// developer runs `db.ApplyMigrations` against the same scratch DB.
//
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
// tests). Skipped without it.
//
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
// 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 db
import (
"database/sql"
"errors"
"fmt"
"os"
"sort"
"strconv"
"strings"
"testing"
_ "github.com/lib/pq"
)
// migration is one *.up.sql file from the embedded migrations FS.
type migration struct {
version int
name string
filename string
}
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
// fails the test on the first SQL error. Reports per-file as a sub-test so
// `go test -v` shows which migration failed.
//
// What "pending" means: greater than the scratch DB's current tracker
// version (or 0 if the tracker doesn't exist yet). In CI against a fresh
// scratch DB, every migration is pending and gets verified. On a developer
// laptop whose scratch DB is already at HEAD, no migrations are pending and
// the test logs the start version and passes — the protection only kicks in
// the moment a new *.up.sql lands in the tree before the developer runs
// `db.ApplyMigrations` against the same scratch DB.
func TestMigrations_DryRun(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
@@ -79,28 +68,32 @@ func TestMigrations_DryRun(t *testing.T) {
t.Fatalf("ensure paliad schema: %v", err)
}
startVersion, dirty, err := currentTrackerVersion(conn)
applied, err := readAppliedVersions(conn)
if err != nil {
t.Fatalf("read tracker: %v", err)
t.Fatalf("read applied_migrations: %v", err)
}
if dirty {
t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+
"or restore from backup); the dry-run cannot trust a dirty starting state",
startVersion)
}
t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward",
startVersion, startVersion+1)
migs, err := loadPendingMigrations(startVersion)
onDisk, err := scanEmbeddedMigrations()
if err != nil {
t.Fatalf("load migrations: %v", err)
t.Fatalf("scan embedded migrations: %v", err)
}
if len(migs) == 0 {
t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion)
var pending []migration
for _, m := range onDisk {
if !applied[m.version] {
pending = append(pending, m)
}
}
if len(pending) == 0 {
t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
len(onDisk))
return
}
t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
len(applied), len(onDisk), len(pending))
for _, m := range migs {
for _, m := range pending {
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
body, err := migrationFS.ReadFile("migrations/" + m.filename)
if err != nil {
@@ -110,10 +103,10 @@ func TestMigrations_DryRun(t *testing.T) {
if err != nil {
t.Fatalf("begin: %v", err)
}
// Always rollback; the dry-run must not leave the scratch DB
// at a different version than where it started. Rollback is
// safe to call even after a failed Exec — Postgres aborts the
// transaction internally on the first error.
// Always rollback; the dry-run must not leave the scratch
// DB at a different applied set than where it started.
// Rollback is safe after a failed Exec — Postgres aborts
// the transaction internally on the first error.
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(string(body)); err != nil {
@@ -123,76 +116,30 @@ func TestMigrations_DryRun(t *testing.T) {
}
}
// currentTrackerVersion reads the latest version + dirty flag from the
// `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the
// tracker doesn't exist yet — that's the "fresh scratch DB" path.
// readAppliedVersions returns the set of versions present in
// paliad.applied_migrations on the scratch DB. Missing table → empty set
// (fresh-DB path; the table only exists after the runner has been called).
//
// We don't use golang-migrate's API to read this because golang-migrate's
// driver locks the tracker row on read; a test runner that calls this while
// the developer has paliad running locally would race. A plain SELECT is
// race-safe and matches what `psql` would show.
func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) {
const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`
row := conn.QueryRow(q)
if scanErr := row.Scan(&version, &dirty); scanErr != nil {
// Missing table → fresh DB → start at 0. lib/pq surfaces this
// as `pq.Error.Code = "42P01"` (undefined_table); the simpler
// sql.ErrNoRows fires if the table exists but is empty (also
// fresh-DB-shaped).
if errors.Is(scanErr, sql.ErrNoRows) {
return 0, false, nil
}
if strings.Contains(scanErr.Error(), "does not exist") {
return 0, false, nil
}
return 0, false, scanErr
}
return version, dirty, nil
}
// loadPendingMigrations returns every *.up.sql in the embedded FS whose
// version is greater than startVersion, sorted by version ascending. A
// filename like "098_submission_codes_prefix_and_rename.up.sql" yields
// version=98, name="submission_codes_prefix_and_rename".
func loadPendingMigrations(startVersion int) ([]migration, error) {
entries, err := migrationFS.ReadDir("migrations")
// We don't pre-create the table here because the dry-run is supposed to be
// a passive observer — it must not mutate the scratch DB outside of its
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
// the right read against a virgin scratch DB.
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
if err != nil {
return nil, fmt.Errorf("read migrations dir: %w", err)
if strings.Contains(err.Error(), "does not exist") {
return map[int]bool{}, nil
}
return nil, err
}
var out []migration
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".up.sql") {
continue
defer rows.Close()
out := map[int]bool{}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
return nil, err
}
v, n, ok := parseMigrationName(name)
if !ok {
return nil, fmt.Errorf("unparseable migration filename: %s "+
"(expected NNN_description.up.sql)", name)
}
if v <= startVersion {
continue
}
out = append(out, migration{version: v, name: n, filename: name})
out[v] = true
}
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
return out, nil
}
// parseMigrationName splits "NNN_description.up.sql" into (NNN, description).
// Returns ok=false on any deviation from that shape.
func parseMigrationName(filename string) (version int, name string, ok bool) {
base := strings.TrimSuffix(filename, ".up.sql")
if base == filename { // suffix wasn't present
return 0, "", false
}
underscore := strings.IndexByte(base, '_')
if underscore <= 0 {
return 0, "", false
}
v, err := strconv.Atoi(base[:underscore])
if err != nil {
return 0, "", false
}
return v, base[underscore+1:], true
return out, rows.Err()
}

View File

@@ -0,0 +1,5 @@
-- Reverse of 107: drop the binding_id column from caldav_sync_log.
-- The associated index drops automatically with the column.
ALTER TABLE paliad.caldav_sync_log
DROP COLUMN IF EXISTS binding_id;

View File

@@ -0,0 +1,53 @@
-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
--
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
-- records which binding the entry belongs to. NULL for legacy rows
-- and for "global" log entries that aren't per-binding (Slice 2a
-- still writes one row per user per tick — Slice 2b's sync rewrite
-- moves to one row per (user, binding) per tick).
--
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
-- its historical sync log (audit trail wins over referential tidiness).
--
-- Idempotent: column added via DO block with information_schema check.
SELECT set_config(
'paliad.audit_reason',
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id'
) THEN
ALTER TABLE paliad.caldav_sync_log
ADD COLUMN binding_id uuid
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
WHERE binding_id IS NOT NULL;
-- Assertion: column exists and is nullable.
DO $$
DECLARE
col_nullable text;
BEGIN
SELECT is_nullable INTO col_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id';
IF col_nullable IS NULL THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
END IF;
IF col_nullable <> 'YES' THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
END IF;
END $$;

View File

@@ -0,0 +1,5 @@
-- Reverse of 108: drop the capability columns.
ALTER TABLE paliad.user_caldav_config
DROP COLUMN IF EXISTS supports_mkcalendar,
DROP COLUMN IF EXISTS mkcalendar_probed_at;

View File

@@ -0,0 +1,67 @@
-- t-paliad-212 — Slice 2c of CalDAV multi-calendar.
--
-- Adds the MKCALENDAR-capability tri-state to paliad.user_caldav_config:
-- * supports_mkcalendar = NULL → unprobed (probe runs lazily on
-- the first /api/caldav-discover or
-- /api/caldav-mkcalendar call).
-- * supports_mkcalendar = TRUE → server accepts MKCALENDAR; the
-- "Create new calendar" affordance
-- in the picker is visible.
-- * supports_mkcalendar = FALSE → Google-style degrade; UI hides the
-- create button and surfaces the
-- "create it in your provider's UI"
-- notice with a manual-URL input.
-- The probed_at timestamp lets us re-probe stale-cached results when
-- the user changes credentials (SaveConfig invalidates by SetNull in
-- the Go service layer; the column is here so the next round of
-- probing has somewhere to land).
--
-- Idempotent (column-exists DO block) + assertion at the bottom.
SELECT set_config(
'paliad.audit_reason',
'mig 108: add user_caldav_config.supports_mkcalendar tri-state for t-paliad-212 Slice 2c capability probe',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'user_caldav_config'
AND column_name = 'supports_mkcalendar'
) THEN
ALTER TABLE paliad.user_caldav_config
ADD COLUMN supports_mkcalendar boolean;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'user_caldav_config'
AND column_name = 'mkcalendar_probed_at'
) THEN
ALTER TABLE paliad.user_caldav_config
ADD COLUMN mkcalendar_probed_at timestamptz;
END IF;
END $$;
-- Assertion — both columns present and nullable.
DO $$
DECLARE
sup_nullable text;
probed_nullable text;
BEGIN
SELECT is_nullable INTO sup_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
AND column_name = 'supports_mkcalendar';
SELECT is_nullable INTO probed_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
AND column_name = 'mkcalendar_probed_at';
IF sup_nullable <> 'YES' OR probed_nullable <> 'YES' THEN
RAISE EXCEPTION
'mig 108 assertion failed: expected both columns nullable, got supports=% probed=%',
sup_nullable, probed_nullable;
END IF;
END $$;

View File

@@ -0,0 +1,3 @@
-- Reverse of 109_user_dashboard_layouts.up.sql.
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;

View File

@@ -0,0 +1,29 @@
-- t-paliad-219 Slice A1: per-user dashboard layout.
--
-- Design: docs/design-dashboard-configurable-2026-05-20.md §5.1 (newton,
-- m-locked 2026-05-20: single layout per user, Q2).
--
-- Stores one configurable dashboard layout per user as a single jsonb
-- column. The layout is an ordered list of (widget_key, visible, settings)
-- triples; see internal/services/dashboard_layout_spec.go DashboardLayoutSpec.
--
-- Single-row-per-user PK because m's Q2 pick is one layout per user (v1) —
-- no named-layout switcher. Forward path to named layouts (drop the PK, add
-- id+name+is_default columns) stays open if m later changes course.
--
-- RLS owner-only mirrors user_card_layouts / user_views — personal working
-- state, not auditable infrastructure. global_admin gets no override.
CREATE TABLE paliad.user_dashboard_layouts (
user_id uuid PRIMARY KEY REFERENCES paliad.users(id) ON DELETE CASCADE,
layout_json jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE paliad.user_dashboard_layouts ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_dashboard_layouts_owner_all
ON paliad.user_dashboard_layouts FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -0,0 +1,22 @@
-- mig 110 (down) — revert 'other' addition to paliad.projects.type
--
-- Coerces any 'other' rows back to 'project' (the historical catch-all)
-- so the narrower CHECK constraint can re-attach. This is a lossy
-- rollback: rows that were genuinely 'other' lose that distinction.
SELECT set_config(
'paliad.audit_reason',
'mig 110 (down): revert ''other'' from projects.type CHECK; coerce rows to ''project''',
true);
UPDATE paliad.projects
SET type = 'project'
WHERE type = 'other';
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_type_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_type_check
CHECK (type IN (
'client', 'litigation', 'patent', 'case', 'project'
));

View File

@@ -0,0 +1,33 @@
-- mig 110 — add 'other' as a sixth paliad.projects.type value
--
-- m/paliad#51 (t-paliad-221): the type chip filter on /projects used to
-- treat unclassified projects as a synthetic "Empty" bucket. We replace
-- that with a real 'other' type so every row carries a meaningful label
-- and the filter UI stops needing a NULL/Empty shim.
--
-- Defensive backfill: NOT NULL + the original IN-list CHECK already
-- forbid NULL rows, but we coerce any stray rows just in case a future
-- migration ever relaxed the constraint. As of 2026-05-20 production
-- carries zero rows that would change here (live query confirmed).
--
-- The Go-side source of truth lives in
-- internal/services/project_service.go (ProjectType constants +
-- isValidProjectType); this migration keeps the DB in sync.
SELECT set_config(
'paliad.audit_reason',
'mig 110: add ''other'' to projects.type CHECK + backfill NULLs (m/paliad#51)',
true);
-- Backfill first so the new CHECK never rejects a pre-existing row.
UPDATE paliad.projects
SET type = 'other'
WHERE type IS NULL;
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_type_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_type_check
CHECK (type IN (
'client', 'litigation', 'patent', 'case', 'project', 'other'
));

View File

@@ -0,0 +1,65 @@
-- Reverse of 111_project_admin_and_select.up.sql.
--
-- Drops effective_project_admin, restores the original RLS policies,
-- and shrinks the responsibility CHECK back to four values. Any rows
-- still carrying responsibility='admin' would violate the restored
-- CHECK; the down-migration backfills them to 'lead' (the closest
-- existing role) before re-adding the constraint.
-- ============================================================================
-- 1. Backfill any responsibility='admin' rows to 'lead'.
-- ============================================================================
UPDATE paliad.project_teams
SET responsibility = 'lead'
WHERE responsibility = 'admin';
-- ============================================================================
-- 2. Restore the original CHECK (lead/member/observer/external).
-- ============================================================================
ALTER TABLE paliad.project_teams
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
ALTER TABLE paliad.project_teams
ADD CONSTRAINT project_teams_responsibility_check
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
-- ============================================================================
-- 3. Restore the pre-110 RLS policies.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
CREATE POLICY project_teams_update
ON paliad.project_teams FOR UPDATE
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
CREATE POLICY project_teams_insert
ON paliad.project_teams FOR INSERT
WITH CHECK (
user_id = auth.uid()
OR paliad.can_see_project(project_id)
);
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
CREATE POLICY project_teams_delete
ON paliad.project_teams FOR DELETE
USING (
paliad.can_see_project(project_id)
AND (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
)
);
-- ============================================================================
-- 4. Drop the predicate function.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.effective_project_admin(uuid, uuid);

View File

@@ -0,0 +1,152 @@
-- t-paliad-223 Slice A: Project Admin role on project_teams.responsibility +
-- inheritable role-edit gate.
--
-- Design: docs/design-team-admin-rework-2026-05-20.md (gauss, m-locked
-- 2026-05-20 via head's "all R approved").
--
-- Adds a fifth 'admin' value to the project_teams.responsibility enum
-- (orthogonal to the profession-driven approval ladder — admin does NOT
-- open the 4-Augen gate by itself). Introduces paliad.effective_project_admin
-- which mirrors paliad.can_see_project's shape and walks the ltree path
-- to compute inheritance. Replaces the three write-side RLS policies on
-- paliad.project_teams so role edits are gated on the new predicate
-- instead of "anyone with visibility".
--
-- Day-1 deploy = no behaviour change for callers who never use the admin
-- value: existing lead/member/observer/external rows keep their meaning,
-- and the global_admin shortcut + self-join INSERT / self-DELETE remain
-- intact.
--
-- Sections:
-- 1. ALTER project_teams.responsibility CHECK to include 'admin'.
-- 2. CREATE paliad.effective_project_admin(uuid, uuid).
-- 3. Replace project_teams_update policy: gated on effective_project_admin.
-- 4. Replace project_teams_insert policy: self-join OR effective_project_admin.
-- 5. Replace project_teams_delete policy: self / global_admin / effective_project_admin.
-- ============================================================================
-- 1. Extend responsibility CHECK to include 'admin'.
--
-- 'admin' inherits down the project tree (see effective_project_admin in §2).
-- A user marked admin on a Mandant-level project is implicitly admin on
-- every Litigation / Patent / Case descendant — same shape as how 'lead'
-- already inherits.
-- ============================================================================
ALTER TABLE paliad.project_teams
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
ALTER TABLE paliad.project_teams
ADD CONSTRAINT project_teams_responsibility_check
CHECK (responsibility IN ('admin', 'lead', 'member', 'observer', 'external'));
COMMENT ON COLUMN paliad.project_teams.responsibility IS
'Per-project responsibility. admin = can manage team + roles on this '
'project and descendants (inherited via paliad.effective_project_admin). '
'lead/member open the 4-Augen approval gate; observer/external close it. '
'admin is orthogonal to the approval gate — it does NOT open it by itself.';
-- ============================================================================
-- 2. paliad.effective_project_admin(_user_id, _project_id)
--
-- Mirrors paliad.can_see_project: STABLE SECURITY DEFINER, ltree path-walk
-- against projects.path. Two branches:
-- (a) global_admin short-circuit — firm-wide admins are always admin.
-- (b) ancestor-or-self project_teams row with responsibility='admin'.
--
-- Used by the project_teams_update / _insert / _delete policies below
-- and by ProjectService for the effective_admin payload field.
--
-- The ltree-array cast is the same pattern can_see_project uses; the
-- existing GiST index on projects.path is the load-bearing index. No new
-- index needed.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.effective_project_admin(_user_id uuid, _project_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = _user_id
AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = _user_id
AND pt.responsibility = 'admin'
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
COMMENT ON FUNCTION paliad.effective_project_admin(uuid, uuid) IS
'True iff the user is global_admin OR has responsibility=admin on the '
'project itself or any ancestor in the materialised ltree path. '
'Drives the role-edit gate on project_teams (UPDATE/INSERT/DELETE RLS).';
-- ============================================================================
-- 3. project_teams_update policy: gated on effective_project_admin.
--
-- Before: USING + CHECK = can_see_project (anyone with visibility could
-- edit anyone's responsibility — the load-bearing gap that t-paliad-223
-- closes).
-- After: USING + CHECK = effective_project_admin (only project-admins
-- and global_admins can change roles).
-- ============================================================================
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
CREATE POLICY project_teams_update
ON paliad.project_teams FOR UPDATE
USING (paliad.effective_project_admin(auth.uid(), project_id))
WITH CHECK (paliad.effective_project_admin(auth.uid(), project_id));
-- ============================================================================
-- 4. project_teams_insert policy: self-join OR effective_project_admin.
--
-- The self-join branch (user_id = auth.uid()) preserves the legacy
-- creator-as-lead INSERT in ProjectService.Create: the project creator
-- auto-joins their own project with responsibility='lead' before any
-- admin exists. Without this branch, the first-ever team row on a new
-- project would fail because no admin has been granted yet.
--
-- For all other inserts (adding other users), the caller must be an
-- effective_project_admin on the target project.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
CREATE POLICY project_teams_insert
ON paliad.project_teams FOR INSERT
WITH CHECK (
user_id = auth.uid()
OR paliad.effective_project_admin(auth.uid(), project_id)
);
-- ============================================================================
-- 5. project_teams_delete policy: self / global_admin / effective_project_admin.
--
-- Additive: self-remove + global_admin still work; project-admin can now
-- also remove members.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
CREATE POLICY project_teams_delete
ON paliad.project_teams FOR DELETE
USING (
paliad.can_see_project(project_id)
AND (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
OR paliad.effective_project_admin(auth.uid(), project_id)
)
);

View File

@@ -0,0 +1,30 @@
-- Down migration for 112_client_role_rework.
--
-- Restores the original 4-value CHECK ('claimant','defendant',
-- 'court','both', NULL) and backfills any rows that landed on a new
-- sub-role value (applicant / appellant / respondent / third_party /
-- other) to NULL so the schema is internally consistent after the
-- step-down.
BEGIN;
-- Backfill new sub-role values to NULL so the old CHECK doesn't reject.
UPDATE paliad.projects
SET our_side = NULL
WHERE our_side IN ('applicant', 'appellant', 'respondent', 'third_party', 'other');
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', 'court', 'both'));
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this project. Used by the '
'Fristenrechner Determinator (Slice 3c) to predefine the '
'perspective chip from the project context. Allowed: claimant, '
'defendant, court, both.';
COMMIT;

View File

@@ -0,0 +1,51 @@
-- mig 112 — t-paliad-222 / m/paliad#47 — Client Role rework.
--
-- Widens paliad.projects.our_side CHECK to seven sub-role values and
-- drops the legacy 'court' / 'both' entries. The DB column name stays
-- as 'our_side' (UI label changes only — see design doc §2.2 Q1).
--
-- New allowed sub-roles, grouped at display time:
-- Active (we initiate) : claimant, applicant, appellant
-- Reactive (we defend) : defendant, respondent
-- Third Party / Other : third_party, other
-- NULL : unknown / not set
--
-- Backfill: any rows still on 'court' / 'both' fall back to NULL.
-- Verified 2026-05-20: all 12 production rows are NULL, so this is
-- a no-op on prod; the UPDATE runs defensively for staging / test
-- fixtures that may carry the legacy values.
--
-- Idempotent so re-runs against a partially-applied state stay safe.
BEGIN;
-- 1. Backfill any 'court' / 'both' rows to NULL.
UPDATE paliad.projects
SET our_side = NULL
WHERE our_side IN ('court', 'both');
-- 2. Swap the CHECK constraint for the widened sub-role set.
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" / "Mandantenrolle" — 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. The form hides the field on non-case project types. '
'Drives the Fristenrechner Determinator perspective chip — Active '
'group → claimant-perspective, Reactive → defendant-perspective, '
'Third Party / Other → null (chip free-pick).';
COMMIT;

View File

@@ -0,0 +1,11 @@
-- Down migration for 113_projects_opponent_code.
BEGIN;
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_opponent_code_check;
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS opponent_code;
COMMIT;

View File

@@ -0,0 +1,50 @@
-- mig 113 — t-paliad-222 / m/paliad#50 — auto-derived project codes.
--
-- Adds an opponent-code slug field on litigation projects. Used as
-- the middle segment when BuildProjectCode assembles an auto-derived
-- project code from the ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI).
--
-- NULL = segment skipped silently. Existing litigation rows yield
-- codes without an opponent segment until the user fills the field.
-- No backfill from `title` — the litigation title is free-text
-- ("Siemens AG ./. Huawei", "Mandant vs Gegner") and any regex would
-- be brittle; the user enters the slug once at project creation /
-- next edit.
--
-- Slug shape: uppercase letters / digits / dashes, max 16 chars.
-- Constraint also gates on type='litigation' so a stray value on a
-- non-litigation row is rejected at the DB level (defence in depth;
-- the form already hides the field on other types).
--
-- Idempotent.
BEGIN;
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS opponent_code text;
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 — e.g. EXMPL.OPNT.567.INF.CFI '
'(t-paliad-222 / m/paliad#50). NULL = segment skipped silently. '
'Only meaningful on type=''litigation'' rows; the CHECK enforces '
'that pairing.';
COMMIT;

View File

@@ -0,0 +1,13 @@
-- Reverse of mig 114 — t-paliad-225 / m/paliad#61 Slice A.
ALTER TABLE paliad.checklist_instances
DROP COLUMN IF EXISTS template_snapshot;
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
DROP FUNCTION IF EXISTS paliad.can_see_checklist(uuid, uuid);
DROP TABLE IF EXISTS paliad.checklists;

View File

@@ -0,0 +1,178 @@
-- mig 114 — t-paliad-225 / m/paliad#61 Slice A — user-authored checklists.
--
-- Design: docs/design-user-checklists-2026-05-20.md
--
-- Introduces paliad.checklists (the authored-template catalog), the
-- paliad.can_see_checklist(uuid, uuid) visibility predicate, and a
-- nullable template_snapshot column on paliad.checklist_instances so
-- per-Akte instances stay decoupled from subsequent template edits.
--
-- Slice A ships with private + firm visibility only; the 'shared' and
-- 'global' values are valid in the CHECK enum so Slice B can add the
-- explicit-share path and admin-promotion without a second migration
-- to the enum.
--
-- Sections:
-- 1. CREATE TABLE paliad.checklists.
-- 2. paliad.can_see_checklist(uuid, uuid) predicate.
-- 3. RLS policies on paliad.checklists.
-- 4. ALTER TABLE paliad.checklist_instances ADD COLUMN template_snapshot.
--
-- Idempotent throughout (CREATE … IF NOT EXISTS / CREATE OR REPLACE
-- FUNCTION / DROP POLICY IF EXISTS + CREATE POLICY).
-- ============================================================================
-- 1. paliad.checklists — authored-template catalog.
--
-- The static Go catalog (internal/checklists/templates.go) stays the
-- firm's curated source for legally-reviewed templates. This table holds
-- user-authored templates that augment that catalog at read time via
-- ChecklistCatalogService.
--
-- Slugs are author-facing and unique within this table. The application
-- layer rejects slugs that collide with the static catalog (see
-- ChecklistTemplateService.Create — applies a 'u-' prefix and falls back
-- through a collision-retry loop).
--
-- body jsonb carries { "groups": [{ "title", "items": [{ "label", "note",
-- "rule" }] }] } — the same shape as the static checklists.Template
-- minus the metadata (which lives in dedicated columns).
-- ============================================================================
CREATE TABLE IF NOT EXISTS paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
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',
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de',
body jsonb NOT NULL,
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz,
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS checklists_owner_idx
ON paliad.checklists (owner_id);
CREATE INDEX IF NOT EXISTS checklists_visibility_idx
ON paliad.checklists (visibility)
WHERE visibility IN ('firm', 'global');
CREATE INDEX IF NOT EXISTS checklists_regime_idx
ON paliad.checklists (regime);
COMMENT ON TABLE paliad.checklists IS
'User-authored checklist templates. Augments the static Go catalog '
'at read time via ChecklistCatalogService. Visibility levels: '
'private (owner only), shared (Slice B), firm (all authenticated), '
'global (admin-promoted into firm catalog — Slice B).';
-- ============================================================================
-- 2. paliad.can_see_checklist(_user_id, _checklist_id)
--
-- Pattern mirrors paliad.can_see_project / paliad.effective_project_admin
-- (mig 111): STABLE SECURITY DEFINER, single-statement, predicate-friendly.
--
-- Slice A only relies on the owner + firm/global branches. The shared
-- branch (matching against paliad.checklist_shares) is wired now so
-- Slice B doesn't need to replace the function — a NULL row count just
-- returns false. The table doesn't exist yet, so the EXISTS clause must
-- be guarded; we inline a NOT EXISTS check on pg_class so the function
-- body compiles cleanly on Slice A while staying ready for Slice B.
-- ============================================================================
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 visibility: every authenticated user.
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
);
$$;
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
'True iff the user owns the checklist OR the checklist visibility is '
'firm/global. Slice B extends this predicate with the explicit-share '
'path over paliad.checklist_shares.';
-- ============================================================================
-- 3. RLS on paliad.checklists.
-- ============================================================================
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist.
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
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.
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner OR global_admin.
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
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.
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
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. paliad.checklist_instances.template_snapshot — instance integrity column.
--
-- Captures the template body (groups + items) at instance create time so
-- subsequent template edits / visibility narrowing don't affect existing
-- per-Akte instances. NULL on rows created before this migration; the
-- service layer falls back to live catalog lookup for those.
-- ============================================================================
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
COMMENT ON COLUMN paliad.checklist_instances.template_snapshot IS
'Snapshot of the template body at instance create time. NULL for '
'pre-mig-114 rows; service layer falls back to live catalog lookup '
'in that case (legacy path; backfilled in Slice C).';

View File

@@ -0,0 +1,26 @@
-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B.
--
-- Restore the owner+firm/global-only body of paliad.can_see_checklist
-- (matches the mig 114 definition) so a rollback of Slice B leaves the
-- function pointing at the Slice A behaviour.
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 $$
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.owner_id = _user_id
)
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
);
$$;
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
DROP TABLE IF EXISTS paliad.checklist_shares;

View File

@@ -0,0 +1,211 @@
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
-- admin-promotion plumbing for user-authored checklists.
--
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
-- / §4.5.
--
-- Introduces paliad.checklist_shares with the polymorphic recipient
-- pattern (xor-check enforces exactly one recipient_* column populated
-- per recipient_kind). Extends paliad.can_see_checklist with the
-- explicit-share branches so the 'shared' visibility level actually
-- gates anything.
--
-- Sections:
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
-- branches (user / office / partner_unit / project).
--
-- Idempotent throughout.
-- ============================================================================
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
--
-- recipient_kind disambiguates which recipient_* column is populated.
-- The XOR check makes the constraint structurally enforce "exactly one
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
-- prevent duplicate grants per (checklist, recipient).
--
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
-- ALTER is needed here.
-- ============================================================================
CREATE TABLE IF NOT EXISTS 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(),
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)
)
);
-- Hot-path lookup for the visibility predicate.
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
ON paliad.checklist_shares (checklist_id);
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
-- doesn't collide with another row's NULL recipient_<other>.
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
ON paliad.checklist_shares (checklist_id, recipient_user_id)
WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
ON paliad.checklist_shares (checklist_id, recipient_office)
WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
ON paliad.checklist_shares (checklist_id, recipient_project_id)
WHERE recipient_kind = 'project';
COMMENT ON TABLE paliad.checklist_shares IS
'Explicit grants for paliad.checklists. Polymorphic recipient '
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
'Owner of the checklist grants and revokes; global_admin can revoke '
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
'the visibility branches that consume these rows.';
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see the row if they own the parent checklist OR
-- they are the recipient (for user-kind grants — recipients shouldn't
-- be surprised by who else can also see the checklist) OR global_admin.
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
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; granted_by must be self.
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
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 — grants are
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
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'
)
);
-- ============================================================================
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
--
-- Owner + firm/global branches stay as in mig 114. Share branches:
-- - user — the row's recipient_user_id matches the caller
-- - office — recipient_office matches caller's office OR is in
-- their additional_offices array
-- - partner_unit — caller is a member of the recipient partner_unit
-- - project — caller can see the recipient project (reuses
-- paliad.can_see_project, ltree-walked)
--
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
-- (same pattern effective_project_admin uses in mig 111).
-- ============================================================================
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
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.owner_id = _user_id
)
-- firm / global
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 (caller's primary 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)
);
$$;
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
'True iff the user owns the checklist OR firm/global visibility OR '
'an explicit share row matches the caller (by user / office / '
'partner_unit / project ancestry).';

View File

@@ -0,0 +1,7 @@
-- Reverse of mig 116 — t-paliad-225 / m/paliad#61 Slice C.
ALTER TABLE paliad.checklist_instances
DROP COLUMN IF EXISTS template_version;
ALTER TABLE paliad.checklists
DROP COLUMN IF EXISTS version;

View File

@@ -0,0 +1,39 @@
-- mig 116 — t-paliad-225 / m/paliad#61 Slice C — template versioning.
--
-- Design: docs/design-user-checklists-2026-05-20.md §3.4 / §6.
--
-- Adds an integer version counter to paliad.checklists that bumps on
-- every meaningful edit (body or title — see
-- ChecklistTemplateService.Update). Adds a matching template_version
-- column on paliad.checklist_instances so the instance detail page can
-- surface "the template you instantiated from has been updated" and
-- offer a diff view.
--
-- Existing rows backfill to version=1 / template_version=NULL. The
-- NULL on instances means "we don't know which version was snapshotted"
-- (pre-Slice-C row); the snapshot column is still authoritative for
-- rendering, but the "outdated" badge stays off because we can't
-- compare.
--
-- Idempotent throughout.
ALTER TABLE paliad.checklists
ADD COLUMN IF NOT EXISTS version int NOT NULL DEFAULT 1;
-- Backfill any rows that somehow ended up at 0 (shouldn't happen with
-- DEFAULT 1, but defensive — the column was added with default so this
-- is a no-op on the live DB).
UPDATE paliad.checklists SET version = 1 WHERE version < 1;
COMMENT ON COLUMN paliad.checklists.version IS
'Monotonic version counter, bumps in ChecklistTemplateService.Update '
'whenever body or title changes. Used by the instance detail page '
'to show an "outdated" badge when the user''s snapshot is older.';
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_version int;
COMMENT ON COLUMN paliad.checklist_instances.template_version IS
'Snapshot of paliad.checklists.version at instance create time. '
'NULL for pre-Slice-C rows where the version wasn''t captured; the '
'"outdated" badge stays off in that case.';

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