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