Commit Graph

417 Commits

Author SHA1 Message Date
m
6fcf34a3e3 feat(determinator/slice-3c): perspective chip + party-tagged cascade narrowing
m's 2026-05-08 18:09 spec — Slice 3c. Adds a Klägerseite / Beklagtenseite
chip strip at the top of the B1 cascade panel; cascade leaves tagged
with a contradictory party get hidden. Klägerseite never files
Klageerwiderung; Beklagtenseite never files Klageschrift.

Migration 071 adds `paliad.event_categories.party text[]` (CHECK on
{claimant, defendant, both, court}) plus a partial GIN index. Backfill
is conservative — only the obvious leaves get tagged on this pass:

  - claimant   ich-moechte-einreichen.klage.* (9 leaves)
                ich-moechte-einreichen.spaetere-schriftsaetze.replik-*
  - defendant  ich-moechte-einreichen.widerklage.*
                ich-moechte-einreichen.spaetere-schriftsaetze.duplik-*

cms-eingang.* (incoming) and frist-verpasst.* (anyone misses a
deadline) stay NULL because the user can be on either side and still
receive the same court communication. Cross-appeal / Anschluss-
berufung / Reply-to-cross-appeal also stay NULL — the role flips
depending on who appealed first; the cascade doesn't have that
context yet. Tag in a follow-up once dogfood validates the chip.

Backend: EventCategoryNode JSON gains optional `party` array;
EventCategoryService.Tree SELECT picks it up via pq.StringArray.

Frontend: new Perspective type + URL state (?role=claimant|defendant)
+ perspective chip strip styled identically to the inbox-channel chip
strip. perspectiveAllowsParty(party) gates each cascade child;
"both"/"court" tagged nodes always pass; neutral nodes always pass.
Persistence is URL-only — dogfood will tell us whether to add a saved
default later.

Migration applied to live Supabase; tracker at v71.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 20:21:13 +02:00
m
e824898a6d feat(navbar/dashboard): t-paliad-162 reorg sidebar groups + inline Agenda + collapsible sections
Sidebar:
- Paliadin lifted out of Übersicht to a top-level entry directly under
  Home (owner-only reveal logic unchanged — same id reused).
- Agenda removed from sidebar; the standalone /agenda route stays for
  direct-link compatibility but the dashboard hosts its content inline.
- Projekte moved into Übersicht; Fristen + Termine moved into a new
  Ansichten group; the Arbeit group is gone.
- Werkzeuge / Wissen / Ressourcen collapsed into one Werkzeuge group
  per m's brief order (calculators → reference → content).
- BottomNav agenda slot repointed to /events?type=deadline so the
  overdue+today badge still has a sensible target on mobile.

Dashboard:
- Agenda renders inline as a new collapsible section between the
  upcoming-rails grid and Letzte Aktivität, with a "Vollständige Agenda
  öffnen →" link to the standalone page.
- Letzte Aktivität moved under Agenda per m's design call.
- Sections (summary, deadlines, appointments, agenda, activity) become
  collapsible via a chevron toggle; state persists in
  localStorage[paliad:dashboard:collapse:<section>]. Matters card stays
  whole-card-tappable, so it's intentionally left non-collapsible.
- Inline agenda fetches /api/agenda directly with a 30-day window and
  refreshes on the existing 60s dashboard poll.

Render primitives:
- New client/agenda-render.ts hosts renderAgendaTimeline + AgendaItem
  type, shared by client/agenda.ts and client/dashboard.ts. Standalone
  agenda.ts shrinks accordingly; behaviour is identical.

i18n:
- Added nav.group.ansichten + dashboard.agenda.* + dashboard.section.*
  keys (DE/EN). Removed nav.group.{arbeit,wissen,ressourcen} (no other
  callers; i18n-keys.ts auto-regenerated).
2026-05-08 20:20:57 +02:00
m
2f27620a5b feat(determinator/slice-3b): scope B1 cascade by project's proceeding type
m's 2026-05-08 18:09 spec: "if we have the project type defined, we
should only have events available that match the type of project /
type of case." Slice 3b wires the project's proceeding_type into the
cascade narrowing alongside the inbox chip and ad-hoc context.

Three inputs feed the cascade now, in priority order:

  1. Inbox chip            (cms / bea / posteingang) — user override.
  2. Ad-hoc Step 1 chip    (upc / de / epa / dpma).
  3. Project's proceeding  (Step 1 picked Akte → proceeding_type_id →
     proceeding_types.code → forum prefix).

activeForumOnPage() returns the first non-null value. The B1
cascade's inboxFilterAllowsForums consults this so a user landing on
/tools/fristenrechner?project=<uuid>&path=b&mode=tree gets the
narrowed cascade automatically — no chip clicks required. The chip
can still override at the top of the panel.

Pieces:

  - ProjectOption gains optional proceeding_type_id (already on the
    JSON; just declared so TypeScript can read it).
  - cachedProceedingTypes Map<int, string> is populated once on init
    via /api/proceeding-types-db and cached for the page lifetime.
  - forumFromProceedingCode() maps "UPC_INF" / "DE_NULL" / "EPA_OPP"
    / "EP_GRANT" / "DPMA_OPP" → upc / de / epa / dpma. EP_ and EPA_
    both hit the EPA branch since EP_GRANT belongs to the EPA forum.
  - triggerCascadeRefresh() is called from selectProject /
    selectAdhoc / clearStep1Context + after the async load completes
    so the cascade re-renders when the context changes.

The role variants (Klägerseite vs Beklagtenseite, Berufungskläger vs
-beklagte) are Slice 3c — they require fetching the user's
project_teams.responsibility for the selected project. Project's
forum lands first; role layers on after.

Refs t-paliad-157 / m/paliad#15. Folds in part of #18 (Item A
rule-vs-event collapse) — when the project context narrows the cascade
to one jurisdiction, the rule-vs-event mismatch surface shrinks.
2026-05-08 20:15:50 +02:00
m
75dc842b8e feat(team-broadcast): add "open in mail client" mailto link to broadcast modal
m's request 2026-05-08 20:12: alongside Paliad's per-recipient
"E-Mail an Auswahl" broadcast (which sends individual envelopes from
the server), users want a one-click way to compose a single multi-
recipient email in their own mail client. Common use case: writing
to a specific team where the response thread should stay client-side
and be visible to every recipient (unlike the privacy-preserving
broadcast where each recipient sees only themselves).

Adds a "Im Mail-Client öffnen" / "Open in mail client" link to the
broadcast modal's recipient summary, alongside the existing
"Alle anzeigen" toggle. Clicking it opens a `mailto:` URL with every
selected recipient comma-separated in the To: line per RFC 6068.

`buildMailtoHref` is exported so it can be unit-tested independently
and reused by other selection surfaces (admin team table, project
team tab) without a refactor.

The existing server-driven broadcast path is unchanged — both options
coexist.
2026-05-08 20:13:49 +02:00
m
6224898f9e Merge: t-paliad-161 — inline Paliadin chat modal + agent-suggested write path
Six commits from mai/dirac/inventor-inline-paliadin (all sliced per
the design's §10 phasing):

  142edca docs(paliadin): t-paliad-161 inventor design
  282e0bb feat(paliadin/migration-070): Slice A — schema + relay seam
  0d1a7ba feat(paliadin/context): Slice B — structured page-context payload
  ba2408e feat(paliadin/inline-widget): Slice C — floating button + drawer
  a3052eb feat(paliadin/suggest): Slice D — agent-suggested write path
  4ecea7a feat(paliadin/agent-glyph): Slice E —  alongside 👀

What ships:

- Floating Paliadin trigger bottom-right + Cmd/Ctrl-K shortcut, opening
  a 420px right slide-out drawer (full-screen on mobile). Visible on
  every authenticated page except /paliadin, /login, /onboarding.
  Same PaliadinOwnerEmail gate as today — no scope expansion.
- Per-route starter-prompt registry in client/paliadin-starters.ts —
  context-aware empty-state nudges users into useful first prompts.
- Structured PaliadinContext payload (route_name + primary_entity_type
  + primary_entity_id + user_selection_text + view hints) flowing from
  the widget through Go into the tmux envelope. SKILL.md gains [ctx …]
  parsing so the persona can use it.
- Agent-suggested write path: paliad__suggest_deadline +
  paliad__suggest_appointment + paliad__suggest_note tools that draft
  rows straight into the existing approval pipeline. Suggestions land
  as approval_requests with requester_kind='agent' and an
  agent_turn_id pointer back to the originating turn.
- Visual provenance:  glyph alongside 👀 on pending-approval rows
  whose request was agent-drafted; persistent  on approved-from-agent
  rows in the audit log. Lives in events.ts/agenda.ts/inbox.ts.

Migration 070 is idempotent (every ALTER guarded by IF NOT EXISTS,
constraints/index inside DO blocks). Live tracker is at v69; deploy
will apply 070 cleanly. Adds:
  paliad.approval_requests.requester_kind text + xor-check
  paliad.approval_requests.agent_turn_id uuid
  paliad.paliadin_turns.context jsonb

m greenlit all 5 inventor decisions (a-a-a-a-a) on 2026-05-08 19:39:
owner-only gate, tmux relay v1, create-only suggestion verbs,
-alongside-👀 visual, selection-text default-on.

Refs m/paliad#20, design doc docs/design-paliadin-inline-2026-05-08.md.
2026-05-08 20:06:07 +02:00
m
4ecea7a4bb feat(paliadin/agent-glyph): t-paliad-161 Slice E — alongside 👀
When a pending row was drafted by Paliadin (requester_kind='agent' on
its in-flight approval_request), surface a sparkle  next to the
existing eye-pill 👀. The two glyphs are orthogonal: 👀 = "needs
approval",  = "Paliadin drafted this". Either can change without the
other, so the visual taxonomy stays decomposable for any future
autopilot mode where 👀 disappears but  stays.

Read-path:

- DeadlineService.ListVisibleForUser + AppointmentService.ListVisibleForUser
  LEFT JOIN paliad.approval_requests on pending_request_id and project
  ar.requester_kind into the row. NULL when no request is pending.
- models.DeadlineWithProject + AppointmentWithProject grow
  RequesterKind *string. The list-projection helpers
  (projectDeadline / projectAppointment in event_service.go) carry it
  into EventListItem.
- /api/events response now includes requester_kind on every pending
  row; /api/inbox already does (Slice D extended approvalRequestViewColumns).

Render-path:

- frontend/src/client/events.ts — new AGENT_PILL_GLYPH constant (""),
  agentPill rendered into the title cell next to the existing
  pendingPill when item.approval_status='pending' AND
  item.requester_kind='agent'. EventListItem TS shape gains
  `requester_kind?: "user" | "agent"`.
- frontend/src/client/agenda.ts — same pattern, agendaItem TS shape
  + agentPill rendered next to pendingPill in the headline span.
- frontend/src/client/inbox.ts — ApprovalRequestView gains
  requester_kind + agent_turn_id; the meta line replaces the
  requester's plain name with "Anna  Paliadin" when the request was
  drafted by the agent.

CSS: new .approval-pill--agent modifier in global.css using only
existing tokens (--color-bg-lime-tint / --color-surface-2 /
--color-text), mirroring the .approval-pill--icon shape so the two
glyphs sit side-by-side at the same baseline.

i18n: 3 new keys × 2 langs (approvals.agent.label /
approvals.agent.byline / approvals.agent.suggestion_pending) — total
1966 → 1969.

Build clean (frontend + go), tests green.

Refs: docs/design-paliadin-inline-2026-05-08.md §8.
2026-05-08 20:04:10 +02:00
m
34e82ead06 feat(determinator/slice-3a): outgoing-intent chooser (File / Draft / Enter)
m's 2026-05-08 18:09 spec: Step 3a is itself a 3-option fan-out. When
the user picks "Etwas einreichen" on Step 2 we no longer drop straight
into the Pathway A wizard; we ask "what kind of einreichen?" first.

Three cards:

  - **File** (Schriftsatz einreichen) → navigates to Pathway A — the
    existing wizard with proceeding picker, trigger date, flags,
    timeline, save modal. The rule-library entry point.
  - **Draft** (Schriftsatz entwerfen) → v1 placeholder. Disabled
    button with a "kommt bald" pill in the corner. m specced this
    as a link to a future drafting surface; for now we show the
    intent without doing anything so the surface exists in the IA.
  - **Enter** (Frist manuell erfassen) → routes to
    `/projects/{id}/deadlines/new` (or `/deadlines/new` in ad-hoc
    mode where there's no project to anchor against).

Pathway type extends to include "outgoing"; readPathwayFromURL +
showPathway both handle it. The Step 3a panel reuses .fristen-step2-
card visuals so File / Draft / Enter look consistent with the parent
Step 2 cards but distinct from Pathway A's proceeding picker.

Back-button policy:

  - Step 3a back → Step 2 (the new "fork" state).
  - Pathway A back → Step 3a (since that's where the user came from
    in the new flow). Two clicks back to the fork.
  - Pathway B back → fork directly (Step 2 happened-card jumps
    straight to Pathway B; no intermediate chooser).

Out of scope for this slice:

  - Step 3b's project-type-scoped event picker (Slice 3b).
  - Klägerseite/Beklagtenseite role variants (Slice 3c).
  - Real /drafts route — Draft stays a soft placeholder.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 19:58:21 +02:00
m
ba2408eb51 feat(paliadin/inline-widget): t-paliad-161 Slice C — floating button + slide-out drawer
The inline Paliadin chat surface — reachable from every authenticated
page, replacing the standalone /paliadin route as the primary entry
point. The standalone page survives as the dedicated full-screen mode
(the drawer's "↗ fullscreen" action links to it).

Components:

- frontend/src/components/PaliadinWidget.tsx — emits the floating
  trigger button (bottom-right, lime , owner-revealed by JS), a
  scrim, and the right-edge slide-out drawer with header (reset /
  fullscreen / close), context chip, message stream, empty-state
  starter list, and textarea+send form. Loads /assets/paliadin-widget.js.

- frontend/src/client/paliadin-widget.ts — runtime. /api/me probe
  reveals the trigger when caller matches PaliadinOwnerEmail (with
  optional is_paliadin_owner flag fast-path); Cmd+J / Ctrl+J shortcut
  toggles open/close (Cmd+K stays reserved for global search per
  client/search.ts). Uses computePaliadinContext() (Slice B) per send
  so route + entity + selection flow into every turn. SSE consumer
  writes assistant bubbles; localStorage persists per-session history.

- frontend/src/client/paliadin-starters.ts — per-route starter prompt
  registry. 14 routes covered (dashboard, projects.*, deadlines.*,
  appointments.*, agenda, events, inbox, tools.*, glossary, courts) +
  a _default fallback. Bilingual (DE/EN); prompts ending in `: ` seed
  the textarea for the user to finish; fully-formed prompts auto-send.

- 39 authenticated TSX pages get a `<PaliadinWidget />` element after
  `<Footer />` via a mechanical pass. paliadin.tsx (the standalone)
  is intentionally excluded — its dedicated UI is the widget's
  fullscreen escape hatch, not a place to overlay another widget.

- frontend/build.ts registers the new bundle.
- frontend/src/styles/global.css gains ~280 lines of widget CSS
  (trigger / scrim / drawer / header / context-chip / messages /
   bubbles / starters / form / send-btn) using only existing tokens.
   Mobile (≤640px): drawer goes full-screen; trigger lifts above
   bottom-nav slots.
- 11 new i18n keys × 2 langs = 22 entries under paliadin.widget.*.

Visibility predicate (paliadin-context.shouldSendContext) hides the
widget on /paliadin, /login, /onboarding. Owner-only gate stays on
PaliadinOwnerEmail.

Build clean: i18n 1955 → 1966 keys, IIFE-wrapped 218KB bundle, go test
green.

Refs: docs/design-paliadin-inline-2026-05-08.md §3, §5.
2026-05-08 19:54:18 +02:00
m
dba8ad3fdd feat(determinator/slice-2): /projects/new return-bounce + Step 1 preselect
m's 2026-05-08 Slice 2: "Neue Akte anlegen" on the Fristenrechner now
round-trips cleanly. The Step 1 link sends `?return=/tools/fristenrechner`
on the way out; projects-new.ts honours the param after a successful
POST and redirects back with `?project=<new_uuid>` appended so the
just-created Akte preselects itself in Step 1.

Two pieces:

  - frontend/src/client/projects-new.ts — new sanitizeReturnUrl()
    rejects anything that could escape to a different origin
    (protocol-relative `//foo`, absolute `https://...`, non-rooted
    relative paths). On submit success, if a sanitized return URL
    exists, build the destination via URL() so existing query params
    on the return path stay intact and ?project= is set without
    clobbering, then redirect there. Falls back to /projects/{id}
    when no return param is present (existing behaviour preserved).
  - frontend/src/fristenrechner.tsx — Step 1 link gets the
    ?return=/tools/fristenrechner query string so the bounce-back
    knows where to land.

Step 1 hydration from Slice 1 already handles `?project=<uuid>` —
fetchProjects() repopulates cachedAkten, the projectId looks up its
ProjectOption record, renderStep1Summary() renders the collapsed
state, Step 2 cards become visible. No client-side state coordination
needed; the URL is the contract.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 19:54:11 +02:00
m
d4c129f0d6 Merge: t-paliad-157 Determinator Slice 1 — project picker + do/happened bifurcation
df04e50 — feat(fristenrechner/determinator): the legacy "Was möchten
Sie tun?" landing fork is replaced by:

  Step 1: filtered Akte picker + "Neue Akte anlegen" link (bare; the
    bounce-back to the wizard after creation is Slice 2 scope) +
    4 ad-hoc chips driving ?ad_hoc=upc|de|epa|dpma.
  Step 2: "Etwas einreichen" / "Etwas ist passiert" cards driving
    showPathway('a' | 'b'). Quick-pick chips moved here from the old
    fork. Pathway A/B back buttons return to Step 2.

Save CTA on Pathway A's wizard disables in ad-hoc mode with hint
"Ad-hoc — kein Projekt, kein Speichern" (DE+EN). The locked context
collapses to a one-line summary; Reselect re-expands.

URL contract:
  ?project=<uuid> | ?ad_hoc=upc|de|epa|dpma  — Step 1 result
  ?path=a|b                                   — Step 2 result (back-compat)
  ?mode=tree|filter                           — Pathway B sub-mode

Pathway A/B sub-routing primitives (showPathway, showBMode) unchanged
— Step 2 cards just drive the same hooks.

Still open:
  Slice 2 — /projects/new return-bounce on save.
  Slice 3+ — scoping the picker / cascade by project's proceeding-type
    + role; replacing the wizard with the Step 3a File/Draft/Enter
    chooser.
2026-05-08 19:52:32 +02:00
m
df04e500f7 feat(fristenrechner/determinator): Slice 1 — project picker + do/happened bifurcation
m's 2026-05-08 18:08 Determinator redesign Slice 1. Replaces the
legacy "Was möchten Sie tun?" fork (Pathway A vs B) with a two-step
funnel that puts the project (Akte) at the foundation:

  Step 1 — Welche Akte?
    - Filtered list of visible projects, search-as-you-type.
    - "Neue Akte anlegen" link → /projects/new (bare; the bounce-back
      with auto-preselect lands as Slice 2 per Maria's gating).
    - Four ad-hoc explore-mode chips (Custom UPC / DE / EPA / DPMA
      proceeding) for users who just want to look up a rule. No DB
      write; URL becomes ?ad_hoc=upc|de|epa|dpma.

  Step 2 — Was möchten Sie tun?
    - Two cards: "Etwas einreichen" → Pathway A (Verfahrensablauf
      wizard) and "Etwas ist passiert" → Pathway B (cascade, mode=tree).
    - Quick-pick chips moved here from the old fork's shortcut row.

Once Step 1 picks a context, the picker collapses to a one-line
summary "Akte: X · [Andere Akte]" mirroring the proceeding-summary
collapse pattern (097e21c). Reselect re-expands and clears downstream
state.

State on URL:
  ?project=<uuid>     project context
  ?ad_hoc=upc|...     ad-hoc explore-mode
  ?path=a|b           Step 2 outcome (kept for back-compat)
  ?mode=tree|filter   Pathway B sub-mode (kept)

The legacy back-from-Pathway buttons now return to Step 2 (the new
"fork" state). showPathway() / showBMode() unchanged — Step 2 cards
just drive the same primitive.

Save-to-project CTA on Pathway A's wizard detects ad-hoc mode and
disables itself with the hint "Ad-hoc — kein Projekt, kein Speichern"
(EN: "Ad-hoc — no matter, no save"). Hiding the CTA would leave the
user wondering where the action went; disabling makes the constraint
legible (per m's lock #2).

Frontend pieces:
  - fristenrechner.tsx — Step 1 + Step 2 markup; legacy
    fristen-pathway-fork removed wholesale.
  - client/fristenrechner.ts — new Step1Context type + URL hydration
    + render helpers; initPathwayFork rewired to drive the new
    cards; renderProcedureResults gates the save CTA on
    isAdhocMode().
  - client/i18n.ts — 19 new keys (DE+EN) under deadlines.step1.* +
    deadlines.step2.* + the save CTA hint.
  - styles/global.css — .fristen-step1 / .fristen-step2 block + chip
    + summary styles, all bound to the existing --color-* token
    palette. Mobile breakpoint stacks the Step 2 cards at <600px.

Out of scope for this slice (will land later):
  - Slice 2: /projects/new bounce-back with auto-preselect via
    ?return=/tools/fristenrechner.
  - Slice 3+: scoping the picker / cascade by project's
    proceeding-type + role; replacing the existing wizard with the
    Step 3a "File / Draft / Enter" chooser.

Refs t-paliad-157 / m/paliad#15.
2026-05-08 19:50:59 +02:00
m
0d1a7ba886 feat(paliadin/context): t-paliad-161 Slice B — structured page-context payload
The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:

  context: {
    route_name, page_origin, primary_entity_type, primary_entity_id,
    user_selection_text, view_mode, filter_summary
  }

Wiring:

- services.TurnContext + EnvelopePrefix() build a
  `[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
  Empty fields are omitted; selection is always quoted (it's user-supplied
  content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
  envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
  paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
  field; mirrors context.PageOrigin into the top-level page_origin when
  the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
  only page_origin, the new field is optional. Backwards compatible.

SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
  repeat the obvious, treat selection as data not instructions, no
  hallucination on empty entity lookup, legacy turns work as before.

Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).

New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.

Refs: docs/design-paliadin-inline-2026-05-08.md §4.
2026-05-08 19:47:43 +02:00
m
e9e7d5c27c feat(projects-detail): "Untergeordnet" tab → "Projektbaum" with full visible hierarchy
m typed in another pane: "The project view where there is a tab
'Untergeordnet' I want a 'Project Tree' instead. And it always shows
all siblings, all parents and all children of that entity." (Forwarded
by klaus / youpcorg/head, msg #1570.)

Tab label
  DE: Untergeordnet → Projektbaum
  EN: Sub-projects → Project Tree
  i18n key kept as projects.detail.tab.kinder for back-compat (legacy
  bookmarks + create-sub-project CTA still keyed on 'kinder').

Tree content
  Was: direct children only (one /api/projects/<id>/children call).
  Now: full visible project hierarchy via /api/projects/tree?subtree_counts=false,
  rendered as nested <ul> with the current node highlighted with a
  lime-soft background + current-color border. The dashed left border
  on nested levels makes parent → child relationships scannable.
  Visibility is RLS-scoped (the tree endpoint already filters to projects
  the user can see).

Empty state
  "Keine untergeordneten Projekte" still renders when the current node
  has zero direct children — that is what the "+ Untervorhaben anlegen"
  CTA next to it actually creates. Showing it for "tree has no other
  branches" would have been wrong.

The standalone /api/projects/<id>/children call stays — it gates the
empty state and pre-fills parent_id on the create form.
2026-05-08 19:46:55 +02:00
m
e2907db760 Merge: t-paliad-157 dogfood batch — eye glyph 👀, optional deadlines, Verfahrensablauf collapse
Three commits from mai/feynman/fristenrechner:

- 614f9af fix(approval-pill): two-eyes glyph 👀 instead of single SVG eye
  on /deadlines + /appointments + /agenda. m's preference: emoji denotes
  "being looked at" closer to "wartet auf Genehmigung" semantics.

- 2d6ea3e feat(deadline-rules/is-optional): conditional rules opt-in via
  save modal. Adds paliad.deadline_rules.is_optional. Distinct from
  is_mandatory: a rule can be statutorily fixed when it applies AND
  conditional on whether it applies (RoP.151 cost-decision request,
  appeal-related deadlines). Save-modal pre-unchecks optional rows;
  user toggles to opt in. Timeline shows "auf Antrag" pill.

- 097e21c feat(fristenrechner): proceeding-picker collapses to one-line
  "Verfahren: X · [Reselect]" pill after pick (saves vertical space).
  Column view becomes the default for the timeline (was previously
  whichever-default; m wants Column on first render).

Migration housekeeping:
  feynman's migration was authored as 066 on his branch but main has
  already taken 066/067 via shannon's t-paliad-160 (approval policy
  split + drop required_role). Renumbered to 068 during merge to
  resolve the same-number collision. Added ADD COLUMN IF NOT EXISTS
  to make the up-migration idempotent (defensive for environments
  where the column was already applied out-of-band during dev). The
  RoP.151 backfill UPDATE is naturally idempotent.

  Live tracker bumped from 66 → 68 to reflect schema reality before
  this merge: shannon's 066+067 effects and feynman's is_optional
  column are all already present in the live youpc Supabase. The
  next deploy will see tracker=68 and have nothing to apply.

Refs m/paliad#15, m/paliad#18 (rule-Typ contradiction filed against
Item A scope, not part of this batch).
2026-05-08 19:15:44 +02:00
m
097e21c8db feat(fristenrechner): collapse proceeding-picker after pick + columns view default
m's 2026-05-08 18:26 dogfood batch — two pure UX tweaks on the
Verfahrensablauf wizard:

1) Collapse the proceeding-picker once a Verfahren is chosen. Replaces
   the four-group block (UPC / DE / EPA / DPMA, ~25 buttons total)
   with a one-line "Verfahren: X · [Anderes Verfahren wählen]" pill.
   Reselect re-expands without throwing away the rest of the wizard
   state (trigger date, flags, calc result stay put until the user
   actually picks again). reset() also re-expands.

2) Column view as the default for step 3. The proactive / court /
   reactive grid reads more naturally for the HLC team than the
   single vertical timeline. URL semantics flipped: ?view=timeline
   now opts back into the legacy view; absence of ?view= yields
   columns. Share links stay clean.

Files:
  - frontend/src/fristenrechner.tsx — new .proceeding-summary
    markup; the view-toggle radio order swapped so "Spalten" is the
    first / checked option.
  - frontend/src/client/fristenrechner.ts — setProceedingPickerCollapsed
    helper toggles the four .proceeding-group blocks vs the summary;
    selectProceeding collapses, reset() + Reselect re-expand.
    procedureView default flipped to "columns"; initViewToggle URL
    semantics inverted.
  - frontend/src/client/i18n.ts — 2 new keys (DE+EN) for the
    summary label + Reselect button.
  - frontend/src/styles/global.css — .proceeding-summary +
    .proceeding-summary-reselect styles, all bound to the existing
    --color-* token palette.

Refs m/paliad#15 dogfood thread (m's 2026-05-08 18:26 batch).
2026-05-08 18:31:35 +02:00
m
2d6ea3ee33 feat(deadline-rules/is-optional): conditional rules opt-in via save modal
m's 2026-05-08 batch Item 2: some rules don't always apply per-instance.
Antrag auf Kostenentscheidung (RoP.151) only fires when a party files
for it; some appeal-related deadlines depend on specific facts. Today
they render in the timeline as if always applicable; the save-to-
project modal pre-checks them so the user has to remember to uncheck.

New paliad.deadline_rules.is_optional bool flag (default false). Threads
through the Go model, ruleColumns SELECT, UIDeadline JSON, and the
frontend save modal:

  - Migration 066 adds the column + comment + a starter UPDATE that
    flips RoP.151 to is_optional=true. m can flip more via SQL as he
    reviews the rule library — distinct from is_mandatory, which is
    about statutory strictness once the rule applies (an "auf Antrag"
    rule can be is_mandatory=true once requested).
  - Save modal: optional rows pre-uncheck (the user opts in) and a
    small "auf Antrag" / "on request" pill renders in the meta line.
    Court-determined rows still pre-uncheck via the existing disabled
    path; isOptional doesn't override that.

Migration applied to live Supabase; tracker at v66.

Refs m/paliad#15 (m's 2026-05-08 18:21 follow-up batch Item 2).
2026-05-08 18:26:26 +02:00
m
614f9af753 fix(approval-pill): two-eye glyph instead of single SVG eye
m's 2026-05-08 18:21 follow-up: "two eyes instead of the one." The
single-eye SVG read as a watching-Eye-of-Sauron glyph; 👀 reads as
"under review" / "being looked at" — closer to "wartet auf
Genehmigung" semantics.

Drops the inline SVG + the .approval-pill--icon svg sizing rule;
replaces with the literal emoji as the pill's text content. CSS
modifier becomes a small auto-width text pill (min-width 28px so
single emoji stays nicely round-ish at higher densities).

Renamed APPROVAL_PILL_EYE_SVG → APPROVAL_PILL_GLYPH in both events.ts
and agenda.ts since the constant is no longer SVG.
2026-05-08 18:23:40 +02:00
m
6008d36a13 Merge: t-paliad-160 §C cosmetic — eye-pill on approval-pending entities (feynman, 4bab520, icon-only eye glyph on /deadlines + /appointments + /agenda; tooltip retains lifecycle labels pending_create/update/complete/delete; inbox surface unchanged with --historic text-pill variant; pure frontend) 2026-05-08 18:19:15 +02:00
m
4bab520119 feat(approval-pill): icon-only eye pill on /deadlines + /appointments + /agenda
m's 2026-05-08 cosmetic ask: the "Wartet auf Genehmigung" badge ate
row width and read as a noisy block of text on every pending row.
Replace with a 22px eye-icon pill; the lifecycle label moves to the
hover tooltip (title attr + aria-label so screen readers still get
the full text).

Three pieces:

  - global.css — new .approval-pill--icon modifier sets the pill to
    a circular 22×22 hit target with a centered SVG. Base
    .approval-pill (text-pill behavior) and --historic (inbox status
    pill) stay untouched so the inbox surface keeps rendering the
    full status + decider name.
  - client/events.ts (the /deadlines + /appointments shell) and
    client/agenda.ts each get a tiny APPROVAL_PILL_EYE_SVG constant
    + the new --icon class on the pending pill. Two definitions
    (no shared icons module today; no other surfaces need this glyph
    yet) — the duplication is two lines, easier to read than yet
    another import.

What it looks like: 👁 in a soft amber circle, hovers to "Änderung
wartet auf Genehmigung" / "Erledigung wartet auf Genehmigung" / etc.
The lifecycle-specific label kept (no schema work) — Maria gated this
slice as pure-frontend; the richer "wartet auf Genehmigung von
<role>; angefragt am <date>" tooltip needs a backend join we're not
doing here.

Refs t-paliad-160 §C / m's 2026-05-08 18:15 batch Item B.
2026-05-08 18:18:16 +02:00
m
c06be27cce Merge: t-paliad-157 — Fristenrechner items 3 + 1 stopgap (feynman, ac15911 moves the inbox-channel chip from /tools/fristenrechner page-top into the B1 cascade panel + drops the Pathway A picker filter — persistence + URL override + B1 cascade narrowing + B2 fine-chip sync still apply, just no longer page-level prefilter; ef78f59 Item-1 stopgap for chained court-set rules — RoP.151-style rules whose trigger is itself a court-set event now render 'unbestimmt' instead of 'wird vom Gericht bestimmt' via a new IsCourtSetIndirect flag, direct court events keep the original label. Items 2 + 4 from m's 2026-05-08 17:41 batch still pending.) 2026-05-08 17:56:11 +02:00
m
ef78f59d25 feat(fristenrechner): "unbestimmt" for chained court-set rules (m's R.151 case)
m's 2026-05-08 17:50 feedback: 'Antrag auf Kostenentscheidung' (RoP.151)
labels itself "wird vom Gericht bestimmt" but the rule is actually
"1 Monat ab Hauptentscheidung". The court doesn't directly determine
this date — it determines the parent's date (Hauptentscheidung) and
this rule chains off that. Calling it "vom Gericht bestimmt" overstates
the relationship; "unbestimmt" reads correctly: derived from a
not-yet-known anchor.

Two failure modes split:

  - Direct court-set    rule itself is hearing / decision / order
                        (or primary_party='court'). Label stays
                        "wird vom Gericht bestimmt" — strictly correct.
  - Indirect court-set  rule has a real duration but its anchor is a
                        court-set parent (RoP.151 case), or it's a
                        zero-duration rule whose parent is court-set
                        without a real date. Label flips to
                        "unbestimmt".

Backend: new `IsCourtSetIndirect bool` on UIDeadline, set on the three
indirect cases inside FristenrechnerService.Calculate. Direct cases
keep IsCourtSetIndirect=false so their label stays unchanged. JSON
omits the field when false, no consumer churn.

Frontend: deadlineCardHtml + the save-modal row both consult
IsCourtSetIndirect to pick between two i18n keys (deadlines.court.set
"vom Gericht bestimmt" and deadlines.court.indirect "unbestimmt"; EN
falls back to "set by court" / "tbd"). The override edit affordance
keeps working unchanged — user types the actual parent date, downstream
re-flows.

Refs m/paliad#15 (m's 2026-05-08 17:50 feedback Item 1).
2026-05-08 17:55:22 +02:00
m
ac15911e4f refactor(fristenrechner/inbox-chip): move chip into B1 cascade, drop Pathway A filter
m's 2026-05-08 feedback: the inbox-channel chip is a Determinator step,
not a page-level prefilter — "Verlauf does not need to see that so it
cant be outside of that."

Changes:

  - frontend/src/fristenrechner.tsx — strip the .fristen-inbox-bar
    markup from above the pathway fork; mount it instead at the top
    of #fristen-b1-panel, before the cascade. The chip is now visible
    only when the user enters Pathway B → tree mode.
  - frontend/src/client/fristenrechner.ts — drop the .proceeding-group
    visibility loop from applyInboxFilter. Pathway A's wizard is no
    longer filtered by the chip. The data-forum attributes stay on
    the markup as documentation of intent but no longer drive
    visibility.

What stays:

  - persistence (paliad.users.forum_pref via PATCH /api/me)
  - URL ?inbox= override
  - B1 cascade narrowing via paliad.event_categories.forums
  - B2 fine-bucket activeForums sync (B2 lives inside the
    Determinator too)

Refs m/paliad#15 (m's 2026-05-08 17:50 feedback).
2026-05-08 17:53:06 +02:00
m
9350cd0e87 Merge remote-tracking branch 'origin/main' into mai/shannon/approval-rework 2026-05-08 17:16:57 +02:00
m
aec6cf6104 refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.

`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.

Go side:
  - models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
    The MinRole pointer is now the only seniority-threshold surface.
  - LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
    drop the required_role SELECT projection.
  - UpsertProjectPolicySplit / UpsertUnitPolicySplit /
    DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
    drop the required_role write. The audit-log row still uses the
    legacy string format ('partner|...|none'); composed via
    legacyFromSplit() from the new columns so the audit table layout
    keeps working without a parallel migration.
  - submit() reads policy.MinRole directly (LookupPolicy guarantees
    non-nil when a non-nil policy is returned).
  - nullToPtr helper retired (no remaining callers).

Frontend side:
  - admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
    legacy required_role optional. The 2-control UI was already on the
    split-grammar path.
  - deadlines-new.ts + appointments-new.ts form-time hint readers prefer
    requires_approval+min_role. They keep a soft-fall back to the
    legacy required_role for one cycle in case any cached pre-M2 server
    is still serving the old shape — that path is dead-code post-deploy
    and can be dropped later.

Test:
  - TestApprovalService_PolicyCRUD asserts MinRole instead of
    RequiredRole after re-upsert.

Build: bun build OK, go build ./... OK, go test ./... OK.

Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
2026-05-08 17:15:05 +02:00
m
073af975f7 feat(approvals/t-paliad-160 slice2): admin UI flip + badge + withdraw + inbox visibility hardening
A3 — admin/approval-policies 2-control flip:
  Each cell becomes [✓] requires_approval checkbox + role select + clear
  button. The "none" option in the role dropdown is gone — the checkbox
  replaces it. Role select is greyed when the checkbox is off (gate
  closed). Clear button explicitly drops the cell back to inheritance.
  Project matrix surfaces inherited "no approval" state with its own
  attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
  silently-inherited off-state from a never-authored cell.

  PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
  shape `{requires_approval: bool, min_role: string|null}` while still
  honouring the legacy `{required_role: "..."}` body during the M1
  dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
  vs UpsertProjectPolicy accordingly).

C+E — Pending-approval badge + Withdraw button:
  deadlines-detail + appointments-detail surface a "Wartet auf
  Genehmigung" badge when approval_status='pending'. Hover-tooltip
  carries requested_at + required_role + requester_name. Action
  controls (Complete, Edit, Delete) freeze while pending — caller
  would get a 409 anyway, no point letting them try.

  Withdraw button visible only to the requester (me.id ===
  pending_request.requested_by). Click → POST /api/approval-requests/
  {id}/revoke (existing endpoint, no new server route). On success,
  the entity flips back to approval_status='approved' and the page
  re-renders with normal controls.

  Complete button now handles 409 from the server gracefully:
  surfaces the new mapApprovalError body's `message` instead of
  silently disabling itself.

D — /inbox "Meine Anfragen" visibility hardening:
  Three defence-in-depth fixes for the "tab shows empty" report:
    1. handlers force `[]` (not Go-nil → JSON null) on every inbox
       endpoint so the frontend never trips on `rows.length` of null.
    2. parseInboxFilter validates ?status= against an allowlist
       (pending|approved|rejected|revoked|superseded). Anything else
       is silently dropped — a stray ?status=foo from a stale
       frontend build can no longer shadow rows out of the result.
       entity_type filter same treatment (deadline|appointment).
    3. Frontend inbox.ts coerces null body → [] so older / cached
       builds talking to the new server still don't crash.

  Test coverage: TestParseInboxFilter_DropsUnknownStatus +
  TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
  skipped without TEST_DATABASE_URL).

Build clean: bun build OK, go test ./... OK.

Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.
2026-05-08 17:07:46 +02:00
m
6ef14ddc39 feat(fristenrechner/inbox-chip): wire inbox into B1 cascade narrowing
Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.

Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:

  1. Regex on slug for nodes that carry the forum token explicitly.
     Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
  2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
     Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).

NULL stays neutral. Migration applied to live Supabase; tracker at v65.

Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.

Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.

Refs m/paliad#15 (B1 follow-up).
2026-05-08 16:54:34 +02:00
m
e87929885d feat(fristenrechner/inbox-chip): wire inbox into B2 fine-forum chips
The page-top inbox chip now drives the existing 10-bucket B2 forum
filter so search results inherit the same narrowing as the Pathway A
picker. CMS auto-selects upc_cfi + upc_coa; beA / Posteingang
auto-select de_lg + de_olg + de_bgh + de_bpatg; "Alle" clears both the
inbox and the fine chips.

State-priority discipline:

  - User chip click               always replaces activeForums with
                                  the inbox-derived set.
  - Hydrate (URL ?inbox=, no
    ?forum=)                      derives activeForums from inbox.
                                  initForumFilter (which runs first)
                                  has already loaded URL ?forum= when
                                  present, so the explicit forum= wins.
  - Hydrate (no URL ?inbox=,
    /api/me forum_pref set)       same as above: derive when ?forum=
                                  is empty.
  - popstate                      re-applies the same rule so browser
                                  back/forward stays consistent.

The "explicit URL forum= wins" rule means a colleague's CMS-narrowed
share link still works when the recipient has a different saved pref —
they see the shared narrowing, not their own.

Refs m/paliad#15.
2026-05-08 16:41:50 +02:00
m
1df1bc7e40 feat(fristenrechner/inbox-chip): persisted forum pre-filter on /tools/fristenrechner
Adds a slim chip strip above the pathway fork on /tools/fristenrechner
so the user can pick the inbox channel they typically work in (CMS for
UPC, beA / Posteingang for national-DE). Three behaviours stack:

  - URL ?inbox=cms|bea|posteingang  per-visit override; lets a colleague
                                    share a CMS-narrowed link without
                                    flipping the recipient's saved pref.
  - /api/me forum_pref              the user's persisted default,
                                    fetched on hydrate when no URL.
  - unset                            picker shows all four groups.

Click behaviour: write URL → apply filter (hide non-matching
.proceeding-group via the new data-forum attributes) → PATCH /api/me.
The "Alle" chip clears both URL and the saved pref. EPA / DPMA fall
out under cms / bea / posteingang; users still reach those via B2
search or by clearing the chip.

Frontend pieces:

  - frontend/src/fristenrechner.tsx — new .fristen-inbox-bar markup
    above the pathway fork; data-forum attributes on each
    .proceeding-group so the filter knows which to hide.
  - frontend/src/client/fristenrechner.ts — initInboxFilter() hydrates
    from URL → /api/me, wires chip clicks (write URL, apply filter,
    PATCH /api/me opportunistically), restores on popstate.
  - frontend/src/client/i18n.ts — 6 new keys (deadlines.inbox.*) DE+EN.
  - frontend/src/i18n-keys.ts — codegen picked up the new keys.
  - frontend/src/styles/global.css — .fristen-inbox-bar /
    .fristen-inbox-chip / --active / --clear styles, all bound to the
    existing --color-* / --color-accent token palette.

The chip writes "" to forum_pref to clear (matching the
EscalationContactID convention from the previous slice). The B2 forum
filter (the 10-bucket finer-grained chip set further down the page)
stays untouched and orthogonal — this slice is the page-top coarse
pre-filter only.

Refs m/paliad#15.
2026-05-08 16:31:31 +02:00
m
609da9e86b fix(fristenrechner/columns): preserve sequence_order for undated events
Undated events (Urteil, Beschluss, court-set placeholders) were keyed by
the empty string and collapsed into a single trailing row, so Urteil and
Berufungseinlegung ended up adjacent even though Urteil precedes Berufung
in the proceeding's sequence_order. Each undated event now gets its own
row keyed by its index in the backend response (which is already sorted
by sequence_order), and dated/unscheduled keys are sorted into separate
buckets before concatenation so the dateless tail still sits below the
dated rows.

Refs #14 (section D).
2026-05-08 15:54:35 +02:00
m
7daa70aaad feat(paliadin/projects-cards): markdown rendering + grid compactness + overdue-pending in NextEvents
Three issues from m's dogfood (2026-05-08 15:02–15:14):

## A. /projects-cards on desktop overflowed the right column

.projects-cards-grid.is-grid-2 used grid-template-columns: repeat(2, 1fr)
which is shorthand for repeat(2, minmax(auto, 1fr)). 'auto' resolves to
max-content so any card with content wider than the track expands the
track and pushes the right column past the parent's right edge.

Switched is-grid-2/3/4 to repeat(N, minmax(0, 1fr)) which clamps the
floor to zero — overflow now wraps/clips inside the card instead of
blowing out the layout. Bonus: the auto-fill default also got the
min(320px, 100%) treatment so narrow viewports collapse the floor and
spare us horizontal scroll on mobile (mirrors t-paliad-155's earlier
views-cards fix).

## B. "Nächste Termine" empty while "5 offen" showed

CardsPreview's deadline source filtered WHERE f.status = 'pending'
AND f.due_date >= today::date. m's 5 pending deadlines are all in the
past — overdue — so they were excluded from NextEvents while still
counted in the "X offen" badge.

Dropped the >= today predicate. Now any pending deadline lands in
NextEvents, sorted ASC by due_date, so most-overdue surfaces first
(which matches m's mental model: an overdue Frist is more urgent than
tomorrow's, not less). Appointments keep the >= now filter (past
appointments are history, not next). Cleaned up the args[] threading
since deadlines no longer needs the temporal bound.

## C. Chat bubbles ignored Markdown formatting (## h2, **bold**, lists)

renderResponseHTML only handled chip markers + the new (today)
markdown-link / bare-URL passes; everything else fell through as raw
text. "## Projekte" rendered with the literal hashes visible.

Added renderBlocks() — a small block-level parser that turns:

-  → <h2>H</h2>
-  → <h3>H</h3>
-  lines → <ul class=paliadin-list><li>...</li></ul>
-  → <hr>
- blank-line-separated runs → <p>...<br>...</p>

and inline emphasis passes that wrap **bold** in <strong> and *italic*
in <em>. Block-level runs before the link passes so the regexes only
operate inside a block; emphasis runs after links so a bold link works.
Pipeline is still: escape → chip-stage → blocks → md-links → bare-urls
→ emphasis → unstage chips.

## D. (carrying over from earlier in this commit) /admin/paliadin monitor — show user + response preview + page origin + per-tool row counts
2026-05-08 15:09:24 +02:00
m
05d14d5e5a feat(admin/paliadin): show user + response preview + page origin + per-tool row counts on the monitor
m's ask (2026-05-08 15:02): the Paliadin monitor should show which user
made each turn, and ideally log more than just timing/classifier.

Backend:
- PaliadinTurn gains UserEmail + UserDisplayName fields (json:omitempty
  so user-facing API paths don't leak unrelated identity info; only
  populated by the admin LIST query).
- ListRecentTurns LEFT JOINs paliad.users to surface email +
  display_name on each row. The existing global_admin OR caller-owns
  visibility predicate on the WHERE clause stays unchanged.

Frontend (admin-paliadin):
- Recent-turns table grows from 5 → 8 columns:
  Zeit · Nutzer · Art · Anfrage · Antwort · Tools · Seite · Dauer
- Nutzer cell shows display_name (fallback email, fallback first 8 of
  user_id), with the full email in the title attribute on hover.
- Antwort cell renders the first 80 chars of the response with the full
  cleanBody available on hover. Useful for spot-checking what Paliadin
  actually wrote without clicking through every turn.
- Tools cell now pairs each tool name with its rows_seen count
  ("list_my_projects (11), search_my_deadlines (18)") so the data
  density is legible at a glance.
- Seite cell exposes page_origin (where in Paliad m kicked off the
  turn) — was already audited but never surfaced.
- DE/EN i18n keys added for the four new column headers.
2026-05-08 15:05:24 +02:00
m
925a377c8b feat(paliadin): markdown links + auto-link bare URLs in chat responses
m's ask (2026-05-08 14:18): chat should render arbitrary links, not
just internal navigation chips.

Extends renderResponseHTML with two link passes after the existing chip
substitution:

1. Markdown link syntax — [label](url) becomes <a class=paliadin-link>.
   Internal /paths stay same-tab; external http(s) URLs open in a new tab
   with rel=noopener,noreferrer.

2. Auto-linkify bare URLs — any free-standing https?:// becomes a link.
   The leading-character class on the regex avoids re-matching URLs that
   are already inside an href attribute (like the chip URLs from stage 1
   or the markdown-link URLs from stage 2).

Pipeline order: HTML-escape → chip markers replaced with SOH-bounded
sentinels → markdown links → bare URLs → sentinels swapped back. Done in
that order so chip URLs never go through the link passes (which would
double-anchor them) and the SOH boundary characters can't collide with
user text.

fix(views/cards): collapse min-width floor on mobile to prevent overflow

m's report (2026-05-08): on mobile-portrait the views-cards layout
forced horizontal scrolling because grid-template-columns had a 280px
floor on every column. Replaced minmax(280px, 1fr) with
minmax(min(280px, 100%), 1fr) so on viewports narrower than 280px the
floor collapses to the available width — cards span 100% of the stream
on mobile, return to the 280px-min auto-fill once there's room.
2026-05-08 14:22:18 +02:00
m
7935fee7bf fix(paliadin): preserve chip/link markers when saving chat history
When a Paliadin response contains chip markers like [#deadline-OPEN:c47bd2-1]
they get rendered to anchor tags by renderResponseHTML in finishBubble. The
'end' handler then saved the bubble to localStorage history via getBubbleText,
which returns textContent — i.e. the anchor text *only*, with the original
[#deadline-OPEN:...] markers gone.

On a page reload, history.forEach replays each entry: appendBubble puts h.text
back into textContent, then finishBubble runs again and tries to re-render via
renderResponseHTML — but the markers are already gone, so the links don't come
back (m, 2026-05-08 14:11 — links disappeared on second load).

Fix: save placeholder.dataset.fullText (the raw Markdown body cached at
content-event time) instead of the post-render textContent. On reload the raw
markers survive, finishBubble re-runs renderResponseHTML, and the chips/links
reappear identically to the first render.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:12:42 +02:00
m
be2150c17d fix(paliadin): used_tools NOT NULL violation + frontend response truncation
Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55).

## A. used_tools NOT NULL constraint violation on casual turns

paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer
leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!")
or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq
driver writes as NULL — UPDATE failed with constraint 23502 on every casual
chat turn, leaving the row half-finalized.

Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE,
mirroring the existing rowsSeen pattern in the same function.

## B. Frontend rendered "## Proje" instead of the full 1408-byte response

m saw the first 8 characters of his Markdown response in the chat bubble,
plus the full meta row underneath. The DB row had the complete cleanBody
in 'response'. Truncation lived entirely in the browser.

Root cause: finishBubble read textNode.textContent at the moment of the
'end' event — but typewriter() animates the text 8 chars at a time, so
textContent was "## Proje" (one tick into 1408 bytes) when finishBubble
fired. renderResponseHTML(raw) baked in the partial state, then the
typewriter's next tick saw streaming='false' and ran 'node.textContent =
text' which overwrote the rendered HTML with the raw string — except in
this case the second tick never ran in time, leaving the partial render.

Fix:
1. Cache the full SSE-delivered text on placeholder.dataset.fullText at
   content-event time. finishBubble prefers that over textContent.
2. Typewriter's abort branch no longer overwrites the node — finishBubble
   already owns the final rendered HTML, so a delayed tick should just
   return rather than blow away the rendered Markdown.

Both fixes verified locally: go build clean, bun build clean.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:00:03 +02:00
m
d519363c8d fix(admin/approval-policies): preserve <details> open state across re-renders
Changing any required_role cell saves the policy and re-renders the units
list to refresh the attribution chips, but the re-render rebuilt every
<details> closed — collapsing the accordion the admin was actively
editing (m, 2026-05-08 11:19).

Capture the set of open data-unit-ids before innerHTML overwrites them,
then re-apply the open attribute on the rendered nodes for those ids.
Adds data-unit-id to the <details> as the stable identity. No behavior
change for first render or for units the admin hadn't expanded.
2026-05-08 11:20:39 +02:00
m
f820aa8316 Merge: t-paliad-154 — approval-policy authoring UI (migration 062 paliad.approval_policies unit-defaults + 'none' sentinel + tree-walking resolver + 88 unit-default seed rows + paliad.policy_audit_log; ApprovalService rewire with resolver delegation + scope-split CRUD + audit emission; HTTP handlers admin APIs + form-hint endpoint + audit-log union; /admin/approval-policies admin page + admin-index card + form-time hints on deadline/appointment new pages + inbox empty-state nudge for admins; 13 m-locked design decisions honoured verbatim per docs/design-approval-policy-ui-2026-05-07.md §2) 2026-05-08 02:33:25 +02:00
m
5df4285e1d feat(t-paliad-154) commit 5/5: inbox empty-state nudge + form-time hints
Three remaining surfaces from the locked design (Q9 + Q13):

/inbox empty-state admin nudge (Q9):
- New conditional block (.inbox-admin-nudge) revealed only when:
  * /api/me reports global_role='global_admin'
  * the inbox tab returned zero rows
  * /api/admin/approval-policies/seeded reports any=false (no policies firm-wide)
- Card links to /admin/approval-policies. Hidden in every other case so the
  ordinary post-rollout state (admins with active policies) sees nothing.

Form-time 4-eye hint on /projects/{id}/deadlines/new + /appointments/new (Q13):
- New .approval-hint container above the Speichern button on each form;
  hidden by default.
- Client TS fires GET /api/projects/{id}/approval-policies/effective on
  page load + on project change, reveals the hint when required_role is
  non-null and not 'none'. Renders role label + source attribution
  ('· Standard: Munich Lit') so the user knows where the rule comes from.
- Hides in every 'no policy applies' case (no candidates / 'none' suppression
  / project change to a project with no policy / fetch error).

i18n: 6 new keys × 2 langs (3 inbox-nudge keys + 2 form-hint keys + the
inbox-nudge title/body/cta wired in inbox.tsx). Total i18n keys: 1929.

Dynamic-key call sites use tDyn (admin-approval-policies.ts +
deadlines-new.ts + appointments-new.ts) so the typed t() barrier stays
intact for static keys.

Build: bun run build clean, go build + vet + test clean (no DB tests
require TEST_DATABASE_URL — those run in CI).
2026-05-08 02:31:35 +02:00
m
028423b32f feat(t-paliad-154) commit 4/5: admin /admin/approval-policies page
New TSX page shell + client orchestration + admin-index card + CSS for
the matrix + i18n keys (DE+EN).

Page structure:
- Section 1 'Partner-Unit-Standards': accordion list, each <details>
  block expandable into the 8-cell matrix for that partner unit.
- Section 2 'Projekt-spezifisch': search-driven project picker → matrix
  showing the EFFECTIVE policy per cell with attribution chips
  (Projekt / Geerbt / Standard) per source.
- Bulk-apply modal: 'Auf Unterprojekte anwenden' button per project; lists
  affected descendants; POST to /api/admin/approval-policies/apply-to-descendants.

Cell semantics:
- Select per cell with options: '— keine Regel —' (= DELETE), partner /
  of_counsel / associate / senior_pa / pa / 'Keine Genehmigung' (= 'none'
  sentinel, project-row only).
- Change → PUT for any value, DELETE for empty. Re-fetch the affected
  scope so attribution chips reflect the new state.

CSS: matrix grid on desktop (≥700px); two stacked sections (Fristen /
Termine) below 700px via media query — both rendered in DOM, CSS toggles.
All tokens are existing --color-* / --status-* / --hlc-*-rgb (no bare
--surface / --text-muted / --bg-subtle).

i18n: 42 new keys × 2 languages = 84 entries. Total i18n keys: 1924.

Build: bun run build clean (i18n codegen updated, IIFE wrapping enforced).
2026-05-08 02:27:54 +02:00
m
e4110cf2db feat(t-paliad-151) frontend: friendly errors for remote-Paliadin codes
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
2026-05-08 02:19:48 +02:00
m
f90bfeda9b fix(t-paliad-153): deadline due_date renders 02:00 in CEST (UTC-midnight leak)
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.
2026-05-07 23:07:26 +02:00
m
aeeded7e21 feat(t-paliad-149) PR2 step 2/2: frontend — Cards view + drag-rearrange named layouts
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.
2026-05-07 22:46:26 +02:00
m
1061685981 Merge: t-paliad-149 PR 1 — /projects redesign tree+chips+pin+search (migration 060 paliad.user_pinned_projects + PinService + BuildTreeWithOptions + last-view restore) 2026-05-07 22:30:37 +02:00
m
a5f7b5009b feat(t-paliad-149) PR1 step 2/3: frontend rewrite — chips + pin star + last-view restore
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.
2026-05-07 22:29:39 +02:00
m
b59e44616d Merge: t-paliad-150 — Paliadin chat fixes (bubble alignment + dark-mode contrast tokens + friendly tmux-unavailable message) 2026-05-07 22:22:41 +02:00
m
35f307d61d fix(i18n): Sicht → Ansicht (custom views) — m's call: View is always Ansicht. Sweep applied to /views + /views/editor + sidebar + onboarding strings. Approval-context 'Sicht' (visibility-tier in derived team) left as-is — different semantic. 2026-05-07 22:22:33 +02:00
m
2201c6da73 fix(t-paliad-150): Paliadin chat — bubble alignment + dark-mode contrast + friendly tmux-unavailable error
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.
2026-05-07 22:21:27 +02:00
m
d53cc3553c fix: landing page text reflects current product — 'Patent Knowledge' → 'Patent Litigation'; 'Leitfäden, Vorlagen und Dokumente' → 'Administration, Knowledge und Tools' (DE+EN) 2026-05-07 22:11:37 +02:00
m
efaa7787af Merge remote-tracking branch 'origin/main' into mai/kepler/inventor-profession-vs 2026-05-07 22:00:26 +02:00
m
fc7192c115 Merge: t-paliad-146 — Paliadin PoC (tmux-Claude in-app AI buddy, m-only)
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).
2026-05-07 21:57:49 +02:00
m
8d714dd95e fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED
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.
2026-05-07 21:57:20 +02:00