Extends the SSE error switch in frontend/src/client/paliadin.ts'
friendlyErrorMessage to map four new error codes from RemotePaliadin
Service into localised messages:
- mriver_unreachable: mRiver is offline / paliadin-shim unreachable
(DE: "mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an,
oder nutze Paliadin lokal mit ./paliad."
EN: "mRiver is offline — Paliadin can't reach it. Wake mRiver, or
run Paliadin locally with ./paliad.")
- shim_auth_failed: SSH key / authorized_keys mismatch (Permission
denied)
- shim_error / bootstrap_failed: generic remote-shim failure
- timeout: Claude didn't write the response file in 60 s
Adds the matching i18n keys (DE + EN) plus the type-union entries in
i18n-keys.ts so the t() typecheck stays sound. The old codes
(tmux_unavailable, connection_lost, upstream) are unchanged — local-PoC
deployments keep their existing UX.
Frontend `bun run build` clean: 1886 keys (unchanged sync).
Refs m/paliad#12
Substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so the
JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time. Feeding
that into new Date() + toLocaleTimeString() produced "02:00" in CEST,
"01:00" in CET, "20:00 the day before" in EST, etc.
Pattern A: don't render time for date-only fields.
- Centralised the date/time formatters used by the views shapes into
frontend/src/client/views/format.ts. parseDateOnly recognises both
"YYYY-MM-DD" and the substrate's "YYYY-MM-DDT00:00:00Z" form; formatDate
formats those in UTC so the day matches the source day in every timezone.
- shape-cards.ts: per-row time slot is empty for deadlines when the day is
already in the heading (groupBy=day). Falls back to formatDate when
groupBy=week|none. Bucketing now anchors date-only inputs to UTC so a
deadline can't slip into the previous day in negative-offset zones.
- shape-list.ts: formatRelative is kind-aware — deadlines reduce to
day-precision ("morgen" / "in 3 Tagen") instead of leaking hour math
("in 2h") off the UTC midnight.
- Appointments and other timestamped sources are untouched.
- format.test.ts: regression coverage in CEST / PST / UTC. 14 tests pass.
Adds the Cards view-mode to /projects (third option in the segment-control
between Tree and Liste).
frontend/src/projects.tsx:
- View-mode segment gains "Karten" button
- Two new toolbars (initially display:none, surfaced by Cards mode):
- .projects-cards-toolbar: layout dropdown + [Bearbeiten] + [Neue Ansicht]
+ "Alle Ebenen anzeigen" toggle
- .projects-cards-edit-toolbar: density radio + grid select + rename /
delete / set-default / discard / save buttons
- New container: .projects-cards-wrap > #projects-cards-grid
frontend/src/client/projects-cards.ts (NEW, ~640 LoC):
- Layout management: GET /api/user-card-layouts on first mount; auto-seeds
Standard layout if empty (POST). Layout dropdown switches active layout
in-place; show_all_levels toggle persists immediately.
- Edit mode: clones the active layout into editDraft; renders per-card
fact list with drag handles + visibility checkboxes + count steppers
(1..5) for next-events / recent-verlauf. HTML5 drag-and-drop reorders
facts; title-row is forced to the first position so the server-side
validator's invariant holds.
- New layout: prompts for a name, seeds with the current draft (or active
layout's facts), POSTs, enters edit mode.
- Set-default / rename / delete: each maps to PATCH or DELETE; default
cannot be deleted (server returns 409 + UI alerts).
- Card render: title row (icon + link + pin star), type/status chips,
client-matter, parent-path-as-reference (parent breadcrumb deferred —
needs an extra fetch per card), deadline-counts (subtree-aggregated
when available), next-events from /api/projects/cards-preview, recent-
verlauf, team-chips initials with overflow count.
- Pin click on a card star does optimistic toggle + POST/DELETE pin
endpoint and updates treeCache in place.
- Cards sort: pinned first, then last_activity_at DESC, then title ASC.
- "Alle Ebenen anzeigen" toggle decides whether Mandanten + Litigations
appear as their own cards (off by default — leaf-ish projects only:
Cases, Patents, Verfahren, Projekte).
frontend/src/client/projects.ts (orchestrator):
- ViewMode type expands to "tree" | "cards" | "flat"
- View segment-control wires through to Cards mode
- render() dispatches to renderCardsView / teardownCardsView based on
active mode
frontend/src/client/i18n.ts: 53 new keys DE+EN under projects.cards.* —
section titles, empty-states, layout picker labels (label/new/edit/save/
discard/set_default/delete/rename/is_default/new.prompt/delete.confirm/
delete.default_blocked), per-fact labels (title-row/type-chip/status-chip/
client-matter/parent-path/deadline-counts/next-events/recent-verlauf/
team-chips/reference/last-activity-at), density values (compact/roomy),
grid values (auto/2/3/4), event-kind labels (deadline/appointment/
project_event), edit toggles (toggle.hide/show/move_up/move_down/count).
frontend/src/styles/global.css: ~290 LoC appended for cards toolbar +
grid + card layout (title row / row / section / event row / team chips)
+ edit-mode chrome (drag handles, drop targets, count steppers) + dark-
themed dashed border on edit cards. Mobile media query forces single-
column grid.
i18n codegen: 1830 → 1882 keys (+52). bun run build clean. tsc on new
files clean (pre-existing JSX-IntrinsicElements noise unrelated).
go build/vet/test still clean.
frontend/src/projects.tsx — strip the legacy 3-select toolbar; replace with
search input + view-mode segment-control (Tree | Liste) + chip filter row
(Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen). Tree
container is the default visible mount; flat-table hidden until view mode
toggles.
frontend/src/client/projects.ts — orchestrator. Owns chip + search + view-
mode state. Last-viewed restore from sessionStorage (Q1 lock-in), URL params
override on load, syncURL on every state change. Debounced search (250ms).
Multi-select panels via <details> for status/type. Delegates rendering to
project-tree.ts (tree mode) or projects-flat.ts (flat mode).
frontend/src/client/projects-flat.ts (NEW) — extracted table render from the
old projects.ts so the orchestrator can mount/unmount cleanly.
frontend/src/client/project-tree.ts — extends ProjectTreeNode shape with
pinned, inherited_visibility, match_kind, *_subtree fields. Renders pin
star button (always-visible per design §4.6 — touch-friendly), greyed-
ancestor opacity for InheritedVisibility=true, lime backdrop on
match_kind=self. Pin click does optimistic toggle + POST/DELETE
/api/projects/{id}/pin then invalidates the tree cache.
frontend/src/styles/global.css — toolbar + chips + pin star + greyed-
ancestor + match highlighting. ~200 LoC appended.
frontend/src/client/i18n.ts — 29 new keys DE+EN under projects.toolbar.*,
projects.chip.*, projects.tree.deadlines.*, projects.tree.pin/unpin,
projects.search.match.*, projects.empty.filtered.action.
internal/services/pin_service_test.go (NEW) — live-DB tests for PinService
(pin/unpin/idempotent/owner-scope/visibility-gate) + 2 BuildTreeWithOptions
cases (PinnedSet surfaces, ScopeMine greys ancestors). Skips without
TEST_DATABASE_URL; pure-Go path runs clean.
Frontend bun build clean. go build / vet / test (short) clean.
Three visual bugs from the t-146 PoC ship.
1. Bubble alignment robustness — keep `align-self: flex-end/-start`
but also pin with `margin-left/right: auto`. align-self was already
correct in CSS, but layered margin-auto makes the alignment
bulletproof against any future cross-axis override.
2. Dark-mode contrast — paliadin CSS used three undefined tokens
(`--color-accent-tint`, `--color-status-red`, `--color-status-red-tint`,
`--color-surface-hover`) whose hardcoded fallbacks (`#e8fbb2`, `#fee`,
etc.) always fired. In dark mode the user bubble rendered light cream
text on light-lime background, the error bubble light cream on light
pink — both unreadable. Repointed to the project's actual tokens:
`--color-bg-lime-tint` (defined in both modes), `--status-red-fg/bg/border`
(defined in both modes), `--color-surface-2` for the starter hover.
Added explicit `color: var(--color-text)` to `.paliadin-bubble` and
`color: var(--status-red-fg)` to the error variant. Same root cause as
t-paliad-144's contrast sweeps (cf. memory `paliad: undefined --color-bg-muted token`).
3. Friendly tmux-unavailable error — Dokploy container has no tmux/claude
CLI per CLAUDE.md, so prod hits `event: error` with
`{"code":"tmux_unavailable", ...}`. The client used to dump the raw
JSON into the bubble. Now `friendlyErrorMessage()` parses the payload
and shows a localised "Paliadin läuft nur lokal" notice (DE+EN), with
a `connection_lost` fallback for native EventSource transport errors
(no `data`) or anything we don't recognise. Same code path also
replaces the generic "Fehler beim Senden: …" pre-SSE catch block with
`paliadin.error.upstream` so transport errors don't leak `String(err)`
into the UI either.
Phase 0 PoC of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only via in-code email gate (services.PaliadinOwnerEmail);
no deploy-time toggle. tmux-Claude pattern lifted from goldi/mVoice
(mVoice/server.py:250-380). Migration 058 introduces
paliad.paliadin_turns audit table (full prompt+response stored at
PoC scope; production v1 swaps to hash-only). 7 unit tests on the
trailer parser / chip counter / sanitiser, all green.
Surface: /paliadin chat panel (sidebar entry under Übersicht,
revealed by /api/me on owner) + /admin/paliadin monitoring dashboard
(daily counts, classifier histogram, tool-use rate, top prompts,
recent turns). Citation chips parsed from inline marker syntax;
tool-use evidence visible under each bubble.
Production safety: routes register everywhere but the per-request
owner gate returns 404 for any user other than m. paliad.de prod
container has no tmux/claude CLI, so even m hitting the route from
there gets "tmux unavailable" — clear failure, no security surface.
Branch: mai/noether/inventor-paliadin-in-app (8d714dd).
m's call (2026-05-07 21:52): "remove the export variable, that is bad
form. It should be connected only to my account."
The PALIADIN_ENABLED env var was a deploy-time toggle: easy to
mis-flip, splits prod/dev behaviour, and reads as "could be turned on
for anyone." Replaced with a per-request gate in code:
services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
handlers/paliadin.go now gates every entry point through
requirePaliadinOwner, which looks up paliad.users.email by the caller's
UUID and returns 404 (not 403 — pretend the route doesn't exist) for
anyone else.
Routes register unconditionally; the gate is in the code, not the
deploy. main.go wires PaliadinService whenever DATABASE_URL is set and
logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED
row and gains an explanatory note about the in-code gate.
Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under
Admin) now render with display:none, revealed by sidebar.ts after
/api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL —
same fail-closed pattern the Admin group already uses.
Side-effect for ops: paliad.de production now serves the routes too,
but only to m, and only successfully if the host has tmux + claude
in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a
"tmux unavailable" — clear failure mode, not a security concern.
One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the
constant aligned with migration 023's seed so a future rename of m's
account doesn't silently strand the gate. All existing tests pass.
projects-detail.tsx (the bug surface):
- Team-add dropdown switches from 7 mixed values (lead/associate/pa/of_counsel/local_counsel/expert/observer) to 4 responsibility-only values (lead/member/observer/external). Default 'member'. Closes m's bug — staffing a person no longer pretends to define their firm tier.
- Team table gains a Profession column (between Name and Responsibility), so the firm-tier badge is glanceable at staffing time.
- form.team-profession-hint surfaces the picked person's profession or warns when none is set ("kann keine 4-Augen-Genehmigungen erteilen").
projects-detail.ts:
- ProjectTeamMember type gains responsibility + user_profession. Legacy .role field kept readable for the deprecation window but UI no longer uses it.
- renderTeam renders 3-column tabular layout. Profession pill is read-only (.projekt-team-profession[--none]); responsibility is visible inline (inline-edit deferred to follow-up).
- canManagePartnerUnits switches from m.role==="lead" to m.responsibility==="lead".
- Team-add submit posts {responsibility} instead of {role}.
admin-team.tsx + client/admin-team.ts:
- New Profession column with inline-edit dropdown (6 values + "(extern)" NULL option). User type extends with profession?: string|null.
- Read-only cell uses .projekt-team-profession pill with "(extern)" placeholder for NULL.
onboarding.tsx + client/onboarding.ts:
- New required profession <select> with default 'associate'. Six values match the new enum. Hint copy explains the difference from job_title.
- POST /api/onboarding payload gains profession field.
i18n.ts: ~30 new keys DE+EN — projects.team.profession.* / .responsibility.* / projects.detail.team.col.profession / .responsibility / .form.responsibility / .form.profession.* / admin.team.col.profession.* / onboarding.profession.* / projects.team.profession.none + .hint variants.
CSS:
- .projekt-team-profession pill (firm-tier, read-only).
- .projekt-team-profession--none italic-dashed for NULL professions.
- .projekt-team-responsibility pill (per-project).
- .form-hint--warning for the team-add no-profession warning.
Build: bun build.ts clean (1723 i18n keys, all referenced). go build + go vet + go test (pure-Go) clean.
Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.
Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
template_key, recipient_filter jsonb, recipient_user_ids uuid[],
send_report jsonb, sent_at). RLS: senders read own rows, global_admin
reads all; inserts must self-attribute. No CHECK-constraint extension to
partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
persists audit row, dispatches via 5-deep goroutine pool with 15s
per-send timeout. Send report (sent/failed counts + per-recipient errors)
is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
*italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
first; only whitelisted tags re-emitted. Script tags and javascript:
URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
{{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
index. Powers the /team project multi-select filter without N round
trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
/admin/broadcasts is gateOnboarded (not adminGate) so leads can see
their own sends; the service applies the per-row visibility filter.
Frontend
- /team gains a project multi-select chip dropdown (visible projects
loaded from /api/projects, intersected against the memberships index)
alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
true (global_admin always; non-admin needs lead-ship on selected
projects, or at least one project when no filter is set). Server still
re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
template dropdown (loads existing email templates and strips Go-template
directives) + recipient preview (first 5 + expand) + send. Hard-blocks
empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
detail (subject, body, recipient list, send_report counts).
Tests
- broadcast_service_test.go: placeholder substitution table-driven,
Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
validation cases (empty subject/body, recipient cap, invalid email),
signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
rules (lead can send on own project, member cannot, admin sees all,
member can't read lead's row). Skips when TEST_DATABASE_URL is unset.
i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.
What lands:
- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
(the editor). One TSX per page; client/views.ts + views-editor.ts
hydrate.
- Three render-shape components in client/views/: shape-list.ts (table
for density=comfortable, compact one-line stream for density=compact —
the activity-feed look without a separate "activity" shape per Q4 lock-
in 2026-05-07), shape-cards.ts (day-grouped chronological), and
shape-calendar.ts (month grid with day-pills, mobile cards-fallback
notice on viewports <600px per design §9 trade-off 8).
- Generic view shell that resolves a slug to a system view (via
/api/views/system) or a user view (via /api/user-views), runs it via
POST /api/views/{slug}/run, dispatches to the matching shape, exposes
a 3-button shape switcher that swaps the live render without re-fetching,
and surfaces the inaccessible-projects toast when the substrate flags
some IDs (Q17 fail-open attribution).
- View editor with widgets for name/slug/icon, sources (4 checkboxes),
scope mode (all_visible / my_subtree / personal_only), time horizon
(six fixed options), shape, and list density. Slug regex enforced
client-side mirroring the server validator. Save → POST/PATCH; delete
→ simple yes/no confirm (Q25 lock-in).
- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
GET /api/user-views on mount, injecting one nav item per saved view
+ an always-present "+ Neue Sicht" trailing entry. show_count=true
views get a sidebar badge updated by a fire-and-forget run query.
- Page handlers /views (most-recently-used redirect or onboarding shell),
/views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.
- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
shape labels, source/kind/horizon/scope vocabulary, editor form,
empty/error/onboarding states.
- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
Meine Sichten sidebar group.
- build.ts registers views.tsx + views-editor.tsx page renderers and
the two client bundles.
Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":
1. **All derived members labeled 'Attorney'.** Migration 055 added
partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
the column in the admin UI. So 100% of pum rows are 'attorney' and
Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
surfaces every member as 'attorney' even when they're really PAs.
2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
wins, every other unit-membership dropped. Judith Molarinho Vaz +
Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.
**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
bool_or(derive_grants_authority), GROUP BY user. One row per user, every
(unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
directly into the Go struct. Pinned by unit test.
**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
(unit, role) source — single-unit users render as before
("über: **Lehment** [Sicht]"); multi-unit users render
"über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.
**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
already shipped in t-paliad-139 Phase 2). Disables during request,
rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.
**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).
**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).
After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
The FOUC script in PWAHead.tsx sets `<html class="sidebar-pinned">`
pre-paint, which kept body padding correct from frame 1 — but the
.sidebar element's own width keyed off `.sidebar.pinned` (set by
initSidebar in DOMContentLoaded). That made every navigation paint
the rail at collapsed width, then animate width 150ms → pinned width
once JS ran. Visible slide-in on Dashboard / Agenda / Projekte / etc.
Fix: extend every `.sidebar.pinned` rule with a parallel
`:root.sidebar-pinned .sidebar ...` selector so the html-class set
pre-paint is sufficient to render the full pinned visual state from
frame 1 (width, label opacity, pin/resize/badge visibility, search
input). Runtime initSidebar still mirrors `.pinned` onto the element
itself for explicit pin/unpin click animation. Same dual-selector
pattern already used by `.has-sidebar.sidebar-pinned` /
`:root.sidebar-pinned .has-sidebar` for body padding.
Mobile unaffected — FOUC script only sets html.sidebar-pinned when
window.innerWidth >= 1024, and initSidebar clears it on resize.
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.
Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.
Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
LEFT JOIN paliad.projects to surface project_title for the Verlauf
attribution chip on /projects/{id}. Inner subquery aliased pp to
avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
enrichment. Other readers leave it nil and the JSON serialiser omits
it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
?direct_only=true|false and passes through to the service. New
handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
DeadlineService.ListForProject + AppointmentService.ListForProject
+ ProjectService.ListEvents (live-DB test, skipped without
TEST_DATABASE_URL).
Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
/api/projects/{id}/deadlines + /appointments (legacy narrow) to
/api/events?type=deadline|appointment&project_id={id} (the union
endpoints, already aggregating + enriching with project_title). The
Verlauf still uses /api/projects/{id}/events but with the new
direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
subtree (true). persistSubtreeMode replaceState keeps back-button
friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
Appointments sections. Shared state across the three; clicking any
toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
.aggregation-chip — small additive styling.
No schema. No migration. Phases 2 + 3 stack on top per design §7.
Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.
Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.
Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
Commit 8 of 8. Bilingual (DE primary / EN secondary) translations for
the four approval event_types per entity that ApprovalService emits
into paliad.project_events:
deadline_approval_requested / _approved / _rejected / _revoked
appointment_approval_requested / _approved / _rejected / _revoked
Each gets:
- event.title.<event_type> — full Verlauf-card heading
- event.description.<event_type> — full-sentence localized description
- dashboard.action.short.<event_type> — verb-form for the dashboard activity feed
The existing translateEvent dispatch in i18n.ts handles these
automatically — it already keys off event.title.<event_type> for the
title, and the deadline_* / appointment_* prefix branch in
translateEventDescription falls through to event.description.<event_type>
when the stored body has no quoted title (which is true for the
approval-event descriptions emitted by ApprovalService).
Result: every project's Verlauf tab now renders the full 4-eye
lifecycle trail inline alongside the existing deadline_created /
deadline_updated / etc. rows. The /admin/audit-log timeline picks
them up too via the union path.
Pair-card rendering (request + decision side-by-side keyed by
metadata.approval_request_id) was a stretch goal in the design doc;
the current per-event row rendering already conveys the full story
chronologically without needing that pairing logic.
Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:
- /events (deadline + appointment list): ⚠ pill next to the title +
soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.
Changes:
- internal/services/event_service.go: EventListItem gains
ApprovalStatus *string; projectDeadline / projectAppointment
populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
f.approval_status / pending_request_id / approved_by / approved_at
to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
ApprovalStatus; the per-source SQL queries select it; the
loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
agenda-item headline.
Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.
Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
Commit 5 of 8. End-user surface for the approval workflow:
- /inbox page (frontend/src/inbox.tsx + client/inbox.ts) with two tabs:
"Zur Genehmigung" (requests I qualify to approve) and "Meine
Anfragen" (requests I submitted). Each row shows the project, entity
title, lifecycle event, requester name + age, the date-field diff
(for update/complete/delete) and the relevant action buttons:
approve + reject when on pending-mine, revoke when on mine.
Historic rows render a status pill instead of buttons.
- Sidebar bell entry "Genehmigungen" (with sidebar-inbox-badge) under
the Übersicht group. sidebar.ts polls /api/inbox/count every 60s and
shows the count (or 9+ ceiling) when > 0.
- Server registration: GET /inbox → dist/inbox.html, gated by
gateOnboarded. Already-registered API endpoints (commit 4) handle
the data path.
- Bilingual (DE primary / EN secondary) i18n strings under
approvals.* — labels, status names, lifecycle names, role names,
decision-kind names, action verbs, error messages. ~50 new keys.
- Pending-state CSS classes: .approval-pill, .approval-pill--historic,
.entity-row--pending-{create,update,complete,delete},
#sidebar-inbox-badge. Soft-tint rows + amber pill so an approver
can scan a list of pending entities at a glance. Used by commit 6
(pending pills across surfaces) — no other surface picks them up
yet, but the styles are wired and ready.
- Sidebar.tsx navItem signature gains an optional badgeID parameter
so any future sidebar entry can host a count-badge with one extra
argument (no per-entry custom rendering).
Edit mode now exposes a project picker so a deadline or appointment can be
moved to a different matter. Backend Update accepts project_id (and
clear_project for appointments), validates visibility on the destination,
and emits *_project_changed audit rows on both the OLD and NEW project so
each side's Verlauf still shows the move.
Personal-to-project linking and project-to-personal unlinking are gated by
the existing personal-Appointment creator check; project-to-project moves
re-use the existing requireMutationRole gate plus a fresh visibility check
on the target.
GET /api/tools/courts[?courtType=UPC-LD] returns the deadline-
computation slice of paliad.courts (id, code, names, country, regime,
court_type) — distinct from the rich Gerichtsverzeichnis at
/api/courts. Optional courtType filter narrows to a single tier.
POST /api/tools/fristenrechner and POST /api/tools/fristenrechner/
calculate-rule both accept an optional courtId field. When set, the
calculator resolves the court's (country, regime) and uses that
calendar; when omitted, the proceeding's existing jurisdiction column
seeds a sensible default — preserves today's behaviour for callers
that don't yet send a court.
Frontend: court-picker-row added to step 2 of the Fristenrechner
wizard. Visible only for proceeding types with multiple compatible
courts (today: every UPC-flavoured proceeding — UPC LDs span 12
countries, plus UPC CD seats and the CoA). DE-only proceedings (BPatG
nullity, BGH appeals, DPMA, EPA, EP grant) keep the form unchanged.
Picker re-runs the calc on selection so the user sees the same
deadlines shift to a different calendar without a manual click. i18n
key deadlines.court.label added for both DE and EN.
Default courts wired sensibly: UPC_INF / UPC_REV / UPC_PI etc. → UPC
LD München (HLC's home venue); UPC_APP / UPC_APP_ORDERS /
UPC_COST_APPEAL → UPC CoA Luxembourg; UPC_REV → UPC CD Paris.
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.
Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
optional condition flags. Returns rule metadata + computed dueDate +
originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
+ HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
Sommerferien skip both apply automatically. Court-determined rules
(party='court' or event_type ∈ hearing/decision/order) return
IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
hasn't supplied the flag — the UI uses this to render checkboxes;
toggling recomputes live. With all flags satisfied + alt_duration_*
present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
and error paths (skipped without TEST_DATABASE_URL).
Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
card (only one open at a time). Pill picker (radio chips) appears
when the card has 2+ rule pills; first preselected. Trigger date
defaults to today (m's Q3). Flag checkboxes auto-render from the
rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
/api/projects/{id}/deadlines/bulk with a single-element array using
source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
(Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
have a single rule to compute.
- Escape collapses the open card.
CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.
UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
The B1 decision tree exposed a "Skip this step" affordance on
intermediate non-leaf nodes that broke the narrowing model — clicking
it left the tree in a half-narrowed state with no clear UX intent.
Drop the button entirely; users who don't know an answer should pick
"Anderes / Sonstiges" or switch to B2 (filter mode).
The step-back button (and its sibling .fristen-b1-loosen-link in the
empty-result state) rendered with `color: var(--color-accent)` over a
transparent background — lime green text on cream is unreadable. Move
both to a secondary-button shape: hairline border, muted text, accent
on focus-visible. Both light and dark themes verified.
Touched:
- frontend/src/client/fristenrechner.ts: drop skip TSX + handler
- frontend/src/client/i18n.ts: drop "deadlines.pathway.b.tree.skip"
- frontend/src/i18n-keys.ts: drop the codegen key
- frontend/src/styles/global.css: split off .fristen-b1-skip selector
and replace the lime-text rule with a bordered secondary style
using --color-text-muted / --color-border (themed both ways)
When the user prints (browser dialog or any Drucken button) the page now
strips everything except the actual result content. Hidden: sidebar nav,
bottom-nav, top header, footer, breadcrumbs, all forms (.tool-input,
.filter-row, .entity-controls, search bars, gebühren-lookup, etc.), the
Fristenrechner pathway-fork buttons, B1 decision-tree cascade, B1/B2 mode
toggle, view toggle, result-action buttons, every <button>. Visible:
timeline / columns view / cost breakdown / gericht cards / entity tables
/ glossar entries / checklist items, plus the page heading + subtitle so
the printed page is identifiable.
Per-page print rules above (kostenrechner / gebühren / checklisten /
gerichte) keep their existing specifics; this block is the catch-all for
chrome those rules miss.
Verified via Playwright print emulation on /dashboard, /tools/kostenrechner,
/tools/fristenrechner (Verfahrensablauf list + Spalten view), /events.
Five m's-bookmark fixes on top of the B1 surface change:
1. Sort proceeding pills inside concept cards by real-world frequency.
New paliad.proceeding_types.display_order column (m's spec values:
UPC_INF=10, DE_INF=20, UPC_REV=30, ..., UPC_PI=920, ...). Default
999 for unmapped legacy codes. Search service surfaces it through
the deadline_search matview (rebuilt to add the column) and uses
it as primary key in pillSortKey, replacing the jurisdiction-rank.
2. Name standardisation: -klage → -verfahren on the proceeding-types
that describe a multi-step process. Specifically:
UPC_REV Nichtigkeitsklage → Nichtigkeitsverfahren
UPC_APP Berufung → Berufungsverfahren
DE_INF Verletzungsklage (LG) → Verletzungsverfahren (LG)
DE_INF_OLG, DE_NULL_BGH, DPMA_OPP, DPMA_BPATG_BESCHWERDE,
UPC_COST_APPEAL, UPC_APP_ORDERS, DPMA_BGH_RB, DE_INF_BGH —
same -verfahren standardisation.
3. legal_source for rev.defence × UPC_REV: was NULL, leaking the
internal local_code 'rev.defence' to the UI. Set to UPC.RoP.49.1
(Defence to Application for Revocation, R.49.1).
4. Frontend renderPill no longer falls back to rule_local_code when
legal_source is missing — the source span just collapses, so no
internal slug ever shows up as a "citation".
5. Quick-pick chips refactored to a slug-based array (QUICK_CHIPS) in
fristenrechner.tsx, single source of truth for both fork-shortcut
and B2-search-bar rows. Each chip carries data-chip-name-de /
data-chip-name-en; relabelChips() rewrites visible text per active
language. Dropped the duplicate "Statement of Defence" chip (same
concept as "Klageerwiderung"). Each chip now maps to one concept
slug — Klageerwiderung→statement-of-defence, Berufung→notice-of-
appeal, Einspruch→opposition, Replik→reply-to-defence,
Beschwerde→nichtzulassungsbeschwerde, Schadensbemessung→
application-for-determination-of-damages, Wiedereinsetzung→
wiedereinsetzung.
Migration 051 uses RAISE WARNING (not EXCEPTION) on coverage gates
per the 049 outage lesson — partial-migration recovery beats whole-
transaction failure. Matview rebuild stays inside the transaction;
RefreshSearchView() on next boot is a cheap no-op.
Pathway B B1 mode previously rendered an empty result area on every
state — the runB1Search() output target was #fristen-search-results,
which lives inside the B2 panel. When B2 is hidden (B1 active), the
results were written into a hidden subtree and never seen.
Changes:
- TSX: add #fristen-b1-results inside #fristen-b1-panel, below the
cascade button row.
- frontend/fristenrechner.ts: extract renderSearchResultsInto() and
wirePillClicks(); runB1Search now writes to fristen-b1-results,
fetches /api/.../search?browse=all when no slug is picked yet (full
landscape on entry), and applies CSS-driven loading dim with a seq
guard against out-of-order responses. Hoisted loadAndRenderB1() so
showBMode("tree") can trigger the tree load on Pathway B entry
(radio.checked = true does not fire change events).
- backend: SearchOptions.BrowseAll, allMappedConceptIDs() returning
the union of every concept reachable from any leaf via
paliad.event_category_concepts, lifted limit ceiling for browse
modes (default 200, max 500). Handler exposes ?browse=all.
- CSS: shared loading-state styling for fristen-b1-results.
m's spec lock §10 Q1 (2026-05-05): "Retire legacy tabs - we are only
resorting." This commit drops the .fristen-mode-tabs nav (Verfahrensablauf
+ Was kommt nach…) and the ?legacy=1 escape hatch. Pathway A becomes
Verfahrensablauf-only; the trigger-event panel (mode-event-panel) stays
in the DOM but is hidden by default and surfaces only via concept-card
pill drill-in (drillToTrigger flips the panels directly).
Frontend deltas:
- frontend/src/fristenrechner.tsx: drop .fristen-mode-tabs section;
rename mode-event-panel role/label to standalone tabpanel.
- frontend/src/client/fristenrechner.ts:
- drop isLegacyMode() + ?legacy=1 branch in showPathway().
- drillToTrigger() now flips procedure ↔ event panels directly
(no more #mode-event-tab click → handler chain).
- initModeTabs() bails on tabs.length===0 (already does); no
further changes needed.
- frontend/src/styles/global.css: drop .fristen-pathway-shell--legacy.
Backend untouched.
Build: clean. Frontend bundle 1473 keys unchanged. go build + vet +
tests pass.
The deadlines.mode.procedure / deadlines.mode.event i18n keys remain
in i18n.ts as orphans for now; cleaning them up is purely cosmetic
and lives outside the v3 scope.
Wires the v3 Gericht/System multi-select filter on the Pathway B/B2
panel. 10 forum-bucket chips per m's spec lock §10 Q8 (UPC CFI, UPC
CoA, DE LG/OLG/BGH/BPatG, EPA Erteilung/Einspruchsabt./Beschwerdek.,
DPMA).
UX:
- Chip click toggles its membership in activeForums Set.
- Multi-select; chips AND across the result set
(UNION within forum, AND with other filters — backend handles).
- ?forum=<comma-separated> URL state round-trips on every toggle.
- popstate restores active set; lang switch re-renders chip labels.
- Shared between B1 and B2: tree-mode reissues runB1Search;
filter-mode dispatches input event on the search box.
Frontend file deltas:
- frontend/src/client/fristenrechner.ts: FORUM_BUCKETS array,
activeForums Set, renderForumChips(), reissueSearchWithCurrentFilters()
(mode-aware), getActiveForumsParam() consumed at every search call.
- B2 search fetch + B1 cascade fetch both send ?forum= when active.
Frontend i18n keys for the 10 forum labels (DE+EN) shipped with
Phase B; this commit just renders them.
Backend was wired in Phase C; this commit completes the user-facing
path. Forum filter narrowing applies AND-wise with q / event_category_slug
/ proc / party / source — empty-result UX shows the existing "no hits"
status, m can drop a chip to widen.
Build: clean. Frontend bundle unchanged size delta (≈+50 lines, 1473 keys).
Phase D-2 (party-perspective selector + is_bilateral mirroring renderer)
ships next.
Reshapes /tools/fristenrechner into the v3 landing fork. Default
view: two big pathway cards (📖 Verfahrensablauf informieren
vs 📅 Frist eintragen aufgrund Ereignis) plus a quick-pick chip
shortcut row that jumps straight into Pathway B + filter mode +
prefilled query.
URL state machine:
- ?path=a → Pathway A (existing wizard, wrapped in fristen-pathway-a)
- ?path=b → Pathway B shell with mode toggle (B1 tree / B2 filter)
- ?mode=tree → B1 panel (stub for Phase B; Phase C wires the cascade)
- ?mode=filter → B2 panel (search bar + chips + concept-card results)
- ?path absent → landing fork
- ?legacy=1 → pre-v3 layout (legacy escape hatch; dropped in Phase E)
- localStorage remembers last-used pathway
Pathway B's B2 panel hosts the existing Phase D search bar (relocated
from page-top into the pathway shell). The forum-filter row + chips
container exist in the DOM hidden — Phase D wires them.
Pathway A wraps the existing Verfahrensablauf wizard (proceeding tile
grid + date input + timeline / columns view) plus the legacy "Was
kommt nach…" tab. Both keep working unchanged in this commit; tabs
retire entirely in Phase E.
Phase B B1 panel is a stub: "Der Entscheidungsbaum ist in Vorbereitung."
Phase C replaces it with the data-driven cascade.
Files:
- frontend/src/fristenrechner.tsx: landing fork + pathway shells
- frontend/src/client/fristenrechner.ts: pathway state machine,
URL parser, popstate restore, fork-chip → ?path=b shortcut
- frontend/src/client/i18n.ts: 30+ new keys (deadlines.pathway.*,
deadlines.filter.forum.*, deadlines.perspective.*) DE+EN
- frontend/src/styles/global.css: .fristen-pathway-fork,
.fristen-pathway-card, .fristen-pathway-shell, .fristen-mode-toggle,
.fristen-forum-filter, .fristen-forum-chip rules
Frontend build: clean (1472 i18n keys). go build + vet: clean.
The legacy tabs (Verfahrensablauf-Tab + Was kommt nach…) live inside
Pathway A and continue to work — m's spec lock §10 Q1 retires them
in Phase E, not now.