Commit Graph

667 Commits

Author SHA1 Message Date
m
afd3aab2b2 feat(t-paliad-171): SmartTimeline backend skeleton — ProjectionService + /timeline endpoint
Slice 1 of the SmartTimeline (Verlauf-tab redesign). Adds a new service
layer + two HTTP endpoints; no projection logic yet (Slice 2). The wire
shape (TimelineEvent) is frozen so future slices add Kind="projected"
rows additively without breaking the frontend consumer.

ProjectionService.For composes three actuals streams for one project:
  - paliad.deadlines           → Kind="deadline"
  - paliad.appointments        → Kind="appointment"
  - paliad.project_events with
    timeline_kind IS NOT NULL  → Kind="milestone"

Visibility goes through the existing inline mirror of
paliad.can_see_project on each underlying service — no new RLS surface.
DirectOnly mirrors the existing "Inkl. Unterprojekte" toggle on
/projects/{id}; IncludeAuditFull broadens project_events to the full
audit log behind the upcoming "Audit-Log anzeigen" header toggle.

ProjectionService.RecordCustomMilestone backs POST /timeline/milestone
("Eigener Meilenstein") — the only write path in Slice 1.

Tests: unit (sort order, status mapping, kind tiebreak — runs by default)
plus a live integration test that seeds one project + dl + appt +
milestone and asserts the merge surfaces all three with the right
ordering. Live test gated on TEST_DATABASE_URL per the existing
convention.

Design ref: docs/design-smart-timeline-2026-05-08.md §2.3 + §9.2 + §10.
2026-05-08 23:34:06 +02:00
m
49c260b888 feat(t-paliad-171): migration 075 — project_events.timeline_kind opt-in column
Adds a nullable text column on paliad.project_events so a subset of
audit rows can opt into surfacing as SmartTimeline content. Existing
rows stay NULL (audit-only); the partial index keeps the lookup tiny
because the SmartTimeline read filter is the indexed predicate.

Value space (enforced in code in internal/services/projection_service.go):
  'milestone'        — structural event (counterclaim_filed, ...)
  'custom_milestone' — free-text "Eigener Meilenstein"
  NULL               — audit only (default)

Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
2026-05-08 23:33:53 +02:00
m
12b35fc9fe Merge: t-paliad-170 — FilterBar mounted in /projects/<id> Verlauf tab
riemann's Phase 2 slice on top of own 1faffb6 Phase 1: the universal
<FilterBar> is now in the project Verlauf tab. Filter facets:
project_event_kind (chip cluster), time (presets including new
HorizonPast7d), personal_only. Empty URL preserves current behaviour
(unfiltered list); ?time=past_30d&pe_kind=deadline_created narrows.

Two extension points added to the bar primitive (forward-compat with
SmartTimeline t-paliad-169 work):
- customRunner: lets a host page own the data fetch (Verlauf keeps
  the legacy /api/projects/{id}/events pipeline so subtree + cursor
  pagination survive — substrate-side scope-with-descendants stays
  SmartTimeline territory).
- timePresets: opt-in past-only horizon set for backward-looking
  surfaces (vs the default future-leaning set used on /inbox).

3-way merge with main: clean. fourier's t-paliad-168 + lagrange's
SmartTimeline design doc preserved.

bun build clean; frontend/dist regenerated. go test internal/... ok
on riemann's worktree (filter-bar url-codec + filter_spec tests).

ebcda13 from mai/riemann/filterbar-phase-2-slice.
2026-05-08 23:23:49 +02:00
m
ebcda13f88 feat(t-paliad-170): mount <FilterBar> in /projects/<id> Verlauf tab
Phase 2 slice of the universal-filter migration (Phase 1 was
t-paliad-163 → /inbox; remaining /agenda /events /deadlines
/appointments stay queued).

What ships:

- FilterBar gains two non-invasive options that future surfaces will
  also need:
    customRunner — bypass the substrate POST and hand the effective
                   spec to a surface-supplied runner. Required by
                   surfaces whose data path can't move to the substrate
                   yet (Verlauf still uses /api/projects/{id}/events for
                   subtree expansion + cursor pagination, both absent
                   from the substrate's project_event runner).
    timePresets  — per-surface override of the time chip cluster, so
                   backward-looking surfaces can show past_*+all without
                   forcing forward-looking next_* chips on every host.

  systemViewSlug becomes optional; the bar enforces "exactly one of
  customRunner | systemViewSlug" at construction.

- project_event_kind axis renderer (was a null stub) — chip cluster
  over KnownProjectEventKinds, labels reuse the existing
  event.title.<kind> i18n table so the chip text matches the Verlauf
  row title for the same kind.

- HorizonPast7d added end-to-end (substrate validate +
  computeViewSpecBounds; FilterBar TimeOverlay + parseHorizon; views
  TimeHorizon mirror) so the chip value is valid in every layer when a
  later SystemView reuses it.

- Verlauf tab on /projects/<id> mounts the bar with
  axes=["time","project_event_kind"], timePresets=
  ["past_7d","past_30d","past_90d","any"], showSaveAsView=false. The
  customRunner reads predicates.project_event.event_types + time.horizon
  off the effective spec, sets a verlaufFilters global, and routes
  through the legacy loadEvents/loadMoreEvents pipeline (which now
  applies the filter set client-side and tracks raw cursor IDs so
  "Mehr laden" still walks the underlying pagination boundary even when
  most rows get filtered out of a page).

- Subtree toggle drives loadEvents through verlaufBar.refresh() so the
  current filter state survives the toggle.

URL state reuses the bar's existing keys (?time=past_30d, ?pe_kind=…).
Empty filter → identity passthrough → current behaviour preserved.

Out of scope (deferred to t-paliad-169 SmartTimeline):
  - Migrating Verlauf to the substrate (needs scope-with-descendants)
  - Past/future split, dated/undated split, source-track facet

Refs m/paliad#23.
2026-05-08 23:22:23 +02:00
m
487fec2672 Merge: t-paliad-169 — SmartTimeline design doc (DESIGN READY FOR REVIEW)
lagrange's inventor pass on m's 23:02 request: redesign the Verlauf tab on
/projects/{id} as a SmartTimeline composing past actuals + future-projected
(via existing AnchorOverrides reflow on FristenrechnerService.Calculate) +
off-script events. Counterclaim shape: sub-project with new counterclaim_of
FK; parent renders parallel right-track when populated.

Doc covers: data-model recommendation (virtual view, ONE optional column),
UI mockup (3 states), counterclaim shape (defended), parent-node aggregation
(per-level kinds/statuses/lanes rule), date-anchoring + reflow semantics,
off-script event UX, 12 open questions, 4-slice phasing.

12 open questions parked for m's review before Slices 2-4. Slice 1 is the
skeleton (no projection yet) — must merge AFTER riemann's t-paliad-170
FilterBar port; pending riemann.

Issue m/paliad#27. Single commit f8cc86c, 739 lines, design only — no
implementation in this merge.
2026-05-08 23:17:48 +02:00
m
f8cc86cd02 docs(t-paliad-169): SmartTimeline design — Verlauf-tab redesign with past+future+off-script
Inventor design for replacing the project-page Verlauf with a SmartTimeline that
composes past actuals (deadlines, appointments, structural project_events),
present, future-projected (deadline_rules calculator at read time), and
off-script events into one project-scoped vertical timeline.

Key calls:
- virtual view, no new top-level table; single optional column
  paliad.project_events.timeline_kind so a subset of audit rows surface as
  timeline content
- counterclaim = sub-project (new paliad.projects.counterclaim_of FK), parent
  renders parallel tracks; default our_side flips on creation
- date-anchoring reuses fristenrechner CalcOptions.AnchorOverrides — actuals
  anchor downstream projections automatically
- new ProjectionService.For(projectID) thin adapter over FristenrechnerService
- 3 new FilterBar axes (timeline_kind, timeline_status, timeline_track) +
  reuse of time, personal_only, deadline_event_type
- per-level aggregation rule: each level removes one tier of detail and adds
  one tier of grouping (Case → Patent → Litigation → Client)
- 4-slice phasing: skeleton, projection+anchor, counterclaim sub-project,
  parent-node aggregation

12 open questions for m before slice 1 PR opens. Inventor parks per gate
protocol; coder shift only after m's go-ahead.
2026-05-08 23:14:30 +02:00
m
69544bf3fb Merge: t-paliad-168 — Verfahrensablauf entry points (Step 2 third card + sidebar nav)
m's complaint @ 22:49 'Verfahrensablauf section… is gone' — the Pathway A wizard
was reachable only via Step 1 → Step 2 (einreichen) → Step 3a (file), three clicks
deep and framed as 'I'm filing a brief.' fourier restores two top-level entries:

- Step 2 third card 'Verfahrensablauf einsehen' (browse / learn) → ?path=a
- Sidebar Werkzeuge entry 'Verfahrensablauf' (open-book icon) → ?path=a

In both browse paths the save-to-project CTA disables (no Akte to save against).
Deliverable 3 (project-page Verfahrensablauf tab) deferred — the SmartTimeline
redesign (t-paliad-169, lagrange) will determine the right component shape.

Commits: 7238b12 (Step 2 card), 7fef641 (sidebar entry).

Closes part of m/paliad coverage; SmartTimeline tracked separately.
2026-05-08 23:05:47 +02:00
m
7fef64159b feat(sidebar): add Verfahrensablauf nav entry
t-paliad-168 deliverable 2. New "Verfahrensablauf" entry under
Werkzeuge, right after Fristenrechner — opens
/tools/fristenrechner?path=a (Pathway A wizard, browse-/learn-mode).

Uses a distinct open-book icon to read separate from the closed-book
Glossar. Both /tools/fristenrechner sidebar entries share the same
pathname, so SSR navItem matching can't pick the right "active" one
on its own — fixVerfahrensablaufActive() in sidebar.ts disambiguates
based on ?path=a at hydration.

i18n key: nav.verfahrensablauf (DE: "Verfahrensablauf",
EN: "Procedure Roadmap"). i18n-keys.ts is regenerated by build.ts.
2026-05-08 23:04:29 +02:00
m
7238b12b05 feat(fristenrechner): Step 2 third card "Verfahrensablauf einsehen"
t-paliad-168 deliverable 1. Adds a discoverable browse-/learn-mode
entry to the determinator alongside "Etwas einreichen" / "Etwas ist
passiert". Click drops straight into Pathway A's proceeding-tile
picker (navigateToPathway("a")).

The save-to-project CTA disables itself in this mode — extends
isAdhocMode() to also return true when no Step 1 context is set,
mirroring the existing ad-hoc explore behaviour.

i18n keys: deadlines.step2.browse.title / .desc (DE + EN).
2026-05-08 23:03:52 +02:00
m
54cf7ac2f6 Merge: t-paliad-167 — Determinator coverage audit (research)
f4815a9 — Determinator coverage audit @ docs/research-determinator-
coverage-2026-05-08.md (394 lines).

Headline numbers (n=76 true Fristenrechner deadlines across 19 active
proceedings):
  Reachable from cascade:    71  (93 %)
  No concept_id:               1  (1 %)
  Concept exists, dead-end:    4  (5 %)

§4 frames the smart-navigation question into a taxonomy of three
"I don't see my event" failure modes — α (real content gap), β
(reachable but mis-modelled path), γ (court-side trigger needs to be
tagged, not reacted-to) — and maps each to the candidate UX patterns
(P1 free-text search / P2 escape-with-telemetry / P3 weiter-unten-
suchen). The recommendation surfaces:

  - P2 + telemetry for type-α (capture which events users actually
    want; drives prioritised migration backlog rather than guessing)
  - P1 + P3 for type-β (search collapses labelling mismatches; flat-
    branch search recovers from wrong-root entries)
  - Type-γ flagged as a separate "tag, don't react" workstream out of
    this scope

Pure research — no code, no schema. Feeds m's next decision: extend
the row-by-row B1 refactor (m/paliad#25 / minkowski's parked task) or
spin a separate inventor pass on the smart-navigation surface.

Refs m/paliad#26.
2026-05-08 22:35:24 +02:00
m
f4815a9f9a docs(t-paliad-167): Determinator coverage audit + smart-navigation framing
Builds on t-paliad-159's UPC RoP audit. Drives from paliad's own corpus
outward: every active rule, every firm-wide event_type, every cascade
leaf — and asks whether a Determinator user can actually reach the row.

Headline finding: 71/76 (93%) of true Fristenrechner deadlines are
reachable from the cascade. The 5 unreachable cluster into one fix:
EP_GRANT (4 rules) plus UPC_INF.inf.app_to_amend lack cascade entry.
Adding an `ich-moechte-einreichen.ep-erteilung` subtree lifts coverage
to 100%.

Per-jurisdiction inventory (UPC, DE, EPO, DPMA) plus a §2.6 cross-cutting
table for the procedural-order categories m flagged (Hinweisbeschluss,
Beweisbeschluss, Streitwertbeschluss, Versäumnisurteil, R.71(3),
Beanstandungsbescheid, etc.).

§4 frames the smart-navigation choice: recommends P2 (persistent escape
button with capture) + P1 (free-text search per cascade level), defers
P3 (flatten deeper levels) until telemetry justifies it. The captured
"Mein Ereignis ist nicht dabei" texts feed both the gap-fill roadmap
and P1's ranking corpus.

No code changes; one markdown doc, 394 lines.
2026-05-08 22:34:23 +02:00
m
ce180123c3 Merge: t-paliad-165 follow-up — Regel+Typ as one field + jurisdiction-aware defaults
Two slices on mai/noether/collapse-regel-typ-on (after rebase onto main):

  6058d21  fix(deadline-rules): pick rule's jurisdiction-aware event_type default
  7a35cad  feat(deadlines/new): collapse Regel + Typ to ONE field when rule sets type

What ships:

- Migration 074 audits the deadline_concept_event_types seed and adds
  per-jurisdiction defaults so a German rule (RoP.029.b /
  § 276 Abs. 1 S. 2 ZPO) maps to the DE event_type
  (de_klageerwiderung) and a UPC rule maps to the UPC event_type
  (upc_statement_of_defence). The text label "Klageerwiderung" reads
  the same in both — the bug m hit at 22:08 was the seed defaulting
  to UPC for every concept regardless of which rule asked.
  Idempotent (IF NOT EXISTS / DO blocks). Live tracker advanced
  73 → 74 during noether's dev run; deploy will see tracker=74 with
  file 074 present and have nothing to apply.

- Frontend deadline create form (m's "these two are connected — it's
  the same thing", #18 + 22:08 dogfood):
    When a Regel is selected and a default event_type exists for it,
    the Typ chip COLLAPSES into an inline pill beneath the rule:
      "Typ: Klageerwiderung (vorgegeben durch Regel)  [Anderen Typ wählen]"
    Clicking [Anderen Typ wählen] re-expands the picker so the user
    can override.
    When the rule has no junction row (or the user hasn't picked a
    rule), the Typ field stays as today (free-text + chip cluster).

- deadline_rule_service.go switched to the jurisdiction-aware lookup;
  the form receives the right default in one round-trip.

Refs m/paliad#18 + the 2026-05-08 22:08 inline-correction thread.
2026-05-08 22:21:48 +02:00
m
7a35cad09f feat(deadlines/new): collapse Regel + Typ to ONE field when rule sets type
m's 2026-05-08 22:08 dogfood: my first auto-fill landed but kept Regel
and Typ as TWO separate input fields. m wanted ONE — "these two are
connected, it's the same thing".

Now: when a Regel is selected and the rule's concept resolves to a
canonical event_type via the jurisdiction-aware junction, the Typ
chip cluster is HIDDEN and replaced by an inline summary —

    Klageerwiderung (vorgegeben durch Regel)   Anderen Typ wählen

Clicking "Anderen Typ wählen" sets a sticky expandedOverride flag
that forces the picker visible for the rest of the form session.
The chip stays in the picker so the user can edit / remove it.
The picker also stays visible when the rule has no canonical
event_type (fallback to free-text Typ) or when the user has picked
a different event_type from the canonical default (mismatch
warning surfaces yellow next to the picker, never blocking).

DE+EN i18n: deadlines.field.rule.{autofill_inline,override}.
New CSS: .event-type-collapsed{,-label,-source,-override} reusing
the existing lime-tint chip palette.
2026-05-08 22:20:48 +02:00
m
6058d21ce6 fix(deadline-rules): pick rule's jurisdiction-aware event_type default
m's 2026-05-08 22:08 dogfood: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung'
(DE) auto-filled to 'Klageerwiderung' label but the chosen event_type was
upc_statement_of_defence (UPC). Both render as 'Klageerwiderung' in the
UI, but they are different legal events in different jurisdictions.

Migration 074 adds a jurisdiction column to
paliad.deadline_concept_event_types and swaps the unique-default index
from per-concept to per-(concept, jurisdiction). Backfills jurisdiction
from each event_type's own column, then re-elects DE / DPMA / EPO
defaults where a non-UPC event_type genuinely exists. Idempotent: uses
ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE, partial unique index.

DeadlineRuleService.hydrateConceptDefaultEventTypes now JOINs
paliad.proceeding_types and matches on (rule.concept, rule.jurisdiction)
with EPA→EPO canonicalisation. Rules whose (concept, jurisdiction) has
no default stay NULL — silent no-op on the form, better than a wrong
jurisdictional default. UPC rules unchanged; DE rules now resolve to
de_klageerwiderung when concept = statement-of-defence, else no autofill.

Live audit confirms: every active rule now resolves to a same-
jurisdiction event_type or no event_type at all. No more cross-
jurisdiction matches in the seed.
2026-05-08 22:16:55 +02:00
m
52caba51ec fix(inbox): default approval_viewer_role chip to "any_visible" so the page lands populated
m's 2026-05-08 22:08 dogfood, after t-paliad-163 Phase 1: "I like the
new inbox filters but now the inbox somehow does not show nothing no
more..." The new bar opened with the chip defaulted to
"approver_eligible" (the legacy "Zur Genehmigung" tab semantics —
requests EXCLUDING ones the caller authored). For users who only
SUBMIT requests and have nothing to approve themselves (incl. m,
who has 4 own pending submissions and 0 incoming), that's an empty
view.

Flip the default to "any_visible" on both ends:

- internal/services/system_views.go InboxSystemView.Filter — base
  spec ViewerRole = "any_visible".
- frontend/src/client/filter-bar/axes.ts approval_viewer_role chip —
  default = "any_visible" when the URL doesn't pin one. The two
  defaults are intentionally redundant: the server narrows on its
  default if the request omits a_role, and the chip highlights the
  same option on the empty URL.

The chip still narrows. "Zur Genehmigung" + "Eigene Anfragen" stay
one click away; the bar just doesn't pre-narrow into "Zur Genehmigung"
on first visit anymore.

The "/views/inbox-mine" SystemView (slug + URL stays "self_requested")
keeps its narrower default — that route exists precisely to land on
the requester's view.
2026-05-08 22:11:19 +02:00
m
1faffb682e Merge: t-paliad-163 Phase 1 — universal <FilterBar> primitive + /inbox migration
Three slices on mai/riemann/inventor-universal:

  d5a01e6  Slice 1 — RenderSpec.list.row_action + validator + tests
  de4e133  Slice 2 — <FilterBar> scaffolding (axes / url-codec / save-modal)
  4670cd6  Slice 3 — /inbox migrates to <FilterBar>; tabs collapse to chips

What ships (Phase 1):

- A new frontend/src/client/filter-bar/ module:
    types.ts        — Spec + RenderSpec + AxisDeclaration types
    axes.ts         — registry of supported filter axes
    url-codec.ts    — URL ↔ FilterSpec serialization (round-tripping)
    save-modal.ts   — "Speichern als Sicht" dialog
    index.ts        — <FilterBar> mounts
  Plus a url-codec.test.ts golden table.
- /inbox surface migrates to the bar:
    Top-level "Zur Genehmigung / Meine Anfragen" tabs collapse into the
    bar's `approval_viewer_role` chip cluster (incoming / outgoing /
    both). One control, three mutually exclusive options. Stateful via
    `?role=` URL param.
    Bookmark-friendly: legacy `?tab=mine` + `?tab=pending-mine` redirect
    to `?role=outgoing` and `?role=incoming` respectively for one
    release.
    Sortable column headers on the result list (list-shape only;
    cards/calendar shape-modes defer their own ordering to the spec).
- RenderSpec.list gains `row_action` ("navigate" | "expand" | "none")
  so list-shape surfaces declare row click behaviour explicitly. The
  validator + tests cover the new field.
- system_views.go gains the inbox SystemView definitions so the bar
  reads its base spec from the same registry that custom views use.

m's locked positions (commit `1e23745` design doc; m's greenlight
2026-05-08 21:47): all 11 default picks honoured. Q4 = collapse
tabs to chips ✓.

Phase 2 surfaces (port /agenda → bar; port /events → bar; port
/deadlines → bar; port /appointments → bar) follow as separate PRs.

Refs m/paliad#23.
2026-05-08 22:03:51 +02:00
m
4b681792ab Merge: t-paliad-165 — Regel ↔ Typ collapse via auto-link on the deadline create form
Two slices on mai/noether/collapse-regel-typ-on:

  0c12644  feat(deadline-rules): expose concept's canonical event_type per rule
  1e97ecc  feat(deadlines/new): auto-link Typ to Regel's concept

What ships:

- New junction paliad.deadline_concept_event_types maps every
  paliad.deadline_concepts row to its canonical paliad.event_types
  row(s). Many-to-many for concepts with multiple legitimate variants
  (statement-of-defence ↔ base + with_ccr + no_ccr; opposition across
  EPO + DPMA). Exactly one row per concept marked is_default = true
  by a partial unique index — that is the row the deadline form
  auto-fills with.

- Backend: paliad.deadline_rules_with_concept_event_type view + the
  deadline-rules read path now expose the rule's default concept
  event_type so the form has the auto-fill target without an extra
  round-trip.

- Frontend deadline create / edit form: when the user picks a Regel,
  the Typ chip auto-fills with the rule's concept's default event_type.
  A small "vorgegeben durch Regel — überschreiben?" hint sits next to
  the chip so the auto-fill is visible. The user can override (free-
  text or pick a different type); the override is explicit, no
  blocking validation.

- Free-text Typ stays available — manual deadlines without a
  matching rule (e.g. "Call me" reminders) keep working as today.

Migration housekeeping
======================

noether authored her migration as 072 on her branch but main had
already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side).
Renumbered to 073 during merge resolution to resolve the same-number
collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX
for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING).

Live tracker bumped 72 → 73 in the same operation: both effects
(our_side column AND deadline_concept_event_types table) were
applied to live during dev (each worker against the same DB), so
the tracker advance reflects schema reality. Next deploy sees
tracker=73 with file 073 present and has nothing to apply.

Refs m/paliad#18.
2026-05-08 22:01:44 +02:00
m
236bb3270e Merge: t-paliad-164 — project our_side + Determinator perspective predefine
Three slices on mai/minkowski/project-level-our-side:

  188d8ec  Slice 1 — paliad.projects.our_side column + service plumbing
  5d9c62d  Slice 2 — "Wir vertreten" select on the project edit form
  3a41ace  Slice 3 — Determinator predefines perspective from our_side

What ships:

- Migration 072 adds paliad.projects.our_side text with check constraint
  IN ('claimant','defendant','court','both', NULL). Idempotent
  (IF NOT EXISTS / DO blocks). NULL stays the default.
- Project model + service plumbing: OurSide *string on models.Project,
  threaded into Create / Update / SELECT projections + handlers.
- Project edit form: new "Wir vertreten" select with the four options
  + "unbekannt / nicht gesetzt", DE+EN i18n.
- Fristenrechner Determinator (Slice 3c — perspective chip): when a
  project is selected and our_side is set, the chip is predefined to
  that value with a "vorgegeben durch Akte" hint above. The user can
  still override (chip click); the override is explicit. When
  our_side is NULL, the existing free-pick behaviour stays.

m's dogfood (2026-05-08 21:42): "We chose a case of ours where our
side should be predefined - yet I can make a selection for which
side we are." Now resolved end-to-end: edit the project once to set
"Wir vertreten = Klägerseite", and the Determinator perspective chip
auto-locks to that side on every subsequent visit.
2026-05-08 22:00:13 +02:00
m
4670cd660a feat(inbox): migrate to <FilterBar> — t-paliad-163 Slice 3
/inbox is the first surface to consume the universal FilterBar. The
two-tab UI collapses into the bar's approval_viewer_role chip cluster
(per Q4 lock-in 2026-05-08 21:47); status / entity_type / time chips
are new affordances; density toggle gives the activity-feed look the
brief asked for.

Changes:
- system_views.go: InboxSystemView + InboxRequesterSystemView render
  spec gains RowAction=approve so shape-list.ts knows which row
  layout to stamp (entity title + diff + approve/reject/revoke).
- shape-list.ts: row_action='approve' branch — stamps the inbox-row
  markup the surface owned today; surface attaches click handlers
  via data-attrs on .views-approval-action / .views-approval-row.
- inbox.tsx: tab row replaced with <div id='inbox-filter-bar'> +
  <div id='inbox-results'>. Heading + admin nudge unchanged.
- client/inbox.ts: shrunk to mountFilterBar with axes [time,
  approval_viewer_role, approval_status, approval_entity_type,
  density, sort]. Action handlers run via fetch + bar.refresh().
  Legacy ?tab=mine -> ?a_role=self_requested redirect on mount so
  bookmarks / sidebar bell still land on the right sub-view.

Build clean: bun run build + go build/vet/test all pass.
2026-05-08 21:59:44 +02:00
m
1e97eccaed feat(deadlines/new): auto-link Typ to Regel's concept
When the user picks a Regel on /projects/{id}/deadlines/new (or the
global /deadlines/new), auto-populate the Typ chip with the rule's
concept's canonical event_type — using the
concept_default_event_type_id field server-side hydrated by mig 072.

Soft hint "Typ vorgegeben durch Regel — entfernen, um zu überschreiben"
when the chip exactly matches the rule's suggestion. Soft warning
"Hinweis: Typ widerspricht Regel" when the user has picked an event_type
that contradicts the rule's concept.

The picker is replaced silently when it still reflects the previous
rule's auto-fill (or is empty); leaves a manually-edited picker alone.
DE+EN i18n via deadlines.field.rule.{autofill,mismatch}. Reuses the
existing .form-hint--warning yellow-tint style; no new CSS.

Closes m/paliad#18 Item A — rule-vs-event redundancy on the manual
deadline create form.
2026-05-08 21:59:22 +02:00
m
3a41acee07 feat(fristenrechner): predefine Determinator perspective from our_side (t-paliad-164 slice 3)
Closes m's 2026-05-08 21:42 dogfood loop: when the user picks an Akte
that knows its own side, the Determinator perspective chip should be
locked to that side instead of asking the user to re-pick something
the project already knows.

ProjectOption gains our_side; the JSON already carries it from
slice 1 (ProjectService.projectColumns). New helper
applyOurSidePredefine maps project.our_side onto the chip:

  claimant  → "claimant"   chip active
  defendant → "defendant"  chip active
  court     → null          chip cleared (court actions are neutral
                            to the user's side, so no narrowing)
  both      → null          explicit "Beide" intent
  null/undef → no-op

URL wins: if ?role= is present at call time the user (or a shared
link) chose it explicitly and we don't overwrite. When we do predefine,
we write the same value to the URL so refresh + back/forward round-trip
correctly. Two call sites:

- selectProject: in-page Akte pick. push history (replaceURL=false) so
  back-button restores the prior state.
- post-fetchProjects hydration: the deep-link / refresh path. Use
  history replace so the URL stays clean.

A small "vorgegeben durch Akte" / "predefined from project" hint
renders next to the chip strip (italic muted). Visible whenever the
active perspective came from the project; cleared on any chip click
(explicit override) and on Step-1 reselect (no Akte = no hint).
popstate restores hint visibility by recomputing from
project.our_side ↔ currentPerspective so back/forward feels right.

Free-pick is preserved: clicking another chip overrides the
predefine and the cascade re-narrows immediately.
2026-05-08 21:58:44 +02:00
m
de4e133f03 feat(filter-bar): scaffolding — t-paliad-163 Slice 2
The universal FilterBar primitive: one client component every list-
shaped paliad surface mounts. Owns URL state (within an optional
namespace), localStorage prefs (density / shape / sort), the per-axis
chrome, and the round-trip to /api/views/{slug}/run with a transient
filter override.

Files:
- client/filter-bar/types.ts       — AxisKey, BarState, MountOpts, BarHandle
- client/filter-bar/url-codec.ts   — parseBar/encodeBar with namespace prefix
- client/filter-bar/url-codec.test.ts — 12 round-trip cases (bun test pass)
- client/filter-bar/axes.ts        — per-axis renderers (10 axes shipped;
  deadline_event_type + project_event_kind stubs land with their surfaces)
- client/filter-bar/save-modal.ts  — Speichern-als-Sicht <dialog>
- client/filter-bar/index.ts       — mountFilterBar + computeEffective overlay

Plus i18n (DE+EN, ~50 keys under views.bar.*) and CSS (.filter-bar*
scoped, reuses .agenda-chip / .filter-group / .entity-select for
parity).

No surface uses the bar yet — Slice 3 wires /inbox.
2026-05-08 21:55:29 +02:00
m
0c12644563 feat(deadline-rules): expose concept's canonical event_type per rule
Add paliad.deadline_concept_event_types junction (mig 072) mapping each
deadline_concept to its canonical paliad.event_types row(s). Hydrate
DeadlineRule.ConceptDefaultEventTypeID via one IN query per List call so
/api/deadline-rules carries the autofill hint for the deadline create
form (t-paliad-165 / m/paliad#18).

Seed mapping covers the active concepts driving existing rules — 29
rows across 26 distinct concepts. Concepts without an obvious event_type
counterpart (decision, filing, grant, the DE-only Begründung family)
stay unmapped; auto-fill silently skips them.
2026-05-08 21:55:15 +02:00
m
5d9c62d858 feat(projects-form): "Wir vertreten" select for our_side (t-paliad-164 slice 2)
ProjectFormFields gains a fifth select between case-specific block and
the description textarea: "Wir vertreten" with options claimant /
defendant / court / both / "" (the unset sentinel labelled
"Unbekannt / nicht gesetzt"). Type-agnostic — every project type
carries it because the Determinator picks it up regardless. Form-hint
explains it predefines the Determinator perspective and stays
overridable.

client/project-form.ts: readPayload writes our_side as a normal
stringField (empty string in edit mode clears the column via the
nullableOurSide helper on the service); prefillForm hydrates the
select from p.our_side. Both gate on tryGet so /projects/new (which
shares the form) still loads if the field is later removed.

i18n already in slice 1; this commit only wires the markup +
client logic.
2026-05-08 21:55:00 +02:00
m
188d8ec9ba feat(projects): add projects.our_side column + service plumbing (t-paliad-164 slice 1)
m's 2026-05-08 21:42 dogfood feedback on the Determinator perspective
chip: when an Akte is selected, the chip should be locked to the firm's
known side instead of asking the user to re-pick. paliad didn't track
that anywhere — paliad.parties.role records each party's role but no
flag for "this is the side we represent".

Migration 072 adds paliad.projects.our_side text with a CHECK
constraint (claimant | defendant | court | both | NULL). NULL stays the
default so existing rows are neutral and the Determinator falls back to
free-pick. Idempotent (ADD COLUMN IF NOT EXISTS + DO-block guarded
constraint) so a re-run against a partially-applied state is safe —
paliad has been bitten by collision twice this week.

Project model + ProjectService:
- OurSide *string field on models.Project
- CreateProjectInput / UpdateProjectInput accept our_side
- INSERT and partial UPDATE thread the value through; validateOurSide
  rejects unknown enum values with ErrInvalidInput before the DB
  constraint would; nullableOurSide turns "" into NULL so the form's
  "unset" sentinel can clear the column
- Update logs an our_side_changed audit event with "<from> → <to>"
  description (matching status_changed / project_type_changed
  shape); both ends use the literal "none" sentinel for NULL so the
  frontend renderer can map it to projects.field.our_side.none

i18n: event.title.our_side_changed (DE/EN), dashboard.action.short
verb form, projects.field.our_side.{label,hint,unset,claimant,
defendant,court,both,none} for the upcoming Slice 2 select.

Frontend translateEventDescription gets an our_side_changed branch
that runs translateArrowSlugs over the projects.field.our_side.*
prefix so the Verlauf tab renders localized labels.

Slice 2 wires the form, Slice 3 wires the Determinator.
2026-05-08 21:52:50 +02:00
m
d5a01e6682 feat(render-spec): add list.row_action — t-paliad-163 Slice 1
Schema bump that lets the universal <FilterBar> tell shape-list which
row interaction to wire (navigate / complete_toggle / approve / none).
Defaults to navigate when empty so existing SystemView definitions and
saved user views continue to render rows that route to the per-kind
detail page.

Validator extended; pure-Go test cases over every enum value + reject.
TS mirror updated in client/views/types.ts. No DB migration — the
field is purely additive on the JSON shape.
2026-05-08 21:49:00 +02:00
m
02d4ac2f4e Merge: t-paliad-161 Slices F + G — Paliadin DB-driven history sync + tmux crash-recovery primer
Two follow-up slices on the inline-Paliadin scope (m's 2026-05-08 21:37):

  1782dfa  Slice F — cross-surface DB-driven history hydrate
  ae1cba4  Slice G — tmux crash-recovery primer

What ships:

- The inline drawer (client/paliadin-widget.ts) and the standalone
  /paliadin page (client/paliadin.ts) now share one session id and
  one history bucket. localStorage stays as a render-cache only;
  the DB (paliad.paliadin_turns) is source of truth. Both surfaces
  hydrate from GET /api/paliadin/history?session=<id>&limit=N on
  mount, then reconcile localStorage with the server response (always
  prefer server). Eliminates the trap klaus warned about (paliad#19,
  the localStorage short-circuit that hid late server-side responses).

- A turn typed into the drawer now shows up when the user opens
  /paliadin and vice versa, on both the same browser and across
  refreshes.

- tmux crash-recovery primer: when LocalPaliadinService /
  RemotePaliadinService detects a fresh pane (tmux session label
  rotated, or no prior turn output in the response dir), it injects
  a context-dump primer with the last N exchanges from
  paliad.paliadin_turns BEFORE the new prompt lands. The persona
  catches up on the conversation rather than starting from zero.
  Primer format documented in scripts/skills/paliadin/SKILL.md.

Auth gate unchanged: /api/paliadin/history honours PaliadinOwnerEmail
just like /api/paliadin/turn. Tests added for the hydrate + reconcile
+ primer paths in paliadin_test.go.
2026-05-08 21:48:52 +02:00
m
ae1cba4e24 feat(paliadin/primer): t-paliad-161 Slice G — tmux crash-recovery primer
When a user's tmux session dies (mRiver reboot, OOM, manual kill,
container restart) the next turn used to wake claude with NO prior
context — the persona had to derive everything from the new turn
alone. Now: when the Go side detects a fresh pane, it pulls the last
N exchanges from paliad.paliadin_turns and prepends them as a
[primer …][/primer] block to the next user envelope.

Format SKILL.md parses (single-line, control-chars stripped):

  [PALIADIN:<turn_id>] [primer last=N] U: … \n A: … \n … [/primer] [ctx …] <Frage>

Detection paths:

- Local (LocalPaliadinService): ensurePane now returns
  (target, isFresh, err). isFresh is true when no prior
  @paliadin-scope=chat window existed and we created one. RunTurn
  passes that into buildPrimerIfFresh.

- Remote (RemotePaliadinService): can't see across the SSH boundary
  to know the pane's true freshness, so we approximate with a
  per-(session, Go-process) "primed" cache. First turn after
  process-start, ResetSession, or healthGate failure rebuilds the
  primer; subsequent turns skip it. ResetSession + healthGate failure
  both call clearPrimed(session) explicitly.

paliadinDB.buildPrimerIfFresh assembles the block:

- Reads the last MaxPrimerTurns=5 exchanges from
  ListHistoryForSession (Slice F).
- truncateForPrimer normalises each side (drops \r\n, collapses
  whitespace, caps at MaxPrimerCharsPerSide=600 with …).
- Returns "" silently when isFresh=false, no SessionID, no prior
  history, or DB error — the user's actual question still lands; we
  only lose the recap.

SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill) gets a new "Crash-recovery primer"
section above the context-envelope block. Five behaviour rules:

  1. Don't re-execute prior tool calls (audit log already has them).
  2. Use the primer for thread continuity, not as a data source.
     Re-call tools for fresh facts.
  3. Truncated lines (ending in …) are partial — paraphrase rather
     than quote.
  4. No primer at all = normal case (existing pane, history is in
     tmux memory). Behave as before.
  5. Acknowledge sparingly — usually just answer the actual question
     with the recap as silent context.

New test TestTruncateForPrimer pins the per-side truncation contract
(no \r\n leaks, repeated spaces collapsed, ellipsis on oversized
input, short input untouched). go test green.

Refs: docs/design-paliadin-inline-2026-05-08.md §6
      (deferred Anthropic API cutover prereq).
2026-05-08 21:48:08 +02:00
m
1e23745792 docs(t-paliad-163): inventor design — universal filter + view-mode primitive
m/paliad#23. Recommends a single <FilterBar> client component on top of
the existing Custom Views substrate (t-paliad-144) — FilterSpec +
RenderSpec + ViewService + 5 code-resident SystemViews + ad-hoc
/api/views/run already cover every axis the issue lists.

Position: m's "halfway there without custom views" is exactly right.
Lift the substrate from /views/{slug} up to "the bar that every list-
shaped page reads from", with one schema bump (RenderSpec.list.row_action)
to keep entity-table row-click contracts intact.

Migrate one surface per PR: /inbox first (lowest blast radius, no filter
today), /events last (proof point, richest filter). /projects stays
bespoke per t-paliad-149 lock-in.

12 open questions (Q1-Q12) for m before lock-in. No hour estimates.

Verified premises: the issue body's `paliad.user_view_layouts` is a
typo — actual table is `paliad.user_views` (056). `/api/views/run` and
`/api/views/{slug}/run` confirmed live in internal/handlers/views.go.
2026-05-08 21:44:09 +02:00
m
1782dfa910 feat(paliadin/cross-surface-sync): t-paliad-161 Slice F — DB-driven history hydrate
Two Paliadin chat surfaces shared a user but not their conversation:
the inline drawer (paliadin-widget.ts) maintained `paliadin:widget:session`
+ `paliadin:widget:history:` while the standalone /paliadin page used
`paliadin:session` + `paliadin:history:`. A turn typed in the drawer
never surfaced on /paliadin and vice versa, and a localStorage wipe
tossed everything.

Fix in three coordinated parts:

1. **Shared session id.** The widget now uses the same `paliadin:session`
   key the standalone page already uses. One-time migration in
   bootSession copies any legacy `paliadin:widget:session` across so
   existing users keep their conversation thread, then deletes the legacy
   key. The widget's HISTORY_PREFIX also drops the `widget:` namespace
   so both surfaces' render-caches address the same bucket.

2. **DB-driven history.** New endpoint:

       GET /api/paliadin/history?session=<id>&limit=<N>

   Returns the caller's turns for the session, oldest → newest,
   gated by PaliadinOwnerEmail (same gate as POST /api/paliadin/turn).
   Backed by paliadinDB.ListHistoryForSession, which mirrors the
   existing visibility predicate (own rows always; all rows for
   global_admin). Default limit 50, capped at 200.

3. **Hydrate-on-mount, hydrate-on-open.**
   - paliadin.ts (standalone page): DOMContentLoaded calls
     hydrateFromServer() right after renderHistory() seeds from
     localStorage. DB rows replace the cache when present.
   - paliadin-widget.ts (inline drawer): revealIfOwner kicks
     hydrateFromServer in the background after rehydrateHistory paints
     the cache. openDrawer() also calls hydrateFromServer so a turn the
     user typed on /paliadin since the last drawer-open shows up
     without a manual reload.

   Reconciliation: DB > localStorage when DB has rows. DB call fails or
   returns empty → keep showing whatever's in cache (offline cushion).
   This kills the trap klaus warned about (paliad#19): every render
   reconciles against the server, no first-paint short-circuits.

Schema: zero migrations. paliad.paliadin_turns already carries
session_id + user_message + response + ts since the t-paliad-146 PoC;
this slice just adds a typed read path.

Backwards compatible: the standalone /paliadin page's session key is
unchanged; only the widget migrates onto it.

Builds + tests green; i18n unchanged.

Refs: m/paliad#19 (localStorage short-circuit), m/paliad#20 (inline modal),
      docs/design-paliadin-inline-2026-05-08.md §3.4.
2026-05-08 21:43:51 +02:00
m
936aca5925 refactor(projects-detail/projektbaum): reuse the /projects tree component
m's 2026-05-08 21:28: "The Projektbaum inside a Project in the tab
with the Unterordner should just be the same as the Tree in Projects.
It has symbols, everything. That should be a shared component."

Drop the inline mini-tree renderer (renderTreeNode / loadProjectTree /
~50 lines of duplicate logic) in client/projects-detail.ts and mount
the existing client/project-tree.ts module into the tab's container.
The shared component carries:
  - per-type icons (Mandant / Litigation / Patent / Case)
  - pin star (touch-friendly)
  - overdue / open-deadline badges with subtree counts
  - status chip + type chip
  - expand / collapse toggles
  - inherited-visibility marking
  - search highlighting (no-op when no search params are passed)

Current project highlight: set aria-current="true" on the matching
.projekt-tree-node after mount. The shared CSS already styles
.projekt-tree-node[aria-current="true"] > .projekt-tree-row with the
lime accent (global.css :5853).

Removed the now-dead mini-tree CSS block that was also accidentally
overriding .projekt-tree-title from the real tree (later-defined rule
won the cascade and erased the shared title weight).

loadChildren() still fetches /api/projects/<id>/children for the
empty-state gate ("Keine untergeordneten Projekte" when this node has
no direct children) and the create-link parent_id pre-fill — both
predicates depend on direct children, not the visible tree.
2026-05-08 21:31:16 +02:00
m
0b47343aa3 fix(projects-cards): start_at not starts_at — cards-preview appointments query
m's dogfood 2026-05-08 21:16: project card for "UPC-CoA Berufung Huawei"
showed "4 offen" but "Nächste Termine — keine bevorstehenden Termine"
even though the four pending deadlines exist with future due dates.

Live container log:

  ERROR service: cards preview appointments:
  pq: column t.starts_at does not exist at position 13:41 (42703)

The cards-preview appointments query used `t.starts_at`; the actual
column on paliad.appointments is `start_at` (singular). The query
errored, CardsPreview returned (nil, error), the handler returned a
500, and the frontend's `r.ok ? r.json() : []` fell through to an
empty preview map for every project — so deadlines that the deadline
half of the same function had already loaded never reached the card.
"4 offen" stayed visible because that count comes from BuildTreeWith-
Options, a separate query untouched by the bug.

Fix: rename starts_at → start_at in the rowAppointment db tag, the
ORDER BY, the WHERE clause, and the SELECT projection. StartsAt as
the Go field name stays — only the db tag + SQL identifiers change.
Same column name everywhere else in the codebase already used start_at.
2026-05-08 21:20:13 +02:00
m
f31307afcb feat(sidebar): newspaper icon for "Neuigkeiten", reserve sparkle for Paliadin
m's 2026-05-08 21:11: the changelog entry was sharing the sparkle 
glyph with the new Paliadin AI surface (inline widget trigger, agent-
suggested provenance pill, /paliadin entry). Now that  carries an
explicit AI semantic in paliad's visual language, swapping the
changelog to a newspaper SVG keeps the two affordances orthogonal.
2026-05-08 21:11:57 +02:00
m
aa112d2589 fix(approvals): NullableJSON for pre_image/payload so /api/inbox/mine doesn't 500
m's dogfood 2026-05-08 20:35: a deadline showed an approval-pending
banner on its detail page but did not appear in /inbox under either
"Zur Genehmigung" or "Meine Anfragen". Live container log:

  ERROR service: list submitted by user: sql: Scan error on column
  index 5, name "pre_image": unsupported Scan, storing driver.Value
  type <nil> into type *json.RawMessage

Root cause: paliad.approval_requests.pre_image is NULL whenever the
lifecycle_event is 'create' (no prior row state to capture). The Go
ApprovalRequest struct binds it as json.RawMessage, which is a []byte
typedef that does NOT implement sql.Scanner — sqlx fell back to
*json.RawMessage and choked on the NULL. Same hazard on .payload for
'complete' / 'delete' rows where there's no payload either.

The handler returned the resulting error as a 500, the inbox.ts catch
swallowed it as a network failure, and rendered the empty state. Both
tabs were dark because both list paths hit the same scan.

Fix: introduce models.NullableJSON, a []byte typedef that implements
sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler and
treats NULL ↔ nil cleanly. Inline JSON output is preserved (no base64
cast that bare []byte would have caused). Bind PreImage + Payload to
NullableJSON; existing call-sites (approval_service.go:606) keep
working — both json.RawMessage and NullableJSON are []byte under the
hood, and len() / json.Unmarshal accept either.

Other nullable jsonb columns (User.EmailPreferences, *Metadata) are
all NOT NULL with default '{}' so they don't hit the same path; left
as json.RawMessage.

Verified: live tracker is at v71, no schema change needed; approval
service tests green; /api/inbox/mine query against prod returns the
three expected rows for m once the binary picks this up.
2026-05-08 21:04:53 +02:00
m
dc35d2da69 Merge: feat(paliadin) late-response reconciliation — janitor + chat polling 2026-05-08 20:58:41 +02:00
m
d2790a0461 feat(paliadin): reconcile late responses via janitor + chat polling
When Claude writes the response file after the 60 s pollForResponse
window expires (e.g. the tmux pane was busy mid-turn when the message
arrived), the SSE stream has already closed with an error and the
file sits unread on disk forever. The chat shows a permanent timeout
even though the answer exists.

Backend:
- LocalPaliadinService.StartJanitor: scans responseDir every 2 s and
  patches rows whose response is still NULL when the file lands.
  completeTurnLate stamps error_code='late' so the FE can render a
  marker. Guarded with WHERE response IS NULL to never overwrite a
  real response if RunTurn races.
- Paliadin.GetTurn(callerID, turnID) on the shared paliadinDB. Same
  visibility predicate as ListRecentTurns.
- GET /api/paliadin/turns/{id} — owner-gated; lets the chat UI
  discover late-arrived responses without a refresh.

Frontend:
- paliadin-late-poll.ts: shared 3 s / 10 min poller.
- paliadin.ts + paliadin-widget.ts: on SSE error, show
  "wartet auf späte Antwort", kick off the poller, swap bubble in
  place when response arrives + retroactively persist to history.
- i18n: paliadin.late.waiting + paliadin.late.marker (DE/EN).
- CSS: --late-pending opacity tweak, --late neutral background,
  italic-grey "verspätet" tag.
2026-05-08 20:56:53 +02:00
m
97d49898b7 fix(paliadin): 200ms settle delay between paste and Enter so submit registers
m's dogfood 2026-05-08 20:35: "the paliadin hook does not always work — it
does not confirm the claude / terminal command... like lacking an enter
key. Or too fast."

Race between two consecutive tmux send-keys calls: the first writes the
prompt literally with `-l`; the second sends an Enter key event. Claude
Code's TUI debounces keyboard input. When the Enter lands while the
paste is still being absorbed, the carriage-return collapses into the
input buffer as a literal newline character instead of registering as a
"submit" gesture — the prompt sits typed but unsubmitted, and the
backend's pollForResponse then times out on the missing response file.

Fix: sleep 200ms between the literal paste and the Enter. Below the
human-perceptible threshold but well above tmux's pty flush window and
the TUI's input-debounce window. Applied to both code paths:

- scripts/paliadin-shim:send_to_pane (the SSH/RPC production path)
- internal/services/paliadin.go:LocalPaliadinService.sendToPane
  (the laptop-only direct-tmux path)

The Go-side variant uses a context-aware sleep so request cancellation
still propagates correctly.

Production shim copy at /home/m/.local/bin/paliadin-shim refreshed
locally on mRiver so the next turn picks up the fix without waiting
for redeploy. (The Dokploy container does not run paliadin — gate on
PaliadinOwnerEmail is owner-only and prod has no claude+tmux anyway —
so no deploy step required for the shim path.)
2026-05-08 20:37:40 +02:00
m
5b08bfcb96 fix(views/sidebar): pad fieldset sections + consolidate Ansichten / Meine Sichten
m's dogfood findings 2026-05-08 20:32:

1. /views/new: form sections (.form-section fieldsets) had no
   container CSS, so content rendered against the bare browser-default
   fieldset border with effectively zero padding. Adds proper padding
   (1.25rem 1.5rem 1.5rem), 8px radius, surface background, and tidies
   the legend / hint typography. Reused by every entity-form fieldset
   that adopts .form-section.

2. Sidebar nav: collapses the prior split between the "Ansichten"
   group (Fristen + Termine) and the "Meine Sichten" group (user-
   defined views + "+ Neue Sicht") into a single "Ansichten" group.
   Same DOM hook (#sidebar-views-items, .sidebar-views-new) so
   client/sidebar.ts's user-view hydration keeps working unchanged —
   the entries just sit alongside the built-ins now instead of in
   their own labelled section.
2026-05-08 20:35:14 +02:00
m
fc048c578e fix(paliadin-widget): render markdown + chips in inline bubbles, fix lime-trigger contrast
m's dogfood findings on the inline drawer:

1. Assistant responses (markdown headings, bold, lists, [chip:nav:…]
   tokens, [#deadline-OPEN:<id>] tokens) showed as raw text. The widget
   was setting body.textContent and skipping the renderer. Extracted
   the standalone /paliadin page's pipeline into client/paliadin-render.ts
   (renderResponseHTML + chip helpers + block markdown parser) so both
   surfaces share one source of truth. The widget now feeds assistant
   bubbles through innerHTML; user bubbles still go through textContent
   (no point parsing the user's typed markup).

2. Floating trigger button rendered the sparkle glyph in white-on-lime
   in dark mode — color: var(--color-text) inherits the dark-mode light
   foreground and washes out completely on a lime background. Lime is
   inherently a light-background colour, so the trigger pins its
   foreground to --hlc-midnight in both themes.

Bubble CSS additions: assistant bubbles get white-space: normal (the
base pre-wrap rule was forcing every source newline to a literal break
and breaking <p>/<h2>/<ul> spacing) plus tight h2/h3/p/ul margins so
the rendered markdown reads as a chat bubble, not a doc page.
2026-05-08 20:31:44 +02:00
m
d0e8c995fe Merge: t-paliad-157 Determinator Slices 3b + 3c — proceeding-type + perspective narrowing
2f27620 — Slice 3b: B1 cascade narrows by the project's proceeding
type. Three-input priority chain (inbox chip > ad-hoc context >
project's proceeding_type). cachedProceedingTypes lookup via
/api/proceeding-types-db; forumFromProceedingCode maps UPC_/DE_/EPA_/
EP_/DPMA_ → upc/de/epa/dpma. /tools/fristenrechner?project=<uuid>&path=b
auto-narrows the cascade without needing chip clicks.

6fcf34a — Slice 3c: perspective chip (Klägerseite/Beklagtenseite/Beide)
at top of the B1 panel. Mig 071 adds paliad.event_categories.party
text[] (claimant|defendant|both|court) with conservative backfill —
claimant on klage.* + replik-*, defendant on widerklage.* + duplik-*.
Cross-appeal/Anschlussberufung leaves stay NULL pending dogfood
(role flips depending on who appealed first). Cascade hides leaves
whose party tag contradicts the chip; both/court tags always pass;
NULL stays neutral. URL-only state (?role=claimant|defendant).

Live tracker is already at v71 (feynman applied 071 during dev).
Deploy will see tracker=71 with file 071 present — no work to apply.

The full Determinator scaffold is now in place:
  Step 1 (Akte/ad-hoc) → Step 2 (do/happened) → Step 3a (File/Draft/Enter)
  for outgoing or Pathway B with auto-narrowing for incoming.

Open follow-ups (small, can wait for dogfood):
- Tag cms-eingang.gegenseite.upc-rev/upc-app/de-bgh-* leaves with party
  (currently neutral; appellate leaves should arguably be tagged based
  on who appealed)
- Persistence for perspective if dogfood says it's wanted
- /drafts route + Step 3a Draft card wiring (proper drafting surface
  is a separate workstream)
2026-05-08 20:22:50 +02:00
m
dd0cee226d Merge: t-paliad-162 — sidebar reorg, inline Agenda on dashboard, collapsible sections
e824898 — feat(navbar/dashboard): per m's 2026-05-08 20:05 design
decisions:

- Sidebar restructured into named groups: Home → Paliadin (gated to
  PaliadinOwnerEmail) → Overview (Projekte) → Views (Fristen,
  Termine) → Tools (Fristenrechner et al). Group headers render as
  small uppercase muted labels.
- Agenda removed from sidebar + BottomNav. Direct link /agenda still
  routes; the dashboard now renders Agenda inline as a section.
- "Letzte Aktivität" relocated to sit under Agenda on the dashboard.
- All dashboard sections become collapsible with a chevron toggle;
  open/collapsed state persists per-section in localStorage under
  paliad:dashboard:collapse:<section>.
- Agenda rendering primitives extracted into client/agenda-render.ts
  so the standalone /agenda page and the dashboard's inline Agenda
  share identical rendering with no fork.

Pure frontend change — no Go work, no migrations.
2026-05-08 20:21:49 +02:00
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
a3052eb085 feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with  added in Slice E).

  POST /api/paliadin/suggest/deadline
  POST /api/paliadin/suggest/appointment

Wiring:

- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
  always creates an approval_request (bypassing policy lookup) and
  stamps requester_kind='agent' + agent_turn_id. Required-role defaults
  to 'associate' so the deadlock check has a non-NULL threshold; m's
  lock-in for Q11 (every agent suggestion needs the user's eye) means
  bypassing the policy gate is correct here, not a regression.

- The shared `submit` kernel takes an optional agent_turn_id pointer.
  All four lifecycle entry points (SubmitCreate / SubmitUpdate /
  SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
  the turn id. INSERT to approval_requests now writes both
  requester_kind + agent_turn_id atomically (xor-check on the schema
  enforces consistency).

- models.ApprovalRequest grows the two columns + their JSON tags so
  the inbox view + Verlauf renderer can read provenance without an
  extra fetch.

- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
  to the SQL projection; both surfaces (ListPendingForApprover,
  ListSubmittedByUser, GetRequest) inherit the new fields free.

- CreateDeadlineInput + CreateAppointmentInput each get an optional
  AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
  SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
  behaviour is unchanged for every existing caller.

- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
  requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
  RFC3339 + ISO-date validation, 409 + a useful message on
  ErrNoQualifiedApprover.

- Project-event audit metadata gains requester_kind + agent_turn_id so
  the project's Verlauf can render "Paliadin hat eine Frist
  vorgeschlagen " without joining approval_requests (Slice E reads
  this).

SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.

go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the  on existing eye-pill surfaces.

Refs: docs/design-paliadin-inline-2026-05-08.md §7.
2026-05-08 19:59:44 +02:00
m
75cfe914ce Merge: t-paliad-157 Determinator Slice 3a — File/Draft/Enter chooser
34e82ea — Step 2 "Etwas einreichen" no longer drops straight into
Pathway A; it now shows a 3-card chooser:

  File  → Pathway A wizard (existing).
  Draft → v1 placeholder, disabled button + "kommt bald" pill. No
          /drafts route exists yet; the chooser slot is reserved.
  Enter → /projects/<id>/deadlines/new, or /deadlines/new in ad-hoc.

Pathway type extends with "outgoing" intent.

Back-button policy:
  Step 3a → Step 2 fork
  Pathway A → Step 3a
  Pathway B → Step 2 fork
2026-05-08 19:58:49 +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