t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.
Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
rule renders read-only as 'Auto | <Name · Citation>' next to the
field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
nullable column, migration 122). Mutually exclusive with rule_id at
the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
the current Type — no stale state.
Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
Existing rows: empty custom_rule_text + non-null rule_id = Auto-
equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
RuleSet=true is the discriminator so absent fields don't overwrite
the row (PATCH semantics). RuleID and CustomRuleText are mutually
exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
surfaces can render it.
Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
dropdown, the override-warning slot, and the collapsed-by-Regel Typ
view. Strip the (Rule→Type) auto-fill machinery — direction is now
one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
rule by project's proceeding, then jurisdiction match, then first
candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
Custom text → proceeding → fallback) so the recipe still produces a
sensible title even when Custom is used.
Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
enterEdit initialises the mode from the persisted deadline; Save
PATCHes with rule_set:true + the chosen rule pointer.
Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
Standardtitel, events.ts ruleDisplay (REGEL column on /events),
projects-detail.ts Fristen table, views/shape-list.ts generic
rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
name + citation chip separately and matches the canonical pattern;
no change needed. Schriftsätze table is column-shaped (name + code
in distinct columns) and out of scope per the addendum.
CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
family (retired with the catalog dropdown).
i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
auto_pick_type, custom_badge, custom_placeholder,
mode.toggle_to_auto, mode.toggle_to_custom).
Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).
Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).
Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.
Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
- validates caller == requested_by AND status = pending
- reuses the existing wider counter-allowlist (buildCounterSetClauses
from SuggestChanges) — every editable field on the entity, not just
the date triggers
- applies the field updates to the entity row via applyEntityUpdate
(including the event_type_ids junction rewrite for deadlines)
- merges new fields into approval_requests.payload (jsonb) so the
approver inbox sees what was revised
- emits a distinct *_approval_edited_by_requester project_event so the
Verlauf surfaces the revision separately from the original *_requested
row and any decision row
- request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
- Body: {"fields": {<entity-shape>}}
- Errors reuse the existing mapApprovalError mapping:
400 suggestion_requires_change, 403 not_authorized,
404, 409 request_not_pending
- Distinguishing audit event types per the spec:
- destructive Withdraw path: existing <entity>_approval_revoked
(no behaviour change — for CREATE deletes the entity, for UPDATE /
COMPLETE reverts to pre_image, for DELETE cancels the delete request)
- edit-instead path: new <entity>_approval_edited_by_requester
Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
- Built on the unified openModal() primitive (t-paliad-217 Slice A)
- Primary CTA "Termin bearbeiten" highlights the non-destructive path
- Secondary defaults to "Abbrechen" (handled by openModal)
- Destructive button "Endgültig zurückziehen und löschen" lives inside
the body (red, separated by a dashed border) so the safe path stays
visually primary in the footer
- Copy adapts per lifecycle:
CREATE → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
UPDATE → "Ihre vorgeschlagenen Änderungen werden verworfen."
DELETE → "Der Eintrag bleibt bestehen."
Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
- Replace confirm() in withdraw flow with openWithdrawWarningModal()
- Edit path: set module-level pendingEditMode = true + enter edit mode
(override existing pending-state freeze on appointments; expose
enterEdit() via late-bound pendingEnterEdit on deadlines)
- Save handler in pendingEditMode routes to /edit-entity instead of
PATCH /api/<entity>/{id} (which still 409s on pending state)
- Destructive Withdraw path: existing /revoke endpoint unchanged
- For CREATE-lifecycle revokes the entity is gone — bounce to the
/events list instead of trying to re-fetch (was reload() before)
i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)
CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.
Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)
Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
rewrite + Save pending-edit branch + form-freeze respects
pendingEditMode)
Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.
The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.
Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.
Approach: A — let the trigger respect its container at every width.
Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.
Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
(UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.
Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
client-side: given a chosen event_type X, candidate rules are
those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
proceeding_type_id, (2) jurisdiction match on the rule's
proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
by court (jurisdiction grouping with optgroup), alphabetical.
Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
payload — no new endpoint.
Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
surfaces whenever the Rule was derived from the chosen Type.
Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
"Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
fill only replaces its own previous suggestion, never a manual
pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
auto-derived from the type — the "vorgegeben durch Regel" copy
reads backwards in that case; show picker + Auto badge instead.
Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
head =
1. event_type label (if exactly one Typ chip is set)
2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
3. proceeding type name from project (create form only)
4. fallback: t("deadlines.field.title.default_fallback")
suffix = " — <project.reference>" when ref is set and not
already in head.
Examples:
Klageerwiderung — C-UPC-0042 (type known)
RoP.023 — Klageerwiderung — REF (rule known, no type)
UPC — Verletzungsverfahren — REF (only proceeding type)
Neue Frist — REF (fallback)
- Click REPLACES current title; no destructive confirmation
because the user invoked it explicitly. Focus moves into the
title input afterwards so the user can fine-tune.
Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
schema, no service, no migration.
Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
+ show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
.form-hint--auto, .form-hint-badge, .form-field-label-row,
.btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.
sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
Concerns A + B + C from m/paliad#81:
A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
(Kläger/Beklagter/Beide) and an appellant selector. The side selector
swaps which column labels which user-side; the appellant selector
collapses party='both' rules into the appellant's column (no mirror)
so role-swap proceedings (Appeal, etc.) stop showing every row
twice in the timeline. Both selectors are URL-driven (?side= +
?appellant=) and re-render without a backend round-trip.
The appellant row hides itself for proceedings without an appellant
axis (first-instance Inf/Rev/Opp) via a small allowlist.
B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
/ "Appealable Decision" instead of falling back to the proceeding
name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
trigger_event_label_{de,en} column on paliad.proceeding_types (mig
121); the frontend prefers it over the proceedingName fallback that
fires when no rule has IsRootEvent=true. No new deadline rules, no
slug changes (hard rule from the issue).
C. Parameter contract for the column projection is unified in
bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
helper extracted from renderColumnsBody so the routing behaviour
stays unit-testable without a DOM. Tests cover the default mirror,
appellant-collapse for both sides, side-swap of column ownership,
the combined case, and row alignment by dueDate.
Verification
- go build ./... clean
- go test ./... all green
- bun run build (frontend) clean
- bun test (frontend/src) 110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
carries the curated trigger label pair.
Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).
Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.
If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
recipient list pre-filled, comma-joined and URL-encoded via
buildMailtoHref (already exported from broadcast.ts).
Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.
No new i18n keys, no backend changes, admin compose flow untouched.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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
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
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 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.
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.
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).
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.
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.
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).
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.
#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).
#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.
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).
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.