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.
m/paliad#54 (t-paliad-221) — fix 92780cf added a status=upcoming option
for appointments and made it the default, but DeadlineFilterUpcoming
only narrowed deadlines. The appointment query had no matching case, so
the bucket fell through to the unfiltered path and past events leaked
into "Ab heute" / "From today".
- Drop the 'upcoming' option from STATUS_OPTIONS_APPOINTMENT — confusing
label that never delivered.
- Default appointments to the 'today' bucket (matches the dashboard
tile; sane lawyer-relevant view).
- Keep 'Alle (auch vergangene)' as the explicit opt-in at the bottom
of the list.
- Defensive backend fix: map DeadlineFilterUpcoming to start_at >= today
in bucketAppointmentWindow so any persisted ?status=upcoming bookmarks
stop leaking past events.
m/paliad#52 (t-paliad-221) — the Compact/Comfortable segmented control
on /approvals was rendering its active pill in plain --color-surface
(white in light mode, midnight-tinted in dark). Switch to the brand
lime so the segmented controls speak the same primary-action language
as the rest of Paliad.
Introduces three semantic tokens (--color-segment-active-bg / fg /
border) so any future surface that adopts .filter-bar-segment
inherits the same accent treatment without a CSS rewrite. The tokens
resolve to --color-accent / --color-accent-dark in both themes,
keeping the midnight foreground WCAG-AA on lime.
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to
treat unclassified projects as a synthetic "Empty" bucket. Make 'other'
a first-class projects.type value so every row carries a meaningful
label and the filter UI stops needing a NULL/Empty shim.
- mig 110: extend projects.type CHECK to include 'other'; backfill any
NULL rows defensively (production query confirmed zero, but the
NOT NULL constraint isn't load-bearing once the IN-list changes).
- Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType
recognise it; handler doc lists 'other' in the ?type whitelist.
- Frontend: new chip in the projects.tsx type filter, new option in the
Create-Project form, DE "Sonstiges" / EN "Other" labels for the
projects.type and projects.chip.type i18n families.
Also drops a stray data-i18n-text attribute on the existing 'project'
chip checkbox (it had no consumer in i18n.ts and the surrounding markup
was nesting a <span> inside an <input>).
Four UX cleanups on /tools/fristenrechner per m's 2026-05-20 14:02–14:04
report:
1. **Pre-fill project on 'Add'** — when Step 1 binds an Akte, both the
Pathway A "Save to Project" modal and the Pathway B card-calc inline
'Add' picker now default their <select> to that project. Override
still allowed; the picker lists all projects. New helper
`preselectedProjectId()` reads `currentStep1Context` once so both
surfaces stay in sync.
2. **Drop 'Custom' prefix from UPC/DE/EPA/DPMA adhoc chips** — the
chip context already reads "oder ad-hoc, ohne Akte"; 'Custom' was
redundant signaling. Labels become "UPC-Verfahren" /
"UPC proceeding" (and the three sister jurisdictions).
3. **Remove 'Ich möchte etwas einreichen' from 'Was ist passiert?'** —
the Fristenrechner is a backward-looking calc ("event happened, what
spawns?"); the forward-workflow framing ("I want to file X") needs a
different tool. Filter the `ich-moechte-einreichen` root subtree out
in `loadEventCategoryTree()` (HIDDEN_CASCADE_ROOTS set) so the picker
never offers it. DB rows preserved for the future forward-workflow
tool, tracked in m/paliad#65.
4. **Same-context-asked-twice on Statement-of-Defence picker** —
when the user clicks a specific rule pill on a concept card, the
calc panel now renders a locked "Kontext: <proceeding — rule>"
caption with an "ändern" affordance instead of re-showing the same
five proceedings as a radio fieldset. When the user clicks the card
body (no specific pill), the picker is still the primary surface, but
the card's rule-pill section hides via CSS while expanded
(`fristen-card-pills-section--rules`) so the same options aren't
listed twice. Cross-cutting trigger pills (Wiedereinsetzung,
Weiterbehandlung etc.) stay visible — they're conceptually
different siblings, not the same proceeding context.
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.
Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:
- openInlineDateEditor(span, onCommit) — swaps the date span for
a `<input type=date>`, commits on blur/Enter, cancels on Escape,
fires `onCommit(ruleCode, newValue)` ("" = revert).
- wireDateEditClicks(container, onCommit) — idempotent delegated
click + keyboard handler that resolves `.frist-date-edit
[data-rule-code]` and opens the editor. Survives innerHTML
rewrites because the listener lives on the container.
verfahrensablauf.ts now:
- Owns its own anchorOverrides Map (cleared when proceeding-type
changes — overrides for one proceeding don't apply to another).
- Forwards overrides in calculateDeadlines() so downstream rules
re-anchor on the user's date.
- Passes `editable: true` to renderColumnsBody + renderTimelineBody.
- Calls wireDateEditClicks() once on #timeline-container in
DOMContentLoaded.
fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.
Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
Wire the configurable dashboard end-to-end on the frontend side. Factory
render only (edit mode is Slice B).
dashboard.tsx:
- Add data-widget-key to every section that participates in the layout
(deadline-summary, matter-summary, upcoming-deadlines, upcoming-
appointments, inline-agenda, recent-activity, inbox-approvals).
- New inbox-approvals section markup with summary line, list, empty
state, and full-inbox link.
- Triple hydration placeholder: data + layout + catalog spliced as
separate window.__PALIAD_DASHBOARD_* globals.
dashboard_shell.go + dashboard.go:
- Three placeholder splice instead of one. splicePlaceholder() helper
consolidates the JS-assignment encoding.
- handleDashboardPage pre-fetches the user's saved layout via
dashboardLayout.GetOrSeed and inlines the WidgetCatalog (code-
resident — always inlined so the widget picker can boot on knowledge-
platform-only deploys too).
dashboard.ts client:
- New InboxSummary / InboxEntry / DashboardLayoutSpec / DashboardWidgetRef
types mirroring the Go shapes.
- settingsFor(key) reads per-widget settings (count, horizon_days) from
the active layout; defaults fall back to catalog values.
- Existing renderers (Deadlines, Appointments, Activity, Agenda) thread
count + horizon settings — backend now returns 60d / LIMIT 40 so the
client narrows per the user's widget config.
- New renderInbox() renders the inbox-approvals widget with summary
copy ("N offene Freigaben warten auf dich"), top-N entry list, and
the empty state.
- applyLayout() walks the saved spec and (a) hides widgets whose
layout entry is visible:false and (b) reorders visible widgets via
parent.appendChild within their existing parent — preserves the
.dashboard-columns 2-up grid for deadlines+appointments.
- filterByHorizonDays() filters list items by date relative to today.
- Boot wiring: read __PALIAD_DASHBOARD_LAYOUT__ at mount; if missing,
best-effort fetch /api/me/dashboard-layout and re-render once data
has landed. Factory order baked into dashboard.tsx is the fallback
so a hydration failure never breaks the dashboard.
i18n: 5 new keys per language for the inbox widget. 2528 → 2533.
go build + go vet + go test ./internal/... -short + bun run build all
clean. Triple placeholder verified present in dist/dashboard.html.
Pixel-identical factory render budget: every previously-visible widget
keeps its DOM markup, classes, IDs, and parent. New widget (inbox-
approvals) lands between agenda and activity per the factory layout
ordering in WidgetCatalog. Visible regression on the factory layout is
+1 section (inbox-approvals), expected per m's Q3 pick.
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.
Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.
CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
MKCALENDAR, falls back to a synthetic MKCALENDAR against a
random .paliad-probe-XX/ path (with DELETE cleanup) to catch
legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
supported-components; returns ErrCalendarNameTaken on 405 so
the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.
Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
/api/caldav-discover call after credential change; result persisted
via UPDATE on user_caldav_config. DiscoverCalendars response now
carries supports_mkcalendar so the UI can show / hide the create-new
radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
via the client (with 3-try -XX-suffix retry on name collision),
creates the matching binding, kicks off PushBindingNow. Returns
the partial result on push failure so the UI can show "created but
initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
re-configured server gets re-probed on next open.
HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
include_personal?} → 201 {calendar_path, binding, initial_pushed}.
Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
upstream. Partial-success (binding created, push failed) carries
initial_sync_error in the body so the UI can surface both bits.
Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
Create radio is visible only when supports_mkcalendar=true;
when false, the bilingual Google-degrade notice is shown
beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
/api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
+ caldav.bindings.error.create_*.
Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
bun run build all clean.
Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
User-visible Slice 2 milestone: the /einstellungen/caldav Kalender
section now lets a user pin multiple calendars to Paliad via a
single-step add modal (Q3 of the Slice 2 brief). m greenlit
"all yes / all R" on 2026-05-20, so this lands with: synchronous
first-push on POST (Q5), lazy cleanup on PATCH scope change (Q6),
5-minute server-side cache on /api/caldav-discover (Q4),
calendar_path retained-but-deprecated (Q7).
Backend
- CalDAVService.PushBindingNow — runs one push pass for a single
binding synchronously; called from POST /api/caldav-bindings so
the modal closes with events already landed.
- CalDAVService.RemoveBinding — best-effort remote-event DELETE +
binding row drop (§2.6 of brief). On partial remote failure,
the binding is disabled instead of dropped and the handler
surfaces 202 Accepted.
- CalDAVService.EnsureLoop — spawns the per-user sync goroutine
for users who didn't have one before this request.
- CalDAVService.DiscoverCalendars — walks current-user-principal
→ calendar-home-set → child PROPFIND (RFC 6764 §6 / RFC 6638
§10). Cached 5 minutes per user; invalidated on SaveConfig /
DeleteConfig.
- caldav_client.go gains DiscoverCalendars + propfindHrefs +
listCalendars + supporting multistatus types. VEVENT-only
filter skips iCloud reminder lists / addr books.
HTTP API
- POST /api/caldav-bindings — create binding + sync first-push;
201 with binding + initial_pushed count, or 201 with
initial_sync_error when the push fails after binding creation.
- PATCH /api/caldav-bindings/{id} — partial update.
- DELETE /api/caldav-bindings/{id} — calls RemoveBinding;
responds 204 (full cleanup) or 202 (partial — binding disabled
for next-tick retry).
- GET /api/caldav-discover — returns {calendars, calendar_home}
for the picker.
Frontend
- /einstellungen/caldav Kalender section: list of binding cards
with enabled toggle / Edit / Remove. "+ Kalender hinzufügen"
opens the single-step modal.
- Single-step add modal: source picker (discovery dropdown or
custom URL toggle) + scope radio (all_visible / personal_only
/ project + project picker) + display name. Edit mode reuses
the modal with the source field hidden.
- 32 new i18n keys under caldav.bindings.* (DE primary, EN
parallel) covering modal copy, card actions, error messages,
delete-confirm, scope labels.
Verification
- Live Supabase BEGIN..ROLLBACK: full CRUD flow exercised
(create → patch display_name → patch scope → second
all_visible after the first scope-shifts → delete);
the partial unique index frees correctly when scope moves
off all_visible, no race or constraint surprise.
- go build ./... + go test ./internal/... + bun run build all
clean.
m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.
Changes:
- Body is built imperatively (renderBody + wireBody) and handed to
openModal as the body element. The submit logic reads form state
from that element on primary-handler invocation.
- Drops the per-modal ESC + close + backdrop + overlay-stack handlers
— the primitive owns them.
- Drops the bespoke .modal-broadcast { width / max-height / padding /
label / input / textarea } CSS overrides. The primitive's data-size
handles width; the existing .form-field rules handle inputs; only
the textarea's code-monospace font is kept as a broadcast-specific
override (placeholder syntax needs to read as code).
- Primary action is "Senden (N)" — clicks invoke the existing
onSubmit logic which POSTs to /api/team/broadcast and on success
shows the per-recipient report inline then closes via the
setTimeout(close, 2500) pattern.
The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.
i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
Rewrite atop the unified openModal() primitive (Slice A). Drops the
per-modal ESC + focus + backdrop + close-button handlers — the
primitive owns them.
New three-section body per design §2:
1. Editable fields. Every editable column on the entity, per m's Q1
Reading A lock-in:
deadline: title, due_date, original_due_date, warning_date,
rule_code, description, notes, event_type_ids
(attached via the existing event-types picker).
appointment: title, start_at, end_at, location, appointment_type,
description.
2. Read-only context. Project title, requester, requested_at, current
approval status. Renders as a definition-list with muted dt/dd
pairs so the eye lands on the editable section first.
3. Vorschlagskommentar (note). Always present, prominent.
Block labels matching /deadlines/new + views editor — reuses the
existing .form-field shapes for typography + spacing parity with the
rest of the app (m's Q6 lock-in).
inbox.ts gains projectTitle / requesterName / requestedAt hydration
from the per-row API response so the context section has data to
render. Falls back gracefully when missing.
Submit-button gate (in the openModal primary handler): refuses when no
field is dirty AND the note is empty. Mirrors the server's
ErrSuggestionRequiresChange.
CSS .approval-suggest-* classes added to global.css alongside the
modal primitive block (committed in Slice A).
frontend/src/client/components/modal.ts — new openModal() primitive,
native <dialog>-backed. The browser handles top-layer stacking, ESC,
ARIA, and focus trap. We layer on top:
- browser back-button closes the modal (history.pushState on open +
popstate listener, matching m's Q5 lock-in)
- focus restoration to whatever was focused before open (the native
<dialog> doesn't do this)
- backdrop click closes
- close (×) button mandatory in the header, always rendered
CSS (global.css):
- dialog.modal + .modal__{header,title,close,body,footer} block. Sizes
sm/md/lg/full via data-size attr.
- Phone breakpoint (≤32rem): full-screen takeover sitting ABOVE the
PWA bottom-nav. max-height accounts for --bottom-nav-height (56px)
and margin-bottom keeps the nav visible.
- Legacy .modal-overlay / .modal-card / .modal-content / .modal stay
in place for the ~7 unmigrated modals — the new BEM-style .modal__*
avoids colliding with the legacy hierarchy. Cleanup is a follow-up
PR after the last legacy modal flips.
i18n keys + i18n-keys.ts regenerated:
- modal.close.label (DE/EN)
- approvals.suggest.section.editable / .context (DE/EN)
- approvals.suggest.context.{project,requester,requested_at,approval_status} (DE/EN)
- approvals.suggest.field.{original_due_date,warning_date,rule_code,description} (DE/EN)
- approvals.suggest.event_type_picker_unavailable (DE/EN)
(Slice C consumes the suggest.section/context/field keys; bundling them
here keeps the i18n.ts diff coherent.)
Adds a Datenexport action button at the end of the project-detail tabs
nav. Hidden by default; revealed when canExportProject() returns true
(global_admin OR direct team responsibility ∈ {lead, member}) — mirror
of the server-side §4 gate. Server re-enforces on the request.
Click handler swaps in a transient <a download> that hits
GET /api/projects/{id}/export — browser handles the download via
Content-Disposition. Same pattern as the personal export in
client/settings.ts.
4 new i18n keys (DE+EN):
- projects.detail.tab.export (n/a — uses .export.button on the action)
- projects.detail.export.button = "Daten exportieren" / "Export data"
- projects.detail.export.tooltip with hint about subtree inclusion
Total i18n keys now 2479.
Adds the DE + EN event title + description keys for the two new
*_approval_changes_suggested event_types emitted by SuggestChanges
(Slice A). The existing translateEvent() helper picks up
event.title.{event_type} and event.description.{event_type} keys by
convention — no renderer code changes are needed; this slice is pure i18n.
Surfaces covered:
- projects-detail.ts Verlauf tab (per-project timeline)
- admin-audit-log.ts admin audit table
Both call translateEvent() which now resolves the new keys.
No icon system is tied to event_type slugs; no server-side event_types
registry needs the new types pre-registered (the slugs are emitted into
project_events.event_type as free text by the service layer, then
localised at read time).
This is the final slice for t-paliad-216. Slice A (mig 103 + service +
handler + tests), Slice B (frontend modal + /inbox UI + back-link
hydration), Slice C (Verlauf i18n) all on main now.
Suggest-changes branches off into handleSuggestChanges() instead of the
generic POST-with-prompt-note path. It:
1. Fetches the full request payload + pre_image via GET
/api/approval-requests/{id} — list payload may be stale.
2. Opens approval-edit-modal with the entity_type-appropriate fields
pre-populated.
3. On submit, POSTs to /api/approval-requests/{id}/suggest-changes
with {counter_payload, note}.
4. On success, refreshes the filter bar (the OLD row flips to
changes_requested, the NEW pending row appears) and the inbox badge.
5. Stamps data-spawned-request-id on the OLD row's <li> so downstream
code / tests can locate the counter without a re-query.
The error mapping (mapApprovalError) gains the two new codes
suggestion_requires_change + suggestion_lifecycle_invalid plus the
generic codes already handled. Body reads now go through `body.code`
preferentially with `body.error` fallback — the server uses {code,
message} envelopes.
New module: frontend/src/client/components/approval-edit-modal.ts.
The approver clicks "Änderungen vorschlagen" on a pending update-lifecycle
row; this modal opens with the requester's original payload pre-populated
in editable date inputs (per entity_type allowlist):
- deadline: due_date, original_due_date, warning_date
- appointment: start_at, end_at
The pre_image value for each field renders as a "Vorher" hint so the
approver sees what's being changed before they commit a counter.
A free-text "Vorschlagskommentar" textarea sits below the inputs. The
submit button stays disabled until the form is dirty OR the note has
non-whitespace content — mirrors the server's ErrSuggestionRequiresChange
no-op guard so the user doesn't bounce off a server-side 400.
API: openApprovalEditModal({entityType, lifecycleEvent, payload,
preImage}) returns Promise<{counterPayload, note} | null>. null = user
cancelled (ESC, overlay click, Cancel button). counterPayload contains
only fields that the user changed; unchanged keys are omitted (the
server's buildRevertSetClauses ignores absent keys cleanly).
Lifecycles other than "update" are guarded with an alert + resolve null —
shape-list.ts hides the button for them, but the modal is defence-in-
depth.
shape-list.ts:
- Pending-row action group extends to four buttons. suggest_changes is
only rendered for lifecycle='update' rows (the backend rejects other
lifecycles with ErrSuggestionLifecycleInvalid).
- ApprovalAction union widened to "approve" | "reject" | "revoke" |
"suggest_changes". Disabled-reason logic shared with approve/reject
(viewer_can_approve gate).
- Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
via the existing approval-pill--historic style — no new colour token.
- ApprovalDetail picks up counter_payload + next_request_id. When a
row is changes_requested AND a next_request_id is present, render a
back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
row (server-side hydrated via correlated subquery on
previous_request_id, indexed by mig 103's partial index).
filter-bar/axes.ts:
- APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
the /inbox filter cluster alongside pending/approved/rejected/revoked.
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.
**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
aria-label=… title=…>ⓘ</span>` inside the meta line (browser
title for hover, aria-label for screen readers, tabindex for
keyboard accessibility)
Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.
**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.
**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.
i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
New "Schriftsätze" tab on /projects/{id}, lazy-loaded by the
existing tab switcher (same pattern as the Checklisten tab — only
hits the API when the user actually opens it). Lists the project's
filing rules in a 4-column table: name (with submission_code under
it), party, legal basis, action button.
Action column shows [Generieren] for rules with a resolvable
template and "Keine Vorlage" / "No template" for rules without one.
The generate button fetches the .docx via XHR, parses the
Content-Disposition filename, creates an object URL, and triggers
the browser download via a hidden <a download>. Disabled
mid-flight to prevent double-submits.
The table opts into the `.entity-table--readonly` modifier — rows
themselves don't navigate; only the inline button does (avoids the
"clickable row that isn't" UX lie called out in the project
CLAUDE.md frontend conventions).
11 new i18n keys per language. New CSS block for the submission-row
typography (name + dim-grey code stacked vertically, right-aligned
action cell, italic no-template hint).
Replaces the one-sentence "endpoint" stub with a proper landing: features list, update flow explainer, fresh-install download link, contact line. Renders the served version live from version.json. Paliad palette (midnight/lime). This is what the HL Patents Style ribbon's Info dialog now links to on OK.
Adds a 4th tab "Datenexport" to /settings (after Profil /
Benachrichtigungen / CalDAV) with a single-button card that triggers
GET /api/me/export. Browser handles the download via
Content-Disposition: attachment.
i18n: 12 new keys under einstellungen.export.* (DE primary, EN
secondary) — subtitle, bullets per format, scope notice, audit
notice, button label, post-click hint.
The tab is loaded lazily (idempotent loadExportTab) like every other
settings tab, and the runExport handler swaps in a transient <a download>
to use the browser's normal download pipeline.
m's call 2026-05-19: opening /events with type=appointment was
defaulting status='all' which surfaces every past appointment in
the corpus. The default should hide past events; 'Alle (auch
vergangene)' is opt-in for the one user who actually wants the
historical view.
Replaces the default with the existing DeadlineFilterUpcoming bucket
(already implemented backend-side at internal/services/deadline_service.go:132
as 'today + future'). New status option 'upcoming' at the top of the
appointment list; existing 'all' moves to the bottom with a clearer
label that calls out 'incl. past'.
Deadlines unaffected — they still default to 'pending'.
i18n keys added in both DE + EN slots (events.filter.status.upcoming
'Ab heute' / 'From today'; .all reframed as 'Alle (auch vergangene)'
/ 'All (incl. past)').
m's call 2026-05-19: the /files/hl-patents-style.dotm link on the
anonymous frontpage shouldn't tempt visitors to try downloading. The
/files/{filename} route IS already auth-gated (302 to /login on
anon click), and the macro-update endpoint at /patentstyle/* stays
public for the in-Word update logic per m's note ('with knowledge
of the direct source link it needs to be available').
Authenticated users never see this page anyway — handleRootPage 302s
them to /dashboard. So removing the section costs them nothing and
removes the obvious affordance for anon visitors. ICON_DOWNLOAD
const dropped along with it.
The Downloads page itself (/downloads + Sidebar nav entry) stays —
that's auth-gated and works for logged-in users.
Leftover surface: /patentstyle/HL-Patents-Style.dotm is still anon-
downloadable (necessary for the Word macro's auto-update poll).
That's m's stated requirement — flagged as the known leak path for
anyone who knows the URL.
Hosts the manifest + .dotm that the Word ribbon's Check-for-Updates button polls. paliad.msbls.de is the primary endpoint; hihlc.msbls.de mirrors it (hihlc/main b871ded). Files live in frontend/public/patentstyle/, copied into dist/ by the frontend build. Cache-Control: no-cache via noCacheAssets so version.json never serves stale after a release.
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.
Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.
Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.
Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.
shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.
i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.
Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.
Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.
Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.
i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.
**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).
**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).
Branch: mai/fermi/interactive-session @ third commit on top of Path A.
m's 2026-05-18 ask: the 5 DE proceeding tiles followed three different
labelling conventions ("Verletzungsklage (LG)" / "Berufung OLG" /
"Nichtigkeitsverfahren" — instance in brackets vs not vs not even
present). Path A reshapes both the picker and the labels so a user
scanning "Deutsche Gerichte" sees the type→instance hierarchy at a
glance and every tile reads <court> (<procedural role>) in parallel.
**Picker structure (verfahrensablauf.tsx + fristenrechner.tsx):**
Inside the existing `<.proceeding-group data-forum="de">` block, the
single flat row of 5 tiles is now two sub-groups with mixed-case h5
headings — Verletzungsverfahren over LG/OLG/BGH, Nichtigkeitsverfahren
over BPatG/BGH. DE_TYPES split into DE_INF_TYPES (3) + DE_NULL_TYPES (2)
in both page shells.
**Labels (i18n.ts, DE + EN parallel):**
| Code | Old DE | New DE |
|--- |--- |--- |
| de.inf.lg | Verletzungsklage (LG) | LG (1. Instanz) |
| de.inf.olg | Berufung OLG | OLG (Berufung) |
| de.inf.bgh | Revision/NZB BGH | BGH (Revision / NZB) |
| de.null.bpatg | Nichtigkeitsverfahren | BPatG (1. Instanz) |
| de.null.bgh | Berufung BGH (Nichtigk.) | BGH (Berufung) |
Two new i18n keys carry the sub-group headings:
- deadlines.de.group.inf — "Verletzungsverfahren" / "Infringement proceedings"
- deadlines.de.group.null — "Nichtigkeitsverfahren" / "Nullity proceedings"
**CSS (global.css):**
New `.proceeding-subgroup` + `.proceeding-subgroup-heading` rules,
co-located with `.proceeding-group h4`. Sub-heading sits one tier below
the h4 (mixed-case, no upper-tracking) so the two-level hierarchy reads
at a glance.
**What this does NOT do** — the "one long sequence" combined-timeline
behaviour (m's same ask, larger scope: spawn rules + de-duplication +
multi-instance UI) is filed as m/paliad#41 and stays a separate
delivery. Per-instance tiles keep their meaning either way.
Build hygiene: go build/vet clean; bun run build clean (2419 keys, +2).
Five intertwined fixes m surfaced in the interactive session:
1. **Jurisdiction prefix on the picked proceeding** — the collapsed
summary chip and the result header now read "UPC Verletzungsverfahren"
/ "DE Verletzungsklage (LG)" instead of the bare proceeding name.
Disambiguates the 4 redundancies in the corpus once the picker
collapses. Driven by .proceeding-group[data-forum] which is already
on every group.
2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
line now shows the first event in the proceeding (e.g. Klageerhebung,
Nichtigkeitsklage) instead of the proceeding name. Populated from
the calc response (isRootEvent=true) on every render; em-dash
placeholder while step 3 hasn't rendered yet. lang-change keeps it
coherent.
3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
stripped the with_ccr / with_amend / with_cci toggles when it lifted
the shared renderer; they never came back. Lifted the 4 existing
rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
preliminary objection, mig 095) — same wiring + show/hide rules on
both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
(R.30 only with a CCR).
4. **Rule references → youpc.org/laws links** — new
BuildLegalSourceURL(src) maps the structured legal_source code to
the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
bodies have no youpc home yet and render as plain display text —
filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
LegalSourceURL so deadlineCardHtml can render <a target="_blank"
rel="noopener"> when the URL is set.
5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
only (EN canonical UPC RoP term stays "Preliminary objection").
Client-side change only — i18n + JSX fallbacks. The matching DB
rename on the two rule-name rows folds into joule's broader mig 097
(legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
applied during the session is captured under that audit reason; the
no-op when joule's mig re-applies is harmless.
Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)
Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.
Branch: mai/fermi/interactive-session. NOT self-merged.
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).
Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
Rechtsgrundlage…"
- Rule interface: code → submission_code field.
Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
upc.inf.cfi.soc-style placeholder (consistent with the backend
RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
pair is named consistently with the list column.
- Rule interface: code → submission_code field.
i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.
bun run build clean.
1. /deadlines list ticking the complete-checkbox now goes through
window.confirm() before firing PATCH /api/deadlines/{id}/complete.
The deadline title is interpolated into the prompt so the user sees
what they're closing. Matches the existing window.confirm() pattern
used in projects-detail / admin-team / approvals-withdraw etc. —
no custom modal layer.
2. The cascade row "ändern" button in the deadline calculator stayed
in German on the EN side. data-i18n="deadlines.row.edit" was set
correctly but applyTranslations() only runs at page init and on
lang-toggle; the cascade re-renders on every state change without
re-hydrating, so the static "ändern" fallback in the HTML stuck.
Render the label via t() directly in the template — same pattern
the rest of the cascade uses, no hydration dependency.
Both i18n keys land on both DE and EN sides (deadlines.complete.confirm
+ existing deadlines.row.edit). bun run build clean, 2414 keys.
Sweep of frontend/src/* for the proceeding-code rename landed by
mig 096. Same scope as the Go sweep — comments + literal string
codes substituted, plus the visible additions:
- fristenrechner.tsx / verfahrensablauf.tsx UPC_TYPES gain
upc.ccr.cfi as a fourth UPC option ("Widerklage auf Nichtigkeit");
it surfaces in the picker and renders the determinator routing
notice from proceeding_mapping.ResolveCounterclaimRouting.
- i18n.ts deadlines.* keys renamed to mirror the new codes exactly
(`deadlines.upc.inf.cfi`, …). DE + EN sides in sync.
- frontend/src/client/fristenrechner.ts fristenrechnerCodeToCascadeSegment
rekeyed to new codes; upc.ccr.cfi shares the upc-inf kebab segment
because the event_categories slug taxonomy is not renamed and ccr
resolves to inf-rules anyway.
- client/views/verfahrensablauf-core.ts court-picker conditions
rewritten against the new codes.
Bun build clean (i18n-keys.ts regenerated from the canonical map).
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."
Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
booleans. Resolved server-side per caller — false on self-authored rows
(caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
(WHERE), and the new viewer_can_approve SELECT expression. Single
source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
$1 = callerID so the SELECT computes the flags inline (one query, no
N+1). GetRequest's signature grows a callerID arg; the handler passes
the authenticated user.
Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
share the viewer_can_approve gate (self → self_approval tooltip;
unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
what's possible — the disabled+tooltip combo explains what's not.
i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
secondary variant via opacity + not-allowed + muted tokens.
Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
self-authored (false/true), eligible peer (true/false), non-eligible
viewer (false/false), global_admin (true/false). Also asserts the flags
on ListPendingForApprover + ListSubmittedByUser rows.
Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
Two issues m hit and reported in one breath while adding a project:
1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
ProjectService.Create and CreateCounterclaim re-referenced the uuid
parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
planner deduced conflicting types for `$1` (uuid in the id column,
text in the cast) and rejected the prepared statement with 42P08
"inconsistent types deduced for parameter". The path placeholder
value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
trigger from mig 018/021) always overwrites it from id and parent
path. Fix: replace `$1::text` with a literal '' in both INSERTs,
keeping the parameter list decoupled from the id column's type.
Same comment now anchors the rationale on both call sites.
2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
`^[0-9]{7}$` CHECK on paliad.projects.client_number and
matter_number was wrong. Mig 094 snapshots affected rows to
paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
values (2 client_numbers, 1 matter_number), then swaps the legacy
`projekte_*_check` constraints from {7} to {6}. Frontend pattern,
maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.
Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
0 rows violate the new 6-digit CHECK. ✓
go build, go test ./internal/..., bun run build all clean.
Closes the Determinator cascade redesign. Three intertwined pieces:
1. The mode row is gone — the `🔍 Direkt suchen` icon at the top of the
row stack now toggles an inline search overlay over the cascade
instead of routing to the legacy B2 surface. Results render into the
same `#fristen-b1-results` container the cascade uses, so users see
one consistent concept-card layout regardless of whether they
reached the rule via cascade narrowing or free-text search. ESC
inside the input clears it on the first press and collapses on the
second; "← Zurück zum Entscheidungsbaum" restores cascade + state.
Deep-link `?mode=filter` still routes to the legacy B2 panel for
backwards-compatible shared URLs but is no longer exposed in the
cascade UI.
2. Mobile responsive per design §7. Three breakpoints layer onto the
`.fristen-row` primitive: <640px (phone — chips full-width single
column, ändern permanently visible, answer wraps to its own line),
<768px (tablet — head wraps so ändern moves down, chips
single-column), <1024px (small desktop / large tablet — chips drop
to 2-column auto-fill). Active row autoscrolls into view on every
render with 60px headroom; the helper is a no-op when the row is
already visible so desktop doesn't jitter.
3. Auto-walk tooltip polish: 200ms fade-in + slide-down via an
is-entering transition state; mobile (<640px) flips the insertion
point so the tip lands below the prefilled row rather than above;
any chip pick or ändern click counts as user-engagement and
dismisses the tip (in addition to the explicit × button).
Refs: docs/design-determinator-row-cascade-2026-05-13.md §6 + §7 + §10 Slice 3.
Wires the project context into the Determinator row stack so a UPC INF
matter doesn't need to be hand-walked through five obvious cascade picks.
Auto-walk descends single-option chains as `is-prefilled` rows, the inbox
row vanishes for UPC matters (CMS implied), and the first prefilled row
carries the project reference inline ("aus Akte: HL-2024-001").
Backend: `internal/services/proceeding_mapping.go` adds
MapLitigationToFristenrechner — single source of truth for bridging the
litigation conceptual codes (INF / REV / APP / CCR / AMD / APM / OPP) onto
fristenrechner codes (UPC_INF / DE_INF / EPA_OPP / …). Ambiguous combos
(APP+DE, ZPO_CIVIL, AMD+DE) return ok=false; callers degrade to "no
narrowing" instead of guessing. Table-driven test covers every documented
mapping plus the ambiguous-degrade cases.
Frontend: `buildRowStack` filters cascade children by project context
along the proceeding axis (kebab segment lookup against the project's
fristenrechner code); auto-walks while filtered scope narrows to one;
caps depth via `cascadeAutoWalkStopAfter` after an "ändern" on a prefilled
row so the user lands at an active chip set without the auto-walk
re-engaging. Result panel narrows on the post-auto-walk effective slug,
not the URL slug. A one-time inline tooltip ("Diese Schritte ergeben sich
aus Ihrer Akte") surfaces when ≥2 rows render prefilled — dismissal flag
persists in localStorage.
Narrowing is purely additive: an Akte without a fristenrechner code
(11/11 live projects pre-Slice-5 were NULL) degrades to today's
forum-only behaviour. Slice 3 (mobile polish + search relocation) follows.
Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 2 + §4 + §5.
Replace the four-layer Pathway B mess (mode radio + perspective chip strip
+ inbox chip strip + breadcrumb cascade) with a single `.fristen-row`
primitive rendered in a top-down stack. Every decision — mode, perspective,
inbox, cascade depth N — now uses the same shape (label · picked answer ·
inline "ändern") and three states (is-active / is-answered / is-prefilled).
The user finally sees their full decision path at a glance instead of
chasing breadcrumb crumbs after each drill. Click on any answered row (or
its ändern affordance) re-actives it; ändern on a cascade depth drops the
descendants (same drop-descendants semantic as today's breadcrumb-click).
Reset link and `🔍 Direkt suchen` escape-hatch live at the top of the stack
per design §6 Option B; the mode-toggle radio is gone, routing to
?mode=filter now flows through the mode row.
Visual-only refactor — narrowing engine (inboxFilterAllowsForums +
perspectiveAllowsParty) is unchanged. Slice 2 will add project-driven
prefills + auto-walk; Slice 3 covers mobile polish and search relocation.
Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 1.
Phase 3 Slice 9 frontend cleanup. The backend's UIDeadline wire
shape stopped emitting (isMandatory, isOptional) in this slice;
the matching legacy-fallback branch in priorityRendering is now
dead code. Drops:
- CalculatedDeadline TS interface: isMandatory + isOptional
fields removed. `priority` is required (not optional) since
every backend response now populates it.
- priorityRendering(): collapsed to a clean switch on `priority`.
Unknown priority falls back to "render as mandatory" (safe
default; never silently drop a rule) — the legacy
(isMandatory, isOptional) inference is gone.
- Save-modal optional-badge rendering in fristenrechner.ts now
reads `dl.priority === "optional"` directly (was previously
`dl.priority === "optional" || dl.isOptional`).
- Timeline row's optional-badge rendering in
verfahrensablauf-core.ts switched from `!dl.isMandatory` to
`dl.priority === "optional"`. Slightly different semantic —
pre-Slice-9 the badge fired on every non-mandatory row
(recommended + optional + informational); post-Slice-9 only
on opt-in rules (RoP.151 pattern). Recommended + informational
are surfaced via their own rendering tier (notice card for
informational) so the badge change tightens the meaning.
Frontend build clean; no i18n keys removed (the priority labels
shipped in Slice 8 stay live).
Surfaces the Slice 11a admin API at /admin/rules so editors can drive
the rule lifecycle without curling. Three new pages, each gated by
adminGate on the route + sidebar reveal via /api/me:
/admin/rules — list page with filters (proceeding,
trigger event, lifecycle chips, fuzzy
search) and a second "Orphans" tab that
loads paliad.deadline_rule_backfill_orphans
via the new GET /admin/api/orphans
endpoint. Pick-chip on each candidate
fires the reason modal → POST resolve.
"+ Neue Regel" opens the same reason modal
with minimal required fields (name DE/EN
+ duration) and routes to the edit page
on success.
/admin/rules/{id}/edit — full form (37 columns grouped: identity /
proceeding / timing / party / display /
lifecycle / condition). Side panel hosts
the preview widget (trigger date + flags
→ GET .../preview, drafts only) and the
audit-log timeline (paginated, 20 per
page). Bottom action bar adapts to
lifecycle_state — save-draft + publish on
drafts, clone on published/archived,
archive on draft/published, restore on
archived. Every action opens the reason
modal with ≥10-char client-side guard per
Slice 11a edge case #4.
/admin/rules/export — minimal SQL preview + "Download as file"
/ "Copy to clipboard". Optional `since`
audit-id scopes the export window.
condition_expr ships with a raw JSON textarea + inline parse
validation; the tree-builder is out of scope for Slice 11b (raw JSON
is sufficient given the existing 172-row corpus and validates the
same grammar live). The dependency on document.querySelectorAll for
form binding follows the admin-event-types / admin-audit-log
playbook — no new component substrate needed.
Wiring:
- frontend/build.ts: 3 new entrypoints + 3 new HTML writes.
- frontend/src/admin.tsx: new "Regeln verwalten" card with ICON_TABLE.
- frontend/src/components/Sidebar.tsx: two new admin nav entries
(Regeln + Regel-Migrations).
- frontend/src/client/i18n.ts: 162 new keys (DE+EN), under
admin.rules.* and admin.rules.edit.* and admin.rules.export.*.
- frontend/src/styles/global.css: new admin-rules-* CSS block
appended (chips, pills, audit timeline, edit-grid, preview list,
orphan cards, export pre). Uses paliad's existing CSS tokens so
light/dark/auto themes inherit automatically.
Route registration:
- GET /admin/rules — list page shell
- GET /admin/rules/{id}/edit — edit page shell
- GET /admin/rules/export — export page shell
All routes adminGate + gateOnboarded, so non-admin users 404 before
the shell even loads. Backend audit and lifecycle invariants from
Slice 11a stay authoritative; the frontend never bypasses them.
Phase 3 Slice 8 frontend wire-shape swap. Save-modal pre-check logic
moves from the legacy (isMandatory, isOptional) pair to the unified
priority enum via a new priorityRendering helper in
verfahrensablauf-core.ts:
- mandatory → pre-checked, save button visible
- recommended → pre-checked, save button visible
- optional → pre-unchecked, save button visible (RoP.151 pattern)
- informational → NO save button — renders as a notice card with a
"Hinweis" / "Note" label, distinct visual tier (no checkbox).
The visible UX win of Phase 3: the 18 F/F filing rules
(Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
currently render as 'recommended'; once editorial review flips
them to 'informational' via the rule editor (Slice 11), this
branch lights up and they stop offering a save action that
would auto-create deadlines users didn't ask for.
priorityRendering falls back to the legacy (isMandatory, isOptional)
pair semantic when priority is missing (pre-Slice-8 backend
responses), so the cutover is bidirectional-safe. After Slice 9
drops the legacy fields, the fallback branch becomes unreachable.
CalculatedDeadline TS interface gains:
- priority: optional 4-way union literal type
- conditionExpr: optional unknown (rule editor reads this; the
save-modal doesn't need to interpret it)
i18n keys added (DE + EN both):
- deadlines.priority.mandatory/recommended/optional/informational
- deadlines.priority.informational.notice_label (Hinweis / Note)
- project.instance_level.first/appeal/cassation/unset
- verlauf.spawn.chip + verlauf.spawn.cycle_warning (reserved for
the SmartTimeline spawn-chip work, deferred to a focused
follow-up so this slice doesn't balloon)
Frontend build clean (2225 i18n keys, 11 new). The instance_level
pill group on the project-edit form is intentionally NOT shipped
in this slice — the project-edit form is large and the pill is
self-contained UI; the data field is exposed via the API and a
follow-up slice (or the rule editor work) can wire the picker
without blocking the wire-shape swap.
Phase 3 Slice 5 frontend. loadProceedingTypes() in projects-detail.ts
now fetches /api/proceeding-types-db?category=fristenrechner so the
project edit picker only ever shows the 19 fristenrechner codes,
never the 7 legacy litigation codes (INF / REV / CCR / APM / APP /
AMD / ZPO_CIVIL).
The Fristenrechner calculator page + Verfahrensablauf page are NOT
touched — they still need the full proceeding_types catalog (the
litigation codes have rule trees the calculator can render, per
design §3.F: "litigation codes stay … reachable via cascade leaves").
Only the project-binding picker is restricted.
Defence-in-depth: even if a future fetch bypasses this filter, the
server-side service guard (ErrInvalidProceedingTypeCategory) and
the mig 088 DB trigger both reject the write. The picker filter is
the UX layer of the chain — invisible bad-shape inputs.
projects-new.ts has no proceeding-type field today (the form lives
on the edit page only); no change needed there.
Slice 4 step 2 (faraday-Q7). Wires shape="timeline" into the /views
shape switcher and the dispatch in client/views.ts.
New file shape-timeline-cv.ts holds the adapter:
- ViewRow.kind="deadline" → TimelineEvent kind="deadline" + deadline_id
- ViewRow.kind="appointment" → kind="appointment" + appointment_id
- ViewRow.kind="project_event" → kind="milestone" + project_event_id
- ViewRow.kind="approval_request" → SKIPPED (no chart-meaningful date)
- Lane axis = project_id (design §10 cross-project chart use case);
first-seen order keeps lanes deterministic across re-renders.
- Rows without project_id collapse to a synthetic "self" lane.
- Status comes from row.detail.status for deadlines (done/overdue),
defaults to "open" everywhere else.
shape-timeline-chart.ts gets a new ChartMountOpts.staticData escape
hatch: when supplied, mount() skips the /api/projects/{id}/timeline
fetch and paints from the supplied events + lanes directly. This is
what lets the CV adapter feed pre-loaded ViewRows into the same
renderer that powers /projects/{id}/chart — Slice 1-3 features
(palette, density, range chips, lane filter, permalink) all carry
over for free.
views.ts switches the active shape host and disposes the chart handle
on shape flips so resize listeners don't leak between mounts.
Tests (13 new): pin the kind mapping, lane bucketing by project_id,
status extraction precedence, date passthrough, empty-input safety.
Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5.
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.
Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.
Design ref: docs/design-project-chart-2026-05-09.md §8.1.