Compare commits

..

416 Commits

Author SHA1 Message Date
m
a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.

Schema (migration 055 update)
-----------------------------
  - paliad.approval_requests.decision_kind CHECK extended to accept
    'derived_peer'. Down migration restores the t-138 two-value CHECK.
    Live SQL dry-run confirmed the new value is accepted.

Service layer
-------------
  - approval_levels.go: new constant DecisionKindDerivedPeer.
  - approval_service.go (4 sites widened with the derivation EXISTS branch):
      1. canApprove — third resolution step after global_admin + direct/
         ancestor team membership: matches partner-unit-derived members
         on path with derive_grants_authority=true and a unit_role whose
         approval_role_from_unit_role mapping meets the threshold.
         Returns DecisionKindDerivedPeer when this branch is the one that
         passed.
      2. hasQualifiedApprover (the deadlock-check at submit time) —
         widened so a project with no direct approvers but an authority-
         granting unit attachment is still submittable.
      3. ListPendingForApprover (the /inbox query) — third UNION ALL
         branch so derived authority sees their queue.
      4. PendingCountForUser (the bell-badge query) — same widening so
         derived authority sees the count tick.
  All four queries reuse paliad.approval_role_from_unit_role(text) added
  by Phase 2 of migration 055.

Frontend
--------
  - 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
    "Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
    derived member (Partner Unit)". Verlauf rendering of the third
    decision_kind value works through the existing translateEvent /
    decision_kind switch with no other change. 1606 keys total.

Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.

Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
2026-05-06 16:45:19 +02:00
m
544bb63684 feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):

  - paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
    CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
    distinction so derivation can target specific tiers without re-
    introducing a firm-wide rank column. The same human can be 'attorney'
    in one unit and 'lead' in another.
  - paliad.project_partner_units junction (project_id, partner_unit_id,
    derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
    DEFAULT false, attached_at, attached_by) with composite PK and RLS
    (read = can_see_project; write = global_admin OR project lead).
  - paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
    derived authority is consulted by the t-138 ladder.
  - paliad.can_see_project extended with one EXISTS branch — derivation
    walks the path: a user is visible on P if any (ancestor of P) is
    attached to a unit they are a member of with a matching unit_role.

No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.

Backend services
----------------
  - DerivationService (new, internal/services/derivation_service.go):
      AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
      ListDerivedMembers (path-walking dedupe by closest attachment),
      ListDescendantStaffed (descendant-direct rows excluding ancestor-
      already-staffed), EffectiveProjectRole (returns role + source ∈
      {direct, ancestor, derived} for the t-138 approval gate in Phase 3).
  - PartnerUnitService extensions:
      PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
      UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
      SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
      role read in tx, audit emit 'member_role_changed'. ListMembers and
      ListWithMembers SELECT projection now includes pum.unit_role.

Handlers
--------
  - GET /api/projects/{id}/partner-units              → ListAttachedUnits
  - POST /api/projects/{id}/partner-units             → AttachUnitToProject
  - DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
  - GET /api/projects/{id}/team/derived               → ListDerivedMembers
  - GET /api/projects/{id}/team/from-descendants      → ListDescendantStaffed
  - PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
  - Services bundle gains Derivation; cmd/server/main.go wires it.

Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
  - "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
  - "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
    Augen] badge per the m-locked honesty rule (§3.5).
  - "Partner Units" — attached-unit list with attach/detach controls
    (lead/admin only) and a form picker for derive_unit_roles +
    derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).

Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.

i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).

CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.

Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
2026-05-06 16:41:41 +02:00
m
f8d8ea591d Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project 2026-05-06 16:26:46 +02:00
m
77d664c5cc Merge: fix inbox sidebar collapse — drop duplicate app.js include 2026-05-06 16:24:36 +02:00
m
8cf95761d0 fix(t-paliad-138): drop double-include of /assets/app.js on /inbox — PWAHead already injects it; the duplicate ran sidebar.ts twice and collapsed the sidebar on navigation 2026-05-06 16:24:36 +02:00
m
d41fc49809 feat(t-paliad-139): Phase 1 — /projects/{id} aggregation bug fix
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.

Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.

Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
  default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
  WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
  WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
  LEFT JOIN paliad.projects to surface project_title for the Verlauf
  attribution chip on /projects/{id}. Inner subquery aliased pp to
  avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
  enrichment. Other readers leave it nil and the JSON serialiser omits
  it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
  ?direct_only=true|false and passes through to the service. New
  handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
  DeadlineService.ListForProject + AppointmentService.ListForProject
  + ProjectService.ListEvents (live-DB test, skipped without
  TEST_DATABASE_URL).

Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
  /api/projects/{id}/deadlines + /appointments (legacy narrow) to
  /api/events?type=deadline|appointment&project_id={id} (the union
  endpoints, already aggregating + enriching with project_title). The
  Verlauf still uses /api/projects/{id}/events but with the new
  direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
  subtree (true). persistSubtreeMode replaceState keeps back-button
  friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
  Appointments sections. Shared state across the three; clicking any
  toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
  Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
  Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
  project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
  Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
  Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
  .aggregation-chip — small additive styling.

No schema. No migration. Phases 2 + 3 stack on top per design §7.
2026-05-06 16:24:31 +02:00
m
1eebf2fc44 Merge: t-paliad-141 — project team-add autocomplete fix + invite-new-user inline flow 2026-05-06 16:22:41 +02:00
m
fb1a709bb8 fix(t-paliad-141): project team-add autocomplete + invite-new-user inline flow
Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.

Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.

Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
2026-05-06 16:21:53 +02:00
m
e2e1381395 Merge: t-paliad-138 — dual-control approvals (4-eye principle) — migration 054 + ApprovalService + inbox/bell + pending pills + CalDAV PENDING + Verlauf integration 2026-05-06 16:10:29 +02:00
m
0d54da1d5b feat(t-paliad-138): Verlauf rendering for approval lifecycle events
Commit 8 of 8. Bilingual (DE primary / EN secondary) translations for
the four approval event_types per entity that ApprovalService emits
into paliad.project_events:

  deadline_approval_requested / _approved / _rejected / _revoked
  appointment_approval_requested / _approved / _rejected / _revoked

Each gets:
- event.title.<event_type>          — full Verlauf-card heading
- event.description.<event_type>    — full-sentence localized description
- dashboard.action.short.<event_type> — verb-form for the dashboard activity feed

The existing translateEvent dispatch in i18n.ts handles these
automatically — it already keys off event.title.<event_type> for the
title, and the deadline_* / appointment_* prefix branch in
translateEventDescription falls through to event.description.<event_type>
when the stored body has no quoted title (which is true for the
approval-event descriptions emitted by ApprovalService).

Result: every project's Verlauf tab now renders the full 4-eye
lifecycle trail inline alongside the existing deadline_created /
deadline_updated / etc. rows. The /admin/audit-log timeline picks
them up too via the union path.

Pair-card rendering (request + decision side-by-side keyed by
metadata.approval_request_id) was a stretch goal in the design doc;
the current per-event row rendering already conveys the full story
chronologically without needing that pairing logic.
2026-05-06 16:08:35 +02:00
m
deef5aaff5 feat(t-paliad-138): CalDAV [PENDING] prefix + reminder digest pending banner
Commit 7 of 8. Outbound surfaces honour the pending-approval state
instead of going silent on it.

CalDAV (caldav_ical.go formatAppointment): when an appointment is
approval_status='pending', the iCal SUMMARY line is prefixed with
"[PENDING] ". External clients (Outlook, Apple Calendar, etc.) thus
display the unverified state honestly. Approved entries sync clean.

Email reminder digest (reminder_service.go):
- digestRow gains ApprovalStatus, sourced from f.approval_status in
  the SELECT.
- Each pending row's Title is rewritten to "[PENDING] <title>" before
  it lands in the template — visible in every email-rendered list.
- Template data carries PendingCount (count of pending rows in this
  digest) + InboxURL so future template revisions can render a
  banner like "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
  /inbox" without further code changes. Existing templates unchanged
  for backwards compat; the prefix on row titles already conveys the
  signal.
- IsPending flag on each item map for future per-row template
  conditionals.

Rationale: silence on a pending change is the worst outcome for a
4-eye system. The user's external calendar and reminder mail must
reflect "this exists but isn't verified" so they can act before the
deadline lapses.
2026-05-06 16:07:14 +02:00
m
bc47d78d97 feat(t-paliad-138): pending pills on /events and /agenda
Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:

- /events (deadline + appointment list): ⚠ pill next to the title +
  soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.

Changes:

- internal/services/event_service.go: EventListItem gains
  ApprovalStatus *string; projectDeadline / projectAppointment
  populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
  f.approval_status / pending_request_id / approved_by / approved_at
  to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
  for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
  ApprovalStatus; the per-source SQL queries select it; the
  loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
  modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
  agenda-item headline.

Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.

Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
2026-05-06 16:05:00 +02:00
m
07a1c17861 feat(t-paliad-138): /inbox page + sidebar bell badge
Commit 5 of 8. End-user surface for the approval workflow:

- /inbox page (frontend/src/inbox.tsx + client/inbox.ts) with two tabs:
  "Zur Genehmigung" (requests I qualify to approve) and "Meine
  Anfragen" (requests I submitted). Each row shows the project, entity
  title, lifecycle event, requester name + age, the date-field diff
  (for update/complete/delete) and the relevant action buttons:
  approve + reject when on pending-mine, revoke when on mine.
  Historic rows render a status pill instead of buttons.
- Sidebar bell entry "Genehmigungen" (with sidebar-inbox-badge) under
  the Übersicht group. sidebar.ts polls /api/inbox/count every 60s and
  shows the count (or 9+ ceiling) when > 0.
- Server registration: GET /inbox → dist/inbox.html, gated by
  gateOnboarded. Already-registered API endpoints (commit 4) handle
  the data path.
- Bilingual (DE primary / EN secondary) i18n strings under
  approvals.* — labels, status names, lifecycle names, role names,
  decision-kind names, action verbs, error messages. ~50 new keys.
- Pending-state CSS classes: .approval-pill, .approval-pill--historic,
  .entity-row--pending-{create,update,complete,delete},
  #sidebar-inbox-badge. Soft-tint rows + amber pill so an approver
  can scan a list of pending entities at a glance. Used by commit 6
  (pending pills across surfaces) — no other surface picks them up
  yet, but the styles are wired and ready.
- Sidebar.tsx navItem signature gains an optional badgeID parameter
  so any future sidebar entry can host a count-badge with one extra
  argument (no per-entry custom rendering).
2026-05-06 16:00:17 +02:00
m
2247c0707d docs(t-paliad-139): design lock — m signed off on all 19 §6 recommendations
m's go/no-go pass at 2026-05-06 15:58: "I agree with all your recommendations
- go." All 19 questions in §6 lock as the recommended answers verbatim.

§0 status flipped from READY-FOR-REVIEW to LOCKED. New "Locked m decisions
on §6" subsection captures the highlights inline so future readers don't
have to scan the whole table to know what's pinned.

§13 end-of-design line updated to reflect the lock.

Implementation phasing (§7) unchanged:
- Phase 1: bug fix on the 3 narrow service methods (no schema, ~400 LoC,
  ships standalone, closes the user-visible /projects/{id} "Keine Fristen"
  bug).
- Phase 2: migration 055 (partner_unit_members.unit_role,
  project_partner_units, extended can_see_project()) + DerivationService +
  frontend Team-tab subsections + /admin/partner-units unit_role tagging
  + project /settings/team Partner Units section. Independent of t-138.
- Phase 3: approval extension — canApprove + inbox SQL widening for
  derived_peer decision_kind. Gates on cronus's t-138 (currently on
  mai/cronus/inventor-dual-control @ b3401ec) landing on main.

Inventor parked. Awaiting head's coder-shift assignment.
2026-05-06 15:59:37 +02:00
m
93c4453ce5 Merge remote-tracking branch 'origin/main' into mai/cronus/inventor-dual-control 2026-05-06 15:53:46 +02:00
m
a42322de3f Merge: t-paliad-140 — editable project on /deadlines/{id} + /appointments/{id} 2026-05-06 15:43:20 +02:00
m
457af2f6c4 fix(t-paliad-140): editable project on /deadlines/{id} + /appointments/{id}
Edit mode now exposes a project picker so a deadline or appointment can be
moved to a different matter. Backend Update accepts project_id (and
clear_project for appointments), validates visibility on the destination,
and emits *_project_changed audit rows on both the OLD and NEW project so
each side's Verlauf still shows the move.

Personal-to-project linking and project-to-personal unlinking are gated by
the existing personal-Appointment creator check; project-to-project moves
re-use the existing requireMutationRole gate plus a fresh visibility check
on the target.
2026-05-06 15:42:22 +02:00
m
abc395fcfa Merge: i18n fix — drop 'informieren' from Verfahrensablauf pathway label 2026-05-06 15:38:45 +02:00
m
747d85fe49 fix(i18n): drop nonsensical 'informieren' from Verfahrensablauf pathway label 2026-05-06 15:38:42 +02:00
m
6c41550945 docs(t-paliad-139): inventor design — hierarchy aggregation + effective team + PA derivation
Three coordinated sub-designs in one doc, scoped to m's locked constraints
(2026-05-06):

1. Surface-by-surface aggregation policy. Bug surface fix:
   /projects/{client_id} renders "Keine Fristen" because
   DeadlineService.ListForProject + AppointmentService.ListForProject +
   ProjectService.ListProjectEvents all WHERE project_id=$1 exact-match
   instead of walking paliad.projects.path descendants. The shipped t-124
   contract (projectDescendantPredicate, deadline_service.go:133 etc.)
   already aggregates correctly on the union endpoints — three legacy
   narrow paths just bypass it. Per-surface decision table for events /
   deadlines / termine / Verlauf / project tree counts / dashboard /
   CalDAV / email / search.

2. Effective-team semantics. Three structural gaps in the issue's
   premise (verified against schema):
   - No project↔unit junction (partner_unit involvement on a project).
   - No PA/lawyer distinction in partner_unit_members (no role column).
   - No lawyer↔PA pairing anywhere — Q11's "where is it stored" → nowhere.
   Proposes:
   - paliad.partner_unit_members.unit_role (lead|attorney|senior_pa|pa|paralegal),
     unit-scoped not firm-wide so 3-axis principle holds.
   - paliad.project_partner_units junction with derive_unit_roles[]
     (default {pa, senior_pa}) + derive_grants_authority bool.
   - Compute-on-read derivation via extended can_see_project() — no
     materialised state, no drift.
   - Display-effective vs visibility-effective team are different sets;
     rename ListEffectiveMembers to ListVisibilityEffectiveMembers + add
     ListSubtreeMembers.

3. Approval policy × hierarchy × derivation. Coordinates with t-138
   (cronus, mai/cronus/inventor-dual-control @ 7d1ddb9):
   - Q10: keep cronus's no-auto-inheritance, harden UX with a "Eltern-
     Politik (zur Information)" panel showing parent rules without
     applying.
   - Q12: derived members visibility-only by default; per-(project, unit)
     opt-in flag derive_grants_authority. When opted in, decision_kind
     extends with derived_peer for honest audit chronology.
   - canApprove + inbox SQL extension shape spec'd; coordinates with
     cronus's t-138 §3.4 / §7.4.

Locked m decisions surfaced in §0:
- Behaviour is surface-specific.
- Effective Team of a Client = direct ∪ descendants ∪ partner-unit-derived.
- PA derivation = unit-on-project trigger.
- Derivation honesty: annotated everywhere.
- paliad-only scope.

19 design questions with proposed answers in §6 for m to lock. Migration
055 specced (§5). Implementation phased into 3 PRs (§7) — Phase 1 bug fix
ships standalone if m wants quick win.

Inventor parked. Awaiting m go/no-go before coder shift.
2026-05-06 15:38:41 +02:00
m
fb6a07f4b7 feat(t-paliad-138): approval API endpoints (policy CRUD + inbox + decisions)
Combined backend API for the upcoming policy-authoring page (commit 4)
and inbox + bell (commit 5). Registers:

Policy CRUD (admin-only via RequireAdminFunc gate):
- GET    /api/projects/{id}/approval-policies
- PUT    /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
- DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}

Inbox (any authenticated user; service-layer query gates by visibility
+ role-tier match):
- GET /api/inbox/pending-mine     — requests I qualify to approve
- GET /api/inbox/mine             — requests I submitted
- GET /api/inbox/count            — bell badge count
- GET /api/approval-requests/{id} — one hydrated request

Decisions (caller authorization checked at service layer; the CHECK
constraint on approval_requests blocks self-approval as a second
defence):
- POST /api/approval-requests/{id}/approve
- POST /api/approval-requests/{id}/reject
- POST /api/approval-requests/{id}/revoke

Error mapping (writeApprovalError):
- ErrSelfApproval        → 403 self_approval_blocked
- ErrNoQualifiedApprover → 409 no_qualified_approver
- ErrConcurrentPending   → 409 concurrent_pending
- ErrNotApprover         → 403 not_authorized
- ErrRequestNotPending   → 409 request_not_pending

Frontend pages (the policy authoring tab on /projects/{id}/settings
and the /inbox page with bell) follow in subsequent commits — the
endpoints are usable via curl + admin tooling immediately.
2026-05-06 15:30:28 +02:00
m
10b3426086 feat(t-paliad-138): wire ApprovalService into deadline + appointment paths
Commit 3 of 8. The 4-eye gate now actually fires. With migration 054
applied and an approval_policies row configured for a project, the
relevant Create/Update/Complete/Delete on a Deadline or Appointment
flips approval_status='pending' and emits a *_approval_requested audit
event. Without policies, behaviour is unchanged.

Backend changes:

- models.Deadline + models.Appointment gain approval_status,
  pending_request_id, approved_by, approved_at; appointments also gain
  completed_at (for the appointment:complete lifecycle event).
- deadlineColumns + appointmentColumns include the new fields so
  every existing read path hydrates them via sqlx StructScan with
  no per-call-site changes.
- DeadlineService gains SetApprovalService (nil-tolerant). Wired in
  main.go after the bundle is built.
- AppointmentService gains the same hook + dependency.

Lifecycle wiring:

- DeadlineService.Create / Update / Complete / Delete each consult
  the approval gate. Update only triggers approval when a date-bearing
  field actually changes (Q4 allowlist: due_date, original_due_date,
  warning_date). Cosmetic edits (title, description, notes,
  rule_code, event_type_ids, status, completed_at via reopen) bypass.
- AppointmentService.Create / Update / Delete same shape. Update
  only gates on start_at / end_at changes. Personal appointments
  (project_id IS NULL) never gate (no project policy to consult).
- Delete is the one stage-then-write exception: the row stays alive
  with approval_status='pending' until the approver hard-deletes
  (approve) or restores it (reject). On no-policy projects, delete
  is immediate as before.
- Concurrent-pending guard: any mutation on a row whose
  approval_status='pending' returns ErrConcurrentPending. The user
  must wait for the in-flight request to settle (or revoke if
  they're the requester).

Pre_image capture: the date-bearing fields that are about to change
are snapshotted into the approval_requests.pre_image jsonb at submit
time. Reject/Revoke applies them back over the row to revert.
2026-05-06 15:27:40 +02:00
m
4ebbf2c1af feat(t-paliad-138): ApprovalService core + tests
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.

Public surface:

- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
  AppointmentService inside their existing tx. Looks up policy,
  runs the deadlock check, inserts paliad.approval_requests, marks
  the entity pending, emits the *_approval_requested project_events
  audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
  finalises the lifecycle (clears pending markers + sets approved_by
  for non-delete; hard-deletes for delete). Reject / Revoke revert
  the entity from pre_image (delete a pending-create, restore date
  fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
  PendingCountForUser — read paths the inbox + bell will hit in
  commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
  authoring page in commit 4.

Self-approval is blocked at three layers:
  1. canApprove() returns ErrSelfApproval when caller == requester.
  2. The DB CHECK constraint approval_requests_no_self_approval.
  3. The deadlock check excludes the requester from the pool.

Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).

Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
  approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
  reject-create deletes; reject-update restores pre_image;
  no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
  Skipped when TEST_DATABASE_URL is unset, mirroring the existing
  audit_service_test pattern.

No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.
2026-05-06 15:21:47 +02:00
m
b3401ec8ac feat(t-paliad-138): migration 054 — dual-control approvals schema
Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.

Migration 054 adds:

1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
   English `project_teams_role_check` and the German-legacy
   `projekt_teams_role_check` (live-DB constraint name carried over
   from migration 018's pre-rename era).

2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
   ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
   > pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
   the upcoming Go `levelOf()`.

3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
   required_role) — UNIQUE composite key gives at most 8 rows per
   project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
   for global_admin (defence-in-depth; service-role pool bypasses RLS,
   so the actual gate is service-layer).

4. `paliad.approval_requests` — operational pending workflow.
   pre_image jsonb captures revert state; payload echoes the diff;
   required_role snapshots the policy at request time. CHECK
   `decided_by != requested_by` is the second layer of self-approval
   block. RLS = same can_see_project predicate as deadlines /
   appointments — anyone with project visibility sees pending requests.

5. `approval_status` (default 'approved'), `pending_request_id`,
   `approved_by`, `approved_at` columns on both deadlines and
   appointments. `appointments.completed_at` (new) lands the
   appointment:complete lifecycle event.

6. Backfill: every existing deadline + appointment row marked
   approval_status='legacy'. Per Q11, no retroactive approval; the
   next mutation on a legacy row that hits an active policy follows
   the normal flow.

Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
2026-05-06 15:13:26 +02:00
m
7d1ddb9b84 docs(t-paliad-138): inventor design — dual-control approvals (4-eye)
Locked design for 4-Augen-Prüfung on Fristen + Termine. m-confirmed
decisions on all 11 open questions:

- Qualification gate reuses paliad.project_teams.role per-project
  (no new firm-wide axis). Adds new value `senior_pa` to the enum.
- Strict ladder: lead > of_counsel > associate > senior_pa > pa.
  Default required_role = associate. Per-project override allows pa-
  approves-pa or senior_pa-tier escalation.
- Per-(project, entity_type, lifecycle_event) policy grammar — up to
  8 settable rows per project in paliad.approval_policies.
- Edit-trigger allowlist = date-bearing fields only (Frist due_date /
  original_due_date / warning_date; Termin start_at / end_at).
- Write-then-approve: row mutates immediately, approval_status flips
  between approved/pending/legacy. Delete is the one stage-then-write
  exception (hard-delete on approve, restore on reject).
- Refuse + global_admin override on single-qualified-approver deadlock.
- Pending state visualised everywhere — list views, agenda, dashboard
  traffic-light, project detail, CalDAV-synced calendars (`[PENDING] `
  title prefix), email reminders.
- Bell + /inbox page with two tabs (zur Genehmigung / meine Anfragen).
- Operational paliad.approval_requests + audit lifecycle written to
  existing paliad.project_events (4 new event_types per entity).
- RLS = same can_see_project predicate; service layer enforces the
  approve/reject action gate. CHECK constraint blocks self-approval.
- Mark-legacy backfill: approval_status='legacy' on existing rows;
  next mutation flows through the gate.

Implementation phasing: single migration 054 + 8-commit PR plan
covering schema, service, wiring, policy authoring page, inbox,
pending pills, CalDAV/email integration, Verlauf rendering.

Inventor parked. Awaiting m go/no-go before any coder shift.
2026-05-06 14:58:01 +02:00
m
c1ceab7f4b Merge: t-paliad-122 — courts entity + per-country holidays (migration 053 + CourtService + HolidayService refactor) 2026-05-06 12:54:15 +02:00
m
733917aae2 feat(t-paliad-122): GET /api/tools/courts + Fristenrechner court picker
GET /api/tools/courts[?courtType=UPC-LD] returns the deadline-
computation slice of paliad.courts (id, code, names, country, regime,
court_type) — distinct from the rich Gerichtsverzeichnis at
/api/courts. Optional courtType filter narrows to a single tier.

POST /api/tools/fristenrechner and POST /api/tools/fristenrechner/
calculate-rule both accept an optional courtId field. When set, the
calculator resolves the court's (country, regime) and uses that
calendar; when omitted, the proceeding's existing jurisdiction column
seeds a sensible default — preserves today's behaviour for callers
that don't yet send a court.

Frontend: court-picker-row added to step 2 of the Fristenrechner
wizard. Visible only for proceeding types with multiple compatible
courts (today: every UPC-flavoured proceeding — UPC LDs span 12
countries, plus UPC CD seats and the CoA). DE-only proceedings (BPatG
nullity, BGH appeals, DPMA, EPA, EP grant) keep the form unchanged.
Picker re-runs the calc on selection so the user sees the same
deadlines shift to a different calendar without a manual click. i18n
key deadlines.court.label added for both DE and EN.

Default courts wired sensibly: UPC_INF / UPC_REV / UPC_PI etc. → UPC
LD München (HLC's home venue); UPC_APP / UPC_APP_ORDERS /
UPC_COST_APPEAL → UPC CoA Luxembourg; UPC_REV → UPC CD Paris.
2026-05-06 12:50:59 +02:00
m
d72990ad1b feat(t-paliad-122): country+regime aware HolidayService + CourtService
Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.

Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.

New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.

Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
2026-05-06 12:47:12 +02:00
m
a9d3695719 feat(t-paliad-122): migration 053 — courts entity + countries lookup + regime split
Adds paliad.countries (13 ISO-3166 codes), paliad.courts (41 entries
seeded from internal/handlers/courts.go), and the country/regime split
on paliad.holidays. The 33 t-paliad-121 UPC vacation rows previously
stored as country='UPC' migrate cleanly to country=NULL + regime='UPC'
— 'UPC' is a supranational regime, not an ISO country, and the new
shape lets a UPC LD München (country='DE', regime='UPC') pull both DE
federal holidays and UPC vacation entries while a UPC LD Paris
(country='FR', regime='UPC') pulls FR + UPC. Holidays now FK-protected
against typo'd country codes.
2026-05-06 12:37:08 +02:00
m
bf06499d9c docs(t-paliad-122): inventor design — courts entity + per-country holidays
Archives m's locked design call (2026-05-05 18:51) plus live-codebase
verification: paliad.holidays.country exists per-country; paliad.courts
does not (must create); proceeding_types.jurisdiction is regime not
country (do not remove); 41 hand-curated courts already in
internal/handlers/courts.go ready to seed; HolidayService.loadYear is
country-blind today (latent bug); germanFederalHolidays merge is
hardcoded (must become country-conditional). Task stays ON-HOLD until a
non-DE forum or EPO closure-day calendar comes into scope.
2026-05-05 23:51:47 +02:00
m
98cb65f2cc Merge: t-paliad-136 Phase B — card-click → calc panel → add-to-project (v4 complete) 2026-05-05 14:09:02 +02:00
m
b54e938bdf feat(t-paliad-136): Phase B — card-click → calc panel → add to project
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.

Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
  either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
  optional condition flags. Returns rule metadata + computed dueDate +
  originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
  + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
  t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
  Sommerferien skip both apply automatically. Court-determined rules
  (party='court' or event_type ∈ hearing/decision/order) return
  IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
  hasn't supplied the flag — the UI uses this to render checkboxes;
  toggling recomputes live. With all flags satisfied + alt_duration_*
  present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
  and error paths (skipped without TEST_DATABASE_URL).

Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
  card (only one open at a time). Pill picker (radio chips) appears
  when the card has 2+ rule pills; first preselected. Trigger date
  defaults to today (m's Q3). Flag checkboxes auto-render from the
  rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
  shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
  Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
  /api/projects/{id}/deadlines/bulk with a single-element array using
  source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
  drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
  (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
  have a single rule to compute.
- Escape collapses the open card.

CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.

UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
2026-05-05 14:04:54 +02:00
m
6c3a6efc34 Merge: t-paliad-136 Phase C — RoP-rigorous tree taxonomy revision (migration 052 + Go coverage test) 2026-05-05 13:38:59 +02:00
m
d22ace1019 feat(t-paliad-136): Phase C — RoP-rigorous tree taxonomy revision
Migration 052 fixes six concept↔leaf mismaps in the v3 seed and adds
three proactive entry leaves under spaetere-schriftsaetze.

1. cms-eingang.gericht.hinweisbeschluss — drop the response-to-
   preliminary-opinion | DE_INF row. DE_INF (LG) has no
   Hinweisbeschluss; the concept lives only in DE_NULL via PatG §83.

2. cms-eingang.gegenseite.upc-inf.klageschrift — drop the notice-of-
   defence-intention | UPC_INF row. UPC has no such rule in the corpus;
   R.23 reaction is captured by statement-of-defence directly.

3. UPC R.221 cost-appeal sequence (m's Q5): three leaves now surface
   BOTH application-for-leave-to-appeal | UPC_COST_APPEAL (sort 100,
   R.221.1, 15 days) AND notice-of-appeal | UPC_APP (sort 200,
   conditional on leave granted, R.220.1). Replaces the wrong notice-of-
   appeal | UPC_COST_APPEAL row that was silently dropping pills.

4. ich-moechte-einreichen.berufung.upc-coa-orders — replace the buggy
   application-for-leave-to-appeal | UPC_APP_ORDERS (no rule for that
   combo) with request-for-discretionary-review | UPC_APP_ORDERS
   (R.220.3).

5. cms-eingang.gericht.anordnung — narrow request-for-discretionary-
   review NULL → UPC_APP_ORDERS. R.220.3 review applies specifically
   to the Anordnungen / 15-day track.

6a. reply-to-cross-appeal coverage: add UPC_APP rows under upc-{inf,
    rev}.berufungsschrift so the reply leaf is reachable when the
    opponent files an Anschlussberufung.

6b. New leaves under ich-moechte-einreichen.spaetere-schriftsaetze for
    proactive entry: r116-eingaben (EPA R.116 final submissions),
    anschlussberufung-upc (R.237), reply-to-cross-appeal-upc (R.238).

NO `RAISE EXCEPTION` coverage gate (m's Q7) — last night's outage was
caused by exactly that pattern in migration 049. Replaced with a Go-
side test in event_category_coverage_test.go that asserts every
category='submission' concept is reachable from at least one leaf
(except the prosecution-only exempt list: filing, request-for-
examination, approval-and-translation). Skipped without
TEST_DATABASE_URL; CI gates on it.

bescheid-mit-frist mapping deferred per m's Q4. Will land separately.

Migration verified via supabase MCP dry-run + ROLLBACK on the live
youpc DB; end-state matches design §3.2-§3.4.
2026-05-05 13:29:47 +02:00
m
7f292e5fa5 Merge: t-paliad-136 Phase A — filter narrowing carries (concept, proc) tuples end-to-end 2026-05-05 13:09:03 +02:00
m
b7470d7d77 fix(t-paliad-136): Phase A — filter narrowing carries (concept, proc) tuples
The v3 B1 decision tree filter collapsed each leaf's
(concept_id, proceeding_type_code) tuple list down to a flat concept_id
slice in EventCategoryService.ConceptIDsForSlug, dropping the per-leaf
proceeding constraint. The search service then loaded pills by
concept_id only, so picking a UPC-specific leaf still surfaced DE/EPA/
DPMA pills for any shared concept (Klageerwiderung, Replik, Duplik,
Berufungsschrift). m's repro: choosing CMS-Eingang → Gegenseite →
UPC Verletzung leaked national submissions.

Confirmed via DB: at least 25 leaves were over-broad pre-fix.

Fix carries the tuple set end-to-end via a new subtreeFilter type with
parallel uuid[] / text[] arrays. The matview SQL now uses
unnest($cids, $procs) AS t(cid, pcode) to match each row against the
allowed tuples — a junction row with NULL proc encodes "any proc for
this concept" (used by cross-cutting concepts like Wiedereinsetzung).

EventCategoryService gains AllOutcomes() for browse-all so the root
view also respects junction tuples. allMappedConceptIDs is gone.

Tests: added 5 v4 subtests under TestDeadlineSearch covering m's
repro slug, multi-tuple narrowing, trigger-pill cross-cutting,
forum AND-narrowing, plus an invariant regression gate that walks
every leaf with non-NULL proc and asserts no pill leaks. Skipped
when TEST_DATABASE_URL is unset; existing v3 assertions unchanged.

No schema change. No migration. Ships independently of Phases B/C.
2026-05-05 13:02:09 +02:00
m
30ac337a78 docs(t-paliad-136): Fristenrechner v4 inventor design
v4 addresses three concerns from m on 2026-05-05 in priority order:

1. Card-click → compute deadline → add-to-project (v3 cards were dead-ends).
2. Filter narrowing bug — slug → concept_id allow-list dropped per-leaf
   proceeding_type_code, so picking "UPC infringement opposing party"
   leaked DE/EPA/DPMA pills. Confirmed via DB query: 25+ leaves overbroad.
3. RoP-rigorous tree audit: 6 confirmed seed errors (Hinweisbeschluss
   DE_INF mismap, notice-of-defence-intention UPC_INF mismap, three
   cost-appeal notice-of-appeal mismaps, request-for-discretionary-review
   needs UPC_APP_ORDERS narrowing), plus reply-to-cross-appeal coverage
   gap and bescheid-mit-frist orphan.

Plan splits into three independent phases (A: filter fix, no schema; B:
card-click flow + new calculate-rule endpoint; C: taxonomy migration 052
without RAISE EXCEPTION coverage gates per last night's outage lesson).

Inventor → coder gate held: no production code in this commit.
2026-05-05 12:11:36 +02:00
m
25b4491681 Merge: t-paliad-135 — Print stylesheet (hide chrome/forms/buttons, show only result content) 2026-05-05 12:09:39 +02:00
m
3d905a0694 Merge: t-paliad-137 — Decision tree B1 remove Skip button + fix lime-on-light Step back contrast 2026-05-05 12:09:02 +02:00
m
19a1b8c942 fix(t-paliad-137): remove B1 "Skip step" + fix step-back contrast
The B1 decision tree exposed a "Skip this step" affordance on
intermediate non-leaf nodes that broke the narrowing model — clicking
it left the tree in a half-narrowed state with no clear UX intent.
Drop the button entirely; users who don't know an answer should pick
"Anderes / Sonstiges" or switch to B2 (filter mode).

The step-back button (and its sibling .fristen-b1-loosen-link in the
empty-result state) rendered with `color: var(--color-accent)` over a
transparent background — lime green text on cream is unreadable. Move
both to a secondary-button shape: hairline border, muted text, accent
on focus-visible. Both light and dark themes verified.

Touched:
  - frontend/src/client/fristenrechner.ts: drop skip TSX + handler
  - frontend/src/client/i18n.ts: drop "deadlines.pathway.b.tree.skip"
  - frontend/src/i18n-keys.ts: drop the codegen key
  - frontend/src/styles/global.css: split off .fristen-b1-skip selector
    and replace the lime-text rule with a bordered secondary style
    using --color-text-muted / --color-border (themed both ways)
2026-05-05 12:07:06 +02:00
m
acaab22ad7 feat(t-paliad-135): print stylesheet — hide chrome, forms, buttons; show only result content
When the user prints (browser dialog or any Drucken button) the page now
strips everything except the actual result content. Hidden: sidebar nav,
bottom-nav, top header, footer, breadcrumbs, all forms (.tool-input,
.filter-row, .entity-controls, search bars, gebühren-lookup, etc.), the
Fristenrechner pathway-fork buttons, B1 decision-tree cascade, B1/B2 mode
toggle, view toggle, result-action buttons, every <button>. Visible:
timeline / columns view / cost breakdown / gericht cards / entity tables
/ glossar entries / checklist items, plus the page heading + subtitle so
the printed page is identifiable.

Per-page print rules above (kostenrechner / gebühren / checklisten /
gerichte) keep their existing specifics; this block is the catch-all for
chrome those rules miss.

Verified via Playwright print emulation on /dashboard, /tools/kostenrechner,
/tools/fristenrechner (Verfahrensablauf list + Spalten view), /events.
2026-05-05 11:57:09 +02:00
m
931673337a Merge: t-paliad-134 v2 — pill ordering + name standardisation + chip dedup + legal_source fix 2026-05-05 11:54:13 +02:00
m
63eb5bde6f feat(t-paliad-134): pill ordering + name standardisation + chip dedup
Five m's-bookmark fixes on top of the B1 surface change:

1. Sort proceeding pills inside concept cards by real-world frequency.
   New paliad.proceeding_types.display_order column (m's spec values:
   UPC_INF=10, DE_INF=20, UPC_REV=30, ..., UPC_PI=920, ...). Default
   999 for unmapped legacy codes. Search service surfaces it through
   the deadline_search matview (rebuilt to add the column) and uses
   it as primary key in pillSortKey, replacing the jurisdiction-rank.

2. Name standardisation: -klage → -verfahren on the proceeding-types
   that describe a multi-step process. Specifically:
     UPC_REV  Nichtigkeitsklage              → Nichtigkeitsverfahren
     UPC_APP  Berufung                       → Berufungsverfahren
     DE_INF   Verletzungsklage (LG)          → Verletzungsverfahren (LG)
     DE_INF_OLG, DE_NULL_BGH, DPMA_OPP, DPMA_BPATG_BESCHWERDE,
     UPC_COST_APPEAL, UPC_APP_ORDERS, DPMA_BGH_RB, DE_INF_BGH —
     same -verfahren standardisation.

3. legal_source for rev.defence × UPC_REV: was NULL, leaking the
   internal local_code 'rev.defence' to the UI. Set to UPC.RoP.49.1
   (Defence to Application for Revocation, R.49.1).

4. Frontend renderPill no longer falls back to rule_local_code when
   legal_source is missing — the source span just collapses, so no
   internal slug ever shows up as a "citation".

5. Quick-pick chips refactored to a slug-based array (QUICK_CHIPS) in
   fristenrechner.tsx, single source of truth for both fork-shortcut
   and B2-search-bar rows. Each chip carries data-chip-name-de /
   data-chip-name-en; relabelChips() rewrites visible text per active
   language. Dropped the duplicate "Statement of Defence" chip (same
   concept as "Klageerwiderung"). Each chip now maps to one concept
   slug — Klageerwiderung→statement-of-defence, Berufung→notice-of-
   appeal, Einspruch→opposition, Replik→reply-to-defence,
   Beschwerde→nichtzulassungsbeschwerde, Schadensbemessung→
   application-for-determination-of-damages, Wiedereinsetzung→
   wiedereinsetzung.

Migration 051 uses RAISE WARNING (not EXCEPTION) on coverage gates
per the 049 outage lesson — partial-migration recovery beats whole-
transaction failure. Matview rebuild stays inside the transaction;
RefreshSearchView() on next boot is a cheap no-op.
2026-05-05 11:53:13 +02:00
m
cc0059d050 Merge: t-paliad-134 — Fristenrechner v3 B1 result cards (browse-all + narrow-on-tree-click) 2026-05-05 11:42:54 +02:00
m
b32cfed37d feat(t-paliad-134): B1 surface — render concept cards beneath decision tree
Pathway B B1 mode previously rendered an empty result area on every
state — the runB1Search() output target was #fristen-search-results,
which lives inside the B2 panel. When B2 is hidden (B1 active), the
results were written into a hidden subtree and never seen.

Changes:
- TSX: add #fristen-b1-results inside #fristen-b1-panel, below the
  cascade button row.
- frontend/fristenrechner.ts: extract renderSearchResultsInto() and
  wirePillClicks(); runB1Search now writes to fristen-b1-results,
  fetches /api/.../search?browse=all when no slug is picked yet (full
  landscape on entry), and applies CSS-driven loading dim with a seq
  guard against out-of-order responses. Hoisted loadAndRenderB1() so
  showBMode("tree") can trigger the tree load on Pathway B entry
  (radio.checked = true does not fire change events).
- backend: SearchOptions.BrowseAll, allMappedConceptIDs() returning
  the union of every concept reachable from any leaf via
  paliad.event_category_concepts, lifted limit ceiling for browse
  modes (default 200, max 500). Handler exposes ?browse=all.
- CSS: shared loading-state styling for fristen-b1-results.
2026-05-05 11:39:30 +02:00
m
ff36528148 fix(t-paliad-133): add reply-to-cross-appeal to coverage exempt list
Migration 049 went dirty in prod because the coverage gate at the end
(DO $coverage$) raised on 'reply-to-cross-appeal' — it's defined as a
submission concept but no leaf in the decision-tree seed maps to it.

reply-to-cross-appeal is a downstream-of-cross-appeal concept, only
reachable after the user has already entered the cross-appeal Pathway B
branch via 'response-to-appeal'. Adding a dedicated leaf would be
useful UX (file a follow-up), but for now exempting it from the
coverage gate matches the established 'pure-administrative' exemption
pattern used for filing / request-for-examination / approval-and-translation.

Manual recovery: set tracker version=48 dirty=false on prod (schema
from 048 was already applied via supabase MCP). Dokploy redeploy will
now run 049 + 050 cleanly and reach version=50.

Refs: t-paliad-133 prod outage 11:15-11:30 Tue 05.05.2026
2026-05-05 11:22:14 +02:00
m
f40b652d01 Reapply "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commit 5bd17de732.
2026-05-05 11:18:38 +02:00
m
5bd17de732 Revert "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commit f7d72ff1d3, reversing
changes made to 1ea983f0c7.
2026-05-05 11:17:58 +02:00
m
f7d72ff1d3 Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs) 2026-05-05 11:15:46 +02:00
m
568bc99a36 feat(t-paliad-133): Phase E — retire legacy mode tabs
m's spec lock §10 Q1 (2026-05-05): "Retire legacy tabs - we are only
resorting." This commit drops the .fristen-mode-tabs nav (Verfahrensablauf
+ Was kommt nach…) and the ?legacy=1 escape hatch. Pathway A becomes
Verfahrensablauf-only; the trigger-event panel (mode-event-panel) stays
in the DOM but is hidden by default and surfaces only via concept-card
pill drill-in (drillToTrigger flips the panels directly).

Frontend deltas:
- frontend/src/fristenrechner.tsx: drop .fristen-mode-tabs section;
  rename mode-event-panel role/label to standalone tabpanel.
- frontend/src/client/fristenrechner.ts:
  - drop isLegacyMode() + ?legacy=1 branch in showPathway().
  - drillToTrigger() now flips procedure ↔ event panels directly
    (no more #mode-event-tab click → handler chain).
  - initModeTabs() bails on tabs.length===0 (already does); no
    further changes needed.
- frontend/src/styles/global.css: drop .fristen-pathway-shell--legacy.

Backend untouched.

Build: clean. Frontend bundle 1473 keys unchanged. go build + vet +
tests pass.

The deadlines.mode.procedure / deadlines.mode.event i18n keys remain
in i18n.ts as orphans for now; cleaning them up is purely cosmetic
and lives outside the v3 scope.
2026-05-05 11:07:41 +02:00
m
c399caff75 feat(t-paliad-133): Phase D-1 — B2 forum filter chip UI + URL state
Wires the v3 Gericht/System multi-select filter on the Pathway B/B2
panel. 10 forum-bucket chips per m's spec lock §10 Q8 (UPC CFI, UPC
CoA, DE LG/OLG/BGH/BPatG, EPA Erteilung/Einspruchsabt./Beschwerdek.,
DPMA).

UX:
- Chip click toggles its membership in activeForums Set.
- Multi-select; chips AND across the result set
  (UNION within forum, AND with other filters — backend handles).
- ?forum=<comma-separated> URL state round-trips on every toggle.
- popstate restores active set; lang switch re-renders chip labels.
- Shared between B1 and B2: tree-mode reissues runB1Search;
  filter-mode dispatches input event on the search box.

Frontend file deltas:
- frontend/src/client/fristenrechner.ts: FORUM_BUCKETS array,
  activeForums Set, renderForumChips(), reissueSearchWithCurrentFilters()
  (mode-aware), getActiveForumsParam() consumed at every search call.
- B2 search fetch + B1 cascade fetch both send ?forum= when active.

Frontend i18n keys for the 10 forum labels (DE+EN) shipped with
Phase B; this commit just renders them.

Backend was wired in Phase C; this commit completes the user-facing
path. Forum filter narrowing applies AND-wise with q / event_category_slug
/ proc / party / source — empty-result UX shows the existing "no hits"
status, m can drop a chip to widen.

Build: clean. Frontend bundle unchanged size delta (≈+50 lines, 1473 keys).

Phase D-2 (party-perspective selector + is_bilateral mirroring renderer)
ships next.
2026-05-05 11:05:37 +02:00
m
7141f4a954 feat(t-paliad-133): Phase C — B1 decision tree cascade + search extension
Wires the v3 Pathway B / B1 decision-tree cascade end-to-end. The
existing Phase D search backend gains two new query params, and the
frontend gets a data-driven button-cascade UI that walks
paliad.event_categories step-by-step.

Backend extension:
- internal/services/deadline_search_service.go
  - SearchOptions gains EventCategorySlug + Forums fields.
  - DeadlineSearchService gains an EventCategoryService dependency
    via SetEventCategoryService(); wired in main.go after both
    services exist (cross-link order).
  - ForumToProceedingCodes map (10 buckets per m's spec lock §10 Q8)
    translates v3 forum slugs to proceeding_type codes. Lives in Go
    so rebucketing = code change, not migration.
  - browseRanks() new query path: when q is empty AND
    EventCategorySlug is set, synthesise rank rows from the slug's
    reachable concept_ids — no trigram, just sort by
    concept_sort_order. Drives B1 narrowing.
  - rankConcepts() + loadPills() gain optional concept_id allow-list
    + forum_codes filters via UNIQUE NULLS NOT DISTINCT-shaped IS-NULL-OR
    PARAM clauses. Trigger pills (kind='trigger') always pass forum
    filter — they're cross-cutting by design.

- internal/handlers/fristenrechner_search.go
  - Reads new ?event_category_slug= and ?forum= (comma-separated)
    query params. Forwards to SearchOptions.
  - parseCSV() helper.

Frontend B1 cascade:
- frontend/src/client/fristenrechner.ts
  - loadEventCategoryTree(): one-shot fetch + in-memory cache of
    /api/tools/fristenrechner/event-categories.
  - renderB1Cascade(slug): renders breadcrumb + step question +
    button row + skip-step + step-back. Buttons walk down, breadcrumb
    walks back. Empty path = root question + 6 root buttons.
  - runB1Search(slug): hits /api/tools/fristenrechner/search?event_category_slug=
    and reuses Phase D's renderSearchResults() for the card list.
    Empty-result path shows "Schritt zurück" link (m's spec lock §10 Q6
    rephrase from "Pfad lockern").
  - URL state ?b1=<dot-path> round-trips. popstate restores cascade.
  - Pathway B default mode flips from filter → tree (B1 is now the
    discovery surface; B2 is for power users).

Frontend i18n: +1 key (deadlines.pathway.b.tree.start_question).

Frontend CSS: .fristen-b1-breadcrumb, .fristen-b1-crumb,
.fristen-b1-question, .fristen-b1-buttons, .fristen-b1-button (with
--leaf modifier border-left accent), .fristen-b1-skip,
.fristen-b1-step-back rules.

Frontend build clean (1473 keys). go build + vet + tests clean.
2026-05-05 11:03:34 +02:00
m
1182771fed feat(t-paliad-133): Phase B — landing fork UI + URL state
Reshapes /tools/fristenrechner into the v3 landing fork. Default
view: two big pathway cards (📖 Verfahrensablauf informieren
vs 📅 Frist eintragen aufgrund Ereignis) plus a quick-pick chip
shortcut row that jumps straight into Pathway B + filter mode +
prefilled query.

URL state machine:
- ?path=a  → Pathway A (existing wizard, wrapped in fristen-pathway-a)
- ?path=b  → Pathway B shell with mode toggle (B1 tree / B2 filter)
  - ?mode=tree   → B1 panel (stub for Phase B; Phase C wires the cascade)
  - ?mode=filter → B2 panel (search bar + chips + concept-card results)
- ?path absent → landing fork
- ?legacy=1 → pre-v3 layout (legacy escape hatch; dropped in Phase E)
- localStorage remembers last-used pathway

Pathway B's B2 panel hosts the existing Phase D search bar (relocated
from page-top into the pathway shell). The forum-filter row + chips
container exist in the DOM hidden — Phase D wires them.

Pathway A wraps the existing Verfahrensablauf wizard (proceeding tile
grid + date input + timeline / columns view) plus the legacy "Was
kommt nach…" tab. Both keep working unchanged in this commit; tabs
retire entirely in Phase E.

Phase B B1 panel is a stub: "Der Entscheidungsbaum ist in Vorbereitung."
Phase C replaces it with the data-driven cascade.

Files:
- frontend/src/fristenrechner.tsx: landing fork + pathway shells
- frontend/src/client/fristenrechner.ts: pathway state machine,
  URL parser, popstate restore, fork-chip → ?path=b shortcut
- frontend/src/client/i18n.ts: 30+ new keys (deadlines.pathway.*,
  deadlines.filter.forum.*, deadlines.perspective.*) DE+EN
- frontend/src/styles/global.css: .fristen-pathway-fork,
  .fristen-pathway-card, .fristen-pathway-shell, .fristen-mode-toggle,
  .fristen-forum-filter, .fristen-forum-chip rules

Frontend build: clean (1472 i18n keys). go build + vet: clean.

The legacy tabs (Verfahrensablauf-Tab + Was kommt nach…) live inside
Pathway A and continue to work — m's spec lock §10 Q1 retires them
in Phase E, not now.
2026-05-05 10:56:58 +02:00
m
2c770ef02f feat(t-paliad-133): Phase A — EventCategoryService + handler + route
Backend layer for the v3 decision tree:

- internal/services/event_category_service.go (NEW)
  - Tree(): nested tree of all active event_categories for the
    Pathway B / B1 cascade UI. Uses single SELECT + in-memory
    parent-child stitching; corpus is small (≤100 nodes).
  - ConceptsForSlug(): recursive CTE walks descendants of a slug and
    joins event_category_concepts to return the candidate concept
    outcomes (with optional proceeding_type_code narrowing).
  - ConceptIDsForSlug(): convenience reduction for
    `WHERE concept_id = ANY(...)` queries against the existing
    deadline_search matview.
  - ProceedingCodesForSlug(): per-leaf proceeding-code narrowing for
    Phase D's forum filter intersection.

- internal/handlers/fristenrechner_event_categories.go (NEW)
  - GET /api/tools/fristenrechner/event-categories returning the
    nested tree as JSON. Frontend will ETag-cache via localStorage.

- Wired EventCategory into handlers.Services + dbServices + main.go.

The existing /api/tools/fristenrechner/search handler stays
unchanged in this commit; Phase D will add ?event_category_slug=
and ?forum= query params on top.

Build + vet clean.
2026-05-05 10:51:58 +02:00
m
4d820892e8 feat(t-paliad-133): Phase A — event taxonomy schema + seed + bilateral flag
Three migrations land the data layer for the Fristenrechner v3 decision
tree (Pathway B / B1) plus the bilateral-rule flag for the new party-
perspective selector. All purely additive — no breaking changes to the
v2 (t-paliad-131) corpus.

Migration 048 — schema:
- paliad.event_categories: recursive taxonomy tree (parent_id self-FK,
  unique slug as materialised dot-path, step_question_de/en on internal
  nodes, is_leaf bool, optional emoji icon).
- paliad.event_category_concepts: many-to-many junction (leaf →
  deadline_concepts) with optional proceeding_type_code narrowing.
  UNIQUE NULLS NOT DISTINCT prevents duplicate (leaf, concept, NULL)
  rows (PG 15+).
- paliad.deadline_rules.is_bilateral bool: when true AND
  primary_party='both', the rule mirrors into both party columns of
  the v3 columns view; otherwise 'both' resolves single-side via the
  perspective selector.

Migration 049 — seed taxonomy:
6 root buckets (cms-eingang, muendl-verhandlung, beschluss-entscheidung,
frist-verpasst, ich-moechte-einreichen, sonstiges) with 70+ leaves and
115+ junction rows. Tree depth reaches 4 today (cms-eingang › gericht
› endentscheidung › <leaf>) but the schema supports unlimited depth
per design lock §10 Q2. Coverage gate at the end raises if any
category='submission' concept is unreachable from a leaf, except the
3 pure-administrative slugs (filing, request-for-examination,
approval-and-translation) that live on Pathway A only.

Migration 050 — bilateral backfill:
Tags exactly 4 genuinely-bilateral rules:
- de_null.stellungnahme (Stellungnahme zum Hinweisbeschluss, PatG §83.2)
- epa_opp.r79_further (Stellungnahme weiterer Beteiligter)
- epa_opp.r116, epa_app.r116 (Eingaben vor mündl. Verhandlung)
All other primary_party='both' rules (Berufungsfristen, Anschlussberufung,
…) are role-swap appeals that resolve via the perspective selector at
render time.

Schema dry-run validated end-to-end against Supabase PG 15.8.

Design ref: docs/plans/unified-fristenrechner-v3.md §4.1 + §10 Q12.
2026-05-05 10:49:18 +02:00
m
7e363ac01d design(t-paliad-133): lock v3 design with m's answers (10:33)
m approved all 12 open questions in one batch. Locked spec:

1. Legacy tabs RETIRED in Phase E.
2. Decision-tree depth UNLIMITED (was: 4 max). Property of
   event_categories data, not hard-coded.
3. Clickable breadcrumb for navigation.
4. Partial-path bookmarks (?b1=...).
5. Multi-select forum filter, default 1 selected.
6. Path-matching cards at each step. Renamed "Pfad lockern" →
   "Schritt zurück".
7. Emojis only, no separate colour treatment.
8. Forum buckets simplified to 10: UPC CFI + UPC CoA + DE LG/OLG/
   BGH/BPatG + EPA Erteilung/Einspruchsabt./Beschwerdek. + DPMA.
   m collapsed UPC LD/CD into UPC CFI (rules identical).
9. B1↔B2 share filter state.
10. Single branch / sequential commits / one final merge.
11. Party perspective default Claimant/Proactive; localStorage
    remembers last-used. URL ?my_side= + ?appeal_filed_by=.
12. Bilateral rules tagged via new is_bilateral column on
    deadline_rules; mirroring only when flagged.

Maria's two scope additions folded in:
- Court-system granularity for forum filter (clarification).
- Party-perspective selector absorbing t-paliad-132.

Implementation now starting on this branch.
2026-05-05 10:37:44 +02:00
m
2ed476dc64 design(t-paliad-133): Fristenrechner v3 — Pathway A vs Pathway B fork
m's 2026-05-05 brief restructures the page surface that v2 (t-paliad-131)
shipped. The current Fristenrechner stacks three blurred entrypoints —
Phase D search bar, Verfahrensablauf tile grid, "Was kommt nach…" tab.
v3 forks the page so each mental model has its own entry:

- Pathway A — Verfahrensablauf informieren (Browse): existing wizard.
- Pathway B — Frist eintragen aufgrund Ereignis (Event → Deadline),
  subdivided into:
  - B1 Entscheidungsbaum: data-driven button cascade (CMS-Eingang →
    Vom Gericht → Hinweisbeschluss → cards), max 4 deep, back +
    breadcrumb + bookmark URLs.
  - B2 Filter / Suche: Phase D concept-card search PLUS new
    Gericht/System multi-select chip filter (Q8 reversal). All filters
    AND-narrow.

Adds two new tables (Phase A — purely additive):

- paliad.event_categories — recursive taxonomy tree, with step
  questions on non-leaf nodes.
- paliad.event_category_concepts — leaf → concept junction with
  optional proceeding_type_code narrowing.

Existing data layer (deadline_concepts, deadline_rules, trigger_events,
deadline_search matview) untouched. Phase D search handler gains
?event_category_slug= and ?forum= query params; forum-bucket map lives
in Go (UPC / DE LG / DE OLG / DE BGH / DE BPatG / EPA / DPMA).

Phasing: A (data) → B (landing fork) → C (B1 tree) → D (B2 forum
filter) → E (retire legacy tabs, gate-gated). Each phase independently
shippable.

Open questions for m at §10: retire legacy tabs, decision-tree depth,
back/breadcrumb, partial-path bookmarks, multi vs single-select forum,
all-vs-path-matching cards per step, austere icons, 7 forum buckets,
B1↔B2 state-sharing, PR phasing.

Inventor parked. Next: m's go/no-go before coder shift.

Cross-references docs/plans/unified-fristenrechner.md (v2, shipped) for
concept-layer / search-backend / coverage details v3 inherits unchanged.
2026-05-05 10:21:20 +02:00
m
1ea983f0c7 Merge: t-paliad-131 Phase D — concept-card UI on /tools/fristenrechner (FINAL — full unified Fristenrechner shipped) 2026-05-05 05:13:10 +02:00
m
1e5df8201b feat(t-paliad-131): Phase D — concept-card UI on /tools/fristenrechner
Closes the user-facing half of the unified Fristenrechner. The proceeding
tile grid + the two existing modes (Verfahrensablauf / Was kommt nach…)
stay in place per m's "augment, not replace" — the search bar lives
above them and drills *into* either mode pre-selected.

frontend/src/fristenrechner.tsx:
  - New search section above the mode tabs:
      • search input with magnifier icon and clear (✕) button
      • 8 quick-pick chips per design Q8 (Klageerwiderung · Berufung ·
        Einspruch · Replik · Beschwerde · Statement of Defence ·
        Schadensbemessung · Wiedereinsetzung)
      • #fristen-search-results container the client renders cards into
  - i18n keys live in deadlines.search.* with DE primary / EN mirror.

frontend/src/client/fristenrechner.ts:
  - Search subsystem with the same debounce-and-sequence-counter pattern
    the existing event-mode and procedure-mode calc paths use.
  - GET /api/tools/fristenrechner/search?q=…&limit=12 with same-origin
    credentials. Empty q clears results; failures fall back to the
    "no hits" placeholder.
  - Concept card layout: name + alt-language name, optional description,
    "auch bekannt als" line for matched aliases, and one pill per
    (proceeding × rule). Cross-cutting trigger pills (Wiedereinsetzung,
    Versäumnisurteil, Schriftsatznachreichung, Weiterbehandlung) render
    in a separate pills section labelled "Verfahrensübergreifend:".
  - Pills are <a href="…drill_url"> elements so middle-click / cmd-click
    opens in a new tab; the JS click handler intercepts plain clicks
    and drills client-side:
      • rule pill   → activate procedure mode tab + selectProceeding(code)
                      + pendingFocus(rule_local_code) so the next
                      renderProcedureResults scrolls to and pulses the
                      focused row (.fristen-focus-highlight, 2.4 s ease).
      • trigger pill → activate event mode tab + selectTriggerEvent(id).
  - URL state on ?q=… via history.replaceState; popstate restores.
    Initial load reads ?q= from the URL so /tools/fristenrechner?q=foo
    shareable links work.
  - onLangChange re-fires the search so card / pill labels follow the
    active locale (matches the existing onLangChange wiring for
    procedure + event results).

frontend/src/styles/global.css:
  - .fristen-search input + .fristen-search-chip + .fristen-search-icon
    (magnifier inset 14px from the left, search-input padded 2.6rem
    on the left to clear it).
  - .fristen-card / .fristen-pill grid layout with party badges in the
    project's existing accent palette (claimant blue, defendant red,
    both grey, court amber). Mobile @media collapses the pill grid
    to a 2-column shape so legal_source + duration stack cleanly.
  - .fristen-focus-highlight keyframes for the post-drill pulse.

Out of scope for this shift (deferred):
  - "Vollständige Instanzenkette" toggle (design Q5). The toggle is a
    multi-stage timeline render that calls Calculate independently per
    stage with one date input per stage anchor — a calculator-side
    feature, not the search bar. Will land as a follow-up phase.
  - Columns-view sequence preservation for undated court-set events
    (design §7 "Out of scope — separate task" note). Already flagged
    as a separate task to file.

Validation: `bun run build` clean (1443 i18n keys, no orphans);
`go build ./... && go vet ./... && go test ./internal/...` green
across all packages. The dist bundles confirm the new symbols
landed in fristenrechner.js (search wiring), global.css (48 hits on
new selectors), and fristenrechner.html (9 unique fristen-search-*
classes). Live browser verification with auth happens after merge —
the route is auth-gated and the playwright profile is held by
another process, so a static smoke test against the dist HTML
isn't representative of the rendered authenticated page.
2026-05-05 05:04:53 +02:00
m
7bd223ecd9 Merge: t-paliad-131 Phase C — search backend (matview + service + handler) 2026-05-05 04:42:40 +02:00
m
b45278b060 feat(t-paliad-131): Phase C — search backend (matview + service + handler)
Closes the search half of the unified Fristenrechner. Phase D (concept-card
UI on /tools/fristenrechner) follows in a subsequent shift.

Migration 047:
  - Seed the missing `wiedereinsetzung` concept and re-point the four
    Wiedereinsetzung trigger_events (200..203) at it. PR-7 referenced
    the slug `re-establishment-of-rights` but never seeded the concept,
    so the four cross-cutting triggers were dropping out of any concept-
    JOINing query. Per m's slug rule (Q1: shared cross-cutting concepts
    use DE slug because German term dominates HLC vocabulary).
  - Create paliad.deadline_search materialised view: UNION ALL of
    (deadline_rules joined to deadline_concepts) and (trigger_events
    joined to deadline_concepts via slug). Trigram GIN indexes on
    legal_source / concept_name_de / concept_name_en / rule_name_de /
    rule_name_en / rule_code; gin (concept_aliases) for array
    containment; UNIQUE INDEX on a synthetic row_key so refresh can
    run CONCURRENTLY.

Refresh strategy: data only mutates via migration files at server
startup, so no AFTER triggers and no pg_cron — main.go calls
services.RefreshSearchView right after db.ApplyMigrations. CONCURRENTLY
keeps reads online and stays well under 100 ms at < 1k rows.

Service `internal/services/deadline_search_service.go`:
  - Two-query pipeline per request: (1) rank concept_ids by
    GREATEST(similarity()) across name / aliases / legal_source / rule_code
    plus a 0.2 alias-hit boost; (2) load all matview rows for the top-N
    concepts and assemble per-pill JSON.
  - normalizeQuery strips legal-prefix noise (`§`, `Art.`, `Section`,
    `Rule `) so users typing `§ 82` find DE.PatG.82.1 even though the
    structured legal_source column doesn't carry the prefix.
  - FormatLegalSourceDisplay renders structured codes back to the
    pleading form HLC users expect:
        UPC.RoP.23.1   → "UPC RoP R.23(1)"
        DE.PatG.82.1   → "PatG §82(1)"
        EU.EPÜ.108     → "EPÜ Art.108"
        EU.EPC-R.79.1  → "EPC R.79(1)"
        EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
  - Drill URLs route per kind: rule pills → ?proc=…&focus=…, trigger
    pills → ?mode=event&triggerId=…

Handler `GET /api/tools/fristenrechner/search?q=&party=&proc=&source=&limit=`:
  - Returns the JSON shape from design §6.1 (cards-with-pills).
  - 503 with friendly DE message when DATABASE_URL is unset, mirroring
    the other Fristenrechner endpoints.
  - Empty q returns an empty cards array (browse surface is Phase D).

Tests:
  - Pure-Go: TestFormatLegalSourceDisplay (12 cases across all known
    prefixes) + TestNormalizeQuery (8 cases).
  - Integration (skipped without TEST_DATABASE_URL): golden table
    pinning the design's binding queries — Klageerwiderung returns the
    statement-of-defence card with UPC.RoP.23.1, DE.ZPO.276.1,
    DE.PatG.82.1, EU.EPC-R.79.1, DE.PatG.59.3 pills; "RoP 23" returns
    the same card; "§ 82" → normalized "82" → BPatG hit; Wiedereinsetzung
    returns one card with exactly 4 trigger pills (ids 200..203);
    party / source filters narrow as expected; limit cap honoured.
  - SQL semantics validated against live data via supabase MCP using a
    CTE-inlined matview definition with the slug fix simulated; results
    match the golden table.

Per design doc `docs/plans/unified-fristenrechner.md` §4.6 (matview
shape) + §6 (search ranking + API).
2026-05-05 04:32:50 +02:00
m
16c991288f Merge: t-paliad-131 Phase B6 — cross-cutting concepts (Wiedereinsetzung × 4 + Versäumnis + Schriftsatznachreichung + Weiterbehandlung) 2026-05-05 03:57:41 +02:00
m
53d5e5306c feat(t-paliad-131): Phase B6 — cross-cutting concepts (Wiedereinsetzung × 4 + Versäumnis + Schriftsatznachreichung + Weiterbehandlung)
PR-7 of the Unified Fristenrechner. Final Phase B migration. Closes
all named cross-procedural deadline gaps in the design.

These concepts fire across many proceedings (any patent application,
any civil case, any opposition, any appeal) and don't naturally belong
to one proceeding-tree timeline. Modelled per design §5.2.4 + §5.3 as
event-trigger-only entries: the user picks the trigger ("the moment
the obstacle was removed", "the date the Versäumnisurteil was served")
and the calculator returns the deadline.

Migration 046 adds 7 trigger_events (ids 200–206, paliad-native space
above the youpc-imported 1–114 range so future resync stays clean) and
7 corresponding event_deadlines + 3 new concepts.

WIEDEREINSETZUNG IN 4 LEGAL CONTEXTS (one shared concept slug
re-establishment-of-rights, seeded in PR-1):
  - PatG §123(2):  trigger 200, 2 months / max 1 year
  - ZPO §234(1):   trigger 201, **2 WEEKS** / max 1 year
                   ← critical distinction; the 2-weeks-not-months ZPO
                     case is the most-confused detail of DE
                     Wiedereinsetzung. notes_de explicitly capitalises
                     "WOCHEN" so the user reads it before computing.
  - EPC Art.122 + R.136(1): trigger 202, 2 months / max 12 months
  - DPMA via PatG §123: trigger 203, 2 months / max 1 year

OTHER CROSS-CUTTING:
  - Versäumnisurteil-Einspruch (ZPO §339): trigger 204, 2 weeks
    Notfrist — keine Verlängerung möglich.
  - Schriftsatznachreichung (ZPO §296a): trigger 205, 3 weeks
    (court-set typical; placeholder the user can adjust via
    click-to-edit if the court actually set a different period)
  - Weiterbehandlung (Art.121 EPÜ + R.135): trigger 206, 2 months
    Distinct from Wiedereinsetzung — niedrigere Gebühr, applies
    BEFORE final loss of rights.

Three new concepts (slug naming per design §4.4):
  - versaeumnisurteil-einspruch (DE-only procedure → DE slug)
  - schriftsatznachreichung (DE-only → DE slug)
  - weiterbehandlung (EPC-native + DE term dominates HLC vocab → DE slug)

Live-verified all 7 trigger_events on paliad.de (tester@hlc.de) via
the existing /tools/fristenrechner "Was kommt nach…" mode:
  trigger 200 → 2026-07-06 (2mo PatG, weekend-shift)
  trigger 201 → 2026-05-18 (2 WEEKS ZPO — the critical case)
  trigger 204 → 2026-05-18 (2 weeks ZPO §339)
  trigger 205 → 2026-05-26 (3 weeks ZPO §296a)
  trigger 206 → 2026-07-06 (2mo EPC weiterbehandlung)

Out of scope (no calculator-relevant deadlines, would just be search
clutter): Mahnverfahren-Widerspruch (ZPO §345), Validierungsfristen
national (Art. 65 EPÜ → varies per state), Teilanmeldung (R.36 EPC →
"until end of pending parent" is anchor-on-revocation-of-grant).

Phase B is now complete. Phase C (search backend) + Phase D (concept-
card UI) follow per design.
2026-05-05 03:46:45 +02:00
m
8c64344126 Merge: t-paliad-131 Phase B5 — EPA gap-fill (R.79.2/3, R.116, R.106) + EPA_OPP/APP anchor fix 2026-05-05 03:27:51 +02:00
m
706afb617f feat(t-paliad-131): Phase B5 — EPA gap-fill (R.79.2/3, R.116, R.106) + EPA_OPP/APP anchor fix
PR-6 of the Unified Fristenrechner. Fills the EPA-side coverage gaps
named in the design + repairs three pre-existing EPA bugs surfaced
during this work.

Migration 045:

PRE-EXISTING BUG FIXES

1. EPA anchor convention bug. epa_opp.grant and epa_app.entsch were
   seeded with party='court' + event_type='decision' → calculator's
   isCourtDeterminedRule(r) returned true → those anchor rows
   rendered as IsCourtSet (no date), propagating IsCourtSet to every
   downstream rule that chained off them. Result on prod: EPA_OPP
   showed "court-set" for Einspruchsfrist / Erwiderung / Entscheidung
   instead of computed dates; ONLY the trailing beschwerde + begr
   rendered dates (and only by accident, because they had parent_id=
   NULL and computed off triggerDate directly).

   Fix: changed both anchors to party='both' + event_type='filing' so
   they render as IsRootEvent. Matches the convention I established
   for DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH / DPMA_BPATG_BESCHWERDE /
   DPMA_BGH_RB anchors in PR-3/4/5.

2. EPA_OPP appeal-phase parent bug. epa_opp.beschwerde +
   beschwerde_begr had parent_id=NULL → were computing 2mo and 4mo
   from the GRANT date instead of from the OPPOSITION DECISION date.
   Re-parented both on epa_opp.entsch. They now correctly render as
   IsCourtSet placeholders (because entsch is court-set) until the
   user enters the real decision date via the Phase A click-to-edit
   affordance.

3. EPA_APP.erwidg modelling bug. Was parent_id=NULL + duration=0 +
   party=both + event=filing → IsRootEvent → emitted the trigger date
   as "Erwiderung". Now properly modelled per Art. 12(1)(c) RPBA 2020:
   parent=epa_app.begr, duration=4 months, name="Beschwerdeerwiderung",
   legal_source=EU.RPBA.12.1.c, response-to-appeal concept.

NEW COVERAGE (per design §5.3)

EPA_OPP gains 2 rules:
  - epa_opp.r79_further: Stellungnahme weiterer Beteiligter
    (R.79(2)/(3) EPC) — court-set, parent=erwidg
  - epa_opp.r116: Eingaben vor mündl. Verhandlung
    (R.116(1) EPC) — court-set, parent=entsch (so it surfaces in the
    opposition phase but stays IsCourtSet until oral hearing date is
    entered via override)

EPA_APP gains 2 rules:
  - epa_app.r116: Eingaben vor mündl. Verhandlung
    (R.116(1) EPC + Art. 13 RPBA) — court-set, parent=oral
  - epa_app.r106: Antrag auf Überprüfung
    (Art. 112a EPÜ) — 2 months from service of decision, parent=
    entsch2 (the BoA decision)

Three new EN-slug concepts (UPC/EPC-native): r79-further-stellungnahme,
r116-final-submissions, petition-for-review.

Live-verified on paliad.de:
  EPA_OPP trigger 2026-05-04 → grant IsRootEvent / Einspruchsfrist
    2027-02-04 (9mo) / Erwiderung 2027-06-04 (4mo from frist) /
    r79_further 2027-06-04 (filed-with-erwidg) / Entscheidung +
    Beschwerde + Begründung + r116 IsCourtSet (waiting for entsch).
  EPA_APP trigger 2026-05-04 → entsch IsRootEvent / Beschwerde
    2026-07-06 (2mo, weekend-shift) / Begründung 2026-09-04 (4mo from
    entsch) / Beschwerdeerwiderung 2027-01-04 (4mo from Begründung
    per RPBA 12.1.c) / r116 IsCourtSet (parent=oral) / r106 IsCourtSet
    (parent=entsch2, will compute 2mo from BoA decision once entered).

Out of scope (deferred to PR-7 cross-cutting): Wiedereinsetzung
(Art. 122 EPÜ + R.136 EPC), Weiterbehandlung (Art. 121 EPÜ + R.135 EPC),
Validierungsfrist national (Art. 65 EPÜ).
2026-05-05 03:17:46 +02:00
m
a9531afbf4 Merge: t-paliad-131 Phase B4 — DPMA proceeding chain (DPMA_OPP + DPMA_BPATG_BESCHWERDE + DPMA_BPATG_RECHTSBESCHWERDE) 2026-05-05 02:57:42 +02:00
m
25076142f4 feat(t-paliad-131): Phase B4 — DPMA proceeding chain
PR-5 of the Unified Fristenrechner. Three new proceeding types
covering the DPMA → BPatG → BGH opposition / appeal chain. Closes the
DPMA gap m named — paliad has had zero DPMA-specific timelines until
now (DPMA-granted patents in Nichtigkeit went to DE_NULL but the DPMA
opposition + Beschwerde + Rechtsbeschwerde chain had no home).

Migration 044 adds:

  - DPMA_OPP (Einspruch DPMA, sort=310): 4 rules. Anchor "Veröffentlichung
    der Erteilung" + Einspruchsfrist (PatG §59.1, 9mo) + Erwiderung
    Patentinhaber (PatG §59.3, court-set ~4mo, party=defendant) +
    DPMA-Entscheidung (court).
  - DPMA_BPATG_BESCHWERDE (Beschwerde BPatG, sort=320): 5 rules. Anchor
    "Zustellung DPMA-Entscheidung" + Beschwerde (PatG §73.2, 1mo) +
    Beschwerdebegründung (PatG §75.1, 1mo from filing, extension on
    request) + mündliche Verhandlung + BPatG-Entscheidung.
  - DPMA_BGH_RB (Rechtsbeschwerde BGH, sort=330): 4 rules. Anchor
    "Zustellung BPatG-Entscheidung" + Rechtsbeschwerde (PatG §100.1, 1mo)
    + Begründung (PatG §102 i.V.m. ZPO §551, 1mo from filing) +
    BGH-Entscheidung.

Naming note: head's PR brief listed the third type as
"DPMA_BPATG_NICHTIGKEIT" but Nichtigkeitsklage is filed directly at
BPatG (already covered by DE_NULL — never chained off DPMA). The
natural BGH endpoint of the DPMA chain is the Rechtsbeschwerde per
§§ 100/102 PatG. Using DPMA_BGH_RB; trivially renamable if head
intended a different shape.

Two new DE-only concepts: rechtsbeschwerde (BGH legal appeal — DE-
specific procedure, no UPC/EPC equivalent), rechtsbeschwerde-
begruendung. Other rules reuse shared concepts (publication,
opposition, statement-of-defence, notice-of-appeal, statement-of-
grounds-of-appeal, oral-hearing, decision).

Frontend: new DPMA tile group in /tools/fristenrechner with 3 tiles,
positioned after the EPA group. 5 new i18n keys (DE+EN: deadlines.dpma
group label + 3 tile names + tile labels for 3 procs).

Live-verified all 3 trees on paliad.de (tester@hlc.de):
  DPMA_OPP trigger 2026-05-04 → Einspruch 2027-02-04 (9mo) /
    Erwiderung 2027-06-04 (4mo from Einspruch).
  DPMA_BPATG_BESCHWERDE trigger 2026-05-04 → Beschwerde 2026-06-04
    (1mo) / Begründung 2026-07-06 (1mo from Beschwerde, weekend-shift).
  DPMA_BGH_RB trigger 2026-05-04 → Rechtsbeschwerde 2026-06-04 /
    Begründung 2026-07-06.
2026-05-05 02:48:31 +02:00
m
d747046bf0 Merge: t-paliad-131 Phase B3 cont — DE instance-split proceeding types (OLG/BGH branches) 2026-05-05 02:27:38 +02:00
m
e3b093d9a2 feat(t-paliad-131): Phase B3 cont — DE instance-split proceeding types
PR-4 of the Unified Fristenrechner. Three new proceeding types so the
user can pick "I'm at OLG defending a Berufung" or "I'm at BGH on the
Nichtigkeitsberufung" and get the per-instance timeline directly,
rather than chaining off DE_INF / DE_NULL trailing rows.

Migration 043 adds:

  - DE_INF_OLG (Berufung OLG, sort_order=210): 7 rules. Anchor
    "Zustellung LG-Urteil" + Berufungsschrift (ZPO §517, 1mo) +
    Berufungsbegründung (ZPO §520(2), 2mo, anchored on Urteil not on
    notice) + Berufungserwiderung (ZPO §521(2), court-set 1mo typ.) +
    Anschlussberufung (ZPO §524(2), filed-with-erwiderung) +
    mündl. Verhandlung + OLG-Urteil.
  - DE_INF_BGH (Revision/NZB BGH, sort_order=220): 8 rules. Anchor
    "Zustellung OLG-Urteil" + parallel NZB (§544.1, 1mo) /
    NZB-Begründung (§544.4, 2mo) / Revisionsfrist (§548, 1mo) /
    Revisionsbegründung (§551.2, 2mo) — all four from the
    OLG-Urteil-Datum since they're alternatives. Plus
    Revisionserwiderung (§554, 1mo court-set) + mündl. + BGH-Urteil.
  - DE_NULL_BGH (Berufung BGH gegen Nichtigkeit, sort_order=230): 6
    rules. Anchor "Zustellung BPatG-Urteil" + Berufungsschrift
    (PatG §110.1, 1mo) + Berufungsbegründung (PatG §111.1, 3mo) +
    Berufungserwiderung (PatG §111.3 → ZPO §521.2, 2mo court-set typ.)
    + mündl. + BGH-Urteil.

Anchor convention: synthetic 0-duration root rule "Zustellung [prev-
instance] Urteil" with party='both' + event_type='filing' so it
renders as IsRootEvent (= the trigger date). Per design, this is the
honest model — the user enters the actual previous-instance Urteil
date, no fabricated inter-stage gap.

Four new DE-only concepts (per slug rule: DE for German-only
procedures): nichtzulassungsbeschwerde, nichtzulassungsbeschwerde-
begruendung, revisionsfrist, revisionsbegruendung. Other rules reuse
the existing shared concepts (notice-of-appeal, statement-of-grounds-
of-appeal, response-to-appeal, cross-appeal, oral-hearing, decision).

Frontend: 3 new tiles in DE_TYPES + 8 new i18n keys (DE+EN). Tiles
appear between DE_INF and DE_NULL per sort_order grouping.

Out of scope (kept in DE_INF / DE_NULL trees during transition until
Phase D Full Appeal Chain ships): the existing trailing rows
de_inf.berufung / de_inf.beruf_begr / de_null.berufung /
de_null.beruf_begr stay live so users picking those trees still see
the appeal-period entry. Phase D will gate the visibility.

Live-verified all 3 trees on paliad.de:
  DE_INF_OLG trigger 2026-05-04 → Berufung 2026-06-04 (1mo) /
    Begründung 2026-07-06 (2mo from Urteil, weekend-shift) /
    Erwiderung 2026-08-06 (1mo from Begründung) / Anschluss
    2026-08-06 (filed-with-erwiderung).
  DE_INF_BGH trigger 2026-05-04 → NZB 2026-06-04 (1mo) /
    NZB-Begr 2026-07-06 / Revision 2026-06-04 / RevBegr 2026-07-06
    (parallel options) / RevErw 2026-08-06.
  DE_NULL_BGH trigger 2026-05-04 → Berufung 2026-06-04 / Begr
    2026-08-04 (3mo per PatG §111.1 = the now-fixed seed) / Erwidg
    2026-10-05 (2mo from Begr, weekend-shift).
2026-05-05 02:19:37 +02:00
m
9a4f45fe48 Merge: t-paliad-131 Phase B3 — DE expansion (PatG §111 fix + BPatG Hinweisbeschluss + ZPO Anzeige) 2026-05-05 01:57:43 +02:00
m
24e22511ec feat(t-paliad-131): Phase B3 — DE expansion (PatG §111 fix + BPatG Hinweisbeschluss + ZPO Anzeige)
PR-3 of the Unified Fristenrechner. Three concerns bundled in migration
042 since they touch only DE_INF / DE_NULL trees and ship together
without coverage interactions:

1. PatG §111(1) bug fix. Current paliad seed had de_null.beruf_begr at
   1 month. Current text of §111(1) BGBl. 2022: "Die Frist zur
   Begründung der Berufung beträgt drei Monate. Sie beginnt mit der
   Zustellung des in vollständiger Form abgefassten Urteils, spätestens
   mit Ablauf von fünf Monaten nach der Verkündung." Bumped to 3 months
   + deadline_notes documenting the 5-month outer cap.

2. DE_NULL Hinweisbeschluss cycle (PatG §83). 4 new rules added between
   Klageerwiderung and Mündliche Verhandlung:
   - de_null.replik_klaeger (Replik, 2mo typical court-set, R.83.2)
   - de_null.hinweisbeschluss (court order, R.83.1) — IsCourtSet
   - de_null.stellungnahme (response, parent=hinweisbeschluss, R.83.2)
     — IsCourtSet via parent propagation
   - de_null.duplik (Rejoinder, 1mo typical court-set, R.83.2)
   The court-set typical durations match the existing DE_INF replik/
   duplik pattern — a placeholder date the user can override via the
   Phase A click-to-edit affordance once the court actually sets it.

3. DE_INF Anzeige der Verteidigungsbereitschaft (ZPO §276(1) Satz 1).
   New rule de_inf.anzeige, 2 weeks from Klage, defendant. Was the
   biggest gap in the LG-civil cycle.

Three new concepts: preliminary-opinion (court order, sort 65),
response-to-preliminary-opinion (submission, sort 39),
notice-of-defence-intention (submission, sort 19). All seeded with
DE+EN aliases for search.

DE_INF + DE_NULL sequence_orders renumbered to leave gaps so future
inserts (B6 cross-cutting Wiedereinsetzung, B4-style instance-split)
can interleave without re-renumbering.

Live-verified on paliad.de (tester@hlc.de):
- DE_INF trigger 2026-05-04 → Anzeige 2026-05-18 (2w), Erwiderung
  2026-06-15 (6w), backbone unchanged.
- DE_NULL trigger 2026-05-04 → Klageerwiderung 2026-07-06 (2mo),
  Replik 2026-09-07 (2mo from Erwiderung, weekend-shift), Duplik
  2026-10-07 (1mo from Replik), Hinweisbeschluss + Stellungnahme
  IsCourtSet, Berufungsbegründung 2026-09-04 (3mo, was 1mo).

Out of scope (deferred to B6): cross-cutting Wiedereinsetzung,
Versäumnisurteil-Einspruch (only fires on default), Schriftsatz-
nachreichung. Out of scope (deferred to PR-4): new instance-split
proceeding types DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH.
2026-05-05 01:49:01 +02:00
m
3b595390c7 Merge: t-paliad-131 Phase B1 — UPC counterclaim cross-flows (R.25/R.30/R.49(2)/R.50) 2026-05-05 01:27:44 +02:00
m
cc68ab2873 feat(t-paliad-131): Phase B1 — UPC counterclaim cross-flows
Closes m's primary complaint: today's `with_ccr` flag on UPC_INF only
swaps the Replik / Duplik durations. Per UPC RoP R.29 the with-CCR flow
ALSO adds 5–7 new submissions across the claimant / defendant exchange.
Same gap on UPC_REV: Application to amend (R.49.2.a → R.55 = R.32 m.m.)
and Counterclaim for infringement (R.49.2.b → R.50, R.56 cycle) were
entirely missing.

UPC_INF gets a nested `with_amend` flag under `with_ccr` (R.30 amend
is only available with a CCR). UPC_REV gets two parallel independent
flags `with_amend` + `with_cci`; both can be on. Citations verified
against data.laws_contents (youpcdb, UPCRoP).

Migration 041 (waved INSERTs because each subsequent rule references
the prior wave's parent_id):
- Wave 0: 11 new concept rows (counterclaim-for-revocation,
  defence-to-counterclaim-for-revocation, defence-to-application-to-amend,
  reply-to-defence-to-counterclaim-for-revocation,
  reply-to-defence-to-application-to-amend,
  rejoinder-on-reply-to-defence-to-ccr, rejoinder-on-reply-to-amend,
  counterclaim-for-infringement, defence-to-counterclaim-for-infringement,
  reply-to-defence-to-counterclaim-for-infringement,
  rejoinder-on-counterclaim-for-infringement). counterclaim-for-revocation
  also seeded for the search bar even though its rule lives implicitly
  in inf.sod (the with_ccr flag captures it).
- UPC_INF + UPC_REV sequence_orders renumbered to leave gaps (10/20/30…)
  so new cross-flow rows interleave chronologically with the backbone.
- 7 new UPC_INF rules: inf.def_to_ccr (R.29.a), inf.app_to_amend (R.30.1),
  inf.def_to_amend (R.32.1), inf.reply_def_ccr (R.29.d),
  inf.reply_def_amd (R.32.3), inf.rejoin_reply_ccr (R.29.e),
  inf.rejoin_amd (R.32.3).
- 8 new UPC_REV rules: rev.app_to_amend (R.49.2.a), rev.def_to_amend
  (R.43.3), rev.reply_def_amd (R.32.3 m.m.), rev.rejoin_amd (R.32.3 m.m.),
  rev.cc_inf (R.49.2.b), rev.def_cci (R.56.1), rev.reply_def_cci (R.56.3),
  rev.rejoin_cci (R.56.4).

Calculator (services/fristenrechner.go):
- Zero-duration rules now split into 4 buckets, not 2:
    1. parent=nil + non-court → IsRootEvent (existing)
    2. parent=nil + court     → IsCourtSet (existing, e.g. inf.oral when stand-alone)
    3. parent set + court     → IsCourtSet (existing, waypoints)
    4. parent set + non-court → "filed-with-parent" — inherit parent's
       date. NEW. Used by rev.app_to_amend / rev.cc_inf which per
       R.49(2) are filed AS PART OF the Defence to revocation.
- AnchorOverrides on a zero-duration rule short-circuits to the user's
  date, propagating downstream as before.

Frontend:
- New checkboxes inf-amend-flag (UPC_INF, nested under ccr-flag),
  rev-amend-flag, rev-cci-flag (UPC_REV). Visibility per proceeding
  type; inf-amend disabled until ccr is on (R.30 dependency).
- Three new i18n keys (DE+EN). Small CSS for nested-checkbox indent
  and disabled-state colour.

Live-verified via curl on paliad.de against tester@hlc.de:
  UPC_INF + with_ccr+with_amend, trigger 2026-05-04 → all 7 new rules
  render at correct dates (R.29.a 2mo, R.30.1 2mo, R.32.1 2mo from
  app_to_amend, R.29.d 2mo from def_to_ccr, R.32.3 1mo, R.29.e 1mo,
  R.32.3 1mo).
  UPC_REV + with_amend+with_cci → rev.app_to_amend / rev.cc_inf show
  rev.defence's date (filed-with-parent), R.43.3 2mo / R.56.1 2mo /
  R.32.3 + R.56.3 1mo / R.32.3 + R.56.4 1mo all line up.
2026-05-05 01:25:03 +02:00
m
258ebb8508 Merge: t-paliad-131 Phase A — concept layer + AnchorOverrides + click-to-edit dates 2026-05-05 00:05:36 +02:00
m
78966ec098 feat(t-paliad-131): Phase A — concept layer + AnchorOverrides + click-to-edit dates
PR-1 of the Unified Fristenrechner. Purely additive: new search-grouping
layer + per-rule date override capability. No coverage changes yet
(those land in PR-2 = Phase B1 UPC counterclaim cross-flows).

Migrations:
- 037: paliad.deadline_concepts (id, slug, name_de/en, aliases text[],
  party, category, sort_order). Trigram + GIN indexes for the search bar.
- 038: deadline_rules.concept_id (uuid FK), legal_source (text);
  event_deadlines.legal_source; trigger_events.concept_id (text slug,
  soft-link — youpc imports keep their bigint PK).
- 039: deadline_rules.condition_flag text → text[] (USING ARRAY[old]).
  Semantic: rule renders iff every element is in CalcOptions.Flags.
  Single-element arrays preserve the legacy with_ccr swap exactly.
- 040: seed 30 concept rows + backfill all 74 fristenrechner deadline_rules
  with concept_id; backfill legal_source from existing rule_code
  (e.g. 'RoP.023' → 'UPC.RoP.23.1', '§ 276 ZPO' → 'DE.ZPO.276.1',
  'Art. 108 EPÜ' → 'EU.EPÜ.108', 'R. 79(1) EPÜ' → 'EU.EPC-R.79.1').

Calculator (services/fristenrechner.go):
- ConditionFlag is now pq.StringArray (matches text[] schema). New
  allFlagsSet() helper gates rule rendering; rules with multi-element
  flags require ALL of them set (prep for Phase B1 with_amend ∧ with_cci).
- CalcOptions.AnchorOverrides map[string]string (rule_code → YYYY-MM-DD).
  The tree-walk consults overrideDates[parent.code] before reading the
  computed-date map; lets a downstream rule re-anchor on a user-set date.
- IsCourtSet rows that get an override stop being placeholder and emit
  the user's date as a real anchor (so downstream cost_app etc. compute
  off it). New IsOverridden flag in UIDeadline so the UI can highlight
  user-edited rows.
- LegalSource surfaced on UIDeadline for future search-card display.

UI (frontend/src/client/fristenrechner.ts + global.css + i18n):
- Each timeline / column rule date is click-to-edit. Click → inline
  date input → blur or Enter → POST with anchorOverrides → re-render.
  Empty value clears the override. Escape cancels. Root-event rows
  (the trigger anchor) stay non-editable — that's the trigger-date input.
- Override map cleared on proceeding switch / reset; persists across
  trigger-date / flag toggle changes within the same proceeding.
- New CSS: subtle hover underline on .frist-date-edit; lime border on
  .timeline-date--overridden + .frist-date-edit-input.
- New i18n key deadlines.date.edit.hint (DE + EN).

Handler (handlers/fristenrechner.go):
- POST body gains optional anchorOverrides map<string,string>; passed
  through to CalcOptions.

Tests:
- TestAllFlagsSet covers single-flag legacy semantic, two-flag AND
  semantic, empty-required unconditional, extra-flags-no-effect.
- Existing TestIsCourtDeterminedRule unchanged.

Phase A ships standalone — Phase B1 (UPC counterclaim cross-flows) and
Phase C/D (search backend + concept-card UI) follow.
2026-05-05 00:05:12 +02:00
m
20eaa9bba4 design(t-paliad-131): v2 — flip slug rule (EN for shared) + drop flag_param
m's revisions (23:36):

- Q1 corrected: EN slug for shared concepts too (klageerwiderung →
  statement-of-defence, replik → reply-to-defence, berufungsfrist →
  notice-of-appeal, einspruchsfrist → opposition, wiedereinsetzung →
  re-establishment-of-rights). DE slug only for German-law-only
  concepts (nichtzulassungsbeschwerde, versaeumnisurteil-einspruch,
  hinweisbeschluss-stellungnahme).

- Q4 simplified: drop the customizable-extension flag_param mechanism.
  Replace with a generalised "user can override any computed date,
  downstream re-anchors off it" capability. CalcOptions gains
  AnchorOverrides map[string]string; tree-walk consults it before the
  computed-date map. UI gives each row a click-to-edit date affordance
  (also unlocks court-set decision dates being entered post-hoc, which
  the existing IsCourtSet placeholder UX has been hinting at). PatG §82
  seed stays at 2 months static; user-set extensions handled by inline
  date override, not by a flag_param mechanism.

  Cleaner. No new DB column. Generalises beyond extensions to any case
  where the user knows the real date better than the calculator's
  projection.
2026-05-04 23:38:23 +02:00
m
94ebc1d043 design(t-paliad-131): v2 — m's answers to the 8 v2 open questions locked
- Q1 concept slug naming: mixed convention. EN slug for UPC/EPC-native
  concepts (application-to-amend, request-for-discretionary-review).
  DE slug for German-only concepts (nichtzulassungsbeschwerde,
  versaeumnisurteil-einspruch). DE slug for SHARED concepts that exist
  in both DE and UPC/EPC (klageerwiderung, replik, berufungsfrist,
  einspruchsfrist, wiedereinsetzung) because m works primarily in
  German and the slug is internal/maintenance-facing only.
- Q2 EU.EPÜ confirmed for EPÜ namespace.
- Q3 PatG §111(1) 1mo→3mo confirmed for Phase B3.
- Q4 PatG §82(1): shape (b) — 1mo base + with_extension flag with
  CUSTOMIZABLE extension duration (default 1mo). New flag_param
  mechanism on flag-conditioned rules: CalcOptions.Flags becomes
  map[string]int; rules with flag_param_code add caller's param to
  duration. UI shows number input next to checkbox. Generalises to
  PatG §75 etc. Phase A5 picks up the calculator extension; Phase B3
  hooks PatG §82.
- Q5 Full Appeal Chain: multiple date inputs per stage, no inter-stage
  gap guessing. Stage N's downstream deadlines render as IsCourtSet
  placeholders until user enters Stage N-1's terminal decision date.
- Q6/Q7/Q8 confirmed as drafted.

§5.2.2 PatG §82 row updated to reflect flag-based shape. §4.4 concept
slug examples expanded with the mixed-convention rule rendered
explicitly. §7 Phase A5 added for the flag_param calculator change.
2026-05-04 23:33:33 +02:00
m
79f09006fc design(t-paliad-131): v2 — incorporate m's go-direction (Unifier shape, concept cards, no tab subsumption)
Significant restructure after m's 10 answers (relayed via head 23:10):

- Augment, not replace — search bar at top + existing tile grid stays as
  browse fallback. Both existing tabs stay live. Phase E (subsumption)
  dropped.
- Unifier shape: new paliad.deadline_concepts layer above existing
  deadline_rules; deadline_rules gains concept_id FK + structured
  legal_source. condition_flag scalar→array (Q3) for AND-of-flags
  semantics on UPC_REV (with_amend ∥ with_cci).
- Search hits as ONE card per concept with proceeding pills inside (NOT
  a flat list of one-per-proceeding hits). Card body: pills [UPC R.23.1
  3mo] [LG §276.1 6w] [BPatG §82.1 1mo] [EPA R.79.1 4mo] etc.
- Structured legal_source codes: UPC.RoP.23.1, DE.ZPO.276.1,
  EU.EPÜ.108, DE.PatG.111.1 — parseable, filterable, indexed.
- "Vollständige Instanzenkette" checkbox synthesises LG→OLG→BGH (or
  BPatG→BGH) timeline as one tree at render-time; data stays per-
  instance.
- Forum filter dropped (Q8). Filters now: Verfahrensart / Partei /
  Rechtsquelle.
- Court-set placeholders ("Verhandlung", "Entscheidung",
  "Zwischenverfügung") surface as searchable triggers (Q10).
- Columns-view sequence preservation (Q9) flagged but punted to a
  separate follow-up task — t-paliad-129 column renderer must respect
  sequence_order even on undated court-set events.

8 remaining open questions for m (concept slug convention, EPÜ
namespace, PatG §82(1) modeling, Full Appeal Chain anchor handoff,
quick-pick chip seed, etc.).
2026-05-04 23:21:28 +02:00
m
355e718516 design(t-paliad-131): unified Fristenrechner — search-by-anything + complete coverage
Inventor design doc at docs/plans/unified-fristenrechner.md.

Covers:
- Single search bar UX over both deadline_rules (proceeding-tree) and
  trigger_events (event-driven) backends, federated via a materialised
  view with pg_trgm indexes.
- Faceted filters: forum, proceeding type, party, legal source.
- UPC counterclaim cross-flows missing today (R.29(a)/(d)/(e), R.30,
  R.32, R.43.3, R.49(2), R.51, R.52, R.55, R.56) — verbatim citations
  pulled from data.laws_contents (UPCRoP).
- German PatG/ZPO gap audit: missing OLG + BGH-Revision + BGH-NZB cycles,
  ZPO §339 Versäumnis, §521 Berufungserwiderung, PatG §111 (likely 1mo→3mo
  fix), DPMA Einspruch + Beschwerde. EPA gaps: R.116, R.79(2/3), R.106
  Überprüfung, Wiedereinsetzung × 3.
- Phased migration plan A–E, each independently shippable.
- 10 open questions for m's go/no-go before coder shift.

No code changes; awaiting m's review.
2026-05-04 23:11:16 +02:00
m
6eece2d0ff Merge: t-paliad-130 — Kostenrechner caps GKG/RVG Streitwert at €30M (§34 GKG / §22(2) RVG) 2026-05-04 21:00:08 +02:00
m
0e1d4869fb fix(t-paliad-130): cap GKG/RVG Streitwert at €30M (§34 GKG / §22(2) RVG)
ComputeBaseFee walked the bracket table indefinitely, so a Streitwert of
e.g. €100M produced fees far above what German law actually permits. §34
GKG / §22(2) RVG cap the table at €30M — above that the fee stays at the
30M-row value.

Surgical fix: clamp streitwert to GermanFeeStreitwertCap (30M) at the top
of ComputeBaseFee. Applies to all GKG/RVG fee versions (2005, 2013, 2021,
2025); UPC value-based fees use a separate code path (lookupUPCValueFee
against UPCFeeSchedule.ValueBased) and stay uncapped — UPC has its own
statutory tier structure with explicit 50M and unlimited brackets.

Tests: cap holds across all four versions for both GKG and RVG; values
below 30M continue to scale as before; UPC remains uncapped.

Spot check (GKG / RVG base, 2025 schedule):
  1M EUR   →   6278.00 / 5553.50
  5M EUR   →  23078.00 / 19553.50
  30M EUR  → 128078.00 / 107053.50
  50M EUR  → 128078.00 / 107053.50  (capped)
  100M EUR → 128078.00 / 107053.50  (capped)
  1B EUR   → 128078.00 / 107053.50  (capped)
2026-05-04 20:58:08 +02:00
m
7fdd74ed5d Merge: t-paliad-129 — Fristenrechner polish (date-aligned columns, both-mirrored, Drucken restyle) 2026-05-04 20:03:03 +02:00
m
cca433cb10 feat(t-paliad-129): Fristenrechner polish — date-aligned columns + Drucken icon
Three changes to the columns view + the Drucken button, per m's 2026-05-04
polish round on top of t-paliad-127 / t-paliad-126:

1. Date-aligned grid timeline. The columns view used to render three
   independent vertical stacks; now each distinct dueDate gets a grid row
   so a Court hearing on the 15th lines up beside a Proactive Antrag on
   the 15th (and an empty cell where the third party has nothing to do).
   Court-set / dateless rows collapse into a final trailing row.

2. "both"-party deadlines are mirrored, not spanned. Previously they
   rendered as a full-width row beneath the columns; now they appear in
   BOTH the Proactive AND Reactive cell of their date-row, with a
   "↔ beide Seiten" / "↔ both parties" caption so the duplication reads
   as deliberate. The Court column at that row stays empty unless a
   court-party deadline also lands on the same date. The full-width
   spans block (.fr-columns-spans) is gone.

3. Drucken button restyle. The grey-square default-button look is
   replaced with a tertiary-action treatment: hairline border, accent
   on hover, subtle lift, inline 16px printer SVG. To keep the icon
   from being wiped by [data-i18n] (which sets textContent), the label
   moved into a child <span data-i18n="deadlines.print"> while the SVG
   sits as its sibling. Both fristen-print-btn and event-print-btn pick
   up the new style via the shared .print-btn class.
2026-05-04 19:57:32 +02:00
m
049136d424 Merge: t-paliad-128 — /events Nur persönliche redefined as created_by=me (deadlines + appointments) 2026-05-04 19:53:02 +02:00
m
9919e04657 feat(t-paliad-128): /events 'Nur persönliche' = items I created
Redefines the "Nur persönliche" filter on /events from "appointment with
NULL project_id" to "items where created_by = me", applied uniformly to
deadlines and appointments.

Before: client-side filter dropped every deadline row because the type
guard was `x.type === "appointment"`. m saw zero deadlines under "Nur
persönliche" even though he created plenty.

After:
- /api/events?personal_only=true (and /api/events/summary?personal_only=true)
  narrow BOTH rails to f.created_by / t.created_by = current user.
  ProjectID is ignored when personal_only is set (the two are
  contradictory).
- DeadlineService.ListFilter and AppointmentService.AppointmentListFilter
  gain CreatedBy *uuid.UUID — composes with existing visibility (AND), so
  a row created on a team the user has since left still won't leak.
- Frontend drops the client-side filter; sends personal_only=true when
  projectFilter === PERSONAL. URL ?personal_only=true also accepted on
  initial load (bookmark-friendly alias for ?project_id=__personal__).
  Personal option now shows for type=Fristen too — applies uniformly.
- 3 new live subtests covering personal_only across type=deadline /
  appointment / all, with mixed-creator + multi-project + null-project
  fixtures.
2026-05-04 19:49:37 +02:00
m
c1ff631257 Merge: t-paliad-127 — Fristenrechner third view (Proactive | Court | Reactive columns)
Conflict resolution: combined fritz's t-126 auto-calc plumbing (procCalcSeq + scheduleProcCalc + selectProceeding's auto-trigger) with turing's t-127 view-state (procedureView + initViewToggle). Both additive, both wired in DOMContentLoaded.
2026-05-04 19:44:27 +02:00
m
bf80c167ba Merge: t-paliad-126 — Fristenrechner auto-calculate on tab open + on input change 2026-05-04 19:37:02 +02:00
m
63e5fb0b86 feat(t-paliad-127): Fristenrechner columns view (Proactive/Court/Reactive)
Add a third Fristenrechner layout that splits the computed deadlines into
three vertical lanes by party:

- Proactive (claimant) | Court | Reactive (defendant)
- Each lane is independently date-ordered.
- party=both rows render as a full-width strip below the columns
  (separate "Beide Parteien" block) since they apply to all sides.
- View toggle (Zeitstrahl / Spalten) lives in step 3 of the procedure
  wizard. Persisted via ?view=columns; reload restores the choice.
- Mobile (≤640px): grid collapses to a single column stack.

Event-mode results are not split into lanes — `EventDeadlineResult` has
no party field and the spec rules out backend changes; the toggle is
scoped to the procedure-mode results panel only.
2026-05-04 19:34:14 +02:00
m
04d034af81 feat(t-paliad-126): Fristenrechner auto-calc on tab open + input change
The Verfahrensablauf and "Was kommt nach" tabs now render results
immediately, without requiring a click on "Fristen berechnen". The
button stays as a manual force-recalc affordance.

- Pre-select the first proceeding type on load so step 3 has data
  out of the box.
- Pre-select the first trigger event on first event-tab activation
  (or right after the list loads if the tab was already active).
- Auto-recalc on date / proceeding-type / condition-flag change.
- Debounce input events to 200ms so spam-edits coalesce into one
  request, with a per-mode sequence counter so a stale fetch result
  can never overwrite a fresher one.
2026-05-04 19:33:47 +02:00
m
371a38a194 Merge: t-paliad-125 — Project picker dropdown sorts by tree path + indents by depth (5 pickers) 2026-05-04 19:33:19 +02:00
m
4d7c74994a feat(t-paliad-125): sort project pickers by tree path with depth indent
The /events Project filter dropdown was sorted by `updated_at DESC`, so a
recently-touched Case appeared above its parent Client and cousins
interleaved unrelated branches — m's report (2026-05-04): "Siemens cases
come directly after 'mandant vs Gegner' and are not under 'Siemens-AG'".

Backend: switch ProjectService.List to ORDER BY p.path so every
descendant immediately follows its ancestor — the same ordering BuildTree
produces. Both callers (handleListProjects, searchProjects) gain a
stable, hierarchical default that matches user expectation.

Frontend: add project-indent.ts shared helper and apply NBSP indent
prefix in every <select> picker fed by /api/projects: events filter,
/deadlines/new, /appointments/new, checklist new-instance modal,
Fristenrechner save modal. NBSP avoids browser whitespace collapse
inside <option> labels. Multi-parent repetition is out of scope (data
model has singular parent_id today).

Tests: project_list_order_test pins the path-order contract against a
seeded mixed-recency tree.
2026-05-04 19:30:37 +02:00
m
062630ca38 Merge: t-paliad-123 — /events Status filter for appointments (date buckets) 2026-05-04 18:58:09 +02:00
m
8123d71d08 Merge: t-paliad-124 — project filter walks descendants (Client filter → all child rows) 2026-05-04 18:58:04 +02:00
m
a69fff73e9 feat(t-paliad-124): project filter includes descendant projects
Selecting a Client in the project filter now returns rows attached to
that Client AND every Litigation / Patent / Case below it (and so on
down the tree). Previously the filter was exact-match: picking a Client
hid every item in the subtree, which was the opposite of what users
expect when they pick a parent in a hierarchical picker.

The descendant set comes from paliad.projects.path - every project's
path always contains its own id and every ancestor's id, so any project
whose path includes the filter UUID is either that project or a
descendant. Pattern matches the existing visibility predicate (which
walks the path UPWARD for inheritance); the new helper just inverts the
direction.

Filter sites updated:
  - DeadlineService.ListVisibleForUser     (/deadlines, /events)
  - DeadlineService.SummaryCounts          (deadline summary cards)
  - AppointmentService.ListVisibleForUser  (/appointments, /events)
  - EventService.deadlineBuckets           (/events deadline rail)
  - EventService.appointmentBuckets        (/events appointment rail)

ListForProject (deadline/appointment/checklist/note) is unchanged - it
fetches items for ONE specific project on the project detail page, not
a filter.

Visibility predicate (paliad.can_see_project) untouched - that walks
upward and is a different concern.
2026-05-04 18:57:06 +02:00
m
1bba9cb3ce feat(t-paliad-123): apply date-bucket Status filter to appointments
Until now, /events hid the Status dropdown when Type=Termine. The
date-bucket filters (Heute, Diese Woche, Nächste Woche, Später) only
worked on the deadline rail — m wanted them on appointments too, even
without a "completed" dimension.

Frontend (events.ts):
- New populateStatusFilter() rebuilds the Status <select> options based
  on currentType: deadlines get the full 8-option set, appointments
  narrow to 5 (Alle + 4 buckets). The "completed/pending/overdue"
  options drop because they have no appointment analogue.
- applyTypeVisibility() no longer hides the Status filter for
  appointments; it calls the populator instead. The populator runs on
  type-chip click and on language change so labels translate live.
- When switching type while a now-invalid status is selected (e.g.
  Termine + status=completed via URL), the populator falls back to the
  per-type default (deadline → pending, appointment → all) and updates
  URL params.
- syncURLParams + isFilterPristine + initFilters use a per-type default
  so the appointment view treats `all` as pristine and stays out of the
  URL until the user picks a bucket.
- loadList always sends `status` to /api/events; backend already
  applies bucket-aware appointment filtering via
  bucketAppointmentWindow().

events.tsx:
- The static <option> list collapses to a single placeholder; the
  populator owns the option set at hydration.

i18n:
- New `events.filter.status.all` ("Alle"/"All") for the appointment-only
  case — `deadlines.filter.all` says "Alle (offen & erledigt)" which is
  wrong for appointments (they don't have a completed/pending state).

Backend (event_service_test.go):
- Three new live subtests confirming type=appointment + status=today
  narrows to today's appointments, status=later narrows to far-future,
  and status=completed collapses the appointment rail (defensive vs.
  URL-hacking — the dropdown excludes that value for appointments).
2026-05-04 18:56:25 +02:00
m
d286da34d5 Merge: t-paliad-121 — UPC court vacations no longer shift deadlines (Court continues to operate) 2026-05-04 18:52:57 +02:00
m
7461c4af49 fix(t-paliad-121): stop shifting deadlines for UPC court vacations
Per UPC AC decision 2023-05-26, the UPC has summer + winter judicial
vacations but the Court continues to operate during them — they do NOT
extend procedural deadlines. paliad's HolidayService was treating every
paliad.holidays row as a non-working day, including vacation entries, so
a deadline landing on Tue 2026-08-04 (a regular working Tuesday) was
incorrectly shifted to Mon 2026-08-31 by walking the entire summer-
vacation run.

Fix: gate IsNonWorkingDay on Holiday.IsClosure (true for public_holiday
and closure types, false for vacation). IsHoliday still returns the row
regardless of type — UI surfaces that want to flag "this date is inside
UPC vacation" can still ask. paliad.holidays data is unchanged: the UPC
vacation rows stay as informational metadata.

The Kind="vacation" branch of AdjustForNonWorkingDaysWithReason is now
unreachable in practice (every vacation entry is IsClosure=false, so the
walk loop never enters with a vacation as the cause). Left in place as
defensive code for any future vacation type that should shift.

Tests: replaced TestAdjustForNonWorkingDaysWithReason_Vacation (asserted
the old wrong behaviour) with TestVacationDoesNotShiftDeadlines covering
m's reproduction (Tue 2026-08-04 → no shift), winter-vacation no-shift
(Mon 2026-12-28), Christmas/Neujahr regression (still shift correctly,
and walk through informational vacation entries to land on the next
real working day), and a Karfreitag regression to lock public-holiday
shifts.
2026-05-04 18:48:23 +02:00
m
0587fc2296 Merge: t-paliad-119 — Fristenrechner shift-reason explainer (UPC vacation, holiday name, weekend) 2026-05-04 18:37:17 +02:00
m
d688ebde90 feat(t-paliad-119): explain WHY a Fristenrechner deadline was shifted
The current "Wochenende/Feiertag" / "weekend/holiday" label hides the cause
of long shifts — m's reproduction had a deadline jump from 4.8.2026 to
31.8.2026 (+27 calendar days) across UPC Summer Vacation, and the UI made
it look like a bug. The math was correct; the explanation was lying.

Backend:
- AdjustForNonWorkingDaysWithReason returns an AdjustmentReason alongside
  the adjusted date. Walks the same 60-iter loop, classifies the dominant
  cause (vacation > public_holiday > weekend), collects every named
  holiday hit, and for vacations scans outward to report the contiguous
  block boundary (27.7.–28.8., not the 25 individual rows).
- AdjustForNonWorkingDays now wraps the new method, preserving its
  3-tuple signature for existing callers (deadline_calculator,
  event_deadline_service).
- UIDeadline gains an AdjustmentReason field; FristenrechnerService
  populates it on every shifted deadline.
- Date fields serialise as YYYY-MM-DD strings (HolidayDTO + string
  vacation span) — the Fristenrechner client already speaks that format.

Frontend:
- AdjustmentReason → human-readable phrase via renderAdjustmentReason:
    vacation       → "{vacation_name} ({span})"
    public_holiday → "Feiertag ({first_holiday_name})" / "{name} holiday"
    weekend        → "Wochenende" / localised weekday
- Surrounding format becomes "Verschoben wegen X: A → B" (DE) or
  "Shifted (X): A → B" (EN). Falls back to the legacy reason string
  when the backend hasn't sent a structured reason.
- Vacation names render verbatim from paliad.holidays — no hardcoded
  i18n mapping for individual closures (those rotate via the seed, not
  via i18n.ts).

Tests cover the three Kind paths plus the no-shift case; UPC vacation
test injects the migration-010 seed into the cache so the assertion
runs without a live DB.

Out of scope (raised in conversation, deferred):
- Whether "UPC Summer Vacation" / "UPC Winter Vacation" are the right
  names for the seeded rows, and whether the winter block belongs in
  paliad.holidays at all (m flagged this as BS while reviewing the
  task — needs a data-side decision before renaming/removing).
- holidays.country isn't filtered by proceeding-type jurisdiction, so
  UPC vacation currently shifts EP_GRANT / EPA_APP / German national
  deadlines too. Bigger fix; flagged for a follow-up issue.
2026-05-04 18:31:55 +02:00
m
6940c1e030 Merge: t-paliad-120 — /events Termin-Typ chip dark-mode color fix 2026-05-04 18:28:01 +02:00
m
f79dbdba4a fix(t-paliad-120): /events Termin-Typ chip — dark-mode text invisible
Add dark-mode rules for `.termin-type-chip.termin-type-XYZ`. Without them
the chip inherited the bare `.termin-type-XYZ` swatch rule (line 8407+),
which intentionally paints text the same colour as the background for
the dot/border-left swatch use case → text invisible in the chip context.

Mirror the existing badge dark rules (lines 8413-8415) so chip and badge
share the same colour family. Adds the missing `consultation` variant for
the chip (badge consultation dark is also missing but out of scope).
2026-05-04 18:25:59 +02:00
m
ecfd62e330 Merge: t-paliad-118 — /admin/email-templates Verfügbare Variablen single-column stack 2026-05-04 18:13:12 +02:00
m
7581444cd4 Merge: t-paliad-117 — /events filter polish (i18n leak + label position + panel anchoring) 2026-05-04 18:08:20 +02:00
m
e9e445fddf fix(t-paliad-118): /admin/email-templates Verfügbare Variablen — single-column stack
The `.admin-et-variables-list` was a 4-track CSS grid designed for flat
name/type/desc/sample siblings. The renderer wraps each variable in a
`.admin-et-variable-row` div, so each variable became a single grid item —
and 4 rows packed into one visual row, producing the wide multi-column
overflow.

Switch the outer container to `flex-direction: column` (one variable per row)
and the row to a baseline-aligned wrapping flex (name, type pill, desc, sample
left-to-right). `margin-left: auto` pushes the sample right when there's room
and lets it wrap below on narrow widths.

Pre-existing dark-mode issue (`.admin-et-variable-name` hardcoded to #1c1917,
unreadable on dark background) is unchanged by this fix and predates the
layout regression — flagged separately, not in scope here.
2026-05-04 18:08:06 +02:00
m
5875a62aba fix(t-paliad-117): /events filter polish — i18n leak, label position, panel anchoring
Three small polish bugs on the unified /events filter row.

A) i18n leak on lang change. The static [data-i18n] options in the appointment-
type select are retranslated by initI18n's applyTranslations(), but the project
select is rebuilt at runtime via populateProjectFilter() and the multi-select
trigger label is set via t() inside attachEventTypeMultiSelectFilter. Neither
re-ran on lang change, so DE→EN→DE left "All matters" / "All types" stuck on
the page. Wire onLangChange:
  - events.ts: re-call populateProjectFilter() inside the existing handler.
  - event-types.ts: attachEventTypeMultiSelectFilter now subscribes to
    onLangChange itself and re-renders updateLabel() + (when open) renderPanel().
    Self-contained side effect — also fixes /agenda's multi-select for free.

B) Filter caption above the field. The pre-existing .filter-row + .filter-group
pattern (already used on /projects) had .filter-group laid out horizontally
(label-left, select-right). Wrapped each events.tsx label+control pair in
.filter-group and switched .filter-group to flex-direction: column with a 4px
gap, so the caption sits above the select / button trigger. The whole filter-
row stays a horizontal flex-wrap of column cells. Mobile (≤480px) still falls
back to a single-column stack. /projects gets the same polish since it already
used the same wrapper pattern.

C) Multi-panel anchoring. The event-type popover was rendered as a sibling of
the trigger inside .filter-row and absolute-positioned with auto/auto, so it
landed wherever the wrapping flex layout put it (~226px above the trigger in
practice). Wrapped trigger+panel in <div class="multi-anchor"> with
position: relative and pinned the panel via top: 100%; left: 0 — only when
inside .multi-anchor, so /agenda's existing column-flex auto-positioning is
untouched.

toggleFilterPair simplified to hide the wrapping .filter-group via closest()
instead of toggling the label and control separately.
2026-05-04 18:02:26 +02:00
m
0ead001811 fix(dashboard,events): drop "Termine auf einen Blick" rail + fix multi-panel overflow leak
m's call (2026-05-04): the Dashboard's secondary "Termine auf einen
Blick" rail (3 cards) was redundant — the upcoming-Termine list lives
right below it on the same page, and the Fristen rail above is what
matters for the at-a-glance read. Drop the cards.

frontend/src/dashboard.tsx: remove the <section
aria-labelledby="dashboard-appointment-summary-heading"> and its 3 cards.

frontend/src/client/dashboard.ts: drop renderAppointmentSummary +
the AppointmentSummary type + the appointment_summary field on
DashboardData (kept the API-side payload — other consumers may use it
later; just stop wiring the dashboard to it).

Also two related event-page styling bugs:

- frontend/src/client/events.ts:721 was force-stamping
  `style.display = "block"` on the event-type multi-panel popup whenever
  the type filter was anything but appointment. The panel is supposed
  to be a hidden flyout owned by the trigger button via `panel.hidden`;
  the inline display:block trumped the `.multi-panel[hidden]` CSS rule
  and left it visible on larger screens (m flagged the
  `<div ... hidden="" style="display: block;">` artefact). Fix: never
  set inline display from this code path; force-close the panel only
  when switching to appointment view.

- frontend/src/styles/global.css `.multi-list` + `.multi-option`: long
  event-type labels overflowed the 22rem panel horizontally because
  `.multi-list` only had `overflow-y: auto` and the flex options had no
  `min-width: 0` / overflow-wrap rules. Add `overflow-x: hidden` +
  `min-width: 0` on the list and `overflow-wrap: anywhere` on options.
2026-05-04 17:08:34 +02:00
m
c09150e744 Merge: t-paliad-116 — event_deadlines i18n follow-up (title_de + notes_en) 2026-05-04 17:04:19 +02:00
m
4e1213fbd1 fix(t-paliad-116): event_deadlines i18n follow-up — title_de backfill + notes_en
Two adjacent i18n leaks in /tools/fristenrechner "Was kommt nach…" mode,
same pattern as t-paliad-112's deadline_rules fix but on event_deadlines:

A) title_de empty for all 70 rows. The DTO already falls back to title
   silently, so DE locale rendered English titles ("Statement of Defence",
   "Decision of the EPO", …). Backfilled via mig 035 with UPC RoP DE
   terminology that matches the trigger_events.name_de translations from
   mig 033, so the picker label and the deadline row read the same.

B) notes column carries English text on rows 50, 52, 70 (DE-named column
   was DE-only in spec, but seeds slipped EN strings through). Mig 036
   adds a parallel notes_en column following the t-112 mig 032 pattern,
   copies the existing English into notes_en, and replaces notes with
   proper DE for those three rows.

Render path:
- service: select notes_en, plumb through EventDeadlineResult.NotesEN
- frontend: getLang() === "en" ? (notesEN || notes) : notes (mirrors the
  proceeding-tree timeline branch already in fristenrechner.ts)
2026-05-04 17:03:58 +02:00
m
df2b2114df Merge: t-paliad-114 follow-up — SMEFee=2015 (reduced fee per Rule 7a(2)(a-d))
Head's fe8ba15 corrected the standard fee but kept SMEFee=2925 (collapsed
the tier). m's call: the data should reflect the actual reduced fee
(2.015€) even while the UI doesn't surface the tier yet. Taking fritz's
SMEFee=2015 over main's 2925.
2026-05-04 16:58:49 +02:00
m
2f44461275 fix(t-paliad-114): EPA Beschwerdegebühr 2.255€ → 2.925€ (2024-04-01 EPO restructure)
Standard appeal fee per Rule 6 EPC-Gebühren v2024-04-01 was raised from
2.255€ to 2.925€; reduced fee per Rule 7a(2)(a-d) was raised from 1.880€
to 2.015€. Both stale in calc.EPAFees, surfaced wrong amount on
/tools/kostenrechner and /tools/gebuehrentabellen EPA tab.

Reduced-fee tier is updated for data accuracy; UI surfacing of that tier
is deferred per m's call (out of scope for this task).
2026-05-04 16:57:58 +02:00
m
fe8ba15477 fix(t-paliad-114): update EPA Beschwerdegebühr to 2.925€ (post 2024-04-01)
The Kostenrechner showed EUR 2.255 for the EPA appeal fee, a stale
pre-restructure figure. Per Rule 6 EPC-Gebühren (version 2024-04-01)
the standard appeal fee is EUR 2.925 — applies to any legal person not
under Rule 7a(2)(a-d). The reduced fee of EUR 2.015 for natural persons
/ SMEs / non-profits / academic institutions / public-research orgs is
documented in a code comment but not surfaced separately yet — m said
keep it simple; ship the standard fee only.

SMEFee aligned to the same 2925 since we're not exposing a tier toggle.
Updated TestComputeEPAInstance_SME accordingly.

Both /tools/kostenrechner and /tools/gebuehrentabellen read from the
same EPAFees map, so this single edit fixes both surfaces.

Picked up from fritz's worktree — fritz hit a Claude usage rate limit
mid-edit; head completed the same diff plus the test update.
2026-05-04 16:32:38 +02:00
m
53f7eae665 fix(t-paliad-111): renumber colliding migration 032→034 — production was down
Two migrations both named 032 collided when t-111 and t-112 merged in
parallel — 032_deadline_notes_en (t-112, already applied to the DB and
tracker bumped to v33) vs. 032_deadlines_rule_code (t-111). The Go
migration runner refuses to init the driver when two files share a
prefix, so paliad.de was 404 across all routes (container in restart
loop with `migration failed: ... duplicate migration file:
032_deadlines_rule_code.down.sql`).

Renumbering t-111's pair to 034 (033 was used by t-112's
trigger_events_de backfill).
2026-05-04 14:57:54 +02:00
m
c554e865eb Merge: t-paliad-111 — bug bundle correctness (UPC GESAMTKOSTEN, court-set dates, REGEL save) 2026-05-04 14:42:51 +02:00
m
0be2dfb5a0 fix(t-paliad-111): bug bundle (correctness) — UPC GESAMTKOSTEN, court-set dates, REGEL save flow
Three correctness bugs from the t-paliad-101 QA sweep, fixed together since
they all change displayed/saved numbers users rely on.

B1 — Kostenrechner UPC GESAMTKOSTEN double-count
  ComputeUPCInstance was setting InstanceTotal = effectiveCourtFee +
  recoverableCeiling. The R.152 recoverable-cost cap is the OPPOSING
  side's worst-case loss-of-suit liability, not the user's own cost —
  folding it into GESAMTKOSTEN inflated the UPC total under a label
  that means "your outlay," and the DE LG/OLG/BGH branches don't add
  any opponent estimate. Drop it from InstanceTotal; the ceiling
  still surfaces as its own RecoverableCeiling line item.

  Live pre-fix on paliad.de (Streitwert 100k, UPC 1. Instanz only):
    instanceTotal = 52600 = 14600 court fee + 38000 R.152 ceiling
  Post-fix:
    instanceTotal = 14600 (court fee only); RecoverableCeiling stays 38000

B3 — Court-determined Termine emit trigger date as a real-looking date
  Zwischenverfahren / Mündliche Verhandlung / Entscheidung all live in
  paliad.deadline_rules with duration_value=0 and parent_id=NULL, so
  Calculate() classified them as IsRootEvent and emitted the trigger
  date as their own DueDate. Worse, RoP.151 "Antrag auf Kostenentscheidung"
  parents off inf.decision and chained 1 month off the placeholder ->
  bogus deadline that the UI rendered as real.

  Fix: classify a zero-duration rule as IsCourtSet (not IsRootEvent)
  when primary_party = 'court' or event_type ∈ {hearing, decision,
  order}. Track court-set rule IDs and propagate IsCourtSet downstream
  to any rule whose parent is court-set, so RoP.151 also surfaces as
  court-set rather than a fabricated date. Save-modal already greys
  out IsCourtSet rows so the "Gerichtsbestimmte Termine ohne Datum
  werden übersprungen" footnote becomes truthful again.

  Live pre-fix on paliad.de (UPC_INF, trigger 2026-04-29):
    Zwischenverfahren / Oral / Entscheidung -> dueDate 2026-04-29
    Antrag auf Kostenentscheidung -> 2026-05-29 (bogus, +1mo from trigger)

B6 — Fristenrechner save flow stored rule code in TITLE
  Frontend was concatenating "RoP.023 — Klageerwiderung" into the
  title because deadlines had nowhere else to put the citation, and
  the /deadlines REGEL column ended up showing "—". Add migration 032
  with a paliad.deadlines.rule_code text column, plumb it through
  CreateDeadlineInput / insertTx, drop the now-redundant r.code AS
  rule_code JOIN alias on the list query (the deadline owns its
  citation), and render f.rule_code on the project-detail deadlines
  table + /deadlines events list + deadline-detail page.

Build, vet, and tests all clean. New unit test
TestIsCourtDeterminedRule pins the B3 discriminator across the
event_type / primary_party combinations seen in migrations 012 + 031.

Repro creds: tester@hlc.de
2026-05-04 14:42:29 +02:00
m
eb6e194684 Merge: t-paliad-115 PR-2 — canonical /events URL + redirect old paths 2026-05-04 14:40:57 +02:00
m
56522adffe feat(t-paliad-115): canonicalise list URL on /events; redirect old paths
PR-2 of t-paliad-115. The unified Fristen + Termine surface now lives at
/events. Old /deadlines and /appointments list URLs 301-redirect to
/events?type=deadline and /events?type=appointment so existing bookmarks
still land on the right view. Detail pages (/deadlines/{id},
/appointments/{id}) stay type-specific.

Backend (Go).
- New `GET /events` route → handleEventsListPage serves dist/events.html.
- `GET /deadlines` → handleDeadlinesListRedirect (301 → /events?type=deadline).
- `GET /appointments` → handleAppointmentsListRedirect (301 → /events?type=appointment).
- /deadlines/new, /deadlines/calendar, /deadlines/{id}, /appointments/new,
  /appointments/calendar, /appointments/{id} unchanged — type-specific
  detail / form / legacy-calendar surfaces stay where they are.

Frontend.
- build.ts now emits ONE events.html (not events-deadlines /
  events-appointments) with defaultType="all" baked in. The page reads
  ?type=… and ?view=… on hydration, so /events?type=deadline lands on
  the Fristen-only Cards view, /events?view=calendar opens the calendar,
  and bare /events shows the Beides view.
- Sidebar Fristen / Termine entries point at /events?type=deadline and
  /events?type=appointment. The SSR active-state matches exactly via
  href === currentPath, so detail/new/calendar pages that pass
  currentPath="/events?type=deadline" (resp. appointment) still
  highlight the right entry.
- events.ts hydration adds applySidebarTypeHighlight(): on bare /events
  the sidebar SSRs with neither entry lit, and we re-highlight the
  matching entry whenever the in-page chip toggle changes the active
  type. Sidebar stays in sync without a server round-trip.
- Updated every list-target reference: palette-actions.ts (Cmd-K
  navigation), deadlines-detail.ts + appointments-detail.ts (post-delete
  redirect), and the back-link / cancel hrefs in the *-new + *-detail +
  *-calendar TSX templates. Detail-page Sidebar/BottomNav currentPath
  also moved from "/deadlines" → "/events?type=deadline" so the new
  highlight contract holds end-to-end.

Out of scope (per task brief).
- A third "Ereignisse / Alle Events" sidebar entry pointing at /events
  bare. m's call: keep two entries; defer until signal.
- Removing /deadlines/calendar + /appointments/calendar standalone
  pages. The new /events?view=calendar covers the same need but the
  legacy URLs stay live for one cycle.

Build clean: `cd frontend && bun run build` + `go build/vet/test ./...`.
2026-05-04 14:40:53 +02:00
m
b88afe8ddd Merge: t-paliad-112 — i18n bug bundle (deadline_notes_en, trigger-event DE, Checkliste PROJECT) 2026-05-04 14:36:57 +02:00
m
341fa6c26f fix(t-paliad-112): i18n leaks — deadline_notes_en, trigger-event DE, Checkliste header
Three i18n bugs from the t-paliad-101 QA sweep, fixed together:

B2 — Fristenrechner deadline notes leaked German into the EN locale.
Migration 032 adds paliad.deadline_rules.deadline_notes_en (TEXT NULL)
and backfills English translations for all 30 rules that carry a
deadline_notes value (UPC RoP / EPC / ZPO terminology). The frontend
prefers _en when locale=EN and falls back to deadline_notes (DE) when
the column is NULL, so future seeds without an EN translation render
in DE rather than empty. UIDeadline DTO gains notesEN. The bulk
"Als Frist(en) speichern" CTA now stores the locale-matched note text
so EN users get an EN note alongside the EN title.

B8 — trigger-event picker labels were English-only when DE locale was
active (102 rows, name_de defaulted to '' in 028, frontend already had
the locale switch but no data). Migration 033 backfills name_de for
all 102 trigger events using standard German UPC RoP terminology
(Klageschrift, Klageerwiderung, Replik, Duplik, Nichtigkeitswiderklage,
Verletzungswiderklage, Berufungsschrift/-begründung, Anschlussberufung,
Schutzschrift, Beweissicherung, etc.).

S3 — frontend/src/client/checklists-instance.ts:154 had a hardcoded
"Project" label in both branches of the locale ternary; the DE branch
now reads "Projekt", matching the surrounding meta-item labels' pattern
(Court / Authority → Gericht / Behörde, Reference → Rechtsgrundlage).
2026-05-04 14:36:50 +02:00
m
45979caf81 Merge: t-paliad-115 PR-1 — events view ⊥ filter + unified calendar view 2026-05-04 14:34:52 +02:00
m
1dad1c7371 feat(t-paliad-115): events view ⊥ filter — view selector + unified calendar view
PR-1 of t-paliad-115. Separates the view axis (cards / list / calendar)
from the filter axis (event type, status, project) on EventsPage. Closes
the duplicate "Kalenderansicht" button that t-paliad-110 left behind on
the Beides view.

Bug source: frontend/src/events.tsx:53-68 rendered TWO separate
"Kalenderansicht" anchors (events-action-deadline-cal +
events-action-appointment-cal) and frontend/src/client/events.ts:533-534
unhid both when type=all. Reproduced live on paliad.de — screenshot at
.playwright-mcp/paliad-115-duplicate-kalenderansicht-before.png.

Architectural change.
- Removed the per-type calendar buttons from the page header.
- Added a 3-button segmented view selector (Karten / Liste / Kalender)
  next to the type chips. The two controls now sit on a shared
  events-axis-row that flexes side-by-side and stacks on narrow viewports.
- View state lives in `currentView`, defaults to "cards", reads from
  ?view= on init, persists to URL on change. Default ("cards") stays
  out of the URL so existing bookmarks don't change.
- Cards view = original (5-card summary + table). List view = table
  only (cards hidden). Calendar view = month grid; cards + table both
  hidden.

Calendar view.
- Plots both deadlines (due_date) and appointments (start_at local
  date) on a Mo–So month grid. Reuses the existing .frist-cal-* CSS
  scaffold from /deadlines/calendar; only new addition is
  .events-cal-dot-appointment for the appointment hue (--bucket-next-week).
- Inherits the page's filters — `?view=calendar&type=appointment` shows
  appointment-only; `?view=calendar&status=all` shows everything; etc.
  Status, type, project, event-type filters apply orthogonally to view,
  matching the spec's "two axes combine" requirement.
- Click a day with items → existing modal pattern lists them with type
  chip + project ref; clicking an item navigates to its detail page.
- Month nav (prev / next / today) is purely client-side — no refetch,
  cheap pagination over the already-loaded items.

Out of scope (per task brief).
- Standalone /deadlines/calendar + /appointments/calendar pages stay
  untouched. PR-2 (URL canonicalisation) handles that surface.
- Custom time-window controls — future iteration per t-110 spec.

Build clean: `cd frontend && bun run build` + `go build/vet/test ./...`.
2026-05-04 14:34:44 +02:00
m
9dbc55638a Merge: t-paliad-113 — bug bundle polish (save modal + Streitwert + EPO + EN dates) 2026-05-04 14:32:16 +02:00
m
2c346672bb fix(t-paliad-113): bug-bundle polish — save modal court-set + project prefix, negative Streitwert, EPO en label, ISO en dates
5 small polish bugs from the t-paliad-101 QA sweep, bundled as one PR.

B4 — Fristenrechner save modal pre-checked court-determined entries:
treat dl.party === "court" as court-determined alongside dl.isCourtSet
so Zwischenverfahren / Mündliche Verhandlung / Entscheidung pre-uncheck
+ disable in the modal. Their meta now reads "vom Gericht bestimmt"
(deadlines.court.set) instead of the urgency placeholder.

B5 — Save modal project dropdown empty-code prefix: render "(reference)
— title" only when reference is non-empty; bare title otherwise. No
more leading "— Title" rows.

B7 — Negative Streitwert in Kostenrechner: blur handler now snaps
negative entries by clearing the input + resetting the slider to its
min and re-running calc, so a "-50000" entry no longer leaves stale
positive results onscreen. Input handler also drops the slider to min
on negatives so the visual state stays in sync mid-typing.

S2 — Courts EN page filter chip "EPA" → "EPO": added
gerichte.filter.epa i18n key (DE: EPA, EN: EPO) and wired it on the
courts.tsx pill.

S4 — Fristenrechner EN date format: switch from en-GB (dd/mm/yyyy,
ambiguous for US readers) to ISO ("Tue, 2026-09-01"). DE format
unchanged.
2026-05-04 14:31:43 +02:00
m
7463831932 Merge: t-paliad-109 — design doc for Fristen/Termine unification 2026-05-04 14:23:01 +02:00
m
165f0c1717 Merge: t-paliad-110 fix — hide Überfällig on Beides view 2026-05-04 13:57:47 +02:00
m
82421b3c86 fix(t-paliad-110): hide Überfällig card on Beides view
Spec is explicit that Überfällig is deadline-view-only — only showing
it on type=deadline matches the rationale that 'overdue' is a deadline-
specific concept (appointments don't go overdue, they happen). The
previous logic also surfaced the card on Beides whenever the
deadline-side overdue count > 0, which would have rendered an alarm
card on a mixed view.

Caught during live verification on paliad.de.
2026-05-04 13:57:40 +02:00
m
b2dfc87f57 Merge: t-paliad-110 PR-4 — Dashboard Termine rail + Fristen rail refactor 2026-05-04 13:52:56 +02:00
m
57237a55a3 feat(t-paliad-110): refactor Dashboard rails — drop Erledigt card, add Später + Termine rail
PR-4 of the Fristen+Termine unification, closing out t-paliad-110.

Fristen rail (was 5 cards):
- Erledigt card removed (status=completed stays reachable via the EventsPage
  filter dropdown — no card on the rail per the new model)
- Später card added (pending deadlines past Mon-week-after, click filters
  to /deadlines?status=later)
- 4+1 final shape: Überfällig (conditional alarm) · Heute · Diese Woche ·
  Nächste Woche · Später

Termine rail (new): 3 cards — Heute · Diese Woche · Später. No Überfällig
(past appointments aren't urgent), no Nächste Woche (low-value distinction
for appointments per the design rationale). Cards click through to
/appointments?status=… so users land in the matching EventsPage view.

Backend (DashboardService.loadSummary):
- DeadlineSummary.CompletedThisWeek dropped, .Later added
- AppointmentSummary added (Today / ThisWeek / Later)
- One CTE-based query computes both rails alongside MatterSummary; bucket
  cutoffs share computeDeadlineBucketBounds with /api/events/summary +
  /api/deadlines/summary so all three surfaces stay in lockstep

Frontend:
- dashboard.tsx: Erledigt card removed, Später card + Termine section added
- client/dashboard.ts: types updated, renderAppointmentSummary added
- 4 new i18n keys (DE+EN): dashboard.summary.later +
  dashboard.appointment_summary.heading
- CSS: .dashboard-card-later (muted blue) + 3 .dashboard-card-appt-* rules
  reusing the existing --bucket-* tokens

go build/vet/test ./... clean. bun run build clean (1396 keys).
2026-05-04 13:52:49 +02:00
m
88af8d3487 Merge: t-paliad-110 PR-3 — mount EventsPage on /deadlines + /appointments 2026-05-04 13:48:58 +02:00
m
50ac065c7d feat(t-paliad-110): mount unified EventsPage on /deadlines + /appointments
PR-3 of the Fristen+Termine unification. Both routes now serve the shared
shell built by renderEvents() — the per-type pages (deadlines.tsx /
appointments.tsx and their client bundles) are deleted entirely.

Hydration is baked at build time, not at request time: build.ts emits
events-deadlines.html and events-appointments.html, each carrying an
inline `window.__PALIAD_EVENTS__={"defaultType":"…"}` script in <head>.
The Go handlers ServeFile the matching artefact, no placeholder swap
needed (cleaner than the dashboard pattern for a single static flag).

Sidebar entries unchanged — "Fristen" still points at /deadlines,
"Termine" at /appointments. Both highlight correctly because each
artefact passes the matching currentPath into <Sidebar />.

Detail / new / calendar pages stay type-specific (out of scope per
task brief). Old endpoints /api/deadlines + /api/appointments remain
live for the calendars, project-detail panes, and CalDAV consumers.

Net: -981 lines (drops the duplicated chrome of the two old pages
in favour of one shared shell).

go build/vet/test ./... clean. bun run build clean.
2026-05-04 13:48:53 +02:00
m
285e97203a Merge: t-paliad-110 PR-2 — shared EventsPage component 2026-05-04 13:46:38 +02:00
m
fe9c1b7de2 feat(t-paliad-110): add shared EventsPage component + bucket-aware backend tweaks
PR-2 of the Fristen+Termine unification. Pure additive change — the existing
deadlines.tsx + appointments.tsx pages stay live; this PR introduces the new
events.tsx shell + client/events.ts runtime that PR-3 will mount onto the
two routes.

Frontend (new):
- frontend/src/events.tsx — shared shell with the 3-chip type toggle
  (Fristen / Termine / Beides), the 5-card summary row (Überfällig
  conditional + 4 universal cards), the union filter row, and the unified
  table that renders a discriminated row per type. Two header CTAs ("Neue
  Frist" + "Neuer Termin") collapse to the relevant one in single-type mode.
- frontend/src/client/events.ts — runtime. Reads window.__PALIAD_EVENTS__
  (PR-3 will inject defaultType from the Go handler), derives the rest from
  ?type query param. Card click sets status filter; the events endpoint
  takes care of bucket-aware appointment-side date windowing so both rails
  stay in sync in Beides mode. Hide-on-uniform pattern applied per column
  (rule, event_type, location, appointment_type, status, row-type chip).
- frontend/build.ts — emits events-deadlines.html + events-appointments.html
  from one renderEvents(currentPath) so each output gets the right Sidebar
  highlight; client/events.ts bundle added.
- 16 i18n keys (DE+EN): events.toggle.*, events.summary.later,
  events.col.*, events.row.type.*, events.empty.*, events.unavailable plus
  the new deadlines.summary.later / deadlines.filter.later pair for the
  Später bucket.
- CSS: --bucket-later (#1d4ed8 light / #60a5fa dark) for the Später card,
  matching events-table--hide-* column hiders, .events-row-type-chip
  styling, .event-type-chip-row spacing.

Backend tweaks (small):
- DeadlineFilterLater (`later`): pending deadlines past Mon-week-after.
  Click-target for the Später card.
- EventService.ListVisibleForUser now derives an appointment-side date
  window from a bucket-style status (today/this_week/next_week/later) so
  card clicks filter both rails consistently. Overdue/Completed exclude
  appointments entirely (no appointment analogue).
- pickLater / pickEarlier helpers intersect the bucket-derived window with
  any caller-supplied from/to.

go build/vet/test ./... clean. bun run build clean (1394 keys, IIFE prologue
guard passes).
2026-05-04 13:46:33 +02:00
m
49eb3c97e1 Merge: t-paliad-110 PR-1 — backend EventService + /api/events 2026-05-04 13:37:36 +02:00
m
2102dfd07d feat(t-paliad-110): add EventService + /api/events + /api/events/summary
PR-1 of the Fristen+Termine unification (t-paliad-110). Backend layer
only — no frontend changes; the existing /deadlines and /appointments
pages still render the type-specific UIs.

EventService delegates to DeadlineService + AppointmentService for the
actual reads (no duplicate visibility logic, no duplicate event_type
hydration), then projects both into the discriminated EventListItem
union and merges/sorts by event_date asc. The handler exposes:

  GET /api/events?type=deadline|appointment|all&status=…&project_id=…
                  &event_type=…&type_filter=…&from=…&to=…
  GET /api/events/summary?type=…&project_id=…

Bucket model (per t-paliad-110 spec, supersedes t-106):
- four universal cards: Heute · Diese Woche · Nächste Woche · Später
- Überfällig is deadline-only, conditional, alarm-styled when > 0
- Erledigt drops from the card row; stays available as a filter option
- appointments have no completed_at — past appointments aren't bucketed

The deadline-side cutoffs reuse computeDeadlineBucketBounds so
/api/events/summary and /api/deadlines/summary can never disagree.

Existing /api/deadlines and /api/appointments stay untouched —
calendars, project-detail panes, and CalDAV consumers still call them
directly.
2026-05-04 13:37:20 +02:00
m
25efce0c76 design(t-paliad-109): unify Fristen + Termine as filtered Events views
Design doc only — no code touched. Recommends keeping /deadlines + /appointments
URLs but rendering one EventsPage component (smallest-diff Option A1), backed
by a new EventService that delegates to existing Deadline/AppointmentService
(Option B1). Two-rail bucket summary on Beides (5 deadline + 3 appointment),
detail pages stay separate, /agenda timeline left alone. §F lists 17 questions
gating m's greenlight, including a premise correction: the brief described
/agenda as the appointment list — actually it's a pre-existing cross-type
timeline; the appointment list is /appointments.
2026-05-04 13:14:52 +02:00
m
1def9e86b9 Merge: t-paliad-108 — bucket card colors (Heute dark-orange, Diese Woche yellow) 2026-05-04 13:02:21 +02:00
m
db09f6e7d5 feat(t-paliad-108): bucket card palette — Heute orange, Diese Woche yellow
5-bucket urgency cards (Dashboard "Fristen auf einen Blick" + /deadlines
summary) now share a single source of truth via new --bucket-* tokens:

  Überfällig red → Heute orange → Diese Woche yellow → Nächste Woche green → Erledigt grey

Light values pass WCAG AA-large (3:1+) on the white surface for the
colored count (1.6–2.25rem, 600–700 weight = large text):
  red #ef4444 · orange #c2410c · yellow #a16207 · green #16a34a · grey #6b7280
Dark mode lifts to bright pastels for readability on midnight:
  red #fca5a5 · orange #fb923c · yellow #fbbf24 · green #4ade80 · grey #9ca3af

Both surfaces (.dashboard-card-* and .frist-card-*) now wire through the
same tokens, so the visual hierarchy reads identically. The Überfällig
alarm pulse (saturated red bg + white text, t-paliad-105/106) remains
unchanged. The dashboard-card-green border previously used --color-accent
(brand lime); it now uses --bucket-next-week to match the Fristen page.
2026-05-04 13:02:12 +02:00
m
99af714d65 Merge: t-paliad-107 — event-type picker browse-all modal 2026-05-04 12:06:26 +02:00
m
de03f3ddcb feat(t-paliad-107): add Alle anzeigen browse-all modal to event-type picker
The picker on /deadlines/new and /deadlines/{id} edit was search-only —
users had to know what to type. Add a third affordance: a "Alle anzeigen"
button next to search and "+ Neuer Typ" that opens a modal listing every
available event type grouped by category, with sticky search and
multi-select checkboxes pre-populated from the picker's current
selection. Apply replaces; Cancel discards.

Modal a11y: focus-trap (Tab cycles in modal), Esc to close, click-outside
to dismiss. Each row shows the localized label, optional jurisdiction
badge, and a checkbox. Empty-search state renders a muted message.

Side effect: added a base `.modal` rule giving every `<div class="modal">`
a proper card surface (background, padding, radius, shadow). The existing
.event-type-add-modal previously had only width — the latent gap meant
its form fields rendered floating over the dim overlay. Now both modals
share the scaffold.

Files:
- frontend/src/client/event-types.ts — openBrowseEventTypesModal +
  browse button on the picker.
- frontend/src/styles/global.css — .modal base + .event-type-browse-*
  + flex-wrap on .event-type-search-row to accommodate three children.
- frontend/src/client/i18n.ts — DE/EN keys: event_types.picker.browse_all,
  event_types.browse.{title,search,empty,apply,cancel,selected_count,
  jurisdiction.none}.

Out of scope per brief: /deadlines + /agenda multi-select filter (already
has its own browse-style listbox-panel), admin moderation panel,
appointment forms (don't carry event types).
2026-05-04 12:06:00 +02:00
m
d19e35bfaf Merge: t-paliad-106 — Deadline summary 5-bucket harmonization 2026-05-04 12:04:20 +02:00
m
37a925d3b2 feat(t-paliad-106): harmonize deadline summary — 5 disjoint buckets across Dashboard + Fristen
Both surfaces now show the same buckets with the same labels and the same
cutoffs: Überfällig (conditional, alarming) · Heute · Diese Woche ·
Nächste Woche · Erledigt. Single-source bucket math via
computeDeadlineBucketBounds — Heute = today, Diese Woche = tomorrow
through the upcoming Sunday inclusive, Nächste Woche = next Monday
through next Sunday inclusive, all disjoint. Items past next Sunday
are visible only via "All open"/"Upcoming" filters; the Überfällig
card stays hidden when count == 0 and switches to a saturated red
pulse + bold white text when count > 0.

Filter dropdown on /deadlines gains today / next_week entries; old
"upcoming" filter still works as a back-compat alias for everything
pending past this Sunday so legacy bookmarks don't 4xx.

Tests: 8 deterministic table cases for the bucket pivots (every
weekday + a 21-day disjointness walk).
2026-05-04 12:03:56 +02:00
m
16eb73bf44 Merge: t-paliad-105 — Überfällig hide-when-zero / alarm-when-nonzero 2026-05-04 11:52:45 +02:00
m
2bbbe562d7 feat(t-paliad-105): hide Überfällig card at zero, alarm at >0
Überfällig is an emergency category — never a normal-state tile. Replace the
prior `.dashboard-card-quiet` dim-but-visible behavior with two states:

- overdue === 0 → card removed from the layout (`*-overdue-hidden`).
  The Dashboard summary grid and the Fristen summary cards now use
  `repeat(auto-fit, minmax(180px, 1fr))` so the row re-flows to 3 cards
  instead of leaving an empty 4th column.
- overdue > 0 → saturated red surface, white text, soft pulsing red ring
  via `paliad-alarm-pulse`. Honors `prefers-reduced-motion`. Dark theme
  uses a slightly lighter red so the alarm still pops on midnight.

Applied on both surfaces (`#dashboard-card-overdue` and the Fristen
`.frist-summary-card[data-status="overdue"]`).
2026-05-04 11:52:35 +02:00
m
102d0168e9 Merge: t-paliad-104 — harmonize light-theme sidebar with cream body 2026-05-04 11:51:46 +02:00
m
e6369bc4c2 feat(t-paliad-104): harmonize light-theme sidebar with cream body
Reverses the t-paliad-083 carve-out that kept the sidebar on midnight in
both themes. m's feedback: in light mode the dark midnight column read as
a separate panel bolted onto the cream body. The brand lime stripe on the
active item is preserved (decoupled from --sidebar-text-active onto
--color-accent) so the active cue stays anchored across themes.

Light mode: --sidebar-bg = --color-bg-subtle (one step up from body
cream), --sidebar-text/-muted track the body palette, --sidebar-text-active
= midnight (full contrast against muted-grey inactive items),
hover/input/scrollbar tints switch to midnight-channel alphas.

Dark mode: original midnight + cream + lime palette restored inside the
:root[data-theme="dark"] override block. No regression there.
2026-05-04 11:51:40 +02:00
m
1a815979f8 docs(t-paliad-103): warn against ::before block-link overlays
Append a sibling note to the .entity-table contract (t-paliad-099)
explaining that pointer-event overlays for whole-card click break
text selection. Steer future hands at the row-handler pattern from
t-098/099/102/103 instead.
2026-05-04 10:54:09 +02:00
m
8a9b9c6611 Merge: t-paliad-103 — replace Verlauf ::before with row-click for text-selectable cards 2026-05-04 10:34:54 +02:00
m
73d108d878 fix(t-paliad-103): replace Verlauf ::before block-link with row-click handler
The t-102 ::before overlay (`inset: 0` on `.entity-event-link`) made the whole
card clickable but also captured pointer events on the title and description
text — users couldn't select-to-copy. Same trap noted in brunel's t-102
debrief: when text-selection matters, switch to a row-level click handler that
skips inner <a>/<button>, matching the .entity-table pattern from t-098/099.

CSS (global.css):
  - drop `.entity-event-link::before` overlay
  - drop `position: relative` from `.entity-event` and `position: static` from
    `.entity-event-link` (no longer anchoring an absolute pseudo-element)
  - keep cursor: pointer + hover-lift on `.entity-event:has(.entity-event-link)`
    so the affordance still telegraphs "clickable"
  - card hover-lift now keys off the card itself, not the link's :hover, so the
    lift triggers from anywhere on the card (matching the new click surface)
  - mirror `.dashboard-activity-item`: cursor + lime-tint hover row-highlight

TS:
  - projects-detail.ts:renderEvents: after innerHTML, attach a row-level click
    handler that reads `.entity-event-link.href` and skips clicks on inner
    <a>/<button>. Cards without a link have no `.entity-event-link` and stay
    non-clickable (cursor stays default via the `:has()` selector).
  - dashboard.ts:renderActivity: same handler reading `.dashboard-activity-project.href`.

Acceptance:
  - Title and description text on /projects/{id} → Verlauf cards and on
    /dashboard activity rows is selectable again.
  - Click anywhere on a card still navigates (no regression from t-102).
  - Title link still navigates; Cmd-/Ctrl-click opens in new tab; keyboard
    tabbing still hits the inner link.
  - `cd frontend && bun run build` clean; `go build/vet/test ./...` clean.
2026-05-04 10:34:45 +02:00
m
0081749f69 Merge: t-paliad-102 — link Verlauf entries to deadlines/appointments/notes 2026-05-03 18:39:16 +02:00
m
95a6df5b49 feat(t-paliad-102): link Verlauf entries to deadlines/appointments/notes
Extends the t-paliad-097 metadata pattern from checklist_* events to the
remaining audit families. Project Verlauf and Dashboard activity feed now
deep-link each event to its originating entity:

  - deadline_{created,updated,completed,reopened} → /deadlines/{id}
  - appointment_{created,updated} → /appointments/{id}
  - note_created → /appointments/{id} | /deadlines/{id} | /projects/{id}
    (most-specific parent — notes have no standalone page)

Backend (Go):
  - deadline_service.go / appointment_service.go: switch single-entity
    mutation events from insertProjectEvent to insertProjectEventWithMeta
    carrying {"deadline_id"|"appointment_id": uuid}.
  - note_service.go:insertWithAudit: derive metadata from noteParent so
    the audit row records {note_id, deadline_id|appointment_id|project_id}.

Frontend (TS):
  - projects-detail.ts: extract eventDetailHref(); wrapEventTitleLink
    delegates to it. Comment block lists every wired event family.
  - dashboard.ts:activityHref: same routing rules as the project Verlauf.
  - global.css: .entity-event becomes position:relative; the
    .entity-event-link::before pseudo expands the link's hit area to the
    full card so a click anywhere on the row navigates (matches what m
    expected from "die Karte ist verlinkt"). Hover lifts border + shadow.

Excluded by design (mirrors checklist_deleted exclusion):
  - *_deleted events — entity is gone.
  - deadlines_imported — bulk event with no single deadline_id; would
    need an aggregate target the product doesn't have today.

Pre-metadata rows stay non-clickable (no backfill — same precedent as
t-paliad-097).
2026-05-03 18:39:06 +02:00
m
f51bce3342 Merge: t-paliad-100 trim — keep only Event-Typen + UPC-Fristen entries 2026-05-03 13:09:41 +02:00
m
268695d83f revert(t-paliad-100): trim changelog backfill — keep only Event-Typen + UPC-Fristen
m's call: most of yesterday's backfill entries weren't worth surfacing.
Removed: Checklisten von überall öffnen, Admin-Verwaltung der Event-Typen,
Mehr Verfahrensarten im Fristenrechner, Dunkler Modus, Rollen-Filter im
Team-Verzeichnis, Checkboxen in Formularen ausgerichtet.

Kept: Event-Typen für Fristen (2026-04-30, Feature) and UPC-Fristen
genauer berechnet (2026-04-30, Fix) — both have direct user impact on
the deadline workflow.
2026-05-03 13:09:37 +02:00
m
e436abd631 Merge: t-paliad-100 — backfill changelog from git history (2026-04-21 → 2026-05-01) 2026-05-03 13:01:26 +02:00
m
a020c1e4c8 feat(t-paliad-100): backfill changelog 2026-04-21 → 2026-05-01
Eight new entries cover the user-facing work landed since the 2026-04-20
Settings entry: dark mode, /team Role filter, event types + admin
moderation panel, UPC RoP fixes, Tier 2 Fristenrechner ports, checklist
"Vorhandene Instanzen" tab, and the checkbox row-alignment fix.

Folded as siblings (per task hint): t-paliad-082 light-mode contrast
into the dark-mode entry; t-paliad-098 row-click into the t-paliad-097
checklist entry — both are small fixes on the same surface as their
sibling feature.

Internal/refactor merges in the window were skipped (t-080/091/092/093/
095/099 plus doc/dead-code/audit/smoke merges).

Tests: go test ./internal/changelog/... green (date-desc invariant
still holds). go build ./... + go vet ./... + bun run build clean.
2026-05-03 13:01:17 +02:00
m
2d46a86c50 docs(t-paliad-099): document .entity-table row-click contract
Anchor the convention surfaced by t-paliad-098/099 so the next
hand on .entity-table sees it before adding a new table:

- frontend/src/styles/global.css: contract comment block above the
  default cursor:pointer rule explaining the navigate-or-readonly choice
- .claude/CLAUDE.md: new "Frontend conventions" section pointing at
  the CSS and the row-handler pattern in client/checklists.ts +
  client/projects-detail.ts

No code changes; pure docs.
2026-05-02 11:47:57 +02:00
m
95cedebea7 Merge: t-paliad-099 — entity-table cursor matches behavior across all consumers 2026-05-02 11:17:11 +02:00
m
19e4bfacfb fix(t-paliad-099): cursor only invites click where rows actually navigate
Audit follow-up to t-paliad-098. The global `.entity-table tbody tr`
rule set `cursor: pointer` and a hover background on every row, but six
tables across the admin and settings surfaces don't navigate on row
click — actions live in inline buttons (admin-team, admin-event-types,
admin-partner-units) or the rows are pure read-only summaries (admin
audit log, CalDAV sync log). The cursor lied and the hover invite was
empty.

- Add `.entity-table--readonly` modifier in global.css that resets
  cursor and neutralises the hover background, including a dark-theme
  override since the existing `:root[data-theme="dark"] .entity-table
  tbody tr:hover` rule outranks the base modifier on specificity.
- Apply the modifier to the six table instances that don't navigate.

The eight tables that DO navigate (projects, deadlines, appointments,
checklists templates+instances, project-detail's deadlines/appointments
/checklists) already have row click handlers and keep the default
clickable affordance.
2026-05-02 11:17:00 +02:00
m
8f7f53b4c8 Merge: t-paliad-098 — checklist row click now navigates from any cell 2026-05-01 10:55:36 +02:00
m
215d4a465b fix(t-paliad-098): row-level click navigates to checklist instance
The .entity-table tbody tr CSS rule sets cursor: pointer on every row,
but the three checklist-instance tables only wired navigation to the
name cell's <a>. Clicks on Vorlage / Fortschritt / Angelegt cells looked
clickable yet did nothing.

Added row-level click handlers (skipping inner <a>/button so the
project-link cell and delete button still work) in:
- checklists.ts (Vorhandene Instanzen tab)
- projects-detail.ts (Checklisten tab on project detail)
- checklists-detail.ts (instance list on template detail)

Search-result anchors (handlers/search.go) already worked since they
are real <a> elements, no row handler needed.
2026-05-01 10:55:30 +02:00
m
a5a05b1a66 Merge: t-paliad-097 — open checklists from anywhere + Vorhandene Instanzen tab 2026-05-01 09:48:35 +02:00
m
df321acb63 feat(t-paliad-097): clickable checklist references + Vorhandene Instanzen tab
Two related checklist UX gaps:

1. Checklist events in a project's Verlauf tab were unclickable — and
   nothing in the project_events row carried the originating instance ID.
   Add an `insertProjectEventWithMeta` helper, write
   {"checklist_instance_id": <uuid>} as project_events.metadata for
   checklist_created / _renamed / _linked / _unlinked / _reset (skipped
   for _deleted — instance is gone). Surface metadata on
   /api/projects/{id}/events and on dashboard recent_activity. The
   Verlauf renderer wraps the title in <a href="/checklists/instances/{id}">
   when metadata.checklist_instance_id is present, and the dashboard's
   activity feed deep-links the project ref to the instance directly for
   checklist_* events. Existing rows (metadata `{}`) stay non-clickable —
   no migration backfill needed.

2. /checklists previously demanded a template pick before any existing
   instance was reachable. Add a tab nav (Vorlagen / Vorhandene Instanzen)
   using the existing entity-tab pattern. New endpoint
   GET /api/checklist-instances and ChecklistInstanceService.ListAllVisible
   return every visible instance across templates + projects, joined with
   project ref/title and sorted by created_at DESC. Rows show template,
   instance name (linked), project link (or "Persönlich"), progress bar,
   and created date. URL state (?tab=instances) keeps the active tab
   shareable. EN + DE i18n covered for tab labels and column headers.

Also adds event.title.checklist_* localizations for the Verlauf header
that translateEvent looks up.
2026-05-01 09:48:25 +02:00
m
a05002299d Merge: t-paliad-096 — fix checkbox row-alignment across forms/lists/modals 2026-04-30 21:45:23 +02:00
m
fd6517d53a fix(t-paliad-096): checkbox row-alignment in form-field contexts
`.form-field input` set `width: 100%` and `padding: 0.55rem 0.75rem` on
all inputs — that includes checkboxes and radios. The visual checkbox
visibly stretched to fill the form column on /settings (Benachrichtigungen
tab) and inside the Fristenrechner "Fristen übernehmen" save modal,
pushing the label text out of place.

Add a targeted override that restores natural sizing for type=checkbox
and type=radio inside `.form-field`. Also bump `.caldav-toggle-label`
specificity (selector → `label.caldav-toggle-label`) so its
`inline-flex; align-items: center; gap: 0.5rem` actually wins over the
generic `.form-field label { display: block }` rule — without that the
checkbox + label kiss with no gap.

Surfaces verified via Playwright on paliad.de:
- /settings?tab=benachrichtigungen — Frist-Erinnerungen master + 3 sub-toggles
- /tools/fristenrechner — "Fristen übernehmen" save modal rows
2026-04-30 21:45:09 +02:00
m
d14c8111eb Merge: t-paliad-093 — CSS class rename .akten-* → English equivalents (F-7 of t-074) 2026-04-30 16:52:25 +02:00
m
c6872f94b0 refactor(t-paliad-093): rename .akten-* CSS classes to English equivalents
F-7 of the t-paliad-074 architecture audit. Sweeps the last German-named
CSS leftovers — purely a class-name change, no behaviour or styling
delta. 466 references across global.css and ~30 TSX/TS files.

Naming rules applied:
- Generic table/tabs/form/empty/controls/detail/events/status/type/
  suggestion/chip/col/ref/search-wrap/select/soon/loading/muted/
  unavailable/row/header-row/title-input -> .entity-*
- Truly generic widgets dropped the prefix: .multi-* (multi-select
  panel), .filter-*, .collab-* (collaborator picker; bare class is
  now .collab-picker), .firmwide-*, .office-*, .back-link
- Project-specific names kept specific: .party-form/-controls/-table
  (parties on a project), .checklists-hint, .netdocs-link
- Page-scoped IDs in projects.tsx -> projects-search/-count/-body;
  projects-new.tsx -> project-new-form/-msg
- German content "akten-bezogen" tightened to "aktenbezogen" (one-word
  form is also valid German) so the strict grep stays clean
2026-04-30 16:52:10 +02:00
m
47af52d7ea Merge: t-paliad-092 — rename Go module path mgit.msbls.de/m/patholo → mgit.msbls.de/m/paliad 2026-04-30 16:47:30 +02:00
m
460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00
m
bbd46f658b Merge: t-paliad-089 — Admin Event-Type moderation panel (bulk archive, merge, promote, restore) 2026-04-30 16:43:09 +02:00
m
4ddab75493 Merge: t-paliad-094 — Tier 2 Fristenrechner rule ports (14 rules across 5 families) 2026-04-30 16:41:56 +02:00
m
90b2f935c2 feat(t-paliad-094): Tier 2 Fristenrechner rule ports — damages, cost-appeals, cross-appeal, lay-open, leave/discretion
Per audit recommendations §6.2 (rec 5-9). Ports 14 RoP rules from
youpc's deadline calc into paliad.deadline_rules so they're surfaceable
in the timeline (course-of-proceedings) Fristenrechner mode in addition
to the existing trigger-event mode.

Adds 4 new proceeding types and extends 2 existing trees:

- UPC_DAMAGES (new) — Schadensbemessung (R.137.2, R.139 reply, R.139 rejoin)
- UPC_DISCOVERY (new) — Bucheinsicht (R.142.2, R.142.3 reply, R.142.3 rejoin)
- UPC_COST_APPEAL (new) — Berufung Kostenentscheidung (R.221.1)
- UPC_APP_ORDERS (new) — 15-day order-flavor (R.220.2, R.220.3, R.237 b, R.238.2)
- UPC_INF extended — R.151 chained off inf.decision
- UPC_APP extended — decision-flavor cross-appeal pair (R.237 a, R.238.1)

Total: 14 RoP rules across 5 families. New types appear in the
proceeding-type picker via deadlines.upc_damages / upc_discovery /
upc_cost_appeal / upc_app_orders i18n keys (DE + EN).

Design notes in the migration explain why some rules live in their own
proceeding type (when the legal anchor differs from UPC_INF/UPC_APP's
trigger date) vs being chained off existing rules.
2026-04-30 16:41:38 +02:00
m
fca7143244 feat(t-paliad-089): admin Event-Type moderation panel
Q6 of t-paliad-088 left firm-wide event_type creation open to any user; this
ships the moderation surface admins use to dedupe and clean up the resulting
drift.

Service layer (internal/services/event_type_service.go):
- ListAllForAdmin(filter) — firm-wide rows with usage_count and
  author_display_name, optionally including archived (single query, scalar
  subquery + LEFT JOIN paliad.users). Sorted live-first, then category +
  label_de.
- ListPrivatePendingPromotion — every private non-archived row across all
  users, sorted by usage_count DESC.
- ArchiveBulk(ids) — UPDATE archived_at=now() WHERE is_firm_wide AND NULL.
- Promote(id) — flip is_firm_wide=true; surfaces ErrEventTypeSlugTaken on
  collision so the admin can merge instead.
- Restore(id) — flip archived_at back to NULL; same slug-collision surface.
- MergeIDs(winner, losers) — tx-scoped INSERT … SELECT … ON CONFLICT
  redirect of deadline_event_types from losers → winner, then DELETE on the
  loser junction rows, then archive the losers. Refuses if the winner is
  archived or private. Junction PK does the dedup.
- requireAdmin gate runs at every method (defence-in-depth on top of the
  handler-level RequireAdminFunc).

Handlers (internal/handlers/admin_event_types.go):
- GET /api/admin/event-types[?include_archived=1]
- GET /api/admin/event-types/private
- POST /api/admin/event-types/archive { ids:[…] }
- POST /api/admin/event-types/merge { winner_id, loser_ids:[…] }
- POST /api/admin/event-types/{id}/promote
- POST /api/admin/event-types/{id}/restore
- GET /admin/event-types page shell.
All wrapped behind auth.RequireAdminFunc at registration time.

Frontend:
- New /admin/event-types SPA (admin-event-types.tsx + client/admin-event-types.ts):
  search, "Archivierte anzeigen" toggle, per-row archive/restore, bulk
  archive, merge modal (winner picker defaults to highest-usage row),
  separate table for private types pending promotion.
- Sidebar entry under Verwaltung; admin landing card.
- ~50 i18n keys DE+EN under admin.event_types.* + nav.admin.event_types.
- CSS for archived badge, merge option list, bulk-actions bar.

Out of scope (deferred): public "merge request" workflow for non-admins.
2026-04-30 16:37:48 +02:00
m
2ee4189d74 Merge: t-paliad-091 — NoteService consolidation (CanSee on parent services) 2026-04-30 16:19:12 +02:00
m
909167b036 refactor(t-paliad-091): NoteService consolidation — push CanSee to parent services
F-3 from t-paliad-074 architecture audit. NoteService used to call
ProjectService.GetByID and AppointmentService.GetByID just for the
visibility bit (~6 cross-service full-row reads in note_service.go).
Each was a full SELECT on the parent row when only a boolean was needed.

Add CanSee(ctx, userID, id) (bool, error) on the two parent services:
single EXISTS round-trip, no projection. Personal Appointments stay
visible only to their creator; project-anchored Appointments inherit
the project's visibility predicate (global_admin shortcut + team-walk).

NoteService gains two private helpers — requireProjectVisible and
requireAppointmentVisible — that wrap CanSee + ErrNotVisible. All
visibility-only sites in note_service.go (ListForProject /
ListForDeadline / ListForAppointment / ListForProjectEvent /
CreateForProject / CreateForDeadline / requireVisible) now go through
the helpers.

CreateForAppointment keeps appointment.GetByID — it legitimately needs
the appointment's project_id for the audit-event row.

DeadlineService.CanSee was not added: note_service never reaches into
the deadline service for visibility (it does its own SELECT project_id
FROM paliad.deadlines and gates via the project predicate).

Test: cansee_test.go covers the gate level for both new methods —
admin sees everything (global_admin shortcut), team member sees their
team's, non-member sees nothing, missing IDs are invisible to all,
personal appointments are private to creator.
2026-04-30 16:18:49 +02:00
m
60653a51be docs(claude.md): update Gitea path to m/paliad after transfer
Repo was transferred from mAi/paliad → m/paliad on 2026-04-30; updating
the project CLAUDE.md to reflect. Auto-redirects keep old URLs working.
2026-04-30 16:17:54 +02:00
m
401186f05b Merge: t-paliad-088 PR-2 — Event Types frontend (picker, multi-select filter, add modal) 2026-04-30 12:57:57 +02:00
m
8ddc8277d0 feat(t-paliad-088): Event Types frontend — picker, multi-select filter, add modal (PR-2)
Shared client module client/event-types.ts exposes three surfaces:

1. attachEventTypePicker — multi-tag chip cluster with typeahead suggest
   and an inline "+ Neuen Typ hinzufügen…" affordance. Mounted on
   /deadlines/new and the /deadlines/{id} edit modal.
2. attachEventTypeMultiSelectFilter — listbox-panel filter (search + Alle
   + Ohne Typ + grouped checkbox list, click-outside / Escape dismiss).
   Mounted on /deadlines and /agenda. Trigger styled like the existing
   <select>s; serialises to ?event_type=<uuid>,<uuid>,none.
3. openAddEventTypeModal — modal with label_de/label_en/category/
   jurisdiction/firm_wide. Live duplicate-warning fed by
   /api/event-types/suggest (Q6 mitigation). Firm-wide checkbox is
   only rendered for global_admin (per the design's permission model).

Added Typ column on /deadlines (hidden when no visible row carries an
event_type — matches the t-paliad-073 hide-on-uniform pattern).
Added Typ display + edit on /deadlines/{id}; PATCH now sends
event_type_ids when the picker is mounted.

i18n: 36 new keys (DE+EN) under event_types.* + deadlines.field/col/
filter.event_type + agenda.filter.event_type + common.cancel.

CSS in global.css: .event-type-picker / .event-type-chip /
.akten-multi-trigger / .akten-multi-panel / .akten-event-type-pill /
.event-type-add-modal. Mobile (<640px) collapses the panel into a
bottom sheet.

bun run build clean (1302 i18n keys regenerated, data-i18n scan clean).
go build / go vet / go test ./... clean (PR-1 still green after rebase).
2026-04-30 12:57:53 +02:00
m
ad9a53a04d Merge: t-paliad-088 PR-1 — Event Types schema + service + handlers (43 firm-wide seeds, junction table) 2026-04-30 12:49:12 +02:00
m
04ce6a8bfa feat(t-paliad-088): Event Types for deadlines — schema + service + handlers (PR-1)
Migration 030 adds paliad.event_types and paliad.deadline_event_types
junction. ~43 firm-wide seeds biased toward submissions (25 UPC
submissions + 8 UPC decisions/orders/hearings + 5 EPO + 4 DPMA/DE + 1
cross-jurisdiction). UPC-seeded rows carry a loose trigger_event_id
column (no FK constraint per Q2: event_types leads, trigger_events
follows). RLS policies are defense-in-depth — primary enforcement is
in the Go service layer. Per Q6, any authenticated user can create
firm-wide types; admins moderate via the soft-delete archive lever.

EventTypeService: List (firm-wide ∪ own-private), GetByID, Create
(slug auto-derived, supports diacritics → ASCII), Update (author OR
admin-on-firm-wide), SuggestSimilar (powers the duplicate-warning in
the add modal), AttachToDeadlineTx + ValidateForUser + ListForDeadlines
for the junction.

DeadlineService gains an EventTypeService dependency and now:
- accepts event_type_ids on Create / Update / CreateBulk
- attaches them in the same transaction as the deadline insert
- hydrates EventTypeIDs on every Get / List / ListForProject
- supports the multi-select Typ filter via ListFilter.EventTypeIDs +
  IncludeUntyped (UNION semantics within types, AND-intersected with
  Status/Project)

AgendaService gets the same Typ filter on its deadline side;
appointments are unaffected.

API:
- GET /api/event-types?category=&jurisdiction=
- GET /api/event-types/suggest?q=
- POST /api/event-types
- PATCH /api/event-types/{id}        (set archive=true to hide)
- GET /api/deadlines?event_type=<uuid>,<uuid>,none
- GET /api/agenda?event_type=<uuid>,<uuid>,none
- POST/PATCH /api/deadlines accept event_type_ids: [uuid]

go build / go vet / go test ./... clean.

Frontend (picker + custom-add modal + multi-select filter) follows in
PR-2. Admin moderation panel deferred to t-paliad-089 follow-up.
2026-04-30 12:49:04 +02:00
m
c74f6b494c Merge remote-tracking branch 'origin/main' into mai/cronus/event-types-for 2026-04-30 12:37:20 +02:00
m
75867b2a3e design(t-paliad-088): resolve open questions per m's calls
m greenlit all 7 open questions on 2026-04-30 12:23. Notable changes
from the initial draft:

- Submissions are explicitly the primary Event-Type use case, not a
  secondary discriminator. m: "those are the event types I mean,
  mainly". Deferring a separate paliad.submissions table stands.
- /deadlines + /agenda Typ filter is MULTI-SELECT (UNION across
  selected types, AND-intersected with Status/Projekt). New
  EventTypeMultiSelect component spec'd in §4: trigger button styled
  like the existing <select>s, popover with search + grouped checkbox
  list. Status/Projekt stay single-select.
- Firm-wide Event-Type creation OPEN to any authenticated user. RLS
  insert policy simplified to created_by=self. Admins moderate via
  archive. Mitigation: duplicate-warning in the add modal. Follow-up
  t-paliad-089 flagged for admin moderation panel.
- Broader-scope seeds confirmed (UPC + EPO + DPMA + DE + contract).
- §12 rewritten as a resolution table.
2026-04-30 12:26:53 +02:00
m
43abb41f28 Merge: t-paliad-087 follow-up — also fix --color-bg-muted in admin email templates (5 sites without fallback) 2026-04-30 12:08:09 +02:00
m
f721d7eccd fix(t-paliad-087): also fix --color-bg-muted in admin email templates
The first PR caught all `var(--color-bg-muted, #fallback)` sites. This catches
the 5 remaining `var(--color-bg-muted)` sites *without* fallback in the admin
email templates page (.admin-et-card-key, .admin-et-card-lang-btn:hover,
.admin-et-variable-type, .admin-et-preview-subject, .admin-et-version-row:hover).

Without fallback, an undefined custom property resolves to `unset` →
`transparent`, so these elements rendered with no visible background in
either mode (rather than light-grey in light mode like the fallback-form
variant). Same root cause though: the `--color-bg-muted` token name was
never defined anywhere.

All 10 sites (5 with hex fallback + 5 without) now use `--color-surface-muted`.

Build clean.
2026-04-30 12:08:00 +02:00
m
413a40c808 design(t-paliad-088): Event Types for deadlines + submissions
Standalone paliad.event_types table with nullable FK on paliad.deadlines,
seeded from a curated subset of paliad.trigger_events (UPC submissions +
decisions) plus hand-written EPO/DPMA/DE-national/contract entries.
Picker on /deadlines/new + edit modal with grouped options + inline
custom-add modal (private types for any user, firm-wide gated to
global_admin). Filter <select> on /deadlines (matching existing
Status/Projekt pattern, not pills) and pill-row on /agenda. Submissions
are NOT a separate entity — category='submission' on event_types carries
the discrimination until a real Schriftsatz-Verwaltung is built.

Awaiting m's go/no-go on §12 before any implementation.
2026-04-30 12:03:40 +02:00
m
8668c7d5ad Merge: t-paliad-087 — /team count pills + sweep of hardcoded light-grey BGs
Fixes the /team count pills (`var(--color-bg-muted, #f4f4f7)` — undefined
token, fallback always wins → light-grey in dark mode) and sweeps the same
class of bug across `frontend/src/styles/global.css`:

- 5 `--color-bg-muted` sites → `--color-surface-muted`.
- 27 fictional-token sites in the trigger-event Fristenrechner block
  (PR-2 / t-paliad-086) → project's `--color-*` tokens.
- 9 `rgba(0, 0, 0, 0.0X)` overlays → `--color-overlay-faint|subtle`.
- 4 redundant hex fallbacks on already-themed tokens removed.

No new tokens introduced.
2026-04-30 12:02:16 +02:00
m
721560074b fix(t-paliad-087): theme-aware count pills + sweep of hardcoded light-grey BGs
`/team` count pills (member counts per office / per partner unit) rendered with
`var(--color-bg-muted, #f4f4f7)` — but `--color-bg-muted` was never defined, so
the literal `#f4f4f7` always won and the pills stayed light-grey in dark mode.

Sweep across `frontend/src/styles/global.css`:

- 5x `--color-bg-muted, #f3f4f6|#f4f4f7` → `--color-surface-muted` (the actual
  themed chip-bg token; light: #f3f4f6, dark: 5% cream over midnight).
  Sites: `.team-group-count`, `.team-dept-tag`, `.admin-soon-badge`,
  `.admin-audit-event`, `.admin-audit-source`.

- Trigger-event Fristenrechner block (added in PR-2 / t-paliad-086) used a
  parallel set of fictional tokens (`--surface-color`, `--surface-soft`,
  `--accent-soft`, `--text-color`, `--text-muted`, `--border-color`,
  `--accent`, `--accent-text`, `--border-light`) that were never defined,
  so the entire panel rendered in fallback hex literals — white card,
  light-grey duration chip, pale-lime rule-code, dark `#111` body text.
  In dark mode the bg/border/divider stayed light while text stayed dark,
  on a midnight body — unreadable.
  Re-pointed all 27 sites onto the project's `--color-*` token system.

- `rgba(0, 0, 0, 0.0X)` literal overlays (hover/active states for
  `.search-result`, `.palette-action`, `.quick-add-row/-cancel`,
  `.pwa-install-dismiss`, `.termin-type-chip/-badge`, `.termin-personal-tag`,
  `.caldav-status-card`) → `--color-overlay-faint|subtle` (the existing
  tokens that flip to white-channel alpha in dark mode).

- Removed redundant hex fallbacks on already-themed tokens
  (`var(--color-surface, #ffffff)` → `var(--color-surface)` etc) — the
  `:root[data-theme="dark"]` block already defines all of them.

Acceptance:

- `cd frontend && bun run build` → clean.
- Sweep-greps from the task brief now return 0 hits (excl. one comment).
- No new tokens introduced — reuses the t-paliad-083 / t-paliad-082 palette.

Refs t-paliad-087.
2026-04-30 12:01:51 +02:00
m
97ea393fe9 Merge: t-paliad-086 PR-3 — Tier 1 Fristenrechner bug fixes (CCR adaptive, UPC_APP grounds, EP_GRANT priority, rule-code normalisation, holiday-cap fix) 2026-04-30 11:11:53 +02:00
m
d00974424f fix(t-paliad-086): Tier 1 Fristenrechner bug fixes — PR-3
Implements the four audit recommendations from §6.1 of
docs/audit-fristenrechner-completeness-2026-04-30.md plus a holiday-
adjustment cap fix surfaced by PR-2's smoke test.

(1) UPC_INF CCR-conditional rejoinder
   Public Fristenrechner now flips inf.reply (RoP.029.b → RoP.029.a) and
   inf.rejoin (1mo / RoP.029.c → 2mo / RoP.029.d) when the user ticks
   "Mit Widerklage auf Nichtigkeit." Implemented via a new
   `condition_flag` column on paliad.deadline_rules: when the rule names
   a flag and the API request's flags array contains it, the calculator
   substitutes alt_duration_value/unit and alt_rule_code. Independent of
   the existing `condition_rule_id` mechanism (which references a real
   rule in the same proceeding tree — only useful for matter-attached
   trees that already seed the CCR rule).

(2) UPC_APP / internal APP grounds anchoring
   `app.grounds` is now anchored on the trigger date (the appealed
   decision) with a 4-month duration, not chained 2mo after `app.notice`.
   Per RoP 220.1 the legal rule is "4 months from notification of the
   decision," independent of when the notice itself was filed. The chain
   only happened to give the right answer when both legs landed on a
   working day; under holiday rollover (e.g. notice deadline pushed to
   Monday) the grounds deadline drifted off the 4mo legal target.

(3) EP_GRANT publish anchor on priority date
   New `anchor_alt` column on paliad.deadline_rules. ep_grant.publish
   carries `anchor_alt='priority_date'`. The Fristenrechner UI surfaces
   an optional "Prioritätstag" input (visible only when EP_GRANT is
   selected) that, when populated, anchors the publish-A1 calculation on
   the priority date instead of the filing. Falls back to filing date
   when the priority field is empty (the case for purely-EP applications
   with no foreign priority claim).

(4) Rule-code format normalisation
   Migration 029 normalises 'RoP 23' → 'RoP.023', 'RoP 29b' / 'RoP.029b'
   → 'RoP.029.b', 'RoP 220.1' → 'RoP.220.1', etc. across deadline_rules.
   Matches the canonical youpc format already used by the PR-1 imported
   event-deadline rule codes.

(+) AdjustForNonWorkingDays cap bumped 30 → 60
   Surfaced by the PR-2 smoke test: SoD on 2026-04-30 (3mo from trigger)
   landed on Sat 2026-08-29 instead of Mon 2026-08-31. The 30-iteration
   safety bound on AdjustForNonWorkingDays cannot walk past the 33-day
   UPC summer vacation plus flanking weekends. Bumped to 60. Pure-Go
   one-liner, locked by a follow-up production smoke (real
   paliad.holidays seed has the UPC vacation).

Schema (migration 029): two new nullable text columns on
paliad.deadline_rules — `condition_flag` and `anchor_alt`. Both ignored
by every existing rule; only the rows updated above carry values.

Models: DeadlineRule gains ConditionFlag + AnchorAlt (nilable strings).

Service: FristenrechnerService.Calculate now takes a CalcOptions struct
(PriorityDateStr, Flags). API handler accepts optional priorityDate and
flags fields on POST /api/tools/fristenrechner.

Frontend: TSX surfaces the priority-date row + CCR checkbox conditionally
on selectedType (only EP_GRANT / UPC_INF respectively). Client TS reads
them and threads through the API call. New i18n keys for both DE+EN.

Migration 029 dry-run validated on prod Supabase (BEGIN/ROLLBACK):
schema + UPDATEs apply cleanly, rule states match expected post-fix
shape. Tests + go build/vet + bun build all clean.
2026-04-30 11:11:47 +02:00
m
29143e15fd Merge: t-paliad-086 PR-2 — trigger-event Fristenrechner mode + working_days primitive 2026-04-30 11:04:38 +02:00
m
d78f20be8a feat(t-paliad-086): trigger-event Fristenrechner mode + working_days primitive — PR-2
Adds the second Fristenrechner mode (mirrored from youpc.org's deadline
calc): pick a UPC trigger event + date, see all deadlines that flow
from it. Coexists with the existing course-of-proceedings timeline mode
via a tab toggle on /tools/fristenrechner.

Backend:
- internal/services/event_deadline_service.go — EventDeadlineService.
  ListTriggerEvents (alphabetical), Calculate (resolves all deadlines
  flowing from a trigger). Routes through HolidayService for weekend +
  holiday rollover. Honours the new working_days unit. Resolves
  composite rules (alt_* + combine_op) by computing both legs and
  picking max/min. Used by R.198/R.213 ("31d OR 20wd, whichever is
  longer") imported in PR-1.
- internal/services/event_deadline_service_test.go — covers
  addWorkingDays (forward, backward, zero, holiday-skip), composite
  rule semantics, before-timing.
- internal/handlers/fristenrechner.go — two new endpoints:
  GET /api/tools/trigger-events, POST /api/tools/event-deadlines.
- handlers.Services / dbServices: new EventDeadline / eventDeadline
  field; wired in cmd/server/main.go from the same HolidayService.

Frontend:
- frontend/src/fristenrechner.tsx — tab strip + second wizard panel
  (3 steps: trigger picker → date → flat result list).
- frontend/src/client/fristenrechner.ts — initEventMode wiring,
  typeahead filter over the 102 trigger events, Calculate flow,
  bilingual rendering, composite-rule labels, lang-change refresh.
- frontend/src/client/i18n.ts — 27 new keys (DE+EN) under
  deadlines.mode.* and deadlines.event.* (incl. units, timing).
- frontend/src/styles/global.css — fristen-mode-tabs, mode-panel,
  event-list, event-result-row visual style.

Working-day arithmetic detail: the new addWorkingDays helper steps
one day at a time and skips runs of non-working days (Sat/Sun + DE
federal + UPC vacations seeded via paliad.holidays). Day-zero is the
caller's job — addWorkingDays(0) returns the input unchanged so
callers can decide whether to roll forward via AdjustForNonWorkingDays.

Composite-rule resolution: when a row carries alt_duration_value +
alt_duration_unit + combine_op, Calculate computes both legs,
picks max/min, and surfaces a compositeNote like
"max(31 days, 20 working_days) → working_days leg" so the UI can
explain which leg won.

PR-3 will land Tier 1 bug fixes from the audit (CCR adaptive,
UPC_APP grounds anchoring, EP_GRANT priority, rule-code normalisation).
2026-04-30 11:04:32 +02:00
m
30e2beed87 Merge: t-paliad-086 PR-1 — import youpc deadline-calc data (102 trigger_events + 70 event_deadlines + 72 rule_codes) 2026-04-30 10:55:19 +02:00
m
b3b85261e1 feat(t-paliad-086): import youpc deadline-calc data — PR-1
New migration 028 mirrors youpc.org's event-driven deadline calc into
the paliad schema. Three new reference tables seeded from production
youpc data:

- paliad.trigger_events (102 rows) — UPC procedural events that start
  deadlines (e.g. statement_of_claim, decision_handed_down, oral_hearing)
- paliad.event_deadlines (70 rows) — deadlines flowing from each trigger,
  with duration/unit/timing + composite-rule support
- paliad.event_deadline_rule_codes (72 rows) — m:n RoP citation links

IDs preserved verbatim from youpc to enable future diff-based re-syncs.

Composite-rule wiring (alt_duration_value + alt_duration_unit + combine_op)
encodes "31 days OR 20 working_days, whichever is longer" for R.198 and
R.213 (start of merits after evidence preservation / provisional
measures). PR-2 wires the working_days primitive into the calculator.

Source bug fix during import: rule_code 'Rop.109' (lowercase typo on
youpc side, deadline 69) → 'RoP.109'. Matches paliad audit
recommendation 4 (canonical RoP.NNN.x format).

Models added: TriggerEvent, EventDeadline, EventDeadlineRuleCode.
PR-2 will add the service + handler + UI; PR-3 ships Tier 1 fixes.

Migration validated via dry-run on production Supabase (BEGIN/ROLLBACK
transaction, schema + check constraints + FKs all consistent).
2026-04-30 10:54:46 +02:00
m
50685e6e13 Merge: t-paliad-085 — /team Role filter pill row 2026-04-30 10:45:27 +02:00
m
2c4e1e5782 feat(t-paliad-085): /team — Role filter pill row
Adds a Role filter row alongside the existing Office row on /team. Pills
are rendered from the distinct paliad.users.job_title values present in
the loaded users; "Alle" + Partner / Counsel / Senior Associate /
Associate / … / PA / Paralegal in seniority order, anything unrecognised
sorted alphabetically after.

The interface field name was previously `role: string`, left over from
before t-paliad-051 split paliad.users.role into job_title +
global_role. The API has been returning `job_title` since then, so the
role line on every card was silently empty. Updated User /
DepartmentMember interfaces to `job_title?: string | null`, and
renderUserCard now displays it via roleLabel(). Search now matches
job_title too.

Role values are normalised case-insensitively (DB still has both
"Associate" and "associate" today — separate cleanup), and a roleLabel()
helper looks up team.role.<slug> with the raw job_title as fallback so
new titles render even before the i18n entry exists.

Files
- frontend/src/team.tsx — second team-filter-row
- frontend/src/client/team.ts — User.job_title, ROLE_ORDER,
  presentRoles, buildRoleFilters, userMatchesRole, roleLabel; render()
  intersects office × role × search
- frontend/src/client/i18n.ts — team.filter.role + 10 team.role.* keys
  (DE/EN)
2026-04-30 10:45:15 +02:00
m
b1bdf8ceb3 fix(checklists): project picker — use 'projects' var instead of stale 'akten'
loadAkten() fetched /api/projects but assigned the result to a stray
'akten' identifier (implicit global) while renderAkteOptions() iterated
over the declared 'projects' variable. Result: the project select on the
new-checklist-instance modal was always empty.

Two-line typo from the akten→projects rename sweep.
2026-04-30 10:37:34 +02:00
m
aab82d4aca docs(t-paliad-084): Fristenrechner completeness audit vs youpc deadline calc
Read-only research deliverable. Compares paliad's 9-proceeding-type
Fristenrechner ruleset (52 public rules in deadline_rules) against
youpc's 70-deadline event-driven calc (data.deadlines + data.events).

Top findings (§1 executive summary):
- youpc covers 64 distinct UPC RoP rule codes; paliad covers ~5
- The two tools answer different questions (timeline-by-procedure vs
  search-by-trigger-event) — biggest gap is structural, not data
- Paliad's holiday system is materially better; youpc's defaults are empty

Critical bugs surfaced (§4):
- Public UPC_INF Fristenrechner ignores CCR-conditional rejoinder
  duration (always uses 029.c/1mo, should be 029.d/2mo when CCR filed).
  KanzlAI internal INF type already wires this; public type doesn't.
- UPC_APP grounds chained off notice instead of decision date,
  giving wrong dates when notice is filed early
- EP_GRANT publish chained off filing instead of priority date
- Rule_code format inconsistent across migrations (RoP 23 vs RoP.023)

Recommendations ranked across 5 tiers (§6) for m to review.
Open product decisions in §7. No code changes.
2026-04-30 10:36:30 +02:00
m
31afab031f Merge: t-paliad-083 followup — sweep remaining hardcoded light-greys to tokens (agenda cards in dark mode) 2026-04-30 10:36:03 +02:00
m
22156f0cd5 fix(t-paliad-083): replace remaining hardcoded greys with --status-* / --color-* tokens
m flagged that agenda cards still rendered light-grey in dark mode. Root
cause: several rules referenced non-existent fallback tokens
(`--color-surface-subtle`, `--color-surface-0`) that resolved to their
hardcoded `#f3f4f6` / `#f9fafb` / `#ffffff` fallbacks in BOTH themes.
Fixed sites:

- .agenda-item-link / .agenda-item-icon / .agenda-chip — read
  --color-surface, --color-bg-lime-tint, --color-surface-muted,
  --color-text-muted directly (no broken fallback chain).
- .agenda-item-{overdue,today,tomorrow,this_week,later} urgency +
  icon backgrounds — read --status-{red,amber,green-soft}-{bg,fg}
  directly so dark mode swap is automatic; bottom-of-file dark-mode
  override block trimmed (was duplicate work).
- .frist-due-chip.* and .akten-status-chip.* — same token pull.
- .dashboard-card-{red,amber,green,done} count colours +
  border-lefts, .dashboard-urgency-* — same pattern.
- .akten-status-{active,completed,archived} — --status-{green,blue,
  neutral}-{bg,fg}.
- .frist-cal-weekday → --color-surface-2; .frist-cal-cell-has:hover
  → --color-bg-lime-tint.
- .form-warn → --status-amber-{bg,fg,border}.
- .akten-unavailable + .dashboard-unavailable → --status-amber-{bg,fg}.
- .projekt-tree-badge-{overdue,open}, .projekt-tree-type-chip,
  .akten-chip → --status-* tokens.
- .admin-audit-source-{project,caldav,reminder} → --status-* tokens.
- .admin-et-preview-frame → --color-surface-2.
- .team-office-badge → --status-green-soft-bg/fg.

Smoke (Playwright): both themes — agenda card link reads
rgb(10,48,71) in dark / #ffffff in light, urgency-overdue chip reads
the alpha-tinted red in dark / saturated pastel-red in light. No
light-grey leakage anywhere now.
2026-04-30 10:35:57 +02:00
m
8ddfb94f9e Merge: t-paliad-083 dark mode (auto + manual toggle, FOUC-safe, sidebar-pin FOUC fold-in) 2026-04-30 05:26:00 +02:00
m
fee6afdb14 feat(t-paliad-083): dark mode — auto + manual toggle, system-pref default (mAi/paliad#2)
Two-palette swap at :root and :root[data-theme="dark"]; FOUC-prevention
inline <script> in PWAHead reads paliad-theme + paliad-sidebar-pinned +
paliad-sidebar-width from localStorage before the stylesheet loads, so
the page paints in the persisted state from frame one. New theme.ts
client owns the runtime side: cycles auto → light → dark → auto, listens
to prefers-color-scheme while pref="auto", broadcasts change events to
the sidebar toggle so the sun/moon/auto icon stays in sync (incl. on
OS-level theme flips). Sidebar gains a sun/moon toggle below the lang
item with localized aria-label/tooltip describing the next click action.

Surface tokens introduced (--color-surface-{2,muted}, --color-input-bg,
--color-overlay-{faint,subtle,strong,modal}, --color-border-strong,
--shadow-{lg,xl}, --status-{red,amber,green,blue,neutral}-{bg,fg,...},
--tree-icon-{client,litigation,patent,case,project},
--sidebar-scrollbar-{thumb,...,width}); status pills, dashboard cards,
agenda urgency markers, frist-due-chip, akten-status-chip, termin-type
badges all read tokens or get a class-level dark override at the bottom
of global.css. Form inputs render on white in light mode (m: 2026-04-30)
and on a value below --color-surface in dark mode so the well still
reads as depressed below the card panel.

Sidebar scrollbar themed thin + cream-channel alpha with
scrollbar-gutter: stable so the collapsed icon column doesn't shift
when the nav overflows on tall (admin) layouts; .sidebar-icon width
shrinks by var(--sidebar-scrollbar-width) to keep icons centered in
the visible content area.

The pre-paint script also fixes the sidebar-pinned FOUC (maria's add):
sets <html class="sidebar-pinned"> from localStorage before paint, with
sidebar.ts mirroring the class on <html> on every pin toggle so the
new selector :root.sidebar-pinned .has-sidebar tracks the existing
.has-sidebar.sidebar-pinned (body) selector. width is also pre-applied
when within clamp.

Build: bun run build clean (1224 i18n keys, 36 pages).
Smoke: Playwright on /login in both modes — body bg/fg/cards/inputs
read from the right tokens, FOUC script lands in <head> before the
stylesheet, dark→light→auto cycle toggles via the sidebar button.
2026-04-30 05:25:39 +02:00
m
34e5ffe94b Merge: t-paliad-080 service-layer naming sweep — Notiz/Termin/Frist/Projekt/Partei → Note/Appointment/Deadline/Project/Party 2026-04-30 04:39:42 +02:00
m
ce3227c1c0 refactor(t-paliad-080): service-layer naming sweep — Notiz/Termin/Frist/Projekt/Partei → Note/Appointment/Deadline/Project/Party
Mechanical rename across 8 service files plus their handler call sites and
two related helpers. The English types existed already; what changed are the
input-struct names, helper functions, list/create method suffixes, and
parameter names so they no longer mix English types with German parameter
names.

Renames cover:
- CreateNotizInput/UpdateNotizInput → CreateNoteInput/UpdateNoteInput,
  notizColumns/notizSelect → noteColumns/noteSelect, ListForProjekt/Frist/
  Termin → ListForProject/Deadline/Appointment, CreateForProjekt/Frist/
  Termin → CreateForProject/Deadline/Appointment, fristProjectID →
  deadlineProjectID
- CreateTerminInput/UpdateTerminInput → CreateAppointmentInput/
  UpdateAppointmentInput, terminColumns → appointmentColumns, ListForProjekt
  → ListForProject; parameter renames terminID → appointmentID, projektID
  → projectID
- CreateFristInput/UpdateFristInput → CreateDeadlineInput/
  UpdateDeadlineInput, fristColumns → deadlineColumns, ListForProjekt →
  ListForProject, isValidFristStatus → isValidDeadlineStatus; parameter
  renames fristID → deadlineID, projektID → projectID
- CreateProjektInput/UpdateProjektInput → CreateProjectInput/
  UpdateProjectInput, projektColumns → projectColumns,
  validateProjektStatus → validateProjectStatus, ProjektRole comment →
  ProjectRole
- CreateParteiInput → CreatePartyInput, parteiColumns → partyColumns,
  ListForProjekt → ListForProject; parameter renames parteiID → partyID
- OnTerminCreated/Updated/Deleted → OnAppointmentCreated/Updated/Deleted on
  the AppointmentCalDAVPusher interface and its CalDAVService impl
- formatTermin → formatAppointment in caldav_ical
- ListForProjekt → ListForProject, listWithProjekt → listWithProject,
  checklistInstanceWithProjektSelect → checklistInstanceWithProjectSelect,
  ClearProjekt → ClearProject (JSON tag clear_projekt unchanged — wire
  format)
- insertProjectEvent helper parameter projektID → projectID, error message
  "insert projekt_event" → "insert project_event"
- TeamService AddMember/RemoveMember/ListDirectMembers/ListEffectiveMembers
  parameter projektID → projectID; matching handler renames
- Frontend doc-comments referencing CreateProjektInput/UpdateProjektInput
  updated to CreateProjectInput/UpdateProjectInput

JSON wire tags (clear_projekt, etc.) and German user-facing strings
(glossary entries, search.go labels, email templates, changelog,
Terminsgebuehr, Fristenrechner product name) are intentionally untouched.

API contract unchanged. go build/vet/test ./... clean. Frontend bun build
clean.
2026-04-30 04:39:23 +02:00
m
4b4c61903d Merge: t-paliad-079 bulk-rename German-prefix i18n keys to English 2026-04-30 04:38:34 +02:00
m
5c11fe5e6d feat(t-paliad-079): bulk-rename German-prefix i18n keys to English
F-9 from t-paliad-074. Aligns the i18n key namespace with the codebase's
English-language convention; no UI text changes.

Renames in frontend/src/client/i18n.ts (the source-of-truth file):
  akten.*    -> projects.*  (merged with projekte.*; projekte wins on collision,
                              the more-recently-edited value, per brief)
  fristen.*  -> deadlines.*
  termine.*  -> appointments.*
  projekte.* -> projects.*
  notizen.*  -> notes.*

Scope of changes:
  - 760 key lines renamed in i18n.ts (380 unique keys × 2 langs)
  - 70 akten/projekte suffix collisions resolved by dropping akten.* lines
    (140 lines dropped total — projekte values preserved)
  - 19 inner-segment fixes (e.g. projects.detail.fristen.add ->
    projects.detail.deadlines.add, and template-literal sites like
    `tDyn(`fristen.${x}`)` whose suffix begins with ${...})
  - 476 caller-side replacements across 27 *.ts/*.tsx files
    (literal t() / tDyn() args, template-literal prefixes,
     "prefix." string concatenations, data-i18n attributes)
  - i18n-keys.ts (generated) regenerated by build.ts: 1218 keys total

t-paliad-078's typed registry + build-time data-i18n scanner caught this
rename was complete: `bun run build` reports "i18n scan: data-i18n
attributes clean", meaning every literal data-i18n attribute in TSX/TS
sources references a key that exists in i18n.ts post-rename.

Out of scope (per brief): backend Go service rename (t-paliad-080 F-4),
URL paths (/akten, /projekte routes still server-side), CSS class names
(akten-table, akten-form, etc.), and German sub-tokens like .akte (label
"Akte:") or .no_akten (the modal hint when no project is linked).
2026-04-30 04:38:06 +02:00
m
74d4d913c2 Merge: t-paliad-082 light-mode contrast — accent text token (--color-accent-fg) 2026-04-30 03:59:20 +02:00
m
b25da860c8 fix(t-paliad-082): introduce --color-accent-fg so accent text isn't lime on cream
Lime text on the cream/white BG fails WCAG AA. Adds a foreground token that
is midnight by default and lime inside the .sidebar scope (which lives on
midnight). Rewires every text-color use of --color-accent to the new token,
including the double-fallback typo variants. Decoration uses (border, BG,
border-bottom) keep --color-accent (= lime).

mAi/paliad#2 (full dark mode) flips --color-accent-fg back to lime in one
place — no need to revisit every rule.
2026-04-30 03:59:12 +02:00
m
d6a91ee43c Merge: t-paliad-078 type i18n key registry + build-time data-i18n scan 2026-04-30 03:56:47 +02:00
m
800668a483 feat(t-paliad-078): type i18n key registry + build-time data-i18n scan
F-8 from the t-paliad-074 audit. Replaces silent `?? key` fallback with a
typed key surface so drift caught at compile/build time, not in prod.

- New `frontend/src/i18n-keys.ts` (generated): `I18nKey` literal union of
  all 1288 keys in `i18n.ts`. Regenerated by `frontend/build.ts` on every
  build; written only when content changes (no spurious diffs).
- `t(key: I18nKey)` is now strict — `t("fristn.detail.title")` fails
  `tsc --noEmit`. New `tDyn(key: string)` is the explicit escape hatch
  for runtime-composed keys (`tDyn(\`fristen.status.${x}\`)`); 27 dynamic
  call sites migrated.
- Build-time scan in `build.ts` walks `src/**/*.{ts,tsx}` for literal
  `data-i18n` / `data-i18n-placeholder` / `data-i18n-title` attributes
  and aborts the build on any value not in the key set. Skips `${...}`
  interpolations (can't resolve statically). Applied before bundling so
  no artefact ships when an unknown literal is present.

Surfaced and fixed during migration:

- `data-i18n="fristen.save.modal.project"` (fristenrechner.ts:145) →
  `fristen.save.modal.akte` — F-04-class bug, would render the raw key.
- `t("termine.field.project.none")` (appointments-new.ts:30) →
  `termine.field.akte.none` — same class.
- `t("checklisten.instance.project.open")` (checklists-instance.ts:155)
  → `checklisten.instance.akte.open` — same class.
- 4 duplicate-key entries in `i18n.ts` removed (TS1117): `nav.termine`
  and `akten.detail.tab.termine` each appeared twice in DE and twice in
  EN with identical values.

Out of scope (per brief): the German-vs-English i18n-key namespace split
flagged as F-9, JSX intrinsic typing, and the `akten` → `projects`
half-rename in checklists-detail.ts. Those stay tsc-noisy until separate
tasks land.
2026-04-30 03:56:32 +02:00
m
2b476e4f25 Merge: t-paliad-076 visibility predicate consolidation (6 sites + delete dead IsEffectiveMember) 2026-04-30 03:49:04 +02:00
m
31db66e3b7 refactor(t-paliad-076): consolidate visibility predicate — 6 dashboard/agenda sites use helper
F-2 from t-paliad-074 audit. The inlined visibility predicate had drifted
back into 6 hot-path SQL sites despite the central helper extracted in
t-paliad-058. Consolidating now so future visibility changes (e.g.
Chinese-wall in design v2 §8) only need one edit.

**Sites converted (6):**
- dashboard_service.go:158, 214, 244, 274
- agenda_service.go:138, 204

All six replace `$N = 'global_admin' OR EXISTS (path-walk)` with the
existing `visibilityPredicatePositional("p", 1)` helper. The helper
resolves global_admin via EXISTS on paliad.users — the role string no
longer flows through positional args, removing one foot-gun (typo'd
literal mismatched against bound role) entirely. Equivalence verified
on the live youpc DB:

    tester@hlc.de (global_admin, 1 team membership):
      old predicate count = 11   new predicate count = 11
    standard user (no team):
      old predicate count =  0   new predicate count =  0

**No new helper variant added.** The audit suggested
`visibilityPredicateLateral`, but the existing positional helper drops
into the dashboard/agenda WHERE clauses unchanged — adding a redundant
variant would be technical debt. dashboard/agenda do not use LATERAL
JOIN; they use plain WHERE EXISTS in (sub-)SELECT context, which is
already what visibilityPredicatePositional emits.

**Other 4 sites flagged by audit — left intentionally:**

- reminder_service.go:312, 325 are role-restricted (`pt.role = 'lead'`)
  membership checks, NOT visibility predicates. Adding a global_admin
  shortcut to the lead branch would over-include rows: every global
  admin would receive every project's lead-targeted reminder, even with
  the `own.escalation_contact_id` override that exists precisely to
  avoid that. global_admin already has its own dedicated branch in the
  query (`$3 = TRUE AND own.escalation_contact_id IS NULL` at line 328).

- deadline_service.go:422 (`assertCanAdminProject`) is role-restricted
  (`pt.role IN ('admin', 'lead')`) and already short-circuits global_admin
  at the Go level before the SQL runs (line 413). Both halves correct;
  no change needed.

- team_service.go:162 (`IsEffectiveMember`) was dead code with no callers
  in the entire repo. "Is this user a structural team member?" and
  "can this user see this project?" are different questions; adding a
  global_admin shortcut would have conflated them. Deleted instead.

**Test:** new TestVisibilityPredicate_DashboardAgendaForGlobalAdmin in
visibility_test.go seeds a project + deadline + appointment + activity
event with project_teams empty, then asserts a global_admin sees all
four on /dashboard and /agenda while a standard user sees none. Skips
when TEST_DATABASE_URL is unset (matching the existing live-DB tests).

**Pre-existing finding (separate concern):** the live-DB test gate is
currently blocked locally by a stale `public.paliad_schema_migrations`
(version=2, dirty=t) left over from before the schema-pinned tracker
landed. Authoritative `paliad.paliad_schema_migrations` is at version
27, dirty=f. Out of scope for this task; should be filed as cleanup.
2026-04-30 03:48:49 +02:00
m
b178c47a44 Merge: t-paliad-081 doc + dead-code batch (F-5/F-10/F-11/F-15/F-16/F-17/F-18) 2026-04-30 03:42:42 +02:00
m
3da11bd798 chore(t-paliad-081): doc + dead-code batch (F-5/F-10/F-11/F-15/F-16/F-17/F-18)
Bundle of small audit findings, all doc-only or dead-code:

- F-5: refresh stale escalation-contact comment in models.User —
  Settings UI dropdown shipped 2026-04-29 (t-paliad-066).
- F-10: add "OBSOLETED by migration 018" note to migrations 004/005/006
  so readers stop hunting for the live shape in obsolete files.
- F-11: document the data-loss semantics of dropping
  paliad.partner_unit_events on the 027 down — audit rows are
  append-only telemetry, accepted loss on rollback.
- F-15: drop the patholo_session / patholo_refresh cookie fallback
  added during the 2026-04-16 rebrand. Active users have long since
  been re-authed through the upgrade path; inactive users hit the
  normal /login flow.
- F-16: refresh stale /api/departments comment in team_pages.go to
  /api/partner-units (renamed in t-paliad-070).
- F-17: move internal/db/migrations/_dev/mock_supabase_auth.sql to
  internal/db/devtools/ so a future loosening of the //go:embed
  pattern can't accidentally ship the dev-only fixture.
- F-18: update docs/project-status.md "Audit polish-2" entry — the
  batch shipped via t-paliad-067 / 068 / 073, follow-ups are now
  tracked under the 2026-04-30 re-audit + t-paliad-074.

go build / vet / test clean.
2026-04-30 03:42:25 +02:00
m
17aa840977 Merge: t-paliad-077 fix /api/links/suggest 500 (sqlx for paliad.link_*) 2026-04-30 03:18:05 +02:00
m
e468930342 fix(t-paliad-077): /api/links/suggest 500 — switch to sqlx for paliad.link_*
The suggestion + feedback handlers wrote to legacy public-schema tables
(`patholo_link_suggestions`, `patholo_link_feedback`) via Supabase PostgREST.
The patHoLo→Paliad rebrand moved those tables into the paliad schema as
`paliad.link_suggestions` / `paliad.link_feedback` — PostgREST is not
configured to expose paliad on the youpc Supabase, so all three callsites
500'd in prod.

Replace the PostgREST integration with a new LinkService backed by the same
sqlx pool every other paliad service uses. Schema-qualified table names
work directly via DATABASE_URL, the inconsistent supabaseInsert/Count
helpers go away, and the suggestion/feedback handlers now use requireDB
for clean 503s when the pool isn't wired.

handleSuggestionCount keeps its tolerant 0-on-error behaviour so the admin
badge never blocks page render. When DATABASE_URL is unset the count
endpoint returns 0 instead of 503 — knowledge-platform-only deployments
still serve the Link Hub page.

Flagged in t-paliad-074 (F-12).
2026-04-30 03:18:03 +02:00
m
8cd67433df Merge: t-paliad-075 admin_users.go comment cleanup 2026-04-30 03:12:46 +02:00
m
25ca1fa763 fix(t-paliad-075): drop stale department_members reference in handler comment 2026-04-30 03:12:45 +02:00
m
db20bf5442 Merge: t-paliad-075 fix AdminDeleteUser SQL (renamed partner_unit tables) 2026-04-30 03:08:14 +02:00
m
8bcfb6b960 fix(t-paliad-075): AdminDeleteUser SQL — use renamed partner_unit tables
Migration 027 renamed paliad.departments → paliad.partner_units and
paliad.department_members → paliad.partner_unit_members but two queries in
AdminDeleteUser were missed by the rename sweep, so admin off-boarding
500'd in prod. Update both DELETE/UPDATE statements and the surrounding
comments to match the current schema.

Flagged by ada in t-paliad-074 (F-1).
2026-04-30 03:08:08 +02:00
m
270f7d7ddc Merge: t-paliad-074 architecture improvement audit (ada) 2026-04-30 03:02:13 +02:00
m
61766161b7 docs(t-paliad-074): architecture improvement audit 2026-04-30
Read-only audit after the 9-merge push of t-paliad-066..073. Surfaces
18 findings across 7 lenses (service boundaries, naming, frontend↔
backend contract, migrations, tests, dead code, doc drift) plus three
architecture observations carried forward from the 2026-04-18 audit.

Top 3 punch list:
- F-1 (🔴 active): AdminDeleteUser SQL writes to dropped tables
  paliad.department_members / paliad.departments. Live production bug,
  blocks admin user-delete. user_service.go:768,773. Missed by
  t-paliad-070 rename sweep (last touched 2026-04-27, predates rename).
- F-13 (🔴 active): 7 live-DB integration tests skip silently when
  TEST_DATABASE_URL unset, no CI exists. Same pattern that masked the
  t-paliad-069 reminder bug for ~24h and that hid F-1 above.
- F-2 (🔴 active): visibility predicate inlined in 10 hot-path SQL
  sites despite central helper in visibility.go (dashboard/agenda/
  reminder/team/deadline service). Inlined sites silently skip the
  global_admin shortcut.

No code changes — head sequences dispatch.
2026-04-30 02:53:50 +02:00
mAi
2c67299740 Merge: t-paliad-073 audit polish-2 DEFER cleanup (F-23/32/38/40/48/49)
Six DEFER findings shipped per docs/audit-polish-2-2026-04-29.md.

Refs t-paliad-073
2026-04-30 00:31:05 +00:00
m
aef40bb425 feat(t-paliad-073): audit polish-2 DEFER list cleanup
Six findings from docs/audit-polish-2-2026-04-29.md DEFER list:

- F-23: hide STATUS column on /deadlines + /projects when every visible
  row shares the same status. Toggled at render time via a CSS class on
  the table; the column re-appears the moment filters re-introduce
  variety.
- F-32: agenda urgency pill now renders only when it disagrees with
  the day-bucket heading (e.g. an Überfällig deadline that lands in
  HEUTE through a filter quirk). Common case drops the redundant tag.
- F-38: bottom-nav agenda badge already counted overdue+today (the
  brief's option (b)); added a localized title + aria-label so the
  count's semantics ("X überfällig + Y heute fällig") is no longer
  ambiguous.
- F-40: glossary filter chips no longer mix EN+DE — DE shows
  "Streitsachen / Erteilungsverfahren / Allgemein", EN keeps
  "Litigation / Prosecution / General". Same i18n keys cover the
  Suggest-modal category dropdown.
- F-48: /projects/{id}/sub-projects now 301-redirects to the canonical
  /children URL via the existing redirects.go mechanism. Added a small
  redirects_test.go to lock the alias in.
- F-49: dropped the meta-circular 2026-04-22 "Neuigkeiten / What's New"
  changelog entry that referenced "this changelog" itself.

go build/vet/test clean, bun run build clean.

F-25 (mobile tables → card layout) is redesign-class and is scoped at
the bottom of the PR description as t-paliad-074, not implemented here.
2026-04-30 02:29:09 +02:00
m
d6ff36dce4 Merge: t-paliad-069 reminder ticker boundary alignment + startup catch-up 2026-04-30 02:28:32 +02:00
m
ee83748089 fix(t-paliad-069): align reminder ticker to natural HH:00 boundaries + startup catch-up
Pre-fix `time.NewTicker(time.Hour)` fired every hour from container start,
so a Dokploy redeploy at 13:27:50 produced ticks at HH:27:50 forever —
drifting the user-visible arrival of a 09:00-Berlin digest anywhere in the
09:xx hour, and entirely losing the slot when redeploys happened to land
during the slot's hour (m saw this on 2026-04-29).

Replace the simple ticker with a recompute-per-iteration sleep to the next
HH:00:00 boundary using nextTopOfHour(now). The recompute self-corrects
any clock skew or RunOnce duration rather than accumulating drift.

Add runStartupCatchUp: on boot, fire any user/slot whose hour has already
passed today but has no log row yet. The slot_date dedup makes this safe
(re-firing a logged slot is a no-op). Without this a single mistimed
redeploy still loses a day for affected users.

Tests: TestNextTopOfHour (boundary math, including the 13:27:50 signature
and sub-second offsets), TestNextTopOfHour_AlwaysLandsOnBoundary (fuzz
across an hour of offsets), TestNextTopOfHour_StableAfterRunOnce (confirms
the next fire is HH+1:00 after a fire at HH:00, not HH:00+delay1),
TestSlotPastDueToday (catch-up filter table), and a live-DB
TestRunStartupCatchUp_RecoversMissedMorningSlot covering the redeploy-at-
11:50-Berlin scenario plus dedup on a second startup the same day.
2026-04-30 02:28:19 +02:00
m
194c61b498 Merge: t-paliad-070 partner units rename + /admin/partner-units 2026-04-29 22:18:26 +02:00
m
832104af9e Merge remote-tracking branch 'origin/main' into mai/cronus/partner-units-rename
# Conflicts:
#	frontend/build.ts
#	frontend/src/admin.tsx
#	frontend/src/client/i18n.ts
#	internal/handlers/handlers.go
2026-04-29 22:17:32 +02:00
m
d50ba363a8 feat(t-paliad-070): partner-units frontend rename + new admin page
Frontend half of the rename:
- New /admin/partner-units page (admin-partner-units.tsx + .ts) with
  full CRUD + member management. Mirrors /admin/team's aesthetic and
  uses the same modal pattern. Card on /admin flips from "Geplant"
  to "Verfügbar" with ICON_BUILDING and a /admin/partner-units link.
- Sidebar gains a "Partner Units" admin nav item between Team and Audit.
- Onboarding form replaces the free-text Dezernat input with a select
  populated from /api/partner-units; submits partner_unit_id which the
  backend uses to insert a membership row in the user-create tx.
- Settings: dezernat tab removed entirely (TabName drops to 3). The
  read-only "Meine Partner Units" view now lives as a card on the
  profile tab. Free-text dezernat input removed from the profile form.
  ~250 lines of admin-CRUD removed; replaced by ~70 lines of read-only
  partner-units summary.
- /admin/team: Dezernat column dropped from the table and the inline
  edit row; "Onboard existing account" modal no longer asks for one.
  Column count drops from 10 to 9.
- /team directory: groups by structured partner_unit_members only;
  drops the free-text fallback grouping and the "Ohne Dezernat" loose
  bucket. Single "Ohne Partner Unit" orphan group catches users in no
  unit.
- i18n: ~30 dezernat.* + onboarding.dezernat + admin.team.col.dezernat
  + admin.card.departments + team.* keys removed; ~30 partner_unit.*
  keys added in DE+EN. "Partner Unit" / "Partner Units" used as a
  loanword in DE.
- /api/departments?include=members → /api/partner-units?include=members
  in team.ts (the only frontend-side fetch URL referencing the old
  endpoints).

go build / vet / test clean. cd frontend && bun run build clean.
2026-04-29 22:14:11 +02:00
m
8dc1beb4e1 Merge: t-paliad-072 admin email-templates editor 2026-04-29 22:10:00 +02:00
m
0e3411c40b feat(admin): /admin/email-templates editor (t-paliad-072)
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.

Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
  and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
  language file when no DB row, Save validates parse + structural
  invariants and writes a version, Reset deletes the active row, Restore
  copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
  back to the embedded default if the active row is malformed at parse
  time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
  text/template strings stored in the (key, lang) row. Default subjects
  ship with a {{/* keep this phrasing */}} comment pointing at the
  reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
  + .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
  No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
  RequireAdminFunc(users) admin middleware, same shape as /admin/team.

Frontend:
- /admin/email-templates list page — three cards (one per template),
  each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
  textarea + variable docs + actions on the left, sandboxed iframe
  preview + version log on the right. 500 ms debounced live preview;
  save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.

Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.

Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean

Design: docs/design-email-templates-2026-04-29.md.
2026-04-29 22:09:39 +02:00
m
76785da3f6 feat(t-paliad-070): rename Department → PartnerUnit on the Go side
Backend rename (frontend lands in next commit):
- Migration 026: rename paliad.departments → paliad.partner_units,
  paliad.department_members → paliad.partner_unit_members, junction FK
  department_id → partner_unit_id, plus all constraints/indexes/policies.
  Pre-drop seed re-runs migration 019's logic to capture any users.dezernat
  drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table
  with RLS (any-authenticated read, global_admin write).
- models.User.Dezernat dropped. Department / DepartmentMember →
  PartnerUnit / PartnerUnitMember.
- DepartmentService → PartnerUnitService (file renamed via git mv to
  preserve blame). Every mutation now opens a tx and emits a
  partner_unit_events row in the same tx (created/updated/deleted/
  member_added/member_removed). Update emits before/after snapshots;
  Delete emits BEFORE the cascade so the FK still resolves, then
  ON DELETE SET NULL keeps the historical row.
- /api/departments/* → /api/partner-units/*. Handlers renamed.
- New /admin/partner-units page handler stub.
- AuditService UNIONs the new partner_unit_events source as a 4th
  branch; handler accepts AuditSourcePartnerUnitEvents.
- user_service: drop dezernat from CreateUserInput / UpdateProfileInput
  / AdminCreateInput / AdminUpdateInput. CreateUserInput gains
  PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and
  the membership row + audit event are inserted in the same tx.
- Settings tab aliases drop dezernat/department.
- Legacy /dezernate and /departments now redirect to
  /admin/partner-units (admins only see it; non-admins land on the
  forbidden bounce).

go build / vet / test compile clean.
2026-04-29 22:03:08 +02:00
m
f963b4b2bc Merge remote-tracking branch 'origin/main' into mai/cronus/partner-units-rename 2026-04-29 21:53:25 +02:00
m
633ce5a9fe design(t-paliad-070): incorporate m's answers — full partner_unit rename
m's 21:44 answers expanded the rename scope and resolved all 5 open Qs:
- Naming: partner_unit everywhere (not 'department')
- API + URL rename too: paliad.departments → paliad.partner_units,
  /api/partner-units, /admin/partner-units
- Settings admin section: removed
- Audit emit: in this PR (paliad.partner_unit_events table)
- users.dezernat: dropped entirely (not renamed)

Migration 026 now does: best-effort second seed of department_members from
dezernat free-text → DROP COLUMN → rename departments + department_members
tables to partner_units + partner_unit_members → rename junction column to
partner_unit_id → rename constraints/indexes/policies → create
partner_unit_events audit table with RLS.

Single tx, exception-trapped renames for idempotency on freshly-provisioned
DBs.

Onboarding form: free-text input replaced with a partner-unit <select> that
inserts a membership row in the user-create tx. Settings profile loses the
free-text field.

PR strategy: still single PR, ~2200 lines net (heavier than v1 due to
structured-side rename + audit plumbing).
2026-04-29 21:50:27 +02:00
m
c4122bc265 docs(admin): t-paliad-072 — m greenlighted all 5 open Qs
DB-backed (Q1), subjects customisable with SYSTEMAUSFALL comment in seed
(Q2), base.html editable (Q3-A), 20-version retention (Q4), note field
kept (Q5). Coder shift unblocked from the inventor side.
2026-04-29 21:46:35 +02:00
m
9e216a4c44 docs(admin): design — admin email-templates editor (t-paliad-072)
DB-backed templates with embedded fallback, per-language split, full
edit/preview/version/restore loop. Subject moves from Go-built strings to
template-rendered. Five open questions for m parked at §8 — most loaded:
should base.html be editable or read-only.
2026-04-29 21:46:35 +02:00
m
933a16b6eb Merge: t-paliad-071 admin audit-log viewer 2026-04-29 19:12:23 +02:00
m
2422603abf feat(admin): /admin/audit-log global timeline (t-paliad-071)
Replaces the "Geplant: Audit-Log" placeholder on /admin with a working
viewer that unions paliad.project_events + caldav_sync_log + reminder_log
into a single keyset-paginated timeline.

- AuditService.ListEntries (internal/services/audit_service.go) does one
  UNION ALL across the three sources, projecting each into a unified
  AuditEntry shape and ordering by (timestamp, id) DESC. Cursor is
  (BeforeTS, BeforeID) — matches the project-event Verlauf pattern. ILIKE
  search escapes %/_ so "100%" doesn't act as a wildcard.

- GET /api/audit-log (internal/handlers/audit.go) accepts
  source/from/to/q/before_ts/before_id/limit, validates the cursor halves
  are paired, and returns { entries, next_cursor }. Both API and the
  GET /admin/audit-log SPA shell are wrapped in auth.RequireAdminFunc, so
  non-admins get 403 (API) / 302 (browser) via the same gate /admin/team
  uses.

- Frontend (admin-audit-log.tsx + client/admin-audit-log.ts) renders the
  table with source dropdown, range presets (24h / 7d / 30d / custom /
  all), free-text search (debounced 250ms), and "Weitere laden" cursor
  pagination. project_events rows reuse translateEvent (t-paliad-067 PR-1)
  for DE/EN narrative parity with the dashboard activity feed; caldav and
  reminder rows have their own per-event-type i18n keys.

- /admin landing card moved from PLANNED to AVAILABLE; sidebar admin
  group gains a third entry.
2026-04-29 19:12:11 +02:00
m
1a89b0c490 design(t-paliad-070): partner units rename + admin departments page
Inventor design doc for the Dezernate→Partner Units rename and the new
/admin/departments management surface that replaces the placeholder card.

Key proposals:
- Single PR, single migration (026: users.dezernat → users.department).
- New /admin/departments page mirrors /admin/team aesthetic; lifts the CRUD
  out of /settings?tab=dezernat.
- User-facing label "Partner unit" / "Partner units" (same in DE+EN per m).
- Defer audit event emission to t-paliad-071 to keep this PR focused.
- Phase 2 follow-up: drop the free-text users.department duplicate once
  onboarding can pick from the structured registry.

Five open questions for m in §12 before coder shift starts.
2026-04-29 19:03:14 +02:00
m
a719eb26a6 fix(reminder): inline offset, drop unused $2 in evening query
$2 was the offset, used only in the morning dateCond. Evening's query
referenced $1, $3, $4 — $2 was passed but unused, and Postgres can't
infer the type of an unreferenced parameter ('could not determine data
type of parameter $2', 42P18).

Inline offset directly into the morning dateCond as a literal '%d days'
(safe — it's clamped to ≥1 above). New positional layout:
  $1 = today
  $2 = userid
  $3 = is_global_admin

Three rounds of SQL fights for one query. Adding integration coverage
to TestRunSlotForUser is a follow-up — it currently skips when
TEST_DATABASE_URL is unset, which is why none of these reached prod via
CI.
2026-04-29 16:34:17 +02:00
m
25a44dcaee fix(reminder): positional placeholders, drop sqlx.Named (collides with ::cast)
Previous fix replaced $arg with :arg per sqlx.Named convention but the
query body contains PostgreSQL `::TYPE` cast operators (`::uuid[]`,
`::date`, `::interval`). sqlx.Named eats the second `:` thinking it's
a named-arg prefix → 'syntax error at or near ":"'.

Switching to positional $1..$4 sidesteps sqlx.Named entirely. Args
passed directly to db.SelectContext.

Order: $1=today, $2=offset, $3=userid, $4=is_global_admin.

Same root cause as 1652436 — the $/literal token form was the right
intuition; the proper fix is positional, not :name.
2026-04-29 16:32:03 +02:00
m
1652436f1b fix(reminder): use sqlx.Named placeholders, not literal $arg
fetchSlotDeadlines built the query with $today_arg / $userid_arg /
$offset_arg / $is_global_admin_arg placeholders. sqlx.Named only
recognises :name (colon prefix). Postgres got the literal $arg and
rejected with 'syntax error at or near "$"' on every tick — that's
why no reminder_log row has been written since the t-paliad-064 deploy
yesterday.

Changed all four placeholders to :name. The integration test that would
have caught this (TestRunSlotForUser) skips when TEST_DATABASE_URL is
unset, so CI never hit the live SQL.

Today's morning slot is permanently lost (hourly-tick design issue
tracked in t-paliad-069). Deploying now while Berlin hour=16 should
fire m's evening slot immediately on container startup via the
Start()→RunOnce() call.
2026-04-29 16:29:47 +02:00
m
93a90b0ffa fix(.mcp.json): point supabase MCP at youpc Kong (paliad's actual DB)
Was hitting msupabase-kong (port 8000 on mlake) which serves the
flexsiebels/msbls Supabase. Paliad lives on youpc Supabase, exposed
via Traefik at ystudio.msbls.de. Verified: query 'SELECT count(*)
FROM paliad.users' returns 31 (matches live DB) instead of 0 (msbls
has the schema but no rows).

Auth header reuses YOUPC_SUPABASE_AUTH which is already in dotenv.

Effective on next Claude Code session start.
2026-04-29 15:47:17 +02:00
m
4a25f2ee0f Merge: t-paliad-068 polish audit 2 PR-3 — tab harmonisation + chip neutralisation + Notiz hint 2026-04-29 15:27:15 +02:00
m
3dc56552fa fix(t-paliad-068): PR-3 tab harmonisation + chip neutralisation + Notiz hint (F-16, F-20, F-37)
Per docs/audit-polish-2-2026-04-29.md PR-3. Greenlit by m 2026-04-29 15:22.

- F-16 type-pill saturated colours collapsed to one neutral midnight-tint
  chip. The five .akten-type-chip.akten-type-{client,litigation,patent,
  case,project} per-type backgrounds (lavender/pink-red/cyan/salmon/
  neutral-green) made /projects feel alarming for a routine type label
  and the colours carried no semantic ranking. Replaced with a single
  rgb(var(--hlc-midnight-rgb) / 0.06) bg + var(--color-text) fg; the type
  label inside the chip carries the differentiation. The per-type
  modifier classes are kept on the markup so a future signal-use
  (highlighting Mandant roots, etc.) can re-introduce a colour for one
  specific type without re-adding the random palette. Same neutralisation
  applied to .akten-office-chip on /admin/team STANDORT — the audit
  flagged it as the same class of issue.
- F-20 .login-tab.active and .gebuehren-tab.active flipped to the
  canonical pattern from .akten-tab.active — lime underline + midnight
  text + 600 weight. Active tabs now read identically across /login,
  /tools/gebuehrentabellen, project detail, deadline detail, appointment
  detail, settings, and admin.
- F-37 Notiz textarea now ships a small footer hint reading
  "Strg+Enter (oder ⌘+Enter) zum Speichern" / "Ctrl+Enter (or ⌘+Enter)
  to save". The keyboard-shortcut listener at notes.ts:426 was already
  wired; this is purely the visible affordance. New i18n key
  notizen.shortcut.hint (DE+EN); new .notiz-form-hint CSS rule sized
  0.75rem muted-text below the actions row.

Verified
- bun run build clean.
- go build/vet/test ./... all green.
- Live smoke pending Dokploy redeploy.
2026-04-29 15:27:09 +02:00
m
d00eb5f598 Merge: t-paliad-067 polish audit 2 — triage doc + PR-1 (i18n leak + activity log) + PR-2 (visual residue) 2026-04-29 15:06:13 +02:00
m
8fe05fe696 Merge main into mai/cronus/audit-polish-2-triage (resolve i18n.ts collision with brunel's t-paliad-066 escalation keys) 2026-04-29 15:05:50 +02:00
m
7d45626d57 fix(t-paliad-067): PR-2 visual residue + per-page polish (F-13, F-15, F-24, F-27, F-28, F-33, F-36, F-39, F-42, F-43, F-47, F-50)
Per docs/audit-polish-2-2026-04-29.md PR-2. Local visual cleanups across
deadlines, appointments, projects, project detail, dashboard, settings,
onboarding.

- F-13 + F-42 the .frist-akte-title CSS rule was renamed to
  .frist-project-title (matching the markup that the rename sweep already
  produced) plus text-overflow:ellipsis and a max-width gutter, and the
  client renderers now stamp title= on the project-title span so the full
  ref+title is reachable on hover. Fixes the
  "L-2026-001Siemens AG ./." collision and trims the deadline rows that
  were ballooning to 2 lines.
- F-15 "Projekt archivieren" demoted from .btn-danger to .btn-secondary
  (neutral outline). Confirm-modal action stays red.
- F-24 the /projects filter row groups label+select pairs into
  .akten-filter-group divs and stacks each as a full-width labelled block
  at <480px instead of wrapping each label/select onto its own line.
- F-27 single-element breadcrumbs hide on root projects — the lone crumb
  used to echo the H1 below it.
- F-28 empty REFERENZ + CLIENTMATTER cells on /projects and ORT on
  /appointments render an em-dash so the placeholder convention matches
  /admin/team and /projects/{id}/deadlines.
- F-33 truncated project refs on the dashboard upcoming-deadlines and
  upcoming-appointments lists carry a title= attribute with the full
  "REF · Title" string, so hover reveals the truncated tail.
- F-36 /projects/new no longer defaults to Mandant — a "Bitte wählen…"
  placeholder is the initial selection (required attr blocks submit).
  New projekte.field.type.choose i18n key in DE+EN.
- F-39 the /projects search counter renders "X / Y" in tree view too
  (was bare "X"), matching the flat-view format.
- F-43 /projects/{id}/parties empty state is now an .akten-empty-card
  with a "Partei hinzufügen" CTA underneath the message, wired to the
  same form-open handler as the toolbar button.
- F-47 onboarding + settings job_title placeholder swaps the EN-DE-EN
  mix "z.B. Associate, Partner, PA" for "z.B. Associate, Partner,
  Patentanwalt" / "e.g. Associate, Partner, Patent Attorney". Three
  named titles, no abbrev, EN-jargon convention kept consistent.
- F-50 mobile bottom-nav clearance bumped from 1rem to 1.75rem on
  body.has-sidebar main so the centre FAB (margin-top: -10px above the
  56px bottom-nav) clears the last list item with a real gutter.

Verified
- go build/vet/test ./... all green.
- bun run build clean.
- Live smoke pending deploy.
2026-04-29 14:32:53 +02:00
m
f583c650a2 fix(t-paliad-067): PR-1 i18n leak sweep + activity narrative (F-04, F-07, F-10, F-12, F-21, F-29, F-35, F-46)
Per docs/audit-polish-2-2026-04-29.md PR-1. Single concern: text rendered
to a German narrative that was still English or raw-keyed.

- F-04 deadlines-new.ts now references the existing fristen.field.akte.*
  keys (the SSR template already used them) instead of the non-existent
  fristen.field.project.* keys, so the picker no longer renders the raw
  i18n key.
- F-07 + F-21 dashboard activity log + project Verlauf:
  • i18n.ts gains the missing dashboard.action.short.project_type_changed
    plus a parallel event.title.* key set (full noun-phrase form for
    Verlauf, complementing the dashboard's verb form) and
    event.description.* templates with {title}/{count}/{parent}
    placeholders.
  • New translateEvent(eventType, title, description) helper localizes a
    stored project_events row for display; parses both new value-only
    descriptions and legacy English+DE-mix shapes ("Deadline „Foo"
    geändert", "Type case → litigation", "Note zu deadline hinzugefügt").
    Wired into dashboard.ts and projects-detail.ts renderers.
  • Go services now write descriptions as value-only payloads (the title,
    the count, the parent slug, or "old → new") so future rows are
    locale-clean. Affected services: deadline_service.go (5 sites),
    appointment_service.go (3 sites), note_service.go (1 site),
    project_service.go (2 sites: status_changed, project_type_changed).
  • Translation covers historical project_events rows too — the
    legacy-format parsers in translateEventDescription strip the English
    "Type"/"Status" prefix and pull the quoted title out of "Deadline
    „Foo" geändert" so DE/EN renders correctly without DB migration.
  • Renamed dashboard.action.short.project_* DE labels from "...Akte" to
    "...Projekt" to match the project-rename direction.
- F-10 deadlines list REGEL column now resolves rule_name/rule_name_en
  via a JOIN-side alias on deadline_service.ListWithProjects (added
  RuleName/RuleNameEN to DeadlineWithProject). New ruleDisplay() helper
  prefers the localized rule name and falls back to em-dash; never
  renders the raw rule_code slug ("inf.rejoin").
- F-12 fristen.col.akte and termine.col.akte DE values flip "Akte" →
  "Projekt"; matching SSR placeholder text on deadlines.tsx and
  appointments.tsx column headers (EN already said "Matter").
- F-29 the checklists empty-state hint on /projects/{id}/checklists is
  split into prefix/link/suffix spans so the <a href="/checklists"> stays
  intact after applyTranslations() runs (the previous single-string i18n
  value collapsed the anchor on first paint).
- F-35 projekte.subtitle DE flips "Fälle" → "Verfahren" (matches the
  actual type taxonomy: Mandant/Streitsache/Patent/Verfahren/Projekt).
  Same fix on projekte.empty.hint. EN keeps "cases" since EN labels the
  case type as "case".
- F-46 dashboard.greeting.prefix EN flips "Good day" → "Hello".

Verified
- go build ./... + go vet ./... + go test ./... all green.
- bun run build clean.
- Dashboard activity widget + project Verlauf renderer verified by
  reading the translated paths; live smoke pending deploy.
2026-04-29 14:26:04 +02:00
m
2ffdcb9c25 Merge: t-paliad-066 — escalation contact dropdown 2026-04-29 13:59:46 +02:00
m
bff2ec5107 feat(t-paliad-066): escalation contact dropdown in Settings → Notifications
Exposes paliad.users.escalation_contact_id (added in migration 025) via
the Benachrichtigungen tab so users can route DRINGEND/overdue
escalation to a specific colleague instead of the global_admins
fallback.

Service:
- UpdateProfileInput.EscalationContactID *string (empty = clear, matches
  Dezernat tri-state pattern). Server-side validation rejects self-
  pointer (also enforced by CHECK in migration 025) and unknown UUIDs.

Reminder read path:
- digestRow now carries owner.escalation_contact_id and the audience
  predicate adds the override. visibleForCategory's "global admin"
  branch suppresses when an override is set, so escalation does not
  fan out to the whole admin team. Test table extended with override
  cases (escalation contact sees overdue / DRINGEND, admin suppressed).

UI / client:
- New "Eskalations-Kontakt" section under Benachrichtigungen with a
  select populated from /api/users (excluding self, sorted by name).
  First option is the default-fallback marker; selecting it clears.
- savePrefs PATCHes escalation_contact_id alongside the existing
  reminder fields.

i18n: einstellungen.prefs.escalation.{heading,hint,default_option}
in DE + EN.

docs/project-status.md: flips the open follow-up to "shipped".
2026-04-29 13:59:30 +02:00
m
80fdab0963 docs(t-paliad-067): polish audit triage 2 — classify F-01..F-50, propose 3 PRs
Re-verified every BATCH finding from docs/audit-polish-2026-04-27.md
against the post-PR-B/D/E + palette + firm-name codebase.

- 18 OBSOLETE (already shipped via t-paliad-060/061/062/063/064/065).
- 26 KEEP — bundled into PR-1 (i18n leak sweep + activity log), PR-2
  (visual residue + small per-page polish), PR-3 (tab/chip/notes-hint
  consistency).
- 1 RESCOPED (F-20 colour fixed by palette sweep, structural rule
  consolidation still pending).
- 7 DEFER — design-call or redesign-class items (F-23, F-25, F-32, F-38,
  F-40, F-48, F-49).

Top-5 user-visible items ranked: F-07 dashboard activity narrative, F-15
red archive button, F-04 raw i18n key on /deadlines/new, F-12 AKTE column
header (rename residue), F-13 appointments AKTE cell collision.

DESIGN-READY GATE — head reviews before any coder shift.
2026-04-29 13:58:51 +02:00
m
1efa0abc10 merge main into mai/brunel/settings-notifications 2026-04-29 13:55:31 +02:00
m
ee1af9d9cf docs: move project status & history out of CLAUDE.md
CLAUDE.md should be AI guidance only. Phase status, shipped milestones,
open follow-ups, and the patHoLo→Paliad rebrand history are project
state — they belong in docs/, not in agent instructions.

Created docs/project-status.md with the full block. CLAUDE.md now points
to it.
2026-04-29 13:54:59 +02:00
m
f0d01a84a4 docs(claude.md): mark Phase I (Notizen) as shipped
Service, handlers, and client module already exist and are wired into
project/deadline/appointment detail pages. The 'pending' note was stale
and risked sending workers to re-implement built code.
2026-04-29 13:53:26 +02:00
m
be40425623 Merge: t-paliad-065 — firm-agnostic branding (single FIRM_NAME constant) 2026-04-28 22:44:18 +02:00
m
495e519475 feat(t-paliad-065): firm-agnostic branding via single FIRM_NAME constant
Paliad ships firm-agnostic per CLAUDE.md ("survives firm renames") but
landing copy, email templates, page titles, and form placeholders still
hard-coded "Hogan Lovells" / "HL Patents". Replaces every user-facing
firm reference with a single source of truth: internal/branding.Name on
the server and frontend/src/branding.ts in the bundle, both reading
FIRM_NAME at startup/build time and defaulting to "HLC".

Server: branding package + boot log; auth, invite, admin_users error
strings; courts/offices/models comments; mail templates thread
{{.Firm}} via injected payload default. Files handler keeps the
upstream "HL Patents Style.dotm" path (must match mWorkRepo's blob
name) but renders the user-visible DownloadName from branding.Name.

Frontend: branding.ts read via Bun.build define so process.env.FIRM_NAME
is statically substituted into client bundles (no runtime process
reference); index/login/downloads/kostenrechner/Sidebar/ProjectFormFields
and every i18n.ts string templated against ${FIRM}.

ALLOWED_EMAIL_DOMAINS whitelist intentionally untouched — email
domains and display name rotate independently.

Verified: go build/vet/test clean; bun run build clean; FIRM_NAME=Acme
override produces "Acme" in HTML and JS bundles end-to-end.
2026-04-28 22:44:06 +02:00
m
4a84814b1d Merge: t-paliad-064 PR-2/3/4 — schema 025 + bundled-digest service + settings UI
Completes the reminder system redesign:
- migration 025: warning_offset_days, escalation_contact_id, reminder_log slot/slot_date
- ReminderService rewrite to bundled-digest model (one email per user/slot/local-date)
- Settings UI: new category toggles (overdue/due_today/due_warning), warning offset input

PR-1 (tz fix) shipped earlier in 525b409. Together these implement the
zero-overdue SLO model from docs/design-reminder-redesign-2026-04-28.md.
2026-04-28 13:17:40 +02:00
m
765bfe0648 feat(t-paliad-064): bundled-digest reminder service + settings UI (PR-3/4)
Replaces the per-deadline reminder model (overdue / tomorrow /
due_today_evening / weekly templates and four per-kind send paths) with
one bundled digest per (user, slot, local-date) — owner + project leads +
global_admins as audience tiers, three category sections per email.

Service rewrite (internal/services/reminder_service.go):
- RunOnce iterates users, evaluates morning/evening slot per user's tz,
  calls runSlotForUser for each match.
- runSlotForUser checks the slot+date dedup (migration 025), fetches the
  three pending-deadline categories visible to u (overdue / due_today /
  due_warning at u.reminder_warning_offset_days), composes a digest, and
  inserts the dedup row only on successful send.
- Audience filter applied per row in Go: due_warning to owner/lead,
  due_today to owner/lead (+global_admin in evening), overdue to
  owner/global_admin (NOT lead — system failure escalates past the team).
- Subject ladder: ÜBERFÄLLIG / SYSTEMAUSFALL when overdues are in the
  bundle; DRINGEND on evening when due_today still pending; "Frist-
  Erinnerung: N offen" otherwise. EN equivalents.
- Retired sendPerFrist, sendWeekly, deliverFristReminder, deliverWeekly,
  buildSubject, slotForKind, matchesLocalDueDate.

Templates:
- Added deadline_digest.html with three category sections (red/amber/
  neutral), DRINGEND wording on evening, IsOtherOwner attribution row.
- Removed deadline_reminder.html, deadline_due_today.html, deadline_weekly.html.

User schema (Go side):
- models.User gains ReminderWarningOffsetDays (int, default 7) and
  EscalationContactID (*uuid.UUID, nullable).
- userColumns SELECT updated; UpdateProfileInput accepts the new offset
  with 1..30 validation.

Settings → Notifications UI (PR-4):
- New reminder categories: overdue / due_today / due_warning. Legacy
  toggles (tomorrow, due_today_evening, weekly) removed and the legacy
  pref keys are explicitly deleted from the email_preferences object on
  next save so they don't linger.
- New "Vorwarnung (Tage vorher)" input (1..30, required), wired into the
  PATCH /api/me payload as reminder_warning_offset_days.
- Times-section copy refreshed: "Morgen-Slot" / "Abend-Slot (Eskalation)"
  with new hint text reflecting the bundled-digest model.
- DE + EN i18n strings added/updated.

Tests:
- TestCategorize, TestVisibleForCategory, TestBuildDigestSubject lock
  the boundary, recipient-rule, and subject-ladder logic.
- TestRunSlotForUser (live DB, skipped without TEST_DATABASE_URL) covers
  the morning/evening flow, slot+date dedup, and off-slot tick.
- TestRunSlotForUser_EmptyDigest enforces the no-spam rule.
- TestDeliverDigest_RendersTemplate runs the new template on the
  digestRow shape so a typo would fail before any SMTP I/O.
- TestRenderTemplateDeadlineDigest replaces the deleted reminder/weekly
  template tests.

go build/vet/test + bun run build all clean.
2026-04-28 13:17:30 +02:00
m
80518e4dd8 feat(t-paliad-064): migration 025 reminder redesign schema (PR-2)
Schema additions for the new digest-style reminder system:

paliad.users
- reminder_warning_offset_days INT NOT NULL DEFAULT 7, range 1..30
  Per-user customisation of how many days before each deadline the heads-up
  email fires. 7 matches the prior Monday-weekly behaviour.
- escalation_contact_id UUID NULL FK paliad.users(id) ON DELETE SET NULL
  Optional override of the escalation channel for overdue / DRINGEND mail.
  NULL means "fall back to global_admins". UI dropdown deferred to a
  follow-up task per m's 2026-04-28 decision; column ships now to avoid
  a second migration. Self-reference forbidden by CHECK constraint.

paliad.reminder_log
- slot TEXT NULL, slot_date DATE NULL — digest dedup keys.
- reminder_type CHECK widened to admit 'morning_digest' / 'evening_digest'
  alongside the legacy 'overdue' / 'tomorrow' / 'weekly' values.
- Partial UNIQUE INDEX (user_id, slot, slot_date) WHERE slot IS NOT NULL
  enforces "one digest per user per slot per local-date". Legacy rows
  with slot IS NULL are unaffected.

CLAUDE.md updated with a §Phase status note pointing to the design doc
and explaining the deferred Settings-UI dropdown for escalation_contact_id.

Migration is fully additive and idempotent (IF NOT EXISTS / DROP-then-ADD
on named constraints). Down migration reverses the schema cleanly; any
'morning_digest' / 'evening_digest' rows must be deleted before downgrading.
2026-04-28 13:05:22 +02:00
m
525b409fd0 Merge: t-paliad-064 PR-1 — embed tzdata + reminder design doc
PR-1 of the reminder system redesign:
- fix(t-paliad-064): embed Go tzdata so LoadLocation works in alpine prod
- docs(t-paliad-064): design doc for the full redesign (PR-2/3/4 to follow)

Stops m's 11:16 reminder emails. PR-2 (migration 025) and PR-3 (service
rewrite) follow on the same branch.
2026-04-28 13:03:21 +02:00
m
f988666ba0 fix(t-paliad-064): embed tzdata + stop silent UTC fallback (PR-1)
Root cause of m's 11:16 reminder emails: the alpine runtime image installs
only ca-certificates and ships no /usr/share/zoneinfo, so
time.LoadLocation("Europe/Berlin") errored in production. inSlot's silent
UTC fallback then matched reminder_morning_time=09:00 against now.UTC().Hour(),
firing at 09:00 UTC = 11:00 Berlin (CEST UTC+2) plus the ticker phase.

Fix:
- import _ "time/tzdata" in cmd/server/main.go (and the services package
  for test parity) — embeds Go's IANA database in the binary, ~450KB,
  works without OS tzdata.
- inSlot now logs and returns false on bad tz instead of pretending the
  user lives in UTC. matchesLocalDueDate mirrors the same change.
- Tests updated: previous "Mars/Olympus falls back to UTC" expectation
  flipped to "skips user", new TestTZDataEmbedded asserts
  LoadLocation("Europe/Berlin") works in the test binary, new
  TestInSlot_BerlinAt0900_NotAt1100 locks the headline regression
  (must fire at 07:05 UTC = 09:05 Berlin, must NOT fire at 09:16 UTC =
  11:16 Berlin).

Validation at the user-save boundary (UserService line 296-300, 619)
already rejected unparseable IANA names — that remains; this PR only
hardens the read path so any pre-existing corrupt rows skip rather than
silently reroute.

Build/vet/test/bun-build all green. Self-merging to main.
2026-04-28 13:02:58 +02:00
m
93fdf10537 docs(t-paliad-064): reminder system redesign — design doc
Design for zero-overdue SLO, per-user bundled digests (one email per slot
per local-day), DRINGEND evening escalation, and global-admin escalation
on overdues. Includes the actual TZ root cause (alpine container has no
tzdata; LoadLocation silently falls back to UTC) and the embed-tzdata fix.

Awaiting m's go/no-go before implementation.
2026-04-28 12:55:33 +02:00
m
12f535abd3 Merge: HLC brand palette adoption (t-paliad-063) 2026-04-27 20:15:37 +02:00
m
b21dacf15c feat(t-paliad-063): adopt HLC brand palette across paliad
Replace ad-hoc lime/forest-green system with the official 4-color HLC
palette. Lime + midnight are the primary pair; cyan + cream supporting.

Tokens
- :root now exposes --hlc-lime, --hlc-midnight, --hlc-cyan, --hlc-cream
  plus channel-token siblings (--hlc-*-rgb) so tints can be expressed as
  rgb(var(--hlc-*-rgb) / a) without hex literals.
- --color-bg → cream, --color-text/--color-hero-bg → midnight,
  --color-accent → lime, --color-accent-dark → midnight (foreground on
  lime; passes WCAG AA where #fff failed).
- New --sidebar-* tokens for the dark sidebar surface.

Sweep (frontend/src/styles/global.css)
- Replaced every hard-coded #c6f41c / #65a30d / #84cc16 / #b8e616 /
  #4d7c0f / #1a2e1a / #1a1a2e / #1a2e05 with the matching var(...).
- rgba(101,163,13,a) and rgba(198,244,28,a) collapsed to
  rgb(var(--hlc-lime-rgb) / a).
- text-on-lime now uses var(--color-accent-dark) instead of #fff;
  btn-danger keeps white on red.

Sidebar reskin (cronus's audit, F-30)
- Background: midnight; text: cream (muted via cream-channel alpha);
  active/hover: lime. Border + hover use cream-channel alphas so no
  rgba hex creep on the dark surface.

Brand assets
- manifest.json theme_color → lime, background_color → cream.
- icon.svg / icon-maskable.svg base recoloured to lime + midnight glyph.
- 32× <meta name="theme-color"> across pages updated to #BFF355.
- Email templates (base.html, invitation.html) lime accent updated;
  mail_service_test.go expectation tracks the new hex.

Deferred / out of scope
- PNG icons under public/icons/ are baked artefacts; regen left to the
  next deploy.
- Categorical chip colours (office tints, traffic-light red/amber/green,
  termin-type hues) are functional, not brand, and deliberately
  untouched.
- Dark mode is not in scope.

Verified
- bun run build clean.
- go build ./... clean; mail render tests pass.
- Visual sweep at 1280×900 against frontend/dist via Playwright on
  /, /login, /dashboard, /projects, /agenda, /team, /fristenrechner,
  /glossary — sidebar midnight + lime active, cream page bg, white
  cards, midnight text on lime CTAs.

Supersedes audit findings F-14, F-30, F-31.
2026-04-27 20:15:17 +02:00
m
5423a4e1f1 Merge: PR-E bug batch (t-paliad-062)
# Conflicts:
#	frontend/src/client/projects.ts
2026-04-27 19:36:21 +02:00
m
c9ca08fcbb fix(t-paliad-062): PR-E bug batch — F-02, F-03, F-08, F-09
Four standalone bugs from the 2026-04-27 polish audit (PR-E batch).

F-02 — /admin/team search input: long placeholder ("Nach Name oder
E-Mail suchen…") visually overlapped the absolutely-positioned count
badge ("31 / 31") because .glossar-search reserved only 0.75rem of
right padding. Bumped padding-right to 4.5rem so the badge sits in its
own gutter — same fix protects every other use of the .glossar-search
shell (admin team, glossary, etc.) without touching individual pages.

F-03 — /api/departments?include=members 500 regression. Migration 020
renamed paliad.dezernat_mitglieder → department_members but missed the
dezernat_id column on prod youpc. Application code (DepartmentService.
ListWithMembers / ListMembers / AddMember / RemoveMember) selects
department_id, which doesn't exist there → "column does not exist"
500. New migration 024 renames the column idempotently, plus the
indexes/constraints/policies that postgres did not auto-rename when
their table was renamed (departments_pkey, departments_office_idx,
departments_lead_idx, departments_lead_user_id_fkey,
departments_office_check, department_members_pkey,
department_members_user_idx, department_members_department_id_fkey,
department_members_user_id_fkey, departments_select / _write,
department_members_select / _write). Every rename uses a DO block that
swallows undefined_object / undefined_column so the migration is a
no-op on dev DBs that already had English names from migration 018.
Down step puts the German names back symmetrically.

F-08 — Project detail tabs (/projects/{id}/Verlauf|Team|…) used
href="#", so middle-click and "open in new tab" were broken even
though the SPA already mirrored the canonical path via
history.replaceState. initTabs() now sets each tab anchor's href to
/projects/{id}/{tab} (id resolved from the URL when the project hasn't
loaded yet) and only intercepts plain left-clicks — middle/ctrl/meta/
shift/alt fall through to the browser. Backend gains the previously-
missing /projects/{id}/history and /projects/{id}/children server
routes (both bound to handleProjectsDetailPage like every other tab),
so opening the URL in a fresh tab no longer 404s.

F-09 — /projects?view=tree was silently ignored: viewMode was hard-
coded to "flat" and the URL was never read. parseInitialView() now
seeds viewMode from ?view=, initFilters() syncs the dropdown to the
parsed value before binding the change handler, and changing the
dropdown rewrites the query string via history.replaceState (default
"flat" stays implicit to keep the canonical path clean). Bookmarks,
dashboard links, and copy-shared URLs round-trip correctly.

Verification:
- /api/departments?include=members live-tested after applying 024 to
  youpc: returns 200 with members enriched.
- go build ./... + go vet ./... + go test ./... clean.
- bun run build clean.
2026-04-27 19:34:56 +02:00
m
6620ac6379 Merge: rename residue + i18n cleanups (t-paliad-061) 2026-04-27 19:28:43 +02:00
m
3a695eca72 Merge main into PR-D 2026-04-27 19:28:18 +02:00
m
c9054ed753 fix(t-paliad-061): rename residue + small i18n cleanups (PR-D)
Per docs/audit-polish-2026-04-27.md PR-D batch:
- F-11 office labels on /projects/{id}/team — use t("office."+key) so
  "duesseldorf"/"munich" render as "Düsseldorf"/"München"
- F-17 "Lead" → "Leitung" in DE on the Rolle column and /projects/new
  subtitle (EN keeps "Lead")
- F-18 admin.team.permission.global_admin → "Globaler Admin" (DE) plus
  matching "globaler Admin" in last_admin error
- F-19 rename DOM IDs: projekt-type → project-type, projekt-view →
  project-view, akten-status → project-status (markup + all
  getElementById/$ callsites in client modules)
- F-26 Akte filter dropdown on /deadlines + /appointments → "Projekt"
  / "Alle Projekte" in DE (column headers stay for PR-A/F-12)
- F-44 admin card "Departments / Dezernate" → "Dezernate"
- F-45 "Dezernat / Partner" → "Dezernat oder Partner" on settings +
  onboarding profile fields

go build/vet/test clean; frontend bun run build clean.
2026-04-27 19:28:05 +02:00
m
f7d01b9996 Merge: lang attr on date/time inputs (t-paliad-060) 2026-04-27 18:51:36 +02:00
m
84145f6599 fix(t-paliad-060): set lang attr on date/time inputs (PR-B F-05/F-06)
Native <input type="date|time|datetime-local"> follows the browser
locale unless lang is set on the element itself — Chrome and Safari
ignore the document-root lang attribute for date-picker formatting.

initI18n and setLang now sweep every date/time/datetime-local input
and stamp the current locale, so DE users see dd.mm.yyyy and 24h on
every form (Termine, Fristen, Settings reminder times, Project
filing/grant dates, Fristenrechner trigger date, Appointments
filters). EN users keep their browser-locale defaults via lang="en".

The sweep runs on every page that calls initI18n (all of them) and
again on the DE/EN toggle, so live-switching language updates the
input formatting too. Inputs are static in the SSR templates today;
dynamically-injected date inputs would need an explicit re-sweep.
2026-04-27 18:51:17 +02:00
m
de2788c2d7 Merge: polish audit doc + 41 screenshots (t-paliad-059) 2026-04-27 18:47:34 +02:00
m
f8982a6628 docs(t-paliad-059): polish audit — 50 findings + top 10 ranked
Survey-only pass across the authenticated paliad surface as test admin
on Playwright at 1280×900 + 375 mobile spot-checks + DE/EN toggle.

Top 10 (best value-per-effort):
1. Strip "Hogan Lovells"/"HL" from public surface (landing, downloads)
2. Pick lime as the single primary green; retire forest-green
3. "Projekt archivieren" red → neutral (reversible, not destructive)
4. /admin/team search input has overlapping placeholder text (visible bug)
5. fristen.field.project.choose raw i18n key on /deadlines/new
6. Activity log leaks project_type_changed + "Type case → litigation"
7. lang="de" on date and time inputs (mm/dd/yyyy + 09:00 AM in DE UI)
8. "Akte" → "Projekt" residue on /deadlines + /appointments
9. Office values lowercased no-umlaut on /projects/{id}/team
10. Project tabs use href="#" — middle-click broken

Plus 40 other findings ranked by severity (broken/friction/polish) and
effort (≤30min/1-2h/half-day+). Suggested 5-PR batching.

41 screenshots in tests/screenshots-polish-2026-04-27/ covering every
sidebar entry + project detail tabs + DE/EN + mobile.

No code changes. Implementation tasks dispatched separately by head.
2026-04-27 18:46:05 +02:00
m
a36e9dffff Merge: build-time IIFE guard (t-paliad-053) 2026-04-27 18:33:41 +02:00
m
71ab1e9916 build(t-paliad-053): guard frontend bundles against non-IIFE format
Extract the bundle format into a single BUILD_FORMAT constant and add a
post-build inspection that aborts if any emitted dist/assets/*.js bundle
is missing the IIFE prologue. Catches the three regression vectors that
would re-introduce t-paliad-043:

- BUILD_FORMAT changed to "esm" / "cjs"
- `format` option dropped from the Bun.build call
- a future Bun version emitting a non-IIFE wrapper despite the option

Without IIFE wrapping, top-level `var`/`function` declarations in each
per-page bundle leak to `window` and the minifier collisions (`var d`
vs `function d()`) take down the whole authenticated surface — that is
what crashed every page on Apr 26.

Server-startup inspection of dist/assets is deferred — the build-time
guard alone makes the bug class impossible to ship.
2026-04-27 18:33:27 +02:00
m
7644c2e2d8 Merge: visibilityPredicate honors global_admin (t-paliad-058)
Fix in-Go visibilityPredicate to mirror paliad.can_see_project — the
positional variant was comparing the bound role to 'admin' instead of
'global_admin', so global_admins without project_teams rows got 404
on every endpoint that called the positional helper (GetByID,
ListAncestors, BuildTree, GetTree, deadline counts).

Refactor to resolve global_admin via EXISTS on paliad.users keyed only
by userID — callers no longer pass role, removing the foot-gun for
future endpoints.
2026-04-27 16:36:11 +02:00
m
abd99980fc fix(t-paliad-058): honor global_admin in visibilityPredicate
Mirror paliad.can_see_project's global-admin shortcut at the application
layer. The in-Go predicate previously relied on callers passing
user.GlobalRole as a separate :role / $roleArg parameter — the positional
variant compared against the literal 'admin' instead of 'global_admin',
so any global_admin without team membership got 404 from
/api/projects/{id} (and the other positional callsites: ListAncestors,
BuildTree, GetTree, deadline counts).

Fold the gate into a Go helper that resolves global_admin via EXISTS on
paliad.users, keyed only by userID. Callers no longer pass role, which
removes the foot-gun entirely. Drops the unused
visibilityPredicatePlaceholder dead helper.

Adds a regression test (visibility_test.go) covering global_admin +
standard user against GetByID and BuildTree without project_teams rows.
2026-04-27 16:35:55 +02:00
m
7e76b0e414 Merge: dashboard activity log split (t-paliad-057) 2026-04-27 16:11:34 +02:00
m
b0ecd24d00 Merge: edit-project type change with data-loss warning (t-paliad-056) 2026-04-27 16:11:25 +02:00
m
f33ac9469c feat(t-paliad-057): split dashboard activity rows into bold action + muted detail
Each "Letzte Aktivität" entry now renders as two visually distinct lines:
  Line 1 (bold): actor + short verb — e.g. "Matthias änderte Frist"
  Line 2 (muted): project ref link + German description — e.g. "C-2024-001 Deadline „ok" geändert"

Previously the row collapsed both into one running line, producing redundant
text like "Matthias änderte Frist Deadline „ok" geändert".

- i18n keys moved to dashboard.action.short.<kind> namespace (DE + EN);
  legacy German action kinds (frist_*, notiz_*, …) kept for historical rows.
- renderActivity in dashboard.ts emits .dashboard-activity-summary +
  .dashboard-activity-detail paragraphs inside the body container.
- CSS: replaced inline actor/action/details styling with stacked summary
  (default size, bold actor) + detail (smaller, muted) rules; project-ref
  link kept as small mono accent.
2026-04-27 16:11:18 +02:00
m
d5d1cffd3a feat(t-paliad-056): allow type change in project edit modal with data-loss warning
Enables the type dropdown in /projects/{id} edit modal. Switching to a
new type clears the old type's specific columns server-side and emits a
project_type_changed audit event. The frontend surfaces an inline
warning naming the fields that will be NULL'd before the user saves.

Field map (kept in sync with services.typeSpecificColumns):
  client → industry, country, client_number
  patent → patent_number, filing_date, grant_date
  case   → court, case_number, proceeding_type_id
  litigation/project → none

Server: PATCH /api/projects/{id} now accepts `type`. ProjectService.Update
collects the obsolete columns up-front and force-NULLs them at the end of
the SET list; per-field appendSet calls for those columns are skipped so
Postgres' "no duplicate column in UPDATE" rule isn't tripped (and the
clear wins regardless of what the client sent). Audit event description
records old → new type slug.

Frontend: openEditModal no longer disables projekt-type. A new
renderTypeChangeWarning() computes the lost-fields list from the loaded
project record and shows it above Save when the selection diverges from
the current type. Empty when nothing would be cleared.

No DB hierarchy CHECK constraint exists on parent/child types, so type
changes don't risk schema violations on existing children. Tree
inheritance rules are not enforced on edit (matching create behaviour).
2026-04-27 16:10:12 +02:00
m
5f11b6a1c8 Merge: dashboard horizontal scroll fix (t-paliad-055) 2026-04-27 16:05:01 +02:00
m
4e796c5627 fix(t-paliad-055): dashboard horizontal scroll on narrow viewports
The Kommende Fristen / Termine cards forced the page wider than the
viewport on mobile. Root cause: .dashboard-col is a CSS Grid item with
the default min-width: auto, so its track sized to the min-content of
the deeply nested .dashboard-list-link content (nowrap title + ref +
badge), expanding the single-column grid track to ~641px even when the
viewport is 375px.

Set min-width: 0 on .dashboard-col so the grid track can shrink to its
column width and the existing text-overflow: ellipsis on the title/ref
spans does the rest.

Verified at 375x900: no horizontal page scroll, columns 327px each,
long titles truncate with ellipsis. Desktop >=768px unchanged
(1fr 1fr -> 504px each at 1280 wide).
2026-04-27 16:04:56 +02:00
m
bad65c3ffe Merge: /admin landing page index (t-paliad-054) 2026-04-27 15:14:07 +02:00
m
c2eb23aa5b feat(t-paliad-054): /admin landing page indexing admin sub-pages
`/admin` was 404 — only `/admin/team` existed. Add a browseable index so
the admin area has a root, with the existing Team-Verwaltung tile alongside
greyed-out roadmap placeholders (Departments, Audit-Log, Email-Templates,
Feature-Flags) so admins see what's coming.

- internal/handlers/admin_users.go: handleAdminIndexPage serves
  dist/admin.html. Same RequireAdminFunc gate as /admin/team — non-admins
  get the standard 302 to /dashboard?forbidden=admin.
- internal/handlers/handlers.go: register GET /admin under the existing
  admin-conditional block.
- frontend/src/admin.tsx + client/admin.ts: card grid built from the
  shared .grid + .card landing-page pattern. .admin-card-soon dims the
  placeholders + adds a "Kommt bald" badge so they read as roadmap, not
  broken links.
- frontend/src/components/Sidebar.tsx: add Admin-Bereich (/admin) above
  Team-Verwaltung in the existing admin group. Both items live in the
  same display:none group that sidebar.ts reveals after /api/me confirms
  global_role='global_admin'.
- frontend/src/client/i18n.ts: nav.admin.bereich + admin.title /
  .heading / .subtitle / .section.{available,planned} / .coming_soon
  plus per-card title+desc, DE+EN.
- frontend/src/styles/global.css: .admin-section-planned spacing,
  .admin-card-soon dimming, .admin-soon-badge pill.
- frontend/build.ts: register the renderAdmin entrypoint and admin.ts
  client bundle.
2026-04-27 15:13:46 +02:00
m
d2777be931 Merge: separate job_title from global permissions (t-paliad-051) 2026-04-27 14:59:15 +02:00
m
b34500ad31 feat(t-paliad-051): split paliad.users.role into job_title + global_role
Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.

Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:

* paliad.users.job_title — free text, NULL allowed, display only.
  NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
  default 'standard'. The only thing that gates ops.

Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
  their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
  with the historic u.role IN ('partner','admin') gates simplified
  to u.global_role='global_admin' only (job_title NEVER gates)

Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
  User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
  global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
  UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
  global_role with last-admin demotion guard (ErrLastGlobalAdmin);
  IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
  department/party/note/checklist_instance): pass user.GlobalRole into
  visibility predicates; partner-or-admin gates simplified to
  global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
  admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
  -> JobTitle (*string)

Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
  'Standard'|'Global Admin' badge + dropdown editor; last-admin
  demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
  gates dropped, only global_admin grants those operations

Tests:
* user_service_test: bootstrap promotes first user to global_admin,
  subsequent default to standard; AdminUpdateUser refuses to demote
  the last global_admin; IsAdmin reads global_role

Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
2026-04-27 14:59:03 +02:00
m
aec150f1cd design(t-paliad-051): split paliad.users.role into job_title + global_role
Conflation today: paliad.users.role is simultaneously job title (display only),
global permission (`role='admin'` checks across Go/SQL/JS), and not-quite-but-
sort-of project_teams.role (already separated). m wants to record his real job
title ("Counsel Knowledge Lawyer") without losing admin access — the existing
admin-team UI even rejects role='admin' on edit, so any UI-driven update
silently demotes him.

Design proposes:
- Rename paliad.users.role -> paliad.users.job_title (free text, NULL allowed)
- Add paliad.users.global_role (CHECK IN ('standard','global_admin'),
  default 'standard')
- Single migration 023 does the rename, populates global_role from the old
  role, fixes m to job_title='Counsel Knowledge Lawyer', updates
  can_see_project, rebuilds RLS policies
- Inventory of every role='admin' call site across services/handlers/
  migrations/frontend bucketed by what migrates vs. what stays
- Keeps the existing 'partner' gate as job_title-driven (already broken in
  prod — "Partner" capital-P vs lowercase 'partner' check; documented as
  out-of-scope follow-up)
- Bootstrap rule (first user becomes admin) keeps the same advisory lock,
  flips global_role instead of role
- API surface: /api/me returns both fields; admin-team UI gets a Permission
  column with a global_role dropdown + last-admin demotion guard

Awaiting m greenlight before implementation phase.
2026-04-27 14:31:15 +02:00
m
1588da371f Merge: admin team-management page (t-paliad-050) 2026-04-27 13:41:38 +02:00
m
d55e98806f Merge main into mai/ritchie/admin-team-management
# Conflicts:
#	frontend/src/styles/global.css
2026-04-27 13:41:26 +02:00
m
c697fe3418 feat(admin): /admin/team page + admin-only user CRUD (t-paliad-050)
- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
  with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
  (full profile fields incl. additional_offices), AdminDeleteUser (also
  removes project_teams + department_members memberships and clears any
  led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
  IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
  PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
  All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
  assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
  role, dezernat, additional_offices, lang), trash with confirm, search +
  office filters, "Onboard existing account" modal driven by
  /api/admin/users/unonboarded, and an Invite button that re-opens the
  shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
  successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
  unauthenticated 401 and lookup-error fail-closed paths.
2026-04-27 13:40:00 +02:00
m
c68e464d67 Merge: full project edit modal + breadcrumb polish (t-paliad-049) 2026-04-27 13:38:08 +02:00
m
59cf47b5ed feat(projects): full edit modal + breadcrumb polish + tab toolbar buttons (t-paliad-049)
- Edit pencil on /projects/{id} now opens a modal with the same form as
  /projects/new, pre-filled from the project. Type and parent are
  intentionally read-only — re-typing/reparenting are structural ops not
  exposed via PATCH today.
- Form body extracted into <ProjectFormFields/> + shared
  client/project-form.ts so create and edit share the same fields,
  visibility logic, parent picker, and payload builder.
- Inline title/description edit removed; one edit path is clearer than two.
- Breadcrumb rewritten as pill chips with type icons (matching the project
  tree), chevron separators, hover lime accent, ellipsis truncation, and
  horizontal-scroll fallback on mobile.
- Tab toolbar action buttons standardised — same height, padding, font
  weight across Verlauf/Team/Untergeordnet/Parteien/Fristen/Termine plus
  the "Mehr laden" secondary so they no longer drift visually.
2026-04-27 13:37:56 +02:00
m
94222f790b Merge: customizable reminder send times + due-today evening sweep (t-paliad-048) 2026-04-27 11:47:30 +02:00
m
e68ff5b434 feat(reminders): per-user send times + due-today evening sweep (t-paliad-048)
Reminders used to fire whenever the hourly ticker happened to scan after
a user's first eligible event — m got mail at 02:28. We now gate delivery
to a user-chosen hour-of-day in their local timezone.

* Migration 022 adds reminder_morning_time / reminder_evening_time /
  reminder_timezone (defaults 09:00, 16:00, Europe/Berlin).
* New "due_today_evening" reminder kind with its own template — fires only
  for due_date = today AND status = pending, in the evening slot.
* Reminder service computes user-local hour each tick and skips users
  outside their slot. SQL widens to a 3-day band; in-process filter
  narrows to per-user local date.
* Settings → Notifications gains time inputs and a timezone field.
* Tests: pure (inSlot, slotForKind, matchesLocalDueDate) plus a live-DB
  TestReminderSlots covering morning, evening, outside-slot, and the
  completed-deadline case.
2026-04-27 11:47:10 +02:00
m
fa1525b620 Merge: Resizable sidebar width — drag handle + persistence (t-paliad-047) 2026-04-26 15:27:39 +02:00
m
132992ba2a feat(sidebar): resizable width with drag handle + persistence (t-paliad-047)
Adds a 6px col-resize strip on the right edge of the desktop sidebar.
Drag updates --sidebar-width on document.documentElement (clamped
180-480px). Mouse + touch handlers; double-click resets to the 240px
default. Width persists via localStorage["paliad-sidebar-width"], read
on every page load before first paint so layout is stable from frame 1.

The handle is opacity-faded on the icon-rail (collapsed) state and
hidden entirely under the mobile breakpoint, since the mobile sidebar
is an overlay drawer that always uses the fixed --sidebar-expanded
width.

Pin/unpin behaviour is preserved: pinned state keeps the user's chosen
width; unpinning drops to the icon rail; hover-expand restores the
chosen width. The hover-collapse mouseleave handler ignores transitions
during an active drag so the sidebar doesn't snap shut mid-resize.
2026-04-26 15:27:24 +02:00
m
fde4cbe2a9 Merge: Cmd/Ctrl+K command palette (t-paliad-044) 2026-04-26 15:16:10 +02:00
m
75b52d49ba feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044)
Implements the design from docs/design-command-palette.md. Adds a fzf-style
command palette on top of the existing global search overlay:

- Cmd+K (Mac) / Ctrl+K (Win/Lin) opens the palette in discoverability mode
  (all 20 actions visible, no entity fetch). Existing "/" shortcut preserved.
- preventDefault + stopPropagation suppress browser-native Ctrl+K behavior
  (Firefox URL-bar focus). Cmd+K explicitly ignores the in-text-input skip
  rule so power users can open the palette from anywhere.
- Action catalog (frontend/src/client/palette-actions.ts) — 12 navigate +
  3 create + 4 toggle/app actions. Substring filter on DE+EN labels (no
  fuzzy lib). runAction dispatcher reuses existing DOM handlers (lang
  toggle button, sidebar pin, invite modal) — no duplicated state.
- Filtered state shows actions on top, entity search results below.
- First item auto-selected so ↵ works without an arrow press first.
- Footer shows ↑↓ / ↵ / Esc kbd hints (hidden on <480px viewports).
- 25 i18n keys (DE + EN) under palette.action.* / palette.section.* /
  palette.footer.*.
- Mobile: BottomNav stays as-is (5 slots full); palette accessed via the
  drawer search input. Documented decision in design doc.

Build / vet / test all clean. Smoke verified on local: login page loads
with no console errors, palette code is bundled into authenticated page
JS bundles. Production verification via Playwright after Dokploy
auto-deploy.
2026-04-26 15:15:58 +02:00
m
c226a8b14d docs(palette): design Cmd/Ctrl+K command palette (t-paliad-044)
Choose Option B (full palette: actions + entities) over Option A
(keybind-only) because:

- pwa-baseline.md canon for multi-entity sites (paliad has 8 entity types).
- 80% of infrastructure already exists in search.ts (sectioned results,
  keyboard nav, i18n, debounce, abortable fetch).
- Patent-lawyer audience benefits from keyboard-first creation flows.
- A-then-B would mean revisiting in 2 weeks anyway.

Scope guardrails: no fuzzy lib, no MRU persistence, no extension API,
no per-action shortcut keys (Cmd+K only). 20-action catalog (12 navigate,
3 create, 4 toggle/action). Mobile gets palette via drawer (no new
BottomNav slot). Desktop preventDefault on Ctrl+K to suppress URL-bar.

Doc covers: trigger surface, UX shape (empty + filtered), action catalog,
component architecture (extend search.ts, new palette-actions.ts data
file), render flow, i18n keys, mobile considerations, acceptance,
implementation plan, risks. Implementer choice deferred to m.

Awaiting m's go/no-go before coder shift.
2026-04-26 15:02:31 +02:00
m
79d332d5b2 Merge: /links search input + ?q= deep-link wiring (t-paliad-046 follow-up) 2026-04-26 14:54:20 +02:00
m
044166ffed feat(links): add text search input + honor ?q= from search palette (t-paliad-046 follow-up)
The global search palette emits /links?q=<title> when a user clicks a link
result, but /links had no search input — only category filter pills — so
the deep link silently landed on the unfiltered catalog.

Added a search input matching the glossary/courts pattern: live keystroke
filtering across title + DE/EN description + URL, combined with the
existing category filter, and ?q= URL prefill on init. Result count chip
("2 / 47") added next to the input for parity with the other catalogs.

i18n: links.search.placeholder added in DE + EN.
2026-04-26 14:54:16 +02:00
m
2a178695ac Merge: reversible deadline status (t-paliad-045) 2026-04-26 14:52:28 +02:00
m
3aa8bae8e9 feat(deadlines): add reversible deadline status — admin/lead reopen (t-paliad-045)
Completed deadlines were irreversible — accidental completions could not be
undone. Adds a symmetric reopen path for global admins and project leads.

Server:
- PATCH /api/deadlines/{id}/reopen flips status back to pending and clears
  completed_at, audit-logged as project_event kind 'deadline_reopened'.
- DeadlineService.Reopen mirrors Complete shape; new
  assertCanAdminProject helper gates on global users.role='admin' OR
  paliad.project_teams.role IN ('admin','lead') walking the project path.
- Service test (skipped without TEST_DATABASE_URL) covers admin + non-admin
  paths and idempotent no-op.

UI:
- /deadlines/{id} detail: Wieder öffnen / Reopen button replaces the
  disabled completed-state Erledigt button (admin/partner only).
- /deadlines list: per-row ↻ icon for completed rows (admin/partner only;
  project-lead-only users use the detail page).
- i18n: fristen.detail.reopen, fristen.action.reopen,
  dashboard.action.deadline_reopened (DE + EN).
2026-04-26 14:52:00 +02:00
m
a3f778c86a Merge: search-palette deep-links honor ?q= on /courts + /glossary (t-paliad-046) 2026-04-26 14:48:25 +02:00
m
58692a4411 fix(courts, glossary): honor ?q= URL param on init for search-palette deep links (t-paliad-046)
The global search palette emits /courts?q=... and /glossary?q=... but the
client bundles only wired the search input to live keystrokes — the URL
parameter was ignored on load. Clicking a court or glossary result in the
palette landed on the right page but showed all 41 courts / all glossary
terms instead of the expected single match.

Fix: in each page's initSearch(), read URLSearchParams.get('q') and prefill
the input value + module-level searchQuery before the data loads. The
existing render() call after fetch resolves already reads searchQuery.

Out of scope: /links has no search input today (palette also emits
/links?q=...). Flagged for a follow-up — adding a search input is a feature
addition, not a bug fix.
2026-04-26 14:48:07 +02:00
m
1b0de2f89c Merge: asset URL versioning + HTML no-cache (t-paliad-043 step 4) 2026-04-26 14:41:50 +02:00
m
ccbb7e9e33 fix(build, handlers): version-stamp /assets URLs + no-cache HTML pages (t-paliad-043 step 4)
Cache-Control: no-cache on /assets/* (step 3) only applies to NEW
responses — cached entries from before the deploy are still served
without revalidation under heuristic freshness, which is exactly the
window that kept users stuck on the broken bundle.

The robust fix is to change the cache key on every deploy:

  - frontend/build.ts now post-processes every dist/*.html and appends
    `?v=<buildVersion>` to every /assets/*.js and /assets/*.css URL.
    Same buildVersion the SW already uses, so the SW cache, the asset
    URL, and the HTML reference all rotate together.

  - internal/handlers/handlers.go wraps the protected mux (and the
    public /login, /logout, /{$} pages) in a noCachePages middleware.
    HTML pages now revalidate on every navigation; combined with the
    versioned asset URLs, a deploy reaches users on their next request:
    new HTML → new ?v= → fresh script load, every time.
2026-04-26 14:41:47 +02:00
m
71d49d8b81 Merge: cache-bypass + install-prompt mobile gate (t-paliad-043 step 3) 2026-04-26 14:37:06 +02:00
m
0800ba97f3 fix(sw, assets, install): bypass HTTP cache + revalidate assets + mobile-only install banner (t-paliad-043 step 3)
After step 2 deployed the IIFE-wrapped bundles, m's browser still saw
the broken page because /assets/projects.js was being served from the
local HTTP cache (no Cache-Control, just heuristic freshness from
Last-Modified). Even after the new SW activated and cleared its own
caches, its cacheFirst handler did `fetch(req)` which goes through the
browser HTTP cache — re-fed the SW cache from the stale bundle and the
loop perpetuated forever.

Three mutually reinforcing fixes:

1. SW cacheFirst now does `fetch(req, { cache: "reload" })` for the
   network leg. Forces the network fetch to bypass the browser's HTTP
   cache, so the SW always seeds its own cache from a true network read.

2. Go static handlers for /assets/* and /icons/* set
   `Cache-Control: no-cache, must-revalidate`. Combined with the
   Last-Modified that http.FileServer already emits, browsers send
   If-Modified-Since and the server replies 304 when unchanged — fast
   for repeat loads, fresh on every deploy. Users without a SW (or after
   the kill-switch unregistered theirs) now also pick up new bundles
   immediately.

3. pwa-install.ts gates the install banner on
   `(min-width: 768px)` — same breakpoint the BottomNav and other
   mobile-shell elements use. Desktop partners no longer get an install
   prompt covering their work area.
2026-04-26 14:37:02 +02:00
m
4c74b960e9 Merge: bundle IIFE wrap + versioned SW (t-paliad-043 step 2) 2026-04-26 14:31:52 +02:00
m
44ad50d5e4 fix(bundle, sw): IIFE-wrap per-page bundles + versioned SW (t-paliad-043 step 2)
ROOT CAUSE of /projects empty state: the per-page bundles (app.js,
projects.js, dashboard.js, …) were emitted by bun build without an IIFE
wrapper, and loaded as classic <script> tags. Every top-level `var`,
`let`, `const`, and `function` declaration therefore became a property
of the global object.

After t-paliad-042 added app.js to every page (loaded with defer, before
DOMContentLoaded), the minified `var d = "patholo-sidebar-pinned"`
inside app.js (the legacy sidebar-pinned localStorage key constant)
clobbered projects.js's minified `function d() { … }` (the
`applyTranslations` helper). When projects.js's DOMContentLoaded handler
called initI18n → applyTranslations → `d()`, `d` was now the string
"patholo-sidebar-pinned" → "TypeError: d is not a function" → the
fetch to /api/projects never even fired → table stayed empty → empty
state showed.

Fix: pass `format: "iife"` to Bun.build so every entry is wrapped in
`(()=>{ … })()`. Top-level identifiers are now scoped per bundle and
cannot collide on `window`. Verified locally: window.d, window.r,
window.K all `undefined` after both app.js and projects.js execute.

While here, replace the t-paliad-043 step 1 kill-switch SW with the
proper versioned cache pattern the brief asked for:
  - frontend/public/sw.js carries `__PALIAD_BUILD_VERSION__` placeholder
  - frontend/build.ts substitutes `v<Date.now()>` after copying public/
    into dist/, so every deploy opens a fresh `<version>-static` cache
  - activate handler deletes any cache whose name doesn't match current,
    which evicts both the old paliad-v1-static cache and any kill-switch
    survivors the moment a user lands on the new deploy
  - skipWaiting + clients.claim so the new SW takes over on the next
    navigation rather than waiting for every tab to close
2026-04-26 14:31:48 +02:00
m
134c807da3 Merge: kill-switch SW (t-paliad-043 step 1) — emergency unstick 2026-04-26 14:26:01 +02:00
m
dc70114d92 fix(sw): kill-switch SW to unstick users with broken cached bundle (t-paliad-043 step 1)
Emergency: t-paliad-042 shipped a service worker that cached a broken
/assets/projects.js (crashes on init with "d is not a function"), making
/projects show the empty state. Mobile Safari users have no devtools to
manually unregister the SW.

Replace sw.js with a self-destructing variant: on activate, delete every
cache, unregister itself, and force every open client to navigate to a
fresh page. /sw.js is served with no-cache headers so browsers refetch
on the next navigation and propagate the kill-switch automatically.

Step 2 (separate commit): fix the projects.js bundle bug, then ship a
properly versioned SW that evicts stale caches on every deploy.
2026-04-26 14:25:49 +02:00
m
4e06a5db39 Merge: PWA app-shell phase 2 — manifest + icons + SW + install (t-paliad-042) 2026-04-26 10:48:42 +02:00
m
8921830f43 feat(pwa): app-shell phase 2 — manifest + icons + service worker + install prompt (t-paliad-042)
Ship the installability bits that t-paliad-041 deferred so iOS / Android
users can add Paliad to their home screen.

What landed:
- frontend/public/manifest.json — name=Paliad, theme_color #65a30d (lime),
  display=standalone, scope=/, start_url=/dashboard, four icon entries
  (192/512 × any/maskable). Served from /manifest.json with the
  spec-mandated application/manifest+json content type (servePWAManifest
  in internal/handlers/pwa.go).
- frontend/public/icons/ — lime "p" logo rendered to 192/512 PNGs in both
  "any" and maskable variants (maskable variant has extra safe-zone
  padding), 180×180 apple-touch-icon, 32×32 favicon. SVG sources kept
  under frontend/icons-src/ for regeneration via rsvg-convert.
- frontend/public/sw.js — minimal cache-first for /assets/* and /icons/*,
  network-first for /api/*, network passthrough for everything else.
  CACHE_VERSION + activate-clean lets us bump and purge cleanly. Served
  from /sw.js so its scope can claim /; Service-Worker-Allowed: / header
  set, no-cache on the SW file itself so updates take effect on next load.
- frontend/src/components/PWAHead.tsx — head fragment (manifest link,
  apple-touch-icon, favicon, app-name metas, <script src="/assets/app.js"
  defer>). Added to all 30 page TSX files via mechanical insertion.
- frontend/src/client/app.ts — universal client bundle loaded on every
  page. Three jobs: register the service worker, init the BottomNav
  (icarus flagged that bottom-nav.ts was written but never wired into
  the build — m reproduced the broken [+] Anlegen and Menü buttons in
  prod), and surface the install banner.
- frontend/src/client/pwa-install.ts — install banner UI. Two flows:
  beforeinstallprompt for Chromium/Android (deferred → CTA → prompt),
  one-time iOS Safari hint pointing at the share sheet. Both dismissals
  persist in localStorage (paliad-install-dismissed / -ios-shown).
- frontend/src/styles/global.css — banner styles, sits above BottomNav on
  mobile and pinned bottom-right on desktop, lime-on-white card with the
  brand "p" mark.
- frontend/build.ts — copies frontend/public → dist verbatim so the
  manifest, icons, and SW land at the application root.

Verification before merge:
- bun run build clean, go build/vet/test clean.
- Local server smoke: curl -sI confirmed manifest.json (200,
  application/manifest+json), all icon files (200, image/png), sw.js
  (200, Service-Worker-Allowed: /), app.js (200, text/javascript).
- Playwright at 390×844: Chrome fired beforeinstallprompt, the banner
  rendered with "Paliad installieren" + "Installieren" CTA in German,
  dismiss persisted across reload via localStorage. Manifest validated
  in-browser (name/short_name/start_url/display/scope all correct, all
  four icon URLs returned 200).
- The InvalidStateError on serviceWorker.register() seen in the MCP
  Playwright profile is a known headless flag; SW registration works in
  real Chrome / Safari on localhost and HTTPS production.

Out of scope: push notifications, runtime offline mode (SW intentionally
stays minimal — cache shell + assets, network passthrough for everything
else).
2026-04-26 10:48:27 +02:00
m
69efafeb33 Merge: PWA mobile BottomNav + Quick-Add (t-paliad-041) 2026-04-26 10:32:33 +02:00
m
ad77eb98a3 Merge: pull latest main into bottom-nav branch 2026-04-26 10:32:19 +02:00
m
3f0c26fd3a feat(frontend): PWA mobile BottomNav + Quick-Add sheet (t-paliad-041)
Phone-first bottom navigation per pwa-baseline.md. Renders only at
<768px; tablets and desktop are unchanged.

Slots: Start / Projekte / [+] Anlegen / Agenda / Menü.

- Center [+] opens a slide-up <dialog> sheet with three rows: Frist,
  Termin, Projekt. Native showModal() + ::backdrop, ESC and backdrop-tap
  dismiss, transform-based slide-up transition.
- Right Menü slot reuses the existing Sidebar mobile drawer via a new
  exported toggleMobileSidebar() (DRY with the legacy hamburger handler).
- Agenda slot carries a red-dot badge: count = today + overdue pending
  deadlines (live via /api/deadlines/summary, refreshed every 60s). Pulse
  animation when overdue > 0 — m: "Due is the latest we can do, OVERDUE
  is a catastrophy."
- visualViewport resize watcher hides the bar when the on-screen keyboard
  opens (>100px height shrink) so it doesn't cover form fields.
- safe-area-inset-bottom padding on the bar; main padding-bottom adjusts
  on phones so the last row stays above the bar.

PWA shell groundwork (defers manifest/SW/install-prompt to follow-ups):
- viewport-fit=cover on every page (required for safe-area to register)
- theme-color #65a30d (lime), apple-mobile-web-app-capable, status-bar
  style — all 30 page heads updated in one sweep.

Backend: deadline_service.SummaryCounts gains a `today` bucket so the
Agenda badge can distinguish "due today" from "this week" without a new
endpoint.

Files added:
  frontend/src/components/BottomNav.tsx
  frontend/src/client/bottom-nav.ts

Verified visually via headless chromium at 375x812, 800x600, 1280x800:
phone shows BottomNav (5 slots, lime [+] elevated), tablet shows the
existing hamburger only, desktop sidebar untouched. go build/vet/test
and bun run build all clean.
2026-04-26 10:32:00 +02:00
m
2b6218ae2d Merge: smoke delta report (t-paliad-040) 2026-04-26 02:16:53 +02:00
m
2cf20448b3 docs(tests): smoke delta report after t-paliad-038/039/040 + project-tab nil fix
Wraps up the post-rename cleanup arc. Records what shipped, what's still
open (none blocking), and the rationale for skipping Bug 10 (browser-
emitted console error, not suppressible from JS).
2026-04-26 02:16:43 +02:00
m
3a1eb07781 Merge: smoke cleanup batch 2 (t-paliad-040) 2026-04-26 02:14:08 +02:00
m
d219ca7cdf fix(redirects, settings): /whatsnew alias + /settings/{tab} deep-links (t-paliad-040)
Bug 7 — /whatsnew was bare 404. Sidebar uses /changelog (canonical) but
users typing /whatsnew from memory hit the not-found chrome. Added
/whatsnew → /changelog as a 301 to internal/handlers/redirects.go,
following the existing legacy-redirect pattern. Wired on the OUTER mux so
unauthenticated bookmarks redirect one-hop instead of round-tripping
through /login. /search left as-is per the brief — sidebar's global-search
overlay is the live UX, /search would only be hit via typo and falls back
to the chromed 404 from t-paliad-037.

Bug 8 — /settings/caldav worked (200 → 301 → /settings?tab=caldav) but
/settings/notifications, /settings/dezernat, /settings/profile all 404'd.
Tabs themselves were fine in-page; only the deep-link form was broken.
Replaced the single CalDAV-only handler with a generic /settings/{tab}
redirector backed by a slug→canonical map that accepts both the German
tab IDs the client TS understands (profil, benachrichtigungen, dezernat)
and intuitive English aliases (profile, notifications, department).
Unknown slugs fall back to /settings (default tab) instead of 404 so
typos don't break.

Bug 10 — login form 401 console replay: skipped per brief permission.
Reproduced in Playwright; the console message is the browser's automatic
"Failed to load resource: the server responded with a status of 401"
emitted by the network stack itself. login.ts has no console.error call.
The only workarounds (server returns 200 with {ok:false}, or 422 instead
of 401) either compromise the security pattern or don't actually suppress
the browser log. Documented in the smoke delta report.

Verified: go build/vet/test clean, bun run build clean.
2026-04-26 02:14:02 +02:00
m
263a4605e3 docs(design): add PWA mobile BottomNav design (t-paliad-041)
Design only — no code changes. Five-slot bottom bar for phones (<768px),
center slot opens slide-up Quick-Add sheet (Frist / Termin / Projekt),
right slot reuses the existing mobile sidebar drawer. Tablets and
desktop unchanged. Awaiting m's review before implementation.
2026-04-26 01:59:31 +02:00
m
b4a409a013 Merge: project tab nil/empty list fix 2026-04-26 01:44:15 +02:00
m
70c3f08668 fix(projects-detail, services): empty-list endpoints returned JSON null → tab content blank
m reported /projects/{id} loaded the chrome and tabs but every panel was
empty even with deadlines/appointments/team rows that should render.
Console error: "Cannot read properties of null (reading 'length')" at
projects-detail.js — the Project Detail page expects every list endpoint
to return [] but at least two were returning literal JSON null.

Reproduced via the in-page fetch console:
  /api/projects/{id}/parties   → 200, body: "null"
  /api/projects/{id}/children  → 200, body: "null"
  /api/projects/{id}/deadlines → 200, body: "[…]"   (had data, fine)
  /api/projects/{id}/team      → 200, body: "[…]"   (had data, fine)

Root cause: every list service in internal/services declared its result
as `var rows []models.X` and returned that to the handler, which
encoding/json marshals as `null` when the SELECT returns zero rows
(nil slice, not empty slice). Most endpoints happen to have data so
the bug stayed dormant until t-paliad-038 hit /projects/{id} where
parties + children are commonly empty.

Fix at the source — every list service that JSON-marshals to a client
now initialises `rows := []models.X{}` so the encoder produces `[]`:

  party_service        ListForProjekt
  project_service      List, ListAncestors, BuildTree, GetTree
                       (ListChildren goes through List)
  deadline_service     List + ListForProjekt
  appointment_service  List + ListForProjekt
  note_service         ListForProjekt
  checklist_instance_service  ListForProjekt
  team_service         List
  department_service   List + ListMembers + ListWithMembers

caldav_service was deliberately left alone — its lists are admin-only
debug surfaces, not user-facing tab fillers, and changing them would
mix scopes.

Belt-and-braces on the client too — projects-detail.ts now coerces every
`await resp.json()` for an array endpoint with `?? []` so a future
service regression can't crash the page.

Verified: go build/vet/test clean, bun run build clean.
2026-04-26 01:44:09 +02:00
m
3ff982cc51 Merge: footer copy 'a tool by flexsiebels.de' 2026-04-26 01:35:55 +02:00
m
6698210e9b fix(footer): replace "Nur für internen Gebrauch" with "ein Werkzeug von flexsiebels.de"
The disclaimer was redundant with the separate flexsiebels.de credit line below.
Merge them into a single line that reads "© 2026 Paliad — ein Werkzeug von flexsiebels.de"
(German default) / "a tool by flexsiebels.de" (English).

Footer.tsx: collapse the two paragraphs into one. The translatable copy stops
before the link so the i18n textContent path doesn't strip the anchor; the
link itself is rendered as plain JSX.

i18n.ts: footer.text DE+EN updated.
2026-04-26 01:35:46 +02:00
m
f782ef7975 Merge: deadlines/{id} notfound + Invalid Date list (t-paliad-039) 2026-04-26 01:32:09 +02:00
m
5611e0154c fix(deadlines, appointments): /deadlines/{id} notfound + /deadlines list "Invalid Date" (t-paliad-039)
URGENT bug: /deadlines/{id} rendered "Frist nicht gefunden oder keine
Berechtigung" while the underlying /api/deadlines/{id} returned 200, and
/deadlines list showed "Invalid Date" in the date column.

Root causes — same class as t-paliad-038, this time on deadlines and
appointments client TS:

1. parseFristID/parseTerminID still checked URL prefix "fristen"/"termine".
   After t-paliad-025 renamed pages to /deadlines and /appointments,
   parts[0] no longer matched → null id → notfound branch fired before any
   API fetch. Renamed to parseDeadlineID/parseAppointmentID with the
   correct "deadlines"/"appointments" prefix.

2. fmtDate in deadlines.ts blindly appended "T00:00:00" to the API's
   due_date string. After the v2 schema, the API returns full ISO
   datetime ("2026-04-22T00:00:00Z"), and "...ZT00:00:00" is invalid →
   "Invalid Date". Guarded both fmtDate and urgencyClass with
   iso.length === 10 / iso.slice(0, 10).

3. Half-renamed variables (`let allDeadlines` declared, `allFristen`
   used; `let deadline`, `frist` referenced). Worked at runtime only
   because the undeclared identifier became a non-strict global. Cleaned
   up to use the declared English names everywhere.

Lockstep DOM ID + variable rename in client TS + matching TSX:

- frist-* → deadline-* (deadlines-detail, deadlines, deadlines-new,
  deadlines-calendar)
- termin-* / termine-* → appointment-* / appointments-* (appointments-detail,
  appointments, appointments-new, appointments-calendar)
- fristen-body/empty/unavailable → deadlines-* (list page)
- termine-body/empty/unavailable → appointments-* (list page)
- frist-cal-grid / termin-cal-grid → deadline-cal-grid /
  appointment-cal-grid (calendars)
- loadFristen/loadTermine/loadAkten/loadFrist/loadTermin/loadAkte →
  loadDeadlines/loadAppointments/loadProjects/loadDeadline/loadAppointment/loadProject
- deadlines.ts: dropped unused projekt_office field from Deadline interface
- appointments.ts: dropped unused projekt_office field from Appointment
  interface

Dashboard cleanup — Go service was still emitting `projekt_ref`:

- internal/services/dashboard_service.go: UpcomingDeadline /
  UpcomingAppointment / ActivityEntry json+db tags `projekt_ref` →
  `project_reference`; SQL aliases `AS projekt_ref` → `AS project_reference`.
- frontend/src/client/dashboard.ts: interfaces switched to
  project_reference; activity link href /projects/{id}/fristen →
  /deadlines, /termine → /appointments (the German per-project subpaths
  were dead — t-paliad-038 already renamed projects-detail tabs).

i18n key strings (fristen.*, termine.*) intentionally kept in German per
the t-paliad-025 convention (frontend default language is German). CSS
class names (frist-row, frist-due-chip, frist-cal-cell, termin-dot,
termin-type-*, akten-table-wrap) untouched — separate stylistic cleanup,
no IDs are referenced in CSS so the rename is safe.

Verified: go build/vet/test clean, bun run build clean, dist HTML
contains only the new English IDs (remaining German strings are i18n
keys and product-name CSS classes).
2026-04-26 01:31:56 +02:00
m
d81da4b3a8 Merge: /projects/{id} notfound fix + German DOM/URL rename (t-paliad-038) 2026-04-26 01:06:13 +02:00
m
cf94f0ca25 fix(projects-detail): /projects/{id} notfound + rename German DOM/URL leftovers (t-paliad-038)
Root cause of the URGENT bug: parseAkteID() in
frontend/src/client/projects-detail.ts only accepted /projekte/{id} and
/akten/{id} URL prefixes. After t-paliad-025 renamed pages to /projects/{id},
parts[0] === "projects" failed both checks → null id → notfound branch
fired before any /api/projects/{id} fetch. The 200 from curl was real;
the page just never asked.

Fix: parseProjectID() now reads /projects/{id}. Old bookmark tab slugs
(verlauf, parteien, fristen, …) are mapped to their English successors so
deep links don't silently fall back to the default tab.

Bundled cleanup — every per-project subpath the client TS still hit was a
404 because the rename only touched top-level routes. Lockstep rename of
URLs, function names, DOM IDs, and the TabId union in projects-detail.ts
+ projects-detail.tsx:

- /api/projects/{id}/parteien|fristen|termine|notizen|checklisten →
  /parties|deadlines|appointments|notes|checklists
- loadParteien/loadFristen/loadTermine/loadAkte/parseAkteID →
  loadParties/loadDeadlines/loadAppointments/loadProject/parseProjectID
  (the old loadParteien/loadFristen/loadTermine bodies even assigned to
  undeclared `parteien`/`fristen`/`termine` — would have thrown
  ReferenceError as soon as the catch branch ran)
- DOM IDs: akten-detail-* → project-detail-*, parteien-* → parties-*,
  partei-* → party-*, project-fristen-* → project-deadlines-*,
  project-termin(e)-* → project-appointment(s)-*,
  project-checklisten-* → project-checklists-*, akten-events-* →
  project-events-*, kinder-* → children-*, projekt-breadcrumb →
  project-breadcrumb, frist-add-link → deadline-add-link,
  termin-add-btn → appointment-add-btn
- Tab slugs in URL + data-tab + tab-* IDs: verlauf/kinder/parteien/
  fristen/termine/notizen/checklisten →
  history/children/parties/deadlines/appointments/notes/checklists
- frist-add-link href: /projects/{id}/fristen/neu →
  /projects/{id}/deadlines/new

Sweep across the rest of frontend/src/client/:

- notes.ts: NotizParentType → NotesParentType, "frist"/"termin" →
  "deadline"/"appointment", baseURL paths /…/notizen → /…/notes; updated
  callers in deadlines-detail.ts and appointments-detail.ts.
- deadlines-new.ts: undeclared `akten` reference (loadAkten was assigning
  to a never-declared name) replaced with `projects`; URL /…/fristen →
  /…/deadlines; path-parsing of /akten/{id}/fristen/neu rewritten as
  /projects/{id}/deadlines/new; preselectedAkteID → preselectedProjectID;
  Project.aktenzeichen field (no longer emitted by API) → reference.
- fristenrechner.ts: bulk endpoint /…/fristen/bulk → /…/deadlines/bulk;
  request body { fristen } → { deadlines } (server expects "deadlines"
  key); ProjectOption interface now uses reference instead of
  aktenzeichen.
- deadlines.ts, appointments.ts, deadlines-detail.ts, appointments-detail.ts,
  checklists-detail.ts, appointments-new.ts: Project interface field
  aktenzeichen → reference (the API returns "reference"; the old field
  rendered as undefined in select options and detail headers).

i18n key strings (akten.detail.*, projekte.*, fristen.*, termine.*,
checklisten.*, notizen.*) intentionally kept in German per the
t-paliad-025 convention. CSS class names (frist-row, akten-table-wrap,
termin-dot, etc.) untouched — separate stylistic cleanup.

Verified: go build/vet/test clean, bun run build clean, dist HTML +
bundled JS contain only the new English IDs (remaining German strings
are i18n keys).
2026-04-26 01:04:07 +02:00
m
05623b673a Merge: polish batch — i18n + departments + 404 chrome (t-paliad-037) 2026-04-26 00:38:14 +02:00
m
3111c7440a fix(polish): i18n leaks, untranslated labels, /api/departments 500, 404 chrome (t-paliad-037)
Four bugs from tests/smoke-auth-2026-04-25.md.

Bug 4 — Dashboard activity log leaked raw i18n keys. Root cause was a mix
of three issues:
  - Go services wrote German event_types (frist_created, termin_*,
    projekt_*, notiz_created, checkliste_*) — no matching i18n key.
  - i18n.ts only had keys for legacy `akte_*` types, none for what was
    actually being written.
  - The dashboard renderer always rendered `e.title` (a static label like
    "Project angelegt") as a trailing detail, duplicating the action verb.
    Old `akte_created` rows had English titles ("Akte created") that
    bled into German output.

Switched all event_type writes to English (deadline_*, appointment_*,
project_*, note_created, checklist_*, deadlines_imported). Moved dynamic
text out of `title` into `description` for status_changed and
deadlines_imported so the static label/description split is consistent.
Added i18n keys for both new English types AND legacy German types so
historical project_events rows render cleanly. Dashboard now prefers
description over title; falls back to title only for events with no
i18n match (defensive for any unknown legacy kinds).

Bug 5 — /deadlines and /appointments matter-filter dropdowns showed raw
keys `fristen.filter.project.all` / `termine.filter.project.all`. The
client TS referenced English-prefix keys that didn't exist; the existing
keys use `fristen.filter.akte.*` / `termine.filter.akte.*`. Updated the
client refs to match the existing keys (kept i18n key namespace stable
to avoid touching every other reference).

Bug 6 — /api/departments?include=members returned 500. Reproduced via
curl: ListWithMembers (and ListMembers) used `LEFT JOIN paliad.users` on
a member.user_id that FKs auth.users — pre-onboarding members produced
NULL u.email/display_name/office/role, which sqlx can't scan into the
non-pointer string fields. Switched both to INNER JOIN; unonboarded
members are skipped (correct UX — without a profile there's nothing to
render anyway).

Bug 9 — Bare `404 page not found` on unknown auth-gated paths
(/whatsnew, /search, /settings/notifications, etc). Added a chromed 404
page (frontend/src/notfound.tsx) with sidebar + friendly card + "back
to dashboard" CTA, plus a catch-all handler on the protected mux that
serves it with HTTP 404 (and JSON 404 for /api/* misses). Anonymous
visitors keep being redirected to /login by the auth middleware before
the catch-all runs, so no separate marketing-shell variant needed.

Verification:
- go build ./... + go vet ./... + go test ./... clean
- bun run build clean (notfound.html + notfound.js produced)
- Visual checks pending after deploy
2026-04-26 00:36:33 +02:00
m
fc8275288a Merge: SummaryCounts db tags (t-paliad-036) 2026-04-25 23:44:56 +02:00
m
4bc23958ee fix(services): add db tags to SummaryCounts so sqlx maps this_week (et al.)
Bug 1 (smoke-auth-2026-04-25.md) had a third symptom beyond the RLS
function bodies and the visibilityPredicate `::uuid[]` issue:
/api/deadlines/summary and /api/appointments/summary returned 500 with
`sqlx: missing destination name this_week in *services.SummaryCounts`.

Cause: SummaryCounts (deadline) and AppointmentSummaryCounts had only
`json:` tags. sqlx falls back to the lower-cased field name when no `db:`
tag is present, so `ThisWeek` mapped to `thisweek` — but the SQL aliases
the column as `AS this_week`. Adding `db:"this_week"` (and matching tags
for the other fields) lets sqlx find the destination.

Verified by hitting both endpoints; previously 500 → now expected 200.
2026-04-25 23:44:52 +02:00
m
144a08d409 Merge: visibilityPredicate sqlx ::uuid[] fix (t-paliad-036) 2026-04-25 23:43:11 +02:00
m
1f9c4d0296 fix(services): use CAST(...AS uuid[]) in visibilityPredicate (sqlx ::uuid[] bug)
Bug 1 in tests/smoke-auth-2026-04-25.md (/api/projects, /api/deadlines,
/api/appointments returning HTTP 500) had a second root cause beyond the
RLS function bodies fixed in 021: sqlx v1.4.0's named-parameter parser
strips one colon from `::uuid[]` while compiling `:user_id` / `:role`
placeholders, producing invalid SQL `:uuid[]` that Postgres rejects with
`syntax error at or near ":"`. The bug was masked by the earlier
`relation "paliad.projekte" does not exist` error.

Replacing `::uuid[]` with the equivalent SQL-standard `CAST(... AS uuid[])`
sidesteps the parser issue without changing semantics. Verified with a
small repro: `sqlx.Named` no longer corrupts the cast.

Only `visibilityPredicate` (the named-bind variant) is affected — the
positional and `?`-placeholder variants don't go through `sqlx.Named`.
2026-04-25 23:43:06 +02:00
m
5c887df5fa Merge: urgent RLS function-body fix after rename (t-paliad-036) 2026-04-25 23:38:18 +02:00
m
0c382b6f69 fix(db): rewrite RLS function bodies after rename (021) — restores /api/projects
Migration 020 renamed paliad.can_see_projekt → can_see_project (and
notiz_is_visible → note_is_visible) via ALTER FUNCTION but never rewrote
the bodies. On production the bodies still queried paliad.projekte /
projekt_teams / fristen / termine / projekt_events — all of which were
dropped or renamed in 018+020. Every RLS-enforced read against the new
English tables exploded with `relation "paliad.projekte" does not exist`,
breaking /api/projects, /api/deadlines, /api/appointments etc.

Same problem for the trigger functions paliad.projekte_sync_path() and
paliad.projekte_rewrite_subtree() — kept their German names and German
bodies; the triggers on paliad.projects still pointed at them.

Migration 021:
  * DROP FUNCTION ... CASCADE drops can_see_project / note_is_visible
    along with their 21 dependent RLS policies (whose names were still
    German on prod: projekte_*, projekt_teams_*, fristen_all, termine_*,
    parteien_all, dokumente_all, projekt_events_all, notizen_all,
    checklist_instances_*).
  * Recreates the two functions with English bodies + English parameter
    names and rebuilds every dependent policy under its canonical
    English name (matching migration 018).
  * Drops the German trigger functions/triggers on paliad.projects and
    recreates them as projects_sync_path / projects_rewrite_subtree.

Idempotent on a fresh DB (where everything is already English): the
CASCADE drops the same policies and the recreate produces an identical
end state.

Verified by running the up.sql in BEGIN/ROLLBACK against the actual
youpc prod Postgres — 21 policies dropped, 21 recreated, function
bodies now reference paliad.projects / project_teams / etc.

Refs: tests/smoke-auth-2026-04-25.md (Bug 3, root cause for Bugs 1+2).
2026-04-25 23:37:51 +02:00
m
50a1dae357 Merge: authenticated production smoke report (t-paliad-034) 2026-04-25 23:26:14 +02:00
m
0d0ba6ee1d test(smoke): authenticated production smoke report (t-paliad-034)
Logged-in smoke test against paliad.de as the seeded test admin
(tester@hlc.de). Login flow + every gated route exercised; 17
screenshots and 10 prioritised bugs filed.

Top finding: paliad.can_see_project() still references the renamed-away
projekte/projekt_teams tables, which 500s every RLS-touching endpoint
(/api/projects, /api/deadlines, /api/appointments and the project-detail
page render). Two of today's three new shipments — project tree and
reminder service — cannot be exercised end-to-end as a result. Team
directory works modulo a stray /api/departments?include=members 4xx/5xx.
2026-04-25 23:21:37 +02:00
m
14d5706a5e Merge: anon 401 cleanup + CLAUDE.md auth-gate clarification (t-paliad-035) 2026-04-25 23:11:10 +02:00
m
83d5973dd6 fix(sidebar): omit changelog badge for anon visitors + clarify CLAUDE.md auth gate (t-paliad-035)
The marketing landing (`/`) renders the same Sidebar as protected pages, so
`initChangelogBadge()` was firing `GET /api/changelog/unseen-count` on every
anon visit and getting 401. Cosmetic noise + wasted round-trip.

Add an `authenticated` prop to Sidebar (defaults to true, no behavior change
on protected pages) and pass `false` from `renderIndex()`. The badge `<a>`
is omitted server-side; the existing `if (!badge) return` guard in
sidebar.ts naturally skips the fetch when the element is absent — no
client change needed.

Also append a clarifying note under the env-var table in .claude/CLAUDE.md:
"work without DB" doesn't mean "ungated for anon". The knowledge-platform
routes (Kostenrechner, Glossar, etc.) are still behind the auth gate; only
`/`, `/login`, `/logout`, and `/assets/*` are public. Misread by the smoke
tester briefer; spelled out now to prevent recurrence.
2026-04-25 23:09:36 +02:00
m
0ad2e86945 Merge: production smoke report + register Playwright MCP (t-paliad-033) 2026-04-25 23:02:25 +02:00
m
761e350261 test(smoke): production smoke report + register Playwright MCP (t-paliad-033)
Anonymous-surface smoke test of paliad.de — all 11 legacy DE→EN redirects,
9 gated routes, /login form, and / marketing landing healthy. Two minor
findings noted in the report (knowledge platform is auth-gated contrary
to brief expectation; anon / fires a 401 on /api/changelog/unseen-count).
Authenticated flows untested — needs follow-up worker with creds.

Also registers @playwright/mcp in .mcp.json so future smoke runs can use
the /mai-tester skill's mcp__playwright__* tools directly instead of
falling back to a bunx script.
2026-04-25 23:01:48 +02:00
m
21415ce941 Merge: fix reminder_service SQL alias mismatch (t-paliad-032) 2026-04-25 13:40:30 +02:00
m
d4abfb7299 fix(reminders): align SQL aliases with renamed struct tags
The German→English rename (t-paliad-025) renamed the projects table and
ReminderService struct fields, but the SQL aliases in sendPerFrist /
sendWeekly still spelled `frist_title`, `akte_aktenzeichen`, and
`akte_title`. sqlx.SelectContext could not map them to the
`deadline_title` / `project_reference` / `project_title` `db:` tags, so
every hourly reminder scan returned a "missing destination name" error
and emails silently stopped going out.

This commit:
* renames struct fields AkteAktenzeichen/AkteTitle on fristReminderRow
  and weeklyRow to ProjectReference/ProjectTitle and updates the `db:`
  tags to project_reference / project_title.
* rewrites the SELECT aliases (deadline_title, project_reference,
  project_title) to match.
* propagates the new keys through deliverFristReminder /
  deliverWeekly into the email template data and renames the matching
  variables in deadline_reminder.html and deadline_weekly.html.
* updates mail_service_test.go fixtures to the new keys.
* adds TestSendPerFrist_ScansCleanly — a live-DB regression test
  (skips without TEST_DATABASE_URL) that seeds a project + deadline
  and asserts sendPerFrist / sendWeekly scan without error, so a
  future tag/alias drift fails CI instead of going silent.
2026-04-25 13:32:57 +02:00
m
c4e6d0eeef Merge: team directory browse (t-paliad-029) 2026-04-25 13:25:42 +02:00
m
9c96446bbe Merge: project tree visualization (t-paliad-028) 2026-04-25 13:25:39 +02:00
m
28d747e656 feat(team): browsable team directory grouped by office or department (t-paliad-029)
Adds /team page that lists every onboarded Paliad user, grouped by office
(default) or by department, with a free-text search and per-office filter
pills. Each card shows display name, role, primary office (with any
additional offices), department tag, and a mailto: link.

Backend:
- /api/users now also returns additional_offices (column was already on the
  model + DB; just missing from the SELECT list).
- /api/departments?include=members returns each department enriched with
  its lead user snapshot and the full member list — single fetch for the
  "by department" grouping.
- New page handler /team behind the onboarding gate.

Frontend:
- frontend/src/team.tsx + frontend/src/client/team.ts (new) for the page
  shell and client-side rendering / filtering.
- New "Team" entry in the Übersicht sidebar group with a users icon.
- DE/EN i18n keys (nav.team, team.*).
- Team-specific CSS for cards, group headers, avatars, and badges.
2026-04-25 13:22:17 +02:00
m
aafbfbbf12 feat(projects): interactive tree view of project hierarchy (t-paliad-028)
- Backend: GET /api/projects/tree returns the full visible project tree as
  nested JSON with embedded children, open/overdue deadline counts per
  node — visibility-scoped via the existing predicate, single round-trip.
- Frontend: new project-tree.ts module renders a collapsible, indented tree
  with type icons (client/litigation/patent/case/project), status badges,
  deadline-count chips and chevron toggles. Top two levels expand by
  default; deeper nodes start collapsed. Expansion state persists in
  sessionStorage so toggling list/tree keeps user choices.
- Wired to /projects via the existing Ansicht select (Liste/Baum/Wurzeln);
  dedicated tree container coexists with the flat-list table.
- New i18n keys (de/en) + tree styles in global.css (lime accent on hover).
2026-04-25 13:22:16 +02:00
m
c893027457 fix: add error logging to writeServiceError + missing log import 2026-04-23 01:27:08 +02:00
m
881bc98eb1 Merge: comprehensive build repair — rename mismatch fixes 2026-04-23 01:26:03 +02:00
m
34194aedd5 fix(rename): align TSX element IDs, REST endpoints, and migration 020 with English rename
Three rename leftovers from t-paliad-025 fixed in one shot:

1. TSX/TS element ID mismatches — every page that worked via getElementById was
   broken because the client TS was renamed (e.g. project-title) but the TSX
   still used the German id (akte-title), so $() / getElementById would throw
   "missing element". Renamed `akte-*` → `project-*`, `termin-akte-*` →
   `termin-project-*`, `frist-akte-*` → `frist-project-*`, `new-instance-akte`
   → `new-instance-project`, `frist-filter-akte` → `frist-filter-project`,
   `termin-filter-akte` → `termin-filter-project` across all affected TSX.

2. Migration 020 idempotency — every ALTER TABLE/FUNCTION/COLUMN now lives in
   a DO $$…EXCEPTION WHEN undefined_table/column/function THEN NULL block.
   Production already has English names (manually patched), and the rewritten
   migration 018 creates English names directly on a fresh DB; the old
   non-defensive 020 would have failed in both scenarios. Down migration
   wrapped the same way for symmetry.

3. PostgREST endpoint names — `checklists_feedback` and `courts_feedback`
   referenced tables that don't exist; migration 020 renames the source
   tables to `checklist_feedback` / `court_feedback` (singular, matching
   `link_feedback`). Handlers now point at those. `glossary_suggestions`
   reverts to `glossar_suggestions` — that table lives in the shared public
   schema (pre-paliad era) and is not under our migration control.

Verified: go build / go vet / go test / bun run build all clean. Migration 020
dry-runs clean against current production state inside a transaction.
2026-04-23 01:00:31 +02:00
m
2131fdbf55 fix(db): rename remaining German columns (frist_id, termin_type, akten_event_id) 2026-04-23 00:36:46 +02:00
m
01de3f736b fix: update all script src references from German to English filenames
knuth's rename changed TS/TSX filenames but left <script src> tags
pointing at old German JS names (akten-neu.js, fristen.js, termine.js,
glossar.js, einstellungen.js, gerichte.js, checklisten.js). These 404'd
in production.
2026-04-23 00:32:22 +02:00
m
edad61478d fix(db): add column renames (projekt_id → project_id) to migration 020 2026-04-23 00:26:42 +02:00
m
544149114c fix: resolve leftover merge conflict markers in sidebar.ts 2026-04-23 00:24:13 +02:00
m
a2d90be72d fix(db): add migration 020 — rename German tables to English
knuth's rename (t-paliad-025) changed all Go code and URLs to English
but forgot the DB migration. Production tables still German (fristen,
termine, projekte etc.) while code references English names (deadlines,
appointments, projects). This caused reminder_service to fail with
'relation paliad.deadlines does not exist'.
2026-04-23 00:21:54 +02:00
m
9705290f3d Merge: Agenda — upcoming deadlines + appointments timeline
# Conflicts:
#	frontend/src/styles/global.css
2026-04-23 00:04:37 +02:00
m
f25113abe0 Merge: What's New changelog with sidebar badge
# Conflicts:
#	frontend/src/client/sidebar.ts
#	frontend/src/components/Sidebar.tsx
#	frontend/src/styles/global.css
2026-04-23 00:04:22 +02:00
m
b13065b61a Merge: global search across all content 2026-04-23 00:03:58 +02:00
m
0d6c58a337 feat(agenda): unified timeline of deadlines + appointments across projects
t-paliad-030. Adds `/agenda` — a single page that merges every visible
deadline and appointment into a day-grouped timeline, the third overview
surface alongside Dashboard and the per-resource lists.

- AgendaService: merges paliad.deadlines + paliad.appointments, gated by
  the same team-membership predicate used everywhere else; personal
  appointments stay creator-only. Items are sorted by date and tagged
  with urgency (overdue / today / tomorrow / this_week / later) so the
  client can apply the traffic-light colours without re-deriving buckets.
- GET /api/agenda?from&to&types and GET /agenda with the same server-side
  hydration pattern as /dashboard (JSON payload spliced into the shell so
  the timeline paints on first frame).
- Frontend: agenda.tsx + client/agenda.ts render a day-grouped timeline
  with type/range chips; filters round-trip through the URL.
- Sidebar entry under "Übersicht"; DE+EN i18n across all new keys.
2026-04-22 23:38:03 +02:00
m
9bb9f0c3df feat(search): global search across projects, deadlines, appointments, glossary, courts, checklists, links, users
Adds a sidebar-wide search bar (t-paliad-026) that hits a single GET
/api/search?q=... endpoint returning grouped results. Static content
(glossary, courts, link hub, checklist templates) is scanned in memory
against the curated Go slices; DB content (projects, deadlines,
appointments, checklist instances, users) is visibility-gated through
the same predicates the normal list endpoints use.

Frontend: new sidebar.ts-owned controller debounces 200ms, renders a
grouped dropdown, supports "/" to focus, Escape/arrows/Enter for
navigation, mobile-full-width overlay, and highlights matches.
2026-04-22 23:36:10 +02:00
m
94e2fc0024 feat(changelog): What's New page with sidebar badge
Adds a hardcoded changelog (internal/changelog) served via
GET /api/changelog and /api/changelog/unseen-count?since=<iso>, a
/changelog page that renders entries newest-first, and a sidebar
"Neuigkeiten" link with a lime badge showing the count of unseen
entries since the caller's last visit (localStorage stamp).

- internal/changelog: Entry struct, 11 pre-populated entries covering
  everything shipped so far (Dashboard, Projects/Deadlines/Appointments,
  CalDAV, Checklists v2, Glossary, Courts, Invitations, Settings,
  Paliad rename, and the changelog itself).
- Handler: public via auth-gated protected mux. Lexicographic string
  compare treats YYYY-MM-DD entries and ISO 8601 cutoffs symmetrically.
- Sidebar: new sidebar-changelog link before the Einladen button; the
  badge is populated by a fetch on every page load, suppressed on
  /changelog itself to avoid flash, and cleared on visit by stamping
  localStorage in changelog.ts's DOMContentLoaded handler.
- i18n: DE + EN keys for nav, page chrome, and tag labels.
- Unit tests for sort order, copy semantics, and same-day cutoff.

Task: t-paliad-027
2026-04-22 23:34:52 +02:00
m
b06a040e2b Merge: rename all German system names to English (tables, URLs, types, services) 2026-04-20 18:18:27 +02:00
m
d20cf8deef fix(routes): register legacy 301 redirects on outer mux
Unauthenticated bookmark hits on old German URLs (/akten, /fristen, …)
should 301 to the new English path directly. Previously the redirects
lived under the auth middleware, so bookmarked URLs triggered a 302 →
/login → (after login) → 301 round-trip. Registering them on the outer
mux gives the expected one-hop 301.
2026-04-20 17:45:56 +02:00
m
caf319e7ee refactor(rename): frontend TSX + client TS files, fetch URLs, nav hrefs
t-paliad-025 Phase 3 — frontend rename pass:

File renames (git mv, preserving history):
  frontend/src/
    akten.tsx               → projects.tsx
    akten-neu.tsx           → projects-new.tsx
    akten-detail.tsx        → projects-detail.tsx
    fristen.tsx             → deadlines.tsx
    fristen-neu.tsx         → deadlines-new.tsx
    fristen-detail.tsx      → deadlines-detail.tsx
    fristen-kalender.tsx    → deadlines-calendar.tsx
    termine.tsx             → appointments.tsx
    termine-neu.tsx         → appointments-new.tsx
    termine-detail.tsx      → appointments-detail.tsx
    termine-kalender.tsx    → appointments-calendar.tsx
    einstellungen.tsx       → settings.tsx
    checklisten*.tsx        → checklists*.tsx
    gerichte.tsx            → courts.tsx
    glossar.tsx             → glossary.tsx

  frontend/src/client/ — same renames, plus notizen.ts → notes.ts.

Render exports renamed (renderAkten → renderProjects, renderFristen →
renderDeadlines, …). build.ts rewired to new names.

Client-side changes:
* fetch() API paths: /api/projekte → /api/projects, /api/fristen →
  /api/deadlines, /api/termine → /api/appointments, /api/notizen →
  /api/notes, /api/gerichte → /api/courts, /api/glossar → /api/glossary,
  /api/dezernate → /api/departments, /api/parteien → /api/parties,
  /api/checklisten → /api/checklists. Legacy /api/akten aliases removed.
* Navigation href/template strings: /akten → /projects, /fristen →
  /deadlines, /termine → /appointments, /einstellungen → /settings,
  /notizen → /notes, /checklisten → /checklists, /gerichte → /courts,
  /glossar → /glossary. Nested paths /neu → /new, /verlauf → /events,
  /kinder → /children, /kalender → /calendar, /dokumente → /documents.
* Interface names in client TS: Frist → Deadline, Termin → Appointment,
  Notiz → Note, Partei → Party, Akte → Project, ProjektMini → ProjectMini,
  Dezernat → Department, DezernatMitglied → DepartmentMember.
* JSON wire-format keys follow backend: projekt_id → project_id, akte_id
  → project_id, frist_id → deadline_id, termin_id → appointment_id,
  akten_event_id → project_event_id, dezernat_id → department_id,
  termin_type → appointment_type.

Go handlers (projects_pages.go, deadlines_pages.go, appointments_pages.go,
checklists.go, courts.go, glossary.go) serve the correctly-named HTML
files from dist/.

Kept German (user-facing i18n + product names):
* i18n keys/strings (src/client/i18n.ts) — DE labels and their keys
* Product names: fristenrechner, kostenrechner, gebuehrentabellen

Build verified: go build / vet / test clean; bun run build clean;
dist/ contains all 26 English-named HTML pages.
2026-04-20 17:44:45 +02:00
m
49c6bc75ca refactor(rename): handler functions, routes, legacy 301 redirects
Second rename pass closing the backend cleanup:

* handler functions (handleListProjekte, handleCreateFrist, …) renamed
  to English equivalents so every symbol in the handler package matches
  the URL/entity it serves.
* services.FristStatusFilter + filter constants renamed to
  DeadlineStatusFilter / DeadlineFilterOverdue etc.
* services.TerminListFilter / TerminCalDAVPusher / TerminSummaryCounts
  renamed to AppointmentListFilter / AppointmentCalDAVPusher /
  AppointmentSummaryCounts.
* GlossarTerm/GlossarSuggestion/glossarTerms → Glossary*.
* CourtsFeedback/CourtsResponse (formerly Gerichte*).
* handlers.Services.{Projekt,Parteien,Frist,Termin,Notiz,Dezernat} →
  {Project,Party,Deadline,Appointment,Note,Department}; dbServices
  struct + consumers likewise.
* email templates: {{.FristURL}} → {{.DeadlineURL}}, {{.FristenURL}} →
  {{.DeadlinesURL}}.
* links.go category IDs: gerichte → courts.
* cmd/server/main.go local vars: projektSvc/terminSvc/dezernatSvc →
  projectSvc/appointmentSvc/departmentSvc.

Routes:
* removed all /api/akten alias routes (API clients use /api/projects now).
* removed /api/akten/*/deadlines, /*/notes, /*/parties, /*/appointments,
  /*/checklists, /*/events, /*/summary alias variants.
* new internal/handlers/redirects.go registers 301 Moved Permanently
  redirects for every legacy German GET path: /akten, /projekte, /fristen,
  /termine, /notizen, /einstellungen, /checklisten, /dezernate, /parteien,
  /gerichte, /glossar. Sub-paths + query strings are preserved so old
  bookmarks keep working.

Kept in German (product names, per task spec):
* /tools/fristenrechner, /tools/kostenrechner, /tools/gebuehrentabellen
* FristenrechnerService / KostenrechnerService types
* User.Dezernat + paliad.users.dezernat free-text legacy column (separate
  from the new paliad.departments entity).

go build / vet / test clean.
2026-04-20 17:40:55 +02:00
m
3faec6c526 refactor(rename): German→English for backend (tables, types, services, handler files)
t-paliad-025 — Phase 1: backend rename.

Migrations 018+019 rewritten from scratch with English table/column
names throughout. Since v2 schema (018/019) has never been applied to
youpc prod DB, this is a clean replacement — not an ALTER RENAME chain.
Pre-existing German tables (parteien, fristen, termine, dokumente,
akten_events, notizen) are renamed inline in 018 via ALTER TABLE … RENAME
TO alongside the akte_id → project_id column rewrite.

Renames applied:
  projekte            → projects
  projekt_teams       → project_teams
  projekt_events      → project_events (via akten_events → project_events)
  fristen             → deadlines
  termine             → appointments
  parteien            → parties
  notizen             → notes
  dezernate           → departments
  dezernat_mitglieder → department_members
  dokumente           → documents
  can_see_projekt     → can_see_project
  notiz_is_visible    → note_is_visible
  akte_id  / frist_id / termin_id / akten_event_id → project_id /
    deadline_id / appointment_id / project_event_id
  termin_type → appointment_type

Go types + services renamed:
  Projekt / ProjektService / ProjektEvent / ProjektTeamMember
  Frist / FristService / FristWithProjekt
  Termin / TerminService / TerminWithProjekt / TerminType
  Notiz / NotizService / ChecklistInstanceWithProjekt
  Dezernat / DezernatService / DezernatMitglied
  Partei / Parteien / ParteienService

Files renamed (git mv):
  internal/services/{projekt,frist,termin,notiz,dezernat,parteien}_service.go
    → {project,deadline,appointment,note,department,party}_service.go
  internal/handlers/{projekte,fristen,fristen_pages,termine,termine_pages,
    notizen,dezernate,akten_pages,gerichte,glossar,checklisten}.go
    → {projects,deadlines,deadlines_pages,appointments,appointments_pages,
       notes,departments,projects_pages,courts,glossary,checklists}.go
  internal/checklisten/ → internal/checklists/
  internal/db/migrations/018_projekte_v2.* → 018_projects_v2.*
  internal/db/migrations/019_seed_dezernate_from_user_text.*
    → 019_seed_departments_from_user_text.*

User-facing i18n strings (DE/EN labels) stay untouched. Product names
Fristenrechner / Kostenrechner / Gebührentabellen stay German.

Build + vet + tests clean.
2026-04-20 17:35:38 +02:00
m
fb401c63c0 docs: update CLAUDE.md — English system language, project hierarchy, team-based visibility 2026-04-20 17:24:18 +02:00
m
eb6de16e88 Merge: point .mcp.json at youpc Supabase for next session 2026-04-20 17:17:59 +02:00
m
51b16a6a41 chore(mcp): point Supabase MCP at youpc (${YOUPC_SUPABASE_AUTH})
Paliad prod data lives on the youpc Supabase (100.99.98.201:11833,
search_path=paliad,public), not on the flexsiebels Supabase that
${SUPABASE_AUTH} resolves to. Next session will pick this up on load
and can run schema work against the correct DB.
2026-04-20 17:17:38 +02:00
m
79889a2b83 Merge: remove Billing-Referenz UI + add Notizen (description) field 2026-04-20 17:17:18 +02:00
m
bde4b57099 feat: remove billing reference UI + add Notizen (description) field at every level
1. **Remove Billing-Referenz from the client create form.** Per m: the field stays
   in the DB (projekte.billing_reference column) but no longer in the UI. Dropped
   the input + label from akten-neu.tsx, the payload write from akten-neu.ts, and
   the projekte.field.billing_reference i18n keys (DE + EN).

2. **Add a Notizen (Notes) free-text field to project create + detail at every level.**
   Uses the existing projekte.description column (added in migration 018 — nullable
   text). Not to be confused with the polymorphic Notizen feature (threaded notes
   per projekt/frist/termin), which stays as-is.

   - akten-neu.tsx: textarea (rows=4) inserted above the Status select, rendered
     for every type (not type-conditional). akten-neu.ts: payload.description set
     on submit when non-empty.
   - akten-detail.tsx: new description block between header + tabs with
     akte-description-display (read) + akte-description-edit (textarea, edit mode).
     Edit/save flow on initTitleEdit extended to also PATCH description. Batch PATCH:
     only sends keys that actually changed.
     renderHeader() populates both, tags the wrapper data-empty=1 when nothing set
     (CSS can hide when empty if desired).
   - i18n: projekte.field.description / projekte.field.description.placeholder /
     projekte.detail.description.heading in DE (Notizen) + EN (Notes).

go build/vet/test + bun run build all clean.
2026-04-20 17:14:11 +02:00
m
ff1c5ceb0e fix(checklisten): consistent button sizing — Feedback uses outline variant of btn-cta-lime 2026-04-20 17:07:08 +02:00
m
59e1cb1445 Merge: i18n fallback fix + missing projekte.* translations 2026-04-20 17:06:38 +02:00
m
449075deaf fix(i18n): preserve default HTML text when key missing + add all projekte.* keys
Root cause: applyTranslations() in client/i18n.ts unconditionally overwrote
textContent/placeholder/title with t(key), and t() falls back to the raw key
name when no translation exists. Result: every projekte.* data-i18n attr in
the v2 pages rendered the literal key string ('projekte.heading',
'projekte.subtitle', ...) because I shipped the pages with new i18n keys
without adding the translations.

Two fixes, both in client/i18n.ts:

1. **Fallback behaviour**: applyTranslations() now uses a new internal
   tOrEmpty(key) that returns '' when the key is missing in DE and EN,
   and the call site only overwrites the DOM when the lookup yielded a
   real value. Missing keys no longer clobber the author-provided default
   text. This is belt-and-braces for any future page that ships a key
   before its translation does.

2. **Missing translations added**: ~90 projekte.* keys for DE and EN,
   covering the list page (projekte.heading/subtitle/new/search/filter.*/
   view.*/col.*/empty.*/unavailable), the create form (projekte.neu.*,
   projekte.field.*, projekte.cancel/submit/error.*), and the detail page
   (projekte.detail.title/back/loading/notfound/edit/save, tab.* for all
   eight tabs, verlauf.*, team.form.*/col.*, kinder.*, parteien.*
   form/role/col/empty, fristen.*, termine.*, checklisten.*, delete.*).

go build/vet/test + bun run build all clean.
2026-04-20 17:06:21 +02:00
m
adb0ce2c9d fix(modals): add padding to .modal-card — content no longer flush to edges 2026-04-20 17:05:53 +02:00
m
746bced30b Merge: projekte-detail v2 + tree view + per-Dezernat member manager (t-paliad-024 follow-ups)
- akten-detail.tsx rewritten as v2 shell: breadcrumb, type chip, ClientMatter with
  ancestor inheritance, netDocuments link, Team tab (direct+inherited), children tab.
- Tree view mode in Projekte list (depth-indented by path).
- Per-Dezernat member management panel in settings (add/remove with typeahead).
- i18n DE+EN coverage for all new keys.

Branch mai/cronus/projekte-detail-v2 @ 7e0c063.
2026-04-20 15:35:39 +02:00
m
7e0c06342b feat: projekte-detail rewrite + tree view + per-Dezernat member manager (follow-ups)
**akten-detail.tsx rewrite (now projekte-detail-shaped):**
- Removed office-chip, firmwide-chip (v2 no longer uses them).
- Added type-chip, ClientMatter display (inherits via ancestors when absent),
  netDocuments external link.
- Breadcrumb nav above header, populated from /api/projekte/{id}/ancestors.
- New 'Untergeordnet' tab with children list from /kinder endpoint;
  'Untervorhaben anlegen' link pre-fills parent via ?parent=<id>.
- New 'Team' tab: lists direct + inherited members (inheritance badge
  shows ancestor title), remove button gated on self-or-partner/admin,
  add form with user typeahead and role picker.
- akten-detail.ts: Akte interface rewritten (reference/type/parent_id/
  path/client_number/matter_number/netdocuments_url/court/case_number).
  parseAkteID now accepts both /projekte/{id} and /akten/{id}. New loaders
  loadAncestors/loadChildren/loadTeam/loadUserList. TabId extended with
  'team' and 'kinder'.
- akten-neu.ts: applyParentFromQueryString pre-fills parent picker when
  navigated from a projekt's 'Untervorhaben anlegen' link, auto-switches
  type from 'client' to 'case'.

**Tree view in Projekte list:**
- Third view mode 'tree' alongside flat/roots. Sorts filtered rows by
  path (ancestors precede descendants); depth-indented title cell with
  ↳ branch glyph based on depthOf(path).

**Per-Dezernat member manager:**
- einstellungen Dezernat tab 'Verwalten' button now toggles an inline
  manage panel per Dezernat (expanded row below the admin table row).
- Panel shows current members with per-row remove (confirm dialog).
- Add-member form with user typeahead against /api/users, posts to
  /api/dezernate/{id}/members.
- Wires once per Dezernat (data-wired guard); reloads My Dezernat on
  any membership change.

i18n: DE + EN keys for dezernat.manage_heading/loading/no_members/
add_member*/add/remove/confirm_remove/error.user_required and for every
projekte.type.* / projekte.team.role.* / projekte.team.direct /
projekte.team.inherited.hint / projekte.view.tree / projekte.detail.team.*
/ projekte.detail.clientmatter.inherited.

go build/vet/test + bun run build all clean.
2026-04-20 15:35:01 +02:00
m
a2388e9a6b Merge: data model v2 — hierarchical Projekte, Teams with inheritance, Dezernate 2026-04-20 15:18:35 +02:00
m
41cc295500 feat: Dezernate settings tab + best-effort seeding migration (Phase 4)
- einstellungen.tsx: fourth tab 'Dezernat'. My Dezernat card (name, office,
  lead, member list). Admin-only 'Dezernate verwalten' section with table
  (name/office/lead/members/delete) + 'Neues Dezernat anlegen' form behind
  a details summary. Admin controls hidden unless /api/me.role='admin'.
- client/einstellungen.ts: loadDezernatTab() fetches /api/dezernate, then
  per-dezernat /api/dezernate/{id}/members to resolve membership for the
  'My Dezernat' view. Admin table with delete-with-confirm. New-Dezernat
  form posts to /api/dezernate; inserts into in-memory list on success.
  TabName + TABS + loadedTabs dispatcher extended.
- i18n: dezernat.* keys (DE+EN) — heading/subtitle/admin section/table
  columns/form labels/error strings.
- Migration 019: best-effort seed of paliad.dezernate + dezernat_mitglieder
  from paliad.users.dezernat free-text. Each distinct non-empty name
  becomes one Dezernat (office = MIN(members.office)); every user whose
  free-text matches joins. free-text column preserved so a second pass
  can clean it up later. down-migration only deletes rows we inserted
  (matches name = btrim(user.dezernat)), leaves admin-created Dezernate
  alone.

go build/vet/test + bun run build all clean.
Branch mai/cronus/implement-data-model-v2 now covers all four phases.
2026-04-20 15:12:24 +02:00
m
640d5c1a23 feat: frontend v2 — Projekte list/create, dashboard + downstream field renames
- akten.tsx + client/akten.ts rewritten for v2: renders /projekte list with
  type filter (client/litigation/patent/case/project), status filter, flat
  vs roots view toggle, search across title/reference/client_number/
  matter_number. Columns now Title / Type / Reference / ClientMatter /
  Status / Updated (no more office column per v2 team-based visibility).
- akten-neu.tsx + client/akten-neu.ts rewritten: type selector drives
  conditional fields (client industry/country/billing; patent number +
  filing/grant dates; case court + case_number). Parent projekt picker
  (typeahead over /api/projekte, stores parent_id). ClientMatter
  client_number + matter_number (7-digit patterns) + netdocuments_url
  fields on every type.
- dashboard.ts field renames: akte_id → projekt_id, akte_title →
  projekt_title, akte_ref → projekt_ref. Activity/deadline/appointment
  links now point to /projekte/{id}.
- Mass field rename across fristen-*, termine-*, checklisten-*,
  fristenrechner.ts, notizen.ts, akten-detail.ts: akte_id → projekt_id,
  akte_aktenzeichen → projekt_reference, akte_title → projekt_title,
  akte_office → projekt_office. URL paths /akten/${...} → /projekte/${...}.

Pages still referencing deprecated shape (owning_office, collaborators,
firm_wide_visible) render blank for those columns — acceptable during
transition, full akten-detail rewrite (add breadcrumb + Team tab with
inheritance) still pending.

go build/vet/test + bun run build all clean.
2026-04-20 15:09:22 +02:00
m
4ac9dacaa0 feat: /projekte routes + sidebar label + legacy POST shim (Phase 3 partial)
Server-side:
- GET /projekte[...] routes alias the existing Akten list/detail/tab pages
  so users can reach the v2 URL without a 404 during the cutover. The TSX
  pages themselves still render the legacy HTML shell pointing at
  /api/akten legacy aliases.
- POST /api/projekte (and legacy POST /api/akten alias) now accepts BOTH
  old shape ({aktenzeichen, owning_office, court_ref}) and new shape
  ({reference, type, parent_id, client_number, matter_number,
  netdocuments_url, case_number}). aktenzeichen → reference,
  court_ref → case_number. owning_office is ignored (no longer part of
  visibility model).

Frontend:
- Sidebar nav link 'Akten' → 'Projekte' → /projekte.
- i18n: nav.projekte added (DE: 'Projekte', EN: 'Projects').

Still PENDING (Phase 3 remainder + Phase 4):
- Frontend TSX pages (akten.tsx, akten-detail.tsx, akten-neu.tsx etc.) still
  use legacy field names (aktenzeichen, owning_office, collaborators,
  firm_wide_visible). GET /api/akten returns NEW shape (reference, type,
  parent_id, path, client_number, matter_number, netdocuments_url). UI will
  display blank fields where the old column is missing. Full rewrite needed
  per task spec (tree view, type filter, breadcrumb, team tab with
  inheritance badges, client create form, projekt create with type +
  parent typeahead).
- Dezernate settings tab (Phase 4) not yet built — API endpoints exist at
  /api/dezernate[...] but no UI.
- Dashboard JSON shape changed (akte_id → projekt_id, akte_title →
  projekt_title, akte_ref → projekt_ref); frontend dashboard.tsx needs an
  update to read the new field names.

Build: go build/vet/test and bun run build all clean.
2026-04-20 14:55:06 +02:00
m
cb2841fba9 feat: handlers — Projekt/Team/Dezernat wiring (Phase 2)
- handlers/projekte.go (was akten.go): Projekt CRUD + tree ops (children,
  tree, ancestors), events cursor-paginated, parteien endpoints.
- handlers/teams.go: GET/POST/DELETE on /api/projekte/{id}/team. ListEffectiveMembers
  returns direct + inherited (annotated with inherited_from_id/title).
- handlers/dezernate.go: admin-gated CRUD for paliad.dezernate + member
  add/remove. Readable by any authenticated user.
- handlers/fristen.go, termine.go, notizen.go, checklist_instances.go updated
  to use projekt_id. Kept /api/akten/{id}/fristen|termine|notizen|checklisten
  as legacy aliases pointing at the same projekt-aware handlers.
- handlers/users.go: dropped handleListAkteEvents (superseded by
  handleListProjektEvents under /api/projekte/{id}/events).
- cmd/server/main.go: ProjektService + TeamService + DezernatService wired
  into handlers.Services. Downstream services (Parteien, Frist, Termin,
  Notiz, Checklist) take projektSvc.
- Removed obsolete internal/services/akte_service_test.go. go build/vet/test
  all clean.

Legacy /api/akten routes still resolve (handlers/JSON shape unchanged on
the GET/POST path) so frontend stays functional during the client cutover.
New /api/projekte routes live alongside.

Phase 3 (frontend tree UI, /projekte page, team tab) + Phase 4 (Dezernat
settings tab) still pending.
2026-04-20 14:52:44 +02:00
m
9aa8037193 refactor: services — Projekt, Team, Dezernat services (WIP Phase 2)
Models: Akte → Projekt (tree type + parent_id + path + client/matter numbers
+ netDocuments URL + type-specific client/patent/case columns). AkteEvent →
ProjektEvent. FristWithAkte → FristWithProjekt. TerminWithAkte → TerminWithProjekt.
Notiz.AkteID → ProjektID. ChecklistInstance.AkteID → ProjektID. Partei.AkteID →
ProjektID. User adds AdditionalOffices pq.StringArray.

Services:
- NEW projekt_service.go replaces akte_service.go. Adds tree ops: List/GetByID/
  ListChildren/ListAncestors/GetTree. Create auto-adds creator to projekt_teams
  role=lead in same tx. ResolveClientNumber walks path for inheritance.
  Visibility helpers (visibilityPredicate / Positional / Placeholder) centralise
  team-based access check: admin OR any ancestor/direct projekt_teams row.
- NEW team_service.go — AddMember/RemoveMember/ListDirectMembers/
  ListEffectiveMembers (unions direct + inherited via path, dedup by user;
  direct wins)/IsEffectiveMember. Inherited=true set at read time only.
- NEW dezernat_service.go — admin-gated CRUD + member add/remove + user
  membership lookup for settings page.
- frist_service.go → projekt_id everywhere, uses visibilityPredicate. ListFilter.
  AkteID → ProjektID.
- termin_service.go → projekt_id everywhere. CalDAV log reads projekt_events.
- notiz_service.go → projekt_id polymorphic branch; eventProjektID() looks at
  projekt_events; akten_event_id column kept (FK now resolves to projekt_events).
- parteien_service.go → projekt_id.
- checklist_instance_service.go → projekt_id with ClearProjekt flag.
- dashboard_service.go → rewrites all four queries against projekte +
  projekt_events + projekt_teams. Matter/Upcoming/Activity surfaces use
  ProjektID/ProjektTitle/ProjektRef.
- reminder_service.go → joins paliad.projekte, aliases a.reference AS
  akte_aktenzeichen for template compat.

Handlers/tests still reference old API — Phase 2 completion requires handler
rewrite (next commit). Build currently broken in internal/handlers.
2026-04-20 14:46:59 +02:00
m
5fcaa7471b feat(schema): data model v2 — migration 018 (projekte tree + teams + dezernate) [t-paliad-024 phase 1]
paliad.projekte — single self-referential tree (types: client/litigation/patent/case/project).
Materialised path (text, '.'-joined UUIDs, inclusive of self) + trigger maintenance.
ClientMatter numbers (client_number + matter_number, 7-digit CHECK each) and netdocuments_url.

paliad.projekt_teams — team membership with inherited flag (writes = false; services annotate
inherited rows on read by walking up path). Unique (projekt_id, user_id).

paliad.dezernate + paliad.dezernat_mitglieder — structural partner units (orthogonal to project
teams; informational office).

paliad.users — adds additional_offices text[] for partners across multiple offices.

Visibility simplified to team-based only: can_see_projekt() = admin OR direct/ancestor team
membership (path @> ancestor). owning_office GONE from every projekt — location is no longer
an access gate. Per head (2026-04-20): cases associate with lead partners, not offices.

Data migration: akten → projekte (same UUIDs, type='case', parent NULL orphans). Creator →
projekt_teams(role='lead'); collaborators → projekt_teams(role='associate'). Orphan akten with
no creator + no collaborators become admin-only until reassigned.

Child FK rename: akte_id → projekt_id on parteien, fristen, termine, dokumente, akten_events,
notizen, checklist_instances. No data move (same UUIDs). akten_events renamed to projekt_events.
notizen keeps its polymorphic 4-FK shape.

paliad.akten dropped. can_see_akte() and notiz_is_visible(akte) replaced.

Down-migration restores v1 schema best-effort: only type='case' projekte come back as akten;
non-case tree rows are lost (documented). owning_office backfilled from creator's primary office.

Followups (Phase 2): replace AkteService with ProjektService + TeamService + DezernatService,
wire creator-auto-lead into Create path, update all child services to use projekt_id.

No code changes in this commit — server will fail to build/start until Phase 2 lands.
2026-04-20 14:34:07 +02:00
m
da509755cf Merge: data model v2 design (Mandanten, nestable Projekte, Teams) 2026-04-20 14:18:36 +02:00
m
fabe32aa56 design: data model v2 — Mandanten + nestable Projekte + Teams (t-paliad-023)
Comprehensive design doc for the replacement of flat paliad.akten with:
  - paliad.mandanten (Clients as first-class table)
  - paliad.projekte (single self-referential typed tree, ltree materialised
    path, 5 project types: mandat/litigation/patent/verfahren/projekt)
  - paliad.teams + paliad.team_mitglieder (Dezernate + project teams in one
    table with kind-shape CHECK)
  - paliad.projekt_mitglieder (hot-path junction replacing akten.collaborators)

Polymorphic FK strategy: single project_id FK on fristen/termine/dokumente/
parteien/akten_events/checklist_instances. Notizen keeps its 4-way polymorphic
shape (akte_id renamed to project_id).

Visibility model: tree-connected — seeing any node grants access to the whole
tree (ancestors + descendants). Office-scope stays at project level; Mandant-
level firm_wide_visible / collaborators override.

Migration plan: 6 phases, non-destructive. UUIDs preserved between akten and
projekte rows so child tables only need column renames, no data moves.

Opinionated: German naming throughout (mandanten, projekte, teams,
team_mitglieder, projekt_mitglieder); /akten URLs alias to /projekte
indefinitely; akten_events table name kept for continuity.

Deliverable: docs/design-data-model-v2.md (920 lines, 14 sections).
2026-04-20 14:17:32 +02:00
m
b370d59eee Merge: settings page — profile, email prefs, CalDAV tabs 2026-04-20 13:18:40 +02:00
m
5fb55164b3 feat: settings page — profile, email preferences, CalDAV as tabs (t-paliad-022)
Unified /einstellungen page replaces the standalone CalDAV screen. Three
tabs today (Profil / Benachrichtigungen / CalDAV); adding more is additive
(one <a> in the tab nav, one <section> panel, one loader). Tab switching
is client-side from ?tab=<name> — default tab is Profil.

Profil tab lets users fix onboarding data without admin intervention:
display name, office, role, Dezernat, language. Email is read-only (the
source of truth is auth.users and an account-level change is out of
scope for the settings page).

Benachrichtigungen tab exposes deadline reminder preferences as a master
toggle plus three per-kind sub-toggles (overdue / tomorrow / weekly).
Preferences land in paliad.users.email_preferences (JSONB); missing keys
are treated as opt-in so existing users keep the behaviour they had
before the page shipped.

CalDAV tab is the old /einstellungen/caldav screen ported inline.
/einstellungen/caldav now 301-redirects to /einstellungen?tab=caldav so
bookmarks keep working.

Backend:
- PATCH /api/me (handlers/users.go) mutates the caller's paliad.users
  row. Attempts to include "email" in the body return 400 — the field is
  always server-authoritative.
- UserService.UpdateProfile builds a dynamic UPDATE from the pointer
  fields supplied; omitted keys are left untouched. Re-uses the
  admin-bootstrap guard for role changes.
- GetByID SELECT now includes lang + email_preferences so /api/me
  returns the data the settings page needs without a second round-trip.
- ReminderService consults email_preferences before sending — the helper
  reminderEnabled covers the master switch and per-kind overrides; corrupt
  JSON falls back to on so a bad row can't silence reminders.
- Migration 017 adds email_preferences jsonb NOT NULL DEFAULT '{}' and
  upgrades lang from nullable (from 016) to NOT NULL DEFAULT 'de' with a
  one-shot backfill. Down restores the nullable lang and drops
  email_preferences.

Model change: User.Lang moved from *string to string — it's NOT NULL in
the DB now, so the indirection was carrying no information. Inviter.Lang
and reminder row structs followed suit; the templates and callers used
""/"en" comparisons that translate 1:1.

Sidebar: the "Einstellungen" group now links to /einstellungen (instead
of just /einstellungen/caldav); the CalDAV sub-item is folded into the
tab nav on the page itself.

Tests: reminderEnabled has table-driven coverage (master switch,
per-kind, corrupt JSON, non-bool values). DB-backed user tests still
skip without TEST_DATABASE_URL as before.

Verified: go build ./..., go vet ./..., go test ./..., bun run build —
all clean.
2026-04-20 13:17:24 +02:00
m
e76056cfd1 Merge: email service — SMTP, deadline reminders, invitations (t-paliad-021) 2026-04-20 13:05:31 +02:00
m
11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
2026-04-20 12:34:38 +02:00
m
45c7cf34ef Merge: onboarding refinement (drop Praxisgruppe, free-text role, add Dezernat) 2026-04-18 21:15:45 +02:00
m
7c44bbec7e refactor: onboarding form — drop Praxisgruppe, free-text role, add Dezernat (t-paliad-020)
- Drop the Praxisgruppe field from the onboarding form. Every Paliad user
  is in patent practice, so the field carried no signal. The DB column is
  retained for future use (set to NULL on insert).
- Switch role from a 4-value enum (partner/associate/pa/admin) to free
  text with a <datalist> of suggestions (Partner, Associate, PA, Of
  Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat).
  German firms have many roles beyond the original four.
- Add an optional Dezernat field — the team led by a specific partner.
  Free text, no FK (the partner may not be registered yet).

Backend:
- Migration 015: drop the role enum CHECK, replace with non-empty CHECK;
  ADD COLUMN dezernat text.
- UserService.Create: drop validRoles map, require non-empty role string,
  trim and persist Dezernat. Admin bootstrap gate unchanged.
- models.User gains Dezernat *string; userColumns SELECT updated so
  /api/me returns it.

Frontend:
- onboarding.tsx: replace role <select> with <input list=...>; add
  dezernat input; remove practice_group.
- onboarding.ts: send dezernat (if non-empty), require role.
- i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder],
  onboarding.error.role; remove the role.* enum and practice_group keys.
2026-04-18 20:26:11 +02:00
m
ebb206307d Merge: user onboarding flow (first-login profile setup) 2026-04-18 19:15:54 +02:00
m
b8f95f5d7a feat: user onboarding flow — first-login profile capture (t-paliad-019)
New users were stuck on the dashboard with a dead-end "Bitte schließen Sie das
Onboarding ab" message because nothing created the paliad.users row that all
matter-management features depend on. This adds the missing Phase D flow.

Backend
- UserService.Create: validates display_name / office / role, inserts the
  paliad.users row with (id, email) from the verified JWT claims (never from
  the request body — prevents onboarding as someone else).
- Admin bootstrap: only the very first paliad.users row may self-assign
  role='admin'; subsequent requests get ErrAdminBootstrapOnly (403). Guarded
  by pg_advisory_xact_lock so two concurrent first-logins can't race past
  the count=0 check under READ COMMITTED.
- POST /api/onboarding + GET /onboarding; the page is authenticated but NOT
  behind the onboarding gate (it's the one page users without a paliad.users
  row may reach).
- gateOnboarded middleware wraps the matter-management pages (Dashboard,
  Akten, Fristen, Termine, Einstellungen/CalDAV) and 302s to /onboarding
  when the caller has no paliad.users row. Knowledge-platform pages
  (Kostenrechner, Glossar, Links, Downloads, Gerichte, Gebührentabellen,
  Checklisten, Fristenrechner) stay ungated.
- auth.VerifiedClaims now carries the email claim; auth.ClaimsFromContext
  exposes it to handlers. GET /api/me includes the email in the 404 body so
  the onboarding form can pre-fill the display name from the local-part.

Frontend
- frontend/src/onboarding.tsx + src/client/onboarding.ts: centred card on the
  existing .login-card styling. Fields: display_name (required, pre-filled
  from email local-part), office (dropdown from /api/offices), role
  (dropdown, default associate), practice_group (optional).
- Dashboard client: toggleOnboardingHint now redirects to /onboarding
  instead of showing the dead-end hint — belt-and-braces behind the server
  gate in case the DB lookup fell through.
- DE + EN i18n keys for every label, placeholder, and error.
- Added onboarding to build.ts.

Tests: internal/services/user_service_test.go covers the valid path,
per-field validation, duplicate (ErrUserAlreadyOnboarded), and the
admin-bootstrap gate. Follows the existing TEST_DATABASE_URL skip pattern.
2026-04-18 19:13:57 +02:00
m
41b28bdfdb Merge: audit medium items (pagination, patholo rename, offices) 2026-04-18 19:03:24 +02:00
m
0cdc644b50 fix: audit medium items — Verlauf pagination, patholo→paliad rename, offices (t-paliad-018)
Three items from docs/improvement-audit.md §2:

I-5 Verlauf pagination
- AkteService.ListEvents now accepts a (before *uuid.UUID, limit int) cursor
- SQL uses a composite (created_at, id) cursor subquery — stable across
  rows written in the same microsecond
- Handler parses ?before=<uuid>&limit=<n>, service clamps to 200
- Frontend fetches first page (50) on init and exposes a "Mehr laden" /
  "Load more" button that keeps paging until the tail returns < page size
- i18n keys akten.detail.verlauf.loadMore / .loadingMore in DE + EN

I-8 patholo → paliad client-side rename with migrations
- i18n.ts: STORAGE_KEY is now paliad-lang; one-shot migration reads the
  old patholo-lang value, writes the new key, deletes the old
- sidebar.ts: same pattern for paliad-sidebar-pinned
- Cookie rename with dual-read grace period: SessionCookieName is
  paliad_session, LegacySessionCookieName keeps patholo_session as
  read-only fallback. Requests using the legacy cookie get upgraded to
  paliad_session in the response; legacy cookie is expired in the same
  response. ClearAuthCookies clears both names to prevent stale-cookie
  resurrection. Remove the legacy fallback after 2026-05-18 (30d cookie
  max age).
- handlers/links.go:extractEmailFromCookie reads either cookie name via
  auth.SessionCookieName / auth.LegacySessionCookieName

P-6 Single source of truth for offices
- New internal/offices package: Office struct + All + IsValid + Keys
- akte_service.go switched from inline isValidOffice to offices.IsValid
- GET /api/offices returns the list with DE + EN labels
- Akte create form (akten-neu.tsx) has an empty <select>; the client TS
  fetches /api/offices and populates options, re-rendering on lang change

Tests:
- internal/offices/offices_test.go covers IsValid + Keys + label coverage
- internal/auth: three new Middleware tests — legacy cookie still
  authenticates + upgrades the browser, new cookie wins when both are
  present (no clobber), missing cookie returns 401 on API paths

Build: go build ./... + go vet ./... + go test ./... + bun run build all clean.

Known out-of-scope: handlers/links.go still POSTs to public.patholo_link_*
via PostgREST; migration 011 created fresh paliad.link_* tables but the
handler refactor (move to direct DB, copy data, drop public tables) is a
separate phase documented in that migration's header.
2026-04-18 18:56:35 +02:00
m
f80d1a5753 Merge: audit quick wins (9 items — Dokumente tab, i18n, URLs, FRAND glossar, footer, calendars) 2026-04-18 09:15:43 +02:00
m
67cd66e054 fix: audit quick wins — important + polish batch (t-paliad-017)
Items from docs/improvement-audit.md §2 + §3:

I-1  Hide Dokumente tab entirely from Akten detail (Phase H deferred);
     drop placeholder TSX panel, VALID_TABS entry, and orphaned
     akten.detail.soon.* i18n keys.
I-2  Add data-i18n keys for all 7 office labels on the landing page.
     EN mode now correctly renders "Milan" (was "Mailand").
I-3  Unify UPC URLs in Gerichtsverzeichnis to the canonical hyphenated
     form (unified-patent-court.org) matching links.go — 43 occurrences.
I-6  Add SEP/FRAND glossary category with 13 entries (FRAND, SEP,
     Standard-essentielles Patent, Patentpool, Anti-Suit, Anti-Anti-Suit,
     Injunction Gap, Orange-Book-Standard, Huawei/ZTE, RAND, ETSI IPR,
     Patent-Hold-up, Patent-Hold-out) + filter pill + suggest-modal option.
I-7  Refresh README: list migration 014 (checklist_instances), mark
     Phase I (Notizen) and Phase J (docs) shipped.
P-1  Remove HL Intern stub links (URL "#") and the now-empty "hl" category.
P-2  Dashboard heading: "Meine Mandate" → "Meine Akten" (matches CLAUDE.md
     naming convention). Onboarding hint updated likewise.
P-4  Drop "Hogan Lovells Patent Practice" from the footer — Paliad is the
     firm-agnostic brand.
P-5  Empty-state text on Fristen- and Termine-Kalender when the viewed
     month has no items.

Verified: bun run build clean, go build / vet / test ./... clean.
2026-04-18 09:14:43 +02:00
m
a7df6eb977 fix: resolve route conflict /api/checklisten/{slug}/instances vs /api/checklisten/instances/{id}
Move instance-specific endpoints to /api/checklist-instances/{id} to avoid
Go 1.22+ ServeMux ambiguity panic. Server was crash-looping.
2026-04-18 09:05:57 +02:00
m
6406aba2a5 Merge: critical security fixes (JWT verification, Termine leak, role gates, email whitelist) 2026-04-18 03:16:03 +02:00
m
3e20806aee fix(security): verify JWT signatures + plug 4 other critical gaps (t-paliad-016)
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
  HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
  reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
  outright and clear cookies.

C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
  TerminService.canSee: personal Termine (akte_id IS NULL) are
  creator-only; admins do NOT see other users' personal Termine.

C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
  partner/admin (or the original creator). Personal Termine stay
  creator-only.

C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
  (default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
  whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.comname@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.

C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
  and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
  ANTHROPIC_API_KEY line for Phase H readiness.

Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
  token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
  whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
2026-04-18 02:23:50 +02:00
m
3e14171808 Merge: product audit + improvement roadmap 2026-04-18 02:15:47 +02:00
cronus
bcdd3d7a59 docs(audit): product audit + improvement roadmap (t-paliad-015)
Post-Phase-A–J full-product audit: UX, code, content, architecture,
ops. 5 Critical findings (JWT signature bypass, dashboard Termine
leak, parteien/termin delete policy gap, @hoganlovells-only email
gate, CALDAV_ENCRYPTION_KEY missing from compose), 8 Important, 10
Polish, 11 Feature ideas, 14 tech-debt items. Each item has a file
reference and a concrete fix.

Top-two exploit-paths (detailed in §1):
  1. internal/auth/auth.go:178 — middleware decodes JWT exp but never
     verifies the signature; sub-claim is trusted downstream by every
     service. Any authenticated cookie → impersonate any user.
  2. internal/services/dashboard_service.go:245 — personal Termine
     leaked cross-user on the /dashboard "Kommende Termine" list
     (missing created_by filter on the akte_id IS NULL branch).
2026-04-18 01:22:23 +02:00
m
117ccefe07 Merge: instanceable checklists — DB-backed, Akte-linked 2026-04-18 01:13:18 +02:00
m
4c0babb2f3 feat(checklisten): instanceable checklists — DB-backed state, Akte linkage
Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.

- Migration 014: paliad.checklist_instances + RLS mirroring the Termine
  pattern (akte_id nullable → creator-only; akte_id set → can_see_akte gate).
- Static template data moves out of internal/handlers into internal/checklisten
  so both handlers and the new ChecklistInstanceService can reference it
  without an import cycle.
- ChecklistInstanceService: CRUD + state merge via `state || $n::jsonb`
  so concurrent checkbox toggles don't clobber each other. Reset clears
  state to {}. Akte-linked mutations append akten_events audit rows.
- Handlers: GET/POST /api/checklisten/{slug}/instances, GET/PATCH/DELETE
  /api/checklisten/instances/{id}, POST .../reset, GET /api/akten/{id}/checklisten.
- /checklisten/{slug} redesigned to show template metadata + instance
  table + "Neue Instanz" modal (with optional Akte dropdown). The
  interactive checkboxes move to /checklisten/instances/{id} where the
  state is DB-backed and Reset posts to the server. Fixes the original
  Reset button regression — it now operates on real server state rather
  than silently failing client-side.
- Akten detail grows a Checklisten tab listing linked instances with
  progress bars; only loads on tab activation.
- localStorage-based progress removed from the overview grid (state no
  longer lives there).
- DE + EN i18n keys added.

Verified: bun run build clean; go build ./...; go vet ./...; go test ./...
all green.
2026-04-17 13:54:32 +02:00
m
e96b9dfb77 fix(login): add autocomplete attributes to email/password fields 2026-04-17 13:47:33 +02:00
m
ee341742b6 fix(sidebar): disable horizontal scrolling on nav 2026-04-17 13:46:35 +02:00
m
42e5a8471c fix(sidebar): make nav scrollable when content overflows 2026-04-17 13:30:31 +02:00
m
59ba1d5778 Merge Phase J: Roadmap rewrite + post-integration status 2026-04-17 13:29:55 +02:00
m
416234b25a Merge Phase I: Notizen (polymorphic notes) 2026-04-17 13:29:55 +02:00
m
5a9f8e5874 feat(notizen): Phase I — Notizen (polymorphic notes)
Polymorphic notes attached to Akten, Fristen, Termine, or AktenEvents.
Schema (paliad.notizen + paliad.notiz_is_visible) shipped with Phase A
migrations; this phase adds the service, handlers, and shared UI.

Backend
- NotizService (internal/services/notiz_service.go): ListForAkte /
  ListForFrist / ListForTermin / ListForAktenEvent + Create / Update /
  Delete. Visibility resolves through the parent row — AkteService.GetByID
  for Akte/Frist/AktenEvent parents, TerminService.GetByID for Termin
  parents (personal Termine are creator-only).
- Edit restricted to the original author; delete allows author +
  partner/admin. Create on an Akte-scoped parent appends an akten_events
  "notiz_created" audit row in the same transaction; personal Termin
  notes skip the audit.
- Author join (paliad.users) surfaces display_name + email on every
  listed note so the client can render "von <Name>" without per-row
  /api/users fetches.
- Routes wired in handlers.go: GET/POST /api/akten|fristen|termine/{id}/
  notizen, PATCH/DELETE /api/notizen/{id}.

Frontend
- Shared client module frontend/src/client/notizen.ts exposes
  initNotes(container, parentType, parentId). Renders an add-note form,
  list of note cards with relative timestamps (gerade eben / vor N
  Minuten / gestern / …), edit + delete affordances gated by author/
  role, optimistic add/edit/delete with rollback on error, Ctrl+Enter
  submit, and URL auto-linkification inside sanitised note bodies.
- Integrated into akten-detail (Notizen tab — placeholder replaced),
  fristen-detail (new "Notizen" section below the detail list), and
  termine-detail (new "Notizen" section above the edit form).
- DE + EN i18n keys added; obsolete akten.detail.soon.notizen placeholder
  keys removed.
- Notiz-card styles added to global.css (accent-coloured focus, hover
  actions, relative-time colour) matching the existing Verlauf card look.
2026-04-17 12:12:29 +02:00
m
d0d4f624a1 docs: Phase J — roadmap rewrite + post-integration status
Rewrite docs/feature-roadmap.md per design-kanzlai-integration.md §5:
- All-in-one positioning: knowledge platform + Aktenverwaltung
- New Phase 0 (Aktenverwaltung Foundation) with shipped A–G items
- "What Paliad Is" replaces "What patholo Is NOT"
- Drop §2.3 UPC Rechtsprechungsübersicht (youpc.org link in Link Hub)
- Phase H (AI Frist-Extraktion) marked deferred
- Mark done items in prioritized backlog with completion dates
- Architecture Notes data-strategy: paliad schema + office-scoped RLS

Refresh .claude/CLAUDE.md:
- Aktenverwaltung + knowledge tools in Purpose
- Env var table incl. DATABASE_URL (Akten*/Fristen/Termine), CALDAV_ENCRYPTION_KEY
- Phase status (A–G shipped, H deferred, I pending)
- Akten naming convention (not "Mandate"/"cases")

Refresh README.md:
- Full feature list (Akten, Fristen, Termine, Dashboard + knowledge tools)
- Migration inventory (001–013), migration tracker note
- Env var table with usage semantics
- Project layout + current project status

Append "Post-Integration Status" section to design-kanzlai-integration.md:
- Per-phase shipment table with merge commits
- Phase H deferral note; Phase I pending note
- Phase J split: docs done here; infra retirement (Dokploy, schema drop,
  repo archive) pending head + m coordination
- Email-gate hardcode flagged for follow-up
2026-04-17 12:10:35 +02:00
m
04bf36666f Merge Phase F: Termine + CalDAV sync 2026-04-17 12:01:47 +02:00
m
b56ef660df feat(termine): Phase F — Termine (appointments) + CalDAV sync
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.

Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
  Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
  Termine follow AkteService.GetByID. Every Akte-attached mutation appends
  an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
  caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
  UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
  Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
  to start with malformed key; unset key leaves CalDAV disabled and all
  /api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
  paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
  /api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.

Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
  TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
  test-connection button, status card, sync log table, delete button that
  purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
  add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.

Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password

t-paliad-010
2026-04-17 11:59:49 +02:00
450 changed files with 80944 additions and 9412 deletions

View File

@@ -1,6 +1,6 @@
# paliad
Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan Lovells) colleagues.
Paliad — the patent paladin. All-in-one patent practice platform for HLC (formerly Hogan Lovells) colleagues. Knowledge platform + Aktenverwaltung in one sidebar, one URL, one login.
**Brand:** Paliad (firm-agnostic — survives firm renames)
**Primary domain:** paliad.de
@@ -10,11 +10,12 @@ Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan
## Purpose
- Share guides, templates, and documents with the patent team
- Centralized knowledge base and toolkit for patent workflows
- Interactive tools: Prozesskostenrechner, Fristenrechner, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Patentglossar, Link Hub, Downloads
- Document best practices and style guides (HL Patents Style)
- Long-term: collaboration features, document management
- **Project management** — hierarchical projects (Client → Litigation → Patent → Case), deadlines, appointments, parties, notes, audit trail. Team-based visibility with inheritance down the project tree. Personal CalDAV sync.
- **Interactive knowledge tools** — Prozesskostenrechner, Fristenrechner, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Patentglossar, Link Hub, Downloads.
- **Dashboard** — logged-in landing with deadline traffic lights, upcoming appointments, recent activity, all scoped to visible projects.
- Share guides, templates, and documents with the patent team.
- Document best practices and style guides (HL Patents Style).
- Long-term: document upload, collaboration annotations, Outlook/Exchange sync.
## Audience
@@ -24,18 +25,41 @@ Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan
## Tech Stack
- **Frontend:** Bun + TSX (custom JSX renderer, no React)
- **Backend:** Go API, net/http
- **Auth:** Supabase (youpc instance) — password-based, `@hoganlovells.com` gate (TBD: update to `@hlc.*` post-merger)
- **Frontend:** Bun + TSX (custom JSX renderer, no React), per-page client TS bundles, HTML-first forms
- **Backend:** Go API, `net/http`, `sqlx` for DB access
- **Migrations:** `golang-migrate/migrate/v4` with SQL files embedded via `embed.FS`; applied at server startup before the HTTP listener binds. Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`).
- **Database:** youpc Supabase Postgres (port 11833), `paliad` schema. Team-based RLS via `paliad.can_see_project(project_id)` — visibility determined by team membership (direct + inherited up the project tree). See `docs/design-data-model-v2.md`.
- **Auth:** Supabase (youpc instance) — password-based, email-domain gate via `ALLOWED_EMAIL_DOMAINS` (default `hoganlovells.com,hlc.com,hlc.de`). The whitelist references real DNS domains and rotates independently from `FIRM_NAME` (display name).
- **Hosting:** Dokploy compose on mlake (72.62.52.189), compose ID `Zx147ycurfYagKRl_Zzyo`
- **Domains on Dokploy:** paliad.de (primary, Let's Encrypt), patholo.de (legacy), patholo.msbls.de (internal)
- **Deploy:** push to main → Gitea webhook → Dokploy auto-deploy
## Environment variables
| Variable | Required | Purpose |
|---|---|---|
| `PORT` | no (default 8080) | HTTP listen port |
| `SUPABASE_URL` | yes | Supabase project URL (auth) |
| `SUPABASE_ANON_KEY` | yes | Supabase anon key (auth) |
| `DATABASE_URL` | for Aktenverwaltung | Direct Postgres conn for migrations + Akten/Fristen/Termine services. Knowledge-platform features (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) work without it — those endpoints return data from static JSON and never touch the pool. Aktenverwaltung endpoints return 503 if unset. |
| `CALDAV_ENCRYPTION_KEY` | for CalDAV sync | 32-byte AES-256 key, base64-encoded. Encrypts CalDAV passwords at rest (AES-GCM). Server fails fast on malformed key; CalDAV is silently disabled if unset (Termine still work locally; `/api/caldav-config` returns 501). |
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion) which is deferred per m's 2026-04-16 decision. Do not set. |
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
> *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages.
## Infrastructure
- **Gitea:** `mAi/paliad` on mgit.msbls.de (renamed from mAi/patholo — auto-redirects)
- **Gitea:** `m/paliad` on mgit.msbls.de (renamed from `mAi/paliad` 2026-04-30; previously `mAi/patholo` — auto-redirects)
- **DNS:** paliad.de → 72.62.52.189 (via Hostinger)
- **Branding:** lime green accent (`#c6f41c`), Sidebar layout, DE/EN i18n
- **Branding:** lime green accent (`#c6f41c`), sidebar layout, DE/EN i18n. Firm-agnostic: every user-facing firm reference is rendered from `internal/branding.Name` (Go) / `frontend/src/branding.ts` (TypeScript). Default "HLC", overridable via `FIRM_NAME`. See t-paliad-065.
## Project status & history
Phase status, shipped milestones, open follow-ups, and the patHoLo→Paliad rebrand history live in `docs/project-status.md`. Read that before assuming a feature is or isn't built.
## Worker Preferences
@@ -43,6 +67,21 @@ Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan
- Use **Sonnet** for implementation
- Prefer **gitster** role for issues
## Historical naming
## Language convention
Previously called *patHoLo* (Patent + Hogan Lovells). Rebranded to Paliad on 2026-04-16 when HL announced the merger into HLC, making "HoLo" obsolete. Paliad — "Patent Litigation Administration" but in UI used as a standalone word evoking *paladin*, the champion. Firm-agnostic so the brand survives any future firm renames. Lime branding kept throughout.
**System language is English.** All code, table names, Go types, service names, URL paths, API endpoints, file names — English. Examples: `projects` not `projekte`, `deadlines` not `fristen`, `appointments` not `termine`, `ProjectService` not `ProjektService`, `/projects` not `/projekte`.
**Frontend default language is German.** User-facing i18n strings are bilingual (DE primary, EN secondary). UI labels, error messages, page titles — all translated via `i18n.ts`. The product speaks German to its users but the codebase speaks English to developers.
**Product tool names stay German** as brand names: Fristenrechner, Kostenrechner, Gebührentabellen (these are proper nouns in the product context, kept in URLs as `/tools/fristenrechner` etc.).
## Frontend conventions
**`.entity-table` row-click contract.** The default `.entity-table tbody tr` rule sets `cursor: pointer` + a hover highlight on every row. If you add an `.entity-table` to a page, the row affordance must match reality:
- **Rows that navigate** — wire a row-level click handler that does `window.location.href = "..."` and skips clicks on inner `<a>` / `<button>` (so nested links and action buttons still work). Pattern lives in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
- **Rows that don't navigate** (read-only summary tables, admin tables where all actions are inline buttons) — add `entity-table--readonly` to the `<table>` className. That modifier neutralises the cursor and hover.
A row that looks clickable but isn't is a UX lie and confuses users (cf. t-paliad-098/099). The CSS rule and modifier are anchored in `frontend/src/styles/global.css` near `.entity-table tbody tr`.
**Whole-card / whole-row click → use a JS row handler, not a `::before` overlay.** Don't make a card fully clickable by spanning a `::before { inset: 0 }` (or any pointer-event overlay) over it — the overlay swallows pointer events on the text and breaks selection / copy (cf. t-paliad-102 → t-paliad-103). Instead, attach a row-level click handler that calls `window.location.href = ...` and skips clicks on inner `<a>` / `<button>` (the same pattern as the `.entity-table` rule above). Examples on `.entity-event` (Verlauf) and `.dashboard-activity-item` in `frontend/src/client/projects-detail.ts` + `client/dashboard.ts`. Text stays selectable, click still navigates, keyboard / Cmd-click semantics intact.

7
.gitignore vendored
View File

@@ -14,3 +14,10 @@ frontend/dist/
# OS
.DS_Store
.worktrees/
# mai worker state
.m/
# Playwright MCP scratch (screenshots + console logs from local verification)
/.playwright-mcp/
/paliad-*.png

View File

@@ -50,7 +50,7 @@ worker:
max_workers: 5
persistent: true
head:
name: ""
name: "maria"
max_loops: 50
infinity_mode: false
max_idle_duration: 2h0m0s

View File

@@ -2,10 +2,19 @@
"mcpServers": {
"supabase": {
"type": "http",
"url": "http://100.99.98.201:8000/mcp",
"url": "https://ystudio.msbls.de/mcp",
"headers": {
"Authorization": "Basic ${SUPABASE_AUTH}"
"Authorization": "Basic ${YOUPC_SUPABASE_AUTH}"
}
},
"playwright": {
"command": "bunx",
"args": [
"@playwright/mcp@latest",
"--headless",
"--user-data-dir",
"/tmp/mai-playwright-profile"
]
}
}
}

View File

@@ -1,27 +1,53 @@
# paliad
Paliad — patent practice platform for HLC colleagues. Knowledge tools (Kostenrechner, Glossar, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Links, Downloads) plus Aktenverwaltung (matters, deadlines, appointments, documents — Phase 0 in progress).
Paliad — all-in-one patent practice platform for HLC (formerly Hogan Lovells). Knowledge tools and Aktenverwaltung behind one sidebar.
- **Aktenverwaltung**: Akten (matters), Fristen (deadlines), Termine (appointments) with CalDAV sync, Parteien, Dashboard. Office-scoped visibility with explicit collaborators.
- **Knowledge tools**: Prozesskostenrechner (DE / UPC / EPA), Fristenrechner, Gebührentabellen, Patentglossar, Gerichtsverzeichnis, Checklisten, Link Hub, Downloads.
Domain: `paliad.de` (legacy: `patholo.de`, `patholo.msbls.de`).
Repo: `mAi/paliad` on `mgit.msbls.de`.
Repo: `m/paliad` on `mgit.msbls.de`.
## Stack
- **Frontend**: Bun + custom JSX/TSX renderer (no React), per-page client TS bundles
- **Backend**: Go (net/http), embedded migrations via `golang-migrate/migrate/v4`
- **Frontend**: Bun + custom JSX/TSX renderer (no React), per-page client TS bundles, HTML-first forms
- **Backend**: Go (`net/http`), `sqlx` for DB access
- **Migrations**: `golang-migrate/migrate/v4` with SQL files embedded via `embed.FS`; applied at server startup before the HTTP listener binds
- **Database**: youpc Supabase Postgres, `paliad` schema. Office-scoped RLS (`paliad.can_see_akte(akte_id)`) — see `docs/design-kanzlai-integration.md` §2
- **Auth**: Supabase password (cookie session, `@hoganlovells.com` / `@hlc.*` email gate)
- **DB**: youpc Supabase Postgres, `paliad` schema (office-scoped RLS — see `docs/design-kanzlai-integration.md` §2)
- **CalDAV**: hand-rolled iCal + minimal WebDAV client in `internal/services/caldav_*.go`; AES-GCM at rest for stored passwords
- **Hosting**: Dokploy compose `Zx147ycurfYagKRl_Zzyo` on mlake
## Database migrations
Migrations live in `internal/db/migrations/` as `NNN_description.up.sql` + `.down.sql` pairs. They are embedded into the Go binary via `embed.FS` and applied automatically at server startup (before the HTTP listener binds) when `DATABASE_URL` is set.
The migration tracker is `paliad.paliad_schema_migrations` (not the default `public.schema_migrations`). This avoids a collision with other apps on the shared youpc Supabase instance — see the memory episode "paliad migration bootstrap collision with shared Postgres" for the incident that drove the change.
Current migrations (as of April 2026):
```
001_paliad_schema schema + extensions
002_users paliad.users (office, role, practice_group)
003_reference_tables proceeding_types, deadline_rules, holidays
004_akten paliad.akten with visibility columns
005_akten_children parteien, fristen, termine, dokumente, akten_events, notizen
006_visibility paliad.can_see_akte() function
007_rls_policies RLS on every paliad table
008_seed_proceeding_types
009_seed_deadline_rules 32 UPC + 4 ZPO rules
010_seed_holidays DE federal + UPC judicial vacations
011_feedback_tables link_suggestions, checklisten_feedback, gerichte_feedback
012_fristenrechner_rules DB-backed rule set for /tools/fristenrechner
013_user_caldav_config per-user CalDAV (encrypted) + sync log
014_checklist_instances persisted checklist instances linkable to Akten
```
Add a new migration:
```
internal/db/migrations/012_<description>.up.sql
internal/db/migrations/012_<description>.down.sql
internal/db/migrations/015_<description>.up.sql
internal/db/migrations/015_<description>.down.sql
```
The down file is required and must reverse the up cleanly (verified by adding a one-off down test before merge).
@@ -32,7 +58,7 @@ To run migrations against a local Postgres:
docker run -d --name paliad-pg -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16-alpine
# bootstrap a mock auth schema (auth.users + auth.uid()) — required because
# the migrations reference Supabase-provided objects:
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/migrations/_dev/mock_supabase_auth.sql
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/devtools/mock_supabase_auth.sql
DATABASE_URL='postgres://postgres:test@localhost:5432/postgres?sslmode=disable' \
SUPABASE_URL=stub SUPABASE_ANON_KEY=stub \
go run ./cmd/server
@@ -45,8 +71,10 @@ go run ./cmd/server
| `PORT` | no (default 8080) | HTTP listen port |
| `SUPABASE_URL` | yes | Supabase project URL (auth) |
| `SUPABASE_ANON_KEY` | yes | Supabase anon key (auth) |
| `DATABASE_URL` | optional today, required after Phase B | Direct Postgres conn for migrations + services |
| `GITEA_TOKEN` | optional | Gitea API token for private file proxy |
| `DATABASE_URL` | for Aktenverwaltung | Direct Postgres conn for migrations + Akten/Fristen/Termine services. Knowledge-platform endpoints (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) don't use the pool and work without it. Aktenverwaltung endpoints return `503` if unset. |
| `CALDAV_ENCRYPTION_KEY` | for CalDAV sync | 32-byte AES-256 key, base64-encoded. Encrypts CalDAV passwords at rest (AES-GCM). Server fails fast on malformed key; if unset, CalDAV is silently disabled (`/api/caldav-config` returns `501`). Generate with `openssl rand -base64 32`. |
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion). Currently deferred — do not set. |
## Development
@@ -54,8 +82,29 @@ go run ./cmd/server
make build # compile backend + frontend
make test # run Go tests + frontend tests
go build ./... # backend only
go vet ./... # static checks
go test ./... # Go tests
bun run build # frontend only (produces frontend/dist/)
```
Project layout:
```
cmd/server/ # main entry point
internal/db/ # sqlx pool + embedded migrations
internal/services/ # AkteService, FristService, TerminService, CalDAV, ...
internal/handlers/ # HTTP handlers (pages + API)
internal/calc/ # Kostenrechner / Fristenrechner logic
frontend/ # Bun + TSX source; static HTML output to frontend/dist/
docs/ # design docs + this roadmap
```
## Deploy
Push to `main` → Gitea webhook → Dokploy auto-deploy on mlake.
## Project status (April 2026)
Phases AG, I and J of the KanzlAI integration are shipped: schema, services, Akten, Fristen, Termine + CalDAV, Dashboard, Notizen service + UI (commit `5a9f8e5`, 2026-04-17), and instanceable Checklisten (migration 014). Phase H (AI Frist extraction) is **deferred** pending a reversal of the "no Anthropic API" decision; the Dokumente tab on Akten detail is hidden until that lands. KanzlAI infra retirement (Dokploy shutdown, `kanzlai` schema drop, Gitea archive) is still pending.
See `docs/feature-roadmap.md` for the full backlog and `docs/design-kanzlai-integration.md` for the integration design.

View File

@@ -1,14 +1,25 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/db"
"mgit.msbls.de/m/patholo/internal/handlers"
"mgit.msbls.de/m/patholo/internal/services"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
// zoneinfo — without this import, every reminder timezone lookup fails
// silently and the hourly reminder slot fires in UTC instead of the
// user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary.
_ "time/tzdata"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/handlers"
"mgit.msbls.de/m/paliad/internal/services"
)
func main() {
@@ -17,38 +28,47 @@ func main() {
port = "8080"
}
// Surface the firm name in the boot log so a deployer can confirm
// FIRM_NAME took effect without curl-ing a rendered page.
log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
if supabaseURL == "" || supabaseAnonKey == "" {
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
}
client := auth.NewClient(supabaseURL, supabaseAnonKey)
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
if jwtSecret == "" {
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
}
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
giteaToken := os.Getenv("GITEA_TOKEN")
if giteaToken == "" {
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
}
// Phase H: optional dependencies for document upload + AI extraction.
// Both services degrade to a 501 response when their env vars are unset;
// the UI hides their buttons based on GET /api/config/features.
supabaseServiceKey := os.Getenv("SUPABASE_SERVICE_KEY")
anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY")
storageClient := services.NewStorageClient(supabaseURL, supabaseServiceKey)
aiService := services.NewAIService(anthropicAPIKey)
if storageClient == nil {
log.Println("SUPABASE_SERVICE_KEY not set — document upload/download disabled")
}
if aiService == nil {
log.Println("ANTHROPIC_API_KEY not set — AI deadline extraction disabled")
// MailService is wired regardless of DB availability — it no-ops when
// SMTP env vars are unset, so the server stays runnable for knowledge-
// platform-only deployments. Template-parse errors at boot are fatal.
mailSvc, err := services.NewMailService()
if err != nil {
log.Fatalf("mail service init: %v", err)
}
// Shared context for background goroutines (CalDAV sync + reminder job).
bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// DATABASE_URL is optional during the Phase A → Phase D transition. The
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
// without a DB. matter-management endpoints return 503 until DATABASE_URL is set.
dbURL := os.Getenv("DATABASE_URL")
var svcBundle *handlers.Services
var caldavSvc *services.CalDAVService
if dbURL != "" {
log.Println("applying database migrations…")
if err := db.ApplyMigrations(dbURL); err != nil {
@@ -60,26 +80,114 @@ func main() {
if err != nil {
log.Fatalf("open db pool: %v", err)
}
// Refresh paliad.deadline_search whenever migrations run, so
// search reflects any newly-seeded rule / concept / trigger.
// Migration 047 created the matview already-populated; this
// is only a no-op for the boot that introduced it. CONCURRENTLY
// keeps reads online and stays well under 100 ms at < 1k rows.
if err := services.RefreshSearchView(bgCtx, pool); err != nil {
log.Printf("refresh deadline_search: %v", err)
}
holidays := services.NewHolidayService(pool)
courts := services.NewCourtService(pool)
users := services.NewUserService(pool)
akteSvc := services.NewAkteService(pool, users)
projectSvc := services.NewProjectService(pool, users)
teamSvc := services.NewTeamService(pool, projectSvc)
partnerUnitSvc := services.NewPartnerUnitService(pool, users)
rules := services.NewDeadlineRuleService(pool)
fristSvc := services.NewFristService(pool, akteSvc)
dokumentSvc := services.NewDokumentService(pool, storageClient, aiService, akteSvc, fristSvc)
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
// the service exists but Enabled() reports false; handlers return 501.
// If the env var is malformed, fail fast — silently skipping would
// leave plaintext-credential bugs hidden.
cipher, err := services.LoadCalDAVCipher()
if err != nil {
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
}
if cipher == nil {
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
} else {
log.Println("CalDAV encryption configured (AES-256-GCM)")
}
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
appointmentSvc.SetCalDAVPusher(caldavSvc)
baseURL := os.Getenv("PALIAD_BASE_URL")
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
// at the point we build mailSvc above.
emailTemplateSvc := services.NewEmailTemplateService(pool)
mailSvc.SetTemplateService(emailTemplateSvc)
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
svcBundle = &handlers.Services{
Akte: akteSvc,
Parteien: services.NewParteienService(pool, akteSvc),
Frist: fristSvc,
Project: projectSvc,
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: services.NewPartyService(pool, projectSvc),
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
Courts: courts,
DeadlineSearch: services.NewDeadlineSearchService(pool),
EventCategory: nil, // wired below; cross-link order matters
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Dokument: dokumentSvc,
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
Audit: services.NewAuditService(pool),
EmailTemplate: emailTemplateSvc,
Link: services.NewLinkService(pool),
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
Approval: services.NewApprovalService(pool, users),
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
// Without this wiring, the policies and request tables exist but no
// mutation path consults them — paliad behaves as before.
deadlineSvc.SetApprovalService(svcBundle.Approval)
appointmentSvc.SetApprovalService(svcBundle.Approval)
// v3 (t-paliad-133): wire EventCategoryService and cross-link
// it into DeadlineSearchService so ?event_category_slug= can
// resolve to a concept-id allow-list during search.
eventCategorySvc := services.NewEventCategoryService(pool)
svcBundle.EventCategory = eventCategorySvc
svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
log.Println("Phase B services initialised")
// Spawn background goroutines: CalDAV sync (one per enabled user)
// and the hourly reminder scanner. Both live for the process
// lifetime; the signal-scoped context cleans them up on SIGTERM.
if err := caldavSvc.Start(bgCtx); err != nil {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")
caldavSvc.Stop()
}()
} else {
log.Println("DATABASE_URL not set — Akten/Frist endpoints will return 503")
log.Println("DATABASE_URL not set — matter-management endpoints will return 503")
}
mux := http.NewServeMux()

View File

@@ -7,6 +7,18 @@ services:
- PORT=8080
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
- GITEA_TOKEN=${GITEA_TOKEN}
- DATABASE_URL=${DATABASE_URL}
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
- ALLOWED_EMAIL_DOMAINS=${ALLOWED_EMAIL_DOMAINS}
- PALIAD_BASE_URL=${PALIAD_BASE_URL}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USERNAME=${SMTP_USERNAME}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM=${SMTP_FROM}
- SMTP_FROM_NAME=${SMTP_FROM_NAME}
- SMTP_USE_TLS=${SMTP_USE_TLS}
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
restart: unless-stopped

View File

@@ -0,0 +1,314 @@
# Audit — Fristenrechner Completeness (paliad vs youpc deadline calc)
**Author:** curie (researcher)
**Date:** 2026-04-30
**Task:** t-paliad-084
**Mode:** read-only research, no code changes
**Question (m):** *"Does paliad's Fristenrechner have all the data from our youpc deadline calc?"*
---
## 1. Executive summary
**No.** Paliad's Fristenrechner is **timeline-shaped**, youpc's deadline calc is **trigger-event-shaped**, and the two have only ~10 % overlap of the rule corpus. They were built for different jobs:
| | paliad Fristenrechner | youpc deadline calc |
|---|---|---|
| Data shape | 9 proceeding-type trees (UPC + DE + EPA) | 102 trigger events → 70 deadlines (UPC only) |
| Rule count | 52 rules across 9 trees (the 9 public types) | 70 standalone deadlines + 36 timeline events |
| Tool UX | "pick proceeding type → see whole timeline from trigger date" | "pick trigger event → see all deadlines that flow from it" |
| RoP rule-code coverage | 5 distinct UPC RoP refs (`023`, `029b`, `029c`, `050`, `220.1`) | **64 distinct RoP refs** (`016.3.a``353`) |
| Working-day arithmetic | ❌ no | ❌ no, but data references it (RoP.198 / RoP.213 notes "or 20 working days, whichever is longer") |
| Holiday handling | ✅ DB-driven, German federal + UPC summer/winter vacation 2026/27 | ❌ empty default config, hardcoded TODO |
| Adaptive rules (with/without CCR) | ✅ via `condition_rule_id` + `alt_*` columns (KanzlAI types only — INF/REV/CCR; **not** in the public Fristenrechner trees) | ✅ via separate trigger events (29.a/b/c/d/e all distinct) |
| Law citation links | ❌ free-text rule_ref | ✅ `deadline_rule_codes``rule_codes``laws.unique_id` |
| Linkage to project / matter | ✅ (via `paliad.deadlines` rows, separate concern) | ✅ (via `data.proceeding_events` graph) |
The short answer for m: **paliad covers the 9 high-level UPC/DE/EPA procedure timelines well, but is missing ~56 of the 64 granular UPC RoP deadlines that the youpc calc exposes.** The ones in paliad are the SoC→SoD→Reply→Rejoinder backbone; the gap is everything around damages determination, protective letters, evidence preservation, lay-open-books, translation orders, leave-to-appeal, rectification, rehearing, cross-appeals, and the "correction of deficiencies" family. None of these have ever been ported.
---
## 2. Rule inventory
### 2.1 youpc — `data.deadlines` (primary "deadline calc" content)
70 active deadlines, grouped by RoP family. Each row: `title`, `duration_value`, `duration_unit` (`days|weeks|months`), `timing` (`before|after`), trigger event(s), rule code(s), notes. Source: `internal/services/deadline_service.go` + production DB.
| RoP family | # deadlines | Examples |
|---|---|---|
| Pleadings (R.019R.032, R.039) | 14 | Preliminary Objection (1mo, R.019.1), SoD (3mo, R.023), CCR (3mo, R.025), Reply 029.a/b/c/d/e (5 distinct rules) |
| Revocation (R.049R.052) | 5 | Defence to revocation (2mo, R.049.1), Counterclaim for infringement (2mo, R.049.2.b), Application to amend (2mo, R.049.2.a) |
| Counterclaim infringement (R.056) | 3 | Defence (2mo, R.056.1), Reply (1mo, R.056.3), Rejoinder (1mo, R.056.4) |
| DNI (R.067R.069) | 3 | Defence (2mo, R.067), Reply (1mo, R.069.1), Rejoinder (1mo, R.069.2) |
| Office decisions (R.088, R.097.1) | 2 | Annul EPO decision (1mo, R.088), Annul unitary-effect refusal (3w, R.097.1) |
| Oral hearing (R.109) | 3 | Simultaneous translation (1mo **before**), Interpreter cost (2w **before**), Translation org (2w after summons) |
| Cost orders (R.118.4, R.151, R.221.1) | 3 | App. for orders consequential on validity (2mo, R.118.4), Cost decision app (1mo, R.151), Leave-to-appeal cost decision (15d, R.221.1) |
| Damages (R.137R.139) | 3 | Defence (2mo, R.137.2), Reply (1mo, R.139), Rejoinder (1mo, R.139) |
| Lay-open books (R.142) | 3 | Defence (2mo, R.142.2), Reply (14d, R.142.3), Rejoinder (14d, R.142.3) |
| Evidence preservation (R.197.3, R.198) | 2 | Review request (30d, R.197.3), Start of merits (31d, R.198 — "or 20 working days, whichever is longer") |
| Provisional measures (R.207.6/9, R.213) | 3 | Correction (14d, R.207.6.a), Renewal (6mo, R.207.9), Start of merits (31d, R.213) |
| Appeals (R.220R.245) | 16 | Statement of Appeal (15d / 2mo, R.224.1.a/b), Grounds (15d / 4mo, R.224.2.a/b), Response (15d / 3mo, R.235.1/2), Cross-appeal (15d / 3mo, R.237), Reply to cross-appeal (15d / 2mo, R.238.1/2), Discretionary review (15d, R.220.3), Reject inadmissible (1mo, R.234.1), Rehearing (2mo, R.245.2.a/b) |
| Other (R.262.2, R.321.3, R.333.2, R.353) | 4 | Confidentiality (14d, R.262.2), Refer to central division (10d, R.321.3), Review CMO (15d, R.333.2), Rectification (1mo, R.353) |
| Registry corrections (multiple) | 6 | "Correction of deficiencies / payment" (14d) under R.016.3.a, R.027.2, R.089.2, R.229.2, R.253.2, R.207.6.a |
**Duration unit distribution:** 44 months · 23 days · 3 weeks. Min 10d, max 6mo.
**`timing='before'`:** 2 rows (R.109 family — simultaneous translation, interpreter cost).
### 2.2 youpc — `data.proceeding_events` (timeline tree, 36 rules)
Self-referential tree; parent_id = sequence, duration = edge weight. 6 proceeding types (INF=8, REV=7, CCR=7, APM=4, APP=8, AMD=2). **This is the table that paliad ported into `paliad.deadline_rules` (via KanzlAI).**
### 2.3 paliad — `paliad.deadline_rules` (96 rules across 16 proceeding types)
| Category | Proceeding types | Rules | Source |
|---|---|---|---|
| Public Fristenrechner (`category='fristenrechner'`) | 9: UPC_INF, UPC_REV, UPC_PI, UPC_APP, DE_INF, DE_NULL, EPA_OPP, EPA_APP, EP_GRANT | **52** | migration `012_fristenrechner_rules.up.sql`, ported from pre-Phase-C in-memory `internal/calc/deadline_rules.go` |
| Internal/matter-attached (KanzlAI port) | 7: INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL | 44 | migration `009_seed_deadline_rules.up.sql`, ported from KanzlAI seed which itself came from `data.proceeding_events` |
The **public Fristenrechner UI** (`frontend/src/fristenrechner.tsx`, lines 2137) only exposes the 9 public types. The KanzlAI 7-type set is for matter-attached fristen (internal Aktenverwaltung), reachable only when a deadline is linked to a project — never as a "calculate deadlines from event" tool.
**UPC_CCR was planned in the design doc** (`design-prozesskostenrechner-fristenrechner.md` §4.1, line 386) but never seeded in `012_fristenrechner_rules.up.sql`. The CCR variant only exists under the internal `CCR` proceeding type.
---
## 3. Gap analysis — youpc rules NOT in paliad
The bulk of the youpc 70-deadline corpus is **invisible to a paliad user** today. Grouped by missing functional area:
### 3.1 Procedural-defect "Correction of deficiencies" family (6 rules, all 14d)
- R.016.3.a (initial), R.027.2 (CCR), R.089.2 (Office annul), R.207.6.a (PM), R.229.2 (appeal), R.253.2 (other) — six distinct triggers, six distinct rule codes, but identical duration. Paliad has no equivalent — there's no procedural-defect proceeding.
### 3.2 Damages determination (R.131.2, R.137R.139)
- Application for damages → 2mo Defence → 1mo Reply → 1mo Rejoinder. **Entirely missing from paliad.**
### 3.3 Lay-open books / discovery (R.142)
- Request → 2mo Defence → 14d Reply → 14d Rejoinder. **Entirely missing.**
### 3.4 Evidence preservation (R.197.3, R.198)
- Review request (30d), Start of merits ("31d **or** 20 working days, whichever is longer", R.198). **Missing.** Note: R.198 is the only deadline in either system that requires the **`max(calendar-days, working-days)` operator** — paliad has no support for it.
### 3.5 Provisional measures (R.207, R.213)
- Paliad's UPC_PI has 4 rules (Antrag → Erwiderung [court-set] → Oral → Beschluss). The **PI Renewal of Protective Letter (6mo, R.207.9)** and **Start of merits (31d / 20wd, R.213)** are missing.
### 3.6 Oral-hearing prep "before"-mode rules (R.109)
- Simultaneous translation (1 month **before** oral hearing, R.109)
- Interpreter cost notice (2 weeks **before**, R.109.4)
- The paliad data model has no `timing` column → all rules implicitly fire `after`. The `DeadlineCalculator.CalculateEndDate` reads `rule.Timing` (a `*string`), but the rule struct has no DB-mapped `timing` column; the `addDuration` helper in `fristenrechner.go:217228` always adds, never subtracts. **Adding "before" support is a tiny change to the schema + service, but no rule today populates it.**
### 3.7 Rule 220 leave/discretionary review (R.220.2, R.220.3)
- Appeal (orders & with leave) → 15d when leave granted; Discretionary review → 15d when leave refused. Both **missing.**
### 3.8 Rehearing (R.245.2.a/b)
- 2mo from final decision OR discovery of fundamental defect (whichever is later). Has its own "max of two trigger dates" semantics. **Missing**, and like R.198 needs a "max of multiple anchors" concept.
### 3.9 Cross-appeal (R.237, R.238)
- Cross-appeal 15d / 3mo (R.237), Reply to cross-appeal 15d / 2mo (R.238.1/2). **Missing.**
### 3.10 Other one-offs
- Rectification (1mo, R.353), Refer to central division (10d, R.321.3), Review of CMO (15d, R.333.2), Confidentiality (14d, R.262.2), Application for the review of leave-to-appeal-refused-on-cost-decision (R.221.1). **Missing.**
### 3.11 Aggregated count
Out of youpc's 64 distinct UPC RoP rule codes referenced by `data.deadline_rule_codes`, paliad's `paliad.deadline_rules.rule_code` references at most 5 (`RoP.023`, `RoP.029b`, `RoP.029c`, `RoP.050`, `RoP.220.1` — and even these have format drift, see §4.3). **That's a 92 % miss on UPC RoP coverage.**
### 3.12 Why so much was unported (likely)
Paliad's Fristenrechner was scoped as a **timeline visualisation** for the 4 most common UPC procedure types (Verletzung / Nichtigkeit / Einstweilige Maßnahme / Berufung) plus DE + EPA, **not** as a search-by-trigger-event tool. The youpc deadline calc is the latter — a reference for "a court just sent me X, what deadlines does this start?" That use case has never been part of paliad's product scope (the design doc `design-prozesskostenrechner-fristenrechner.md` doesn't mention it).
This is a product question, not a porting oversight. m needs to decide whether paliad should grow that second mode or keep the timeline-only shape and accept the corpus gap.
---
## 4. Divergences — rules in both systems with different logic/labels
### 4.1 RoP.029.b/c/d/e — Adaptive Reply/Rejoinder
- **youpc** models the with-CCR vs without-CCR variants as **separate trigger events**: "Statement of defence which includes a Counterclaim for Revocation" → Reply 029.a (2mo); "Statement of defence without a Counterclaim for Revocation" → Reply 029.b (2mo). The user picks the right trigger event; the data drives the rule code.
- **paliad** (KanzlAI port, internal `INF` type) uses `condition_rule_id` + `alt_rule_code`/`alt_duration_value` columns on a single rule row. The default is no-CCR (029.c, 1mo Rejoinder), `condition_rule_id=ccr_root` flips it to with-CCR (029.d, 2mo). Migration `009_seed_deadline_rules.up.sql:331341`.
- **public Fristenrechner type `UPC_INF`** uses **only the no-CCR path**`RoP.029b/c` hard-coded, no `condition_rule_id`. So paliad's public Fristenrechner is incorrect when a defendant filed a counterclaim for revocation: the rejoinder duration should be 2mo not 1mo, and the rule code should be RoP.029d not RoP.029c.
### 4.2 SoD duration anchor
- **youpc:** SoD is 3 months from the trigger event "Statement of Claim" (which is anchor day = filing day).
- **paliad:** SoD (`inf.sod`) is 3 months from `inf.soc`, with `inf.soc` itself being the trigger event (`duration=0`, `parent_id=NULL``IsRootEvent`). Same outcome, different shape.
### 4.3 Rule-code format inconsistency in paliad
```
RoP 23 ← UPC_INF (Fristenrechner, public)
RoP.023 ← INF (KanzlAI, internal)
RoP 29b ← UPC_INF
RoP.029b ← INF
RoP 29c ← UPC_INF
RoP.029c ← INF
RoP 220.1 ← UPC_APP
RoP.220.1 ← APP
```
Both halves of the codebase use different formatting for the same rule. youpc is uniform: `RoP.029.b` (period before letter). Paliad's two seeds disagree: `RoP 29b`, `RoP.029b`, never `RoP.029.b`.
If/when these surfaces ever merge (e.g. a deeplink from Fristenrechner result to a law-citation page), this drift will bite. Pick one canonical format (recommend youpc's `RoP.029.b`) and normalise.
### 4.4 EPA Beschwerdebegründung
- **paliad** UPC_APP has both `app.notice` (2mo, RoP 220.1) and `app.grounds` (2mo, RoP 220.1) — same rule code on both, both anchored 2mo from prior step.
- **youpc** has `Statement of Appeal` (R.224.1.a, 2mo from decision) and `Statement of grounds` (R.224.2.a, **4 months from decision**, not from notice). Paliad's chain "decision → 2mo notice → 2mo grounds" gives a final grounds date 4mo after decision by accident, but it models the dependency wrong: the official Rule is "grounds = 4 months **from decision**" — i.e., independent of when the notice was filed.
This is a **subtle but meaningful divergence**. If the appellant files the notice early (e.g. 1 month after decision), paliad would compute grounds at "1mo + 2mo = 3 months after decision" — incorrect; the real deadline is still 4 months after decision regardless.
The same pattern applies to the EPA Beschwerdeverfahren (`epa_app.beschwerde` + `epa_app.begr`): paliad chains them, youpc anchors both to the decision date. The note `'Ab Zustellung, nicht ab Beschwerdeeinlegung'` (migration `012_fristenrechner_rules.up.sql:211`) acknowledges this: **the data is right, the parent_id is wrong**. `epa_app.begr.parent_id = epa_app_entsch` would be correct, not `epa_app_entsch` indirectly via `epa_app.beschwerde`.
Wait — re-reading migration 012:206214 — `epa_app.begr` already has `parent_id = r_epa_app_entsch` (the decision row), not `r_epa_app.beschwerde`. So the EPA case is **right**. Verify: `epa_app.entsch → 2mo → epa_app.beschwerde` and **`epa_app.entsch → 4mo → epa_app.begr` (independent siblings).** OK. EPA is fine.
The UPC_APP case (`app.notice``app.grounds`) is **still wrong**`app.grounds.parent_id = r_app_notice` (line 153), so grounds compounds onto notice. Should be `app.grounds.parent_id = NULL` with anchor on the trigger date (the appealed decision), with duration = 4 months. Alternatively store both as siblings of an `app.decision_appealed` root. Today the displayed dates work out fine when the user enters the decision date as trigger and the notice is filed on day 60, but **break** if the user enters the notice-filing date or if the notice is filed on a non-canonical day.
### 4.5 EP_GRANT publish date
- paliad: 18 months from filing (`ep_grant.publish.parent_id = ep_grant.filing`, `ab Prioritätstag` in notes).
- youpc: not modelled in `data.deadlines`.
- Note inconsistency: paliad's `parent_id = ep_grant_filing` but the note says "Ab Prioritätstag". Filing date and priority date can differ. If the patent has a foreign priority claim, paliad will compute the publish date from the filing of the **EP** application, not the priority date — typically off by up to 12 months.
- This is the same parent-vs-anchor confusion as §4.4 UPC_APP.
---
## 5. Edge cases — youpc handles, paliad doesn't
### 5.1 Working days vs calendar days
youpc has notes on R.198 + R.213: *"Or 20 Working days, whichever is longer."* No code today implements `max(calendarDays, workingDays)`. Paliad's `DeadlineCalculator.CalculateEndDate` only takes `(value, unit)` where unit ∈ {days, weeks, months}. **Neither system actually computes the correct R.198/R.213 deadline.** If paliad ports these rules, it needs:
- A new unit `working_days`
- A `max_of_units` semantics, or two duration columns + a `combine` operator (`max` / `min`)
### 5.2 "Whichever is later" trigger events
youpc R.245.2.a/b: trigger event is *"Final decision (Service) **/** Discovery of the fundamental defect (whichever is later)"* — a single trigger event row that wraps two real-world dates. The user picks the later. youpc handles this by encoding it in the **event name** (the user reads the name, picks the later date themselves). Paliad doesn't have any "meta" trigger events like this, so the same rule would either need:
- A "compound trigger" event family, or
- Multiple separate triggers + a UI-level guidance note
### 5.3 Holiday handling — paliad WINS
youpc's `internal/services/holidays.go:4669` has an empty `defaultHolidayConfig()` with TODOs to populate. **No production holiday data is loaded**`IsUPCNonWorkingDay()` only catches Saturday/Sunday. So a deadline falling on Christmas Day in youpc is silently treated as a working day.
paliad's `internal/services/holidays.go` is materially better:
- DB-driven via `paliad.holidays` (55 rows in production, covering 2026 + 2027)
- Race-safe per-year cache via `sync.Map` of `*sync.Once`
- German federal holidays as embedded fallback (Easter via Anonymous Gregorian — same algorithm in both repos)
- Seeded UPC summer (27 Jul 28 Aug 2026) and winter vacation (24 Dec 2026 6 Jan 2027) per the official UPC Annual Report
If paliad ports the missing UPC rules, **the holiday system carries them**. youpc would need its holiday system filled in first.
### 5.4 Forward-only adjustment
Both systems push non-working deadlines **forward** to the next working day. Neither supports "previous working day" (which some legal systems use for "before" deadlines — e.g., translations 1 month *before* hearing should land on a working day at or before the target). For paliad's R.109 family port, this matters: if oral hearing is Mon, translation deadline is "1 month before" = Sun, → forward-adjustment would push to Mon (the hearing itself), which is wrong. Should push backward to Fri.
### 5.5 Soft "non-month" deadlines
youpc has `15d / 3mo` and `4mo / 15d` — wildly different durations on the same rule depending on which sub-rule (R.224.2.a vs .b) applies. paliad's tree shape can model this via two separate rules in different proceeding types, but if the same proceeding has both branches it'll need either conditional rules (`condition_rule_id`) or duplicate trees per branch.
### 5.6 Court-set deadlines (`pi.response`, `de_inf.replik`, etc.)
paliad already has `IsCourtSet` semantics: `duration=0` + non-NULL `parent_id` → UI shows "vom Gericht gesetzt" placeholder. youpc's data has the same gap (R.131.2 indication, etc.) and just stores them as separate trigger events with no calculation. **This is one area where paliad is slightly cleaner.**
### 5.7 Date arithmetic correctness
Both use Go `time.AddDate(0, n, 0)` for months — correct calendar math. Note however that youpc's `time_relationship_calculator.go:282` approximates months as 30 days (`time.Duration(value) * 30 * 24 * time.Hour`) for the **graph-based timeline calculator** path — **wrong** for legal months. This is a youpc bug, not a gap to port.
---
## 6. Amendment recommendations
Ranked by user value × implementation effort. **None of these should be implemented under this task — m needs to review §3 and §4 first.**
### Tier 1 — fix existing paliad bugs (no scope question)
1. **UPC_INF — wire the CCR-conditional adaptive rule** (§4.1).
- Public Fristenrechner today silently always uses 029.b/c (no-CCR variant). When defendant counterclaims for revocation, rejoinder is 2mo not 1mo and rule code is 029.d. Add `condition_rule_id` + `alt_*` to `inf.reply` and `inf.rejoin` rows in the public `UPC_INF` tree, mirroring the KanzlAI `INF` rows.
- Surface the toggle in the Fristenrechner UI: a checkbox "Mit Widerklage auf Nichtigkeit" between step 1 and step 2.
2. **UPC_APP grounds anchoring** (§4.4).
- Today: `app.grounds.parent_id = app.notice`, so grounds = (decision + 2mo notice + 2mo) instead of (decision + 4mo).
- Fix: change `app.grounds.parent_id` to NULL (sibling of notice) and `duration=4mo` from trigger date. Or add an explicit `app.decision_appealed` root and re-parent both.
- Same review needed for the matter-attached `APP` tree (`009_seed_deadline_rules.up.sql:269296`) — `app.grounds.parent_id = v_app_notice` there too.
3. **EP_GRANT publish — anchor on priority date, not filing date** (§4.5).
- Today: chained off `ep_grant.filing`. Note acknowledges "Ab Prioritätstag" but parent says otherwise.
- Fix: model priority date as a separate (court-event-style) input on the proceeding; or document that this rule assumes filing == priority.
4. **Normalise rule_code format** (§4.3).
- Migrate `RoP 23` / `RoP.029b` / `RoP 220.1` → uniform `RoP.023` / `RoP.029.b` / `RoP.220.1` (youpc style).
- One-time UPDATE; no schema change.
### Tier 2 — port a high-value subset of youpc deadlines
5. **Damages determination family** (R.137.2 / R.139, 3 rules) — common follow-on, no special arithmetic needed.
6. **Cost-decision appeals** (R.151, R.221.1) — frequently relevant after main proceedings end.
7. **Statement-of-Appeal "with leave" / discretionary review** (R.220.2, R.220.3) — closes a legitimate gap in the UPC_APP timeline.
8. **Cross-appeal family** (R.237, R.238.1/2, 4 rules) — straightforward calendar math, fills out the Berufung tree.
9. **Lay-open books / discovery** (R.142, 3 rules) — common in infringement cases where damages claim raised.
### Tier 3 — needs new arithmetic primitives
10. **R.198 / R.213 "Start of merits" — 31d OR 20 working days, whichever is longer.**
- Requires:
- New `duration_unit = 'working_days'` value (DeadlineService skips weekends + holidays via existing `HolidayService.IsNonWorkingDay`)
- Either a second `(alt_duration_value, alt_duration_unit, combine='max')` triple on the rule, or a Go-side composite rule type
- Decide whether to support this only for R.198/R.213 or generalise.
11. **R.245.2.a/b — Rehearing "whichever is later" trigger** (§5.2).
- Could ship as a "compound trigger" date input in the UI: two date pickers, take max.
- Alternatively, document the rule and accept manual user judgement.
### Tier 4 — separate product mode
12. **"Search by trigger event" mode for the public Fristenrechner.**
- This is the youpc deadline-calc UX. Inputs: trigger event (autocomplete from a list) + date. Output: all deadlines that flow from it.
- Requires either porting `data.events` + `data.deadlines` + `data.deadline_events` into the paliad schema, or an alternative data shape (e.g. flat list of rules tagged with trigger codes).
- This is the most fundamental gap and the most expensive — it's a second product, not a deeper rule set. m should explicitly decide whether paliad wants both modes.
13. **Procedural-defect "Correction of deficiencies" (6 rules, all 14d).**
- Hard to fit into the timeline model since the trigger ("Notification by the Registry to correct deficiencies") can fire from many different proceeding states with different rule codes. Naturally fits the trigger-event model (Tier 4), not the proceeding-tree model.
### Tier 5 — purely cosmetic
14. **Add law-citation links on rule codes** (paliad has no `deadline_rule_codes` / `deadline_laws` join). Low-value until paliad has a law-text database to link to. **Defer.**
---
## 7. Open questions for m
1. **Is the "search by trigger event" mode (Tier 4) in scope for paliad?**
- This is the single biggest gap. The youpc deadline calc is fundamentally event-driven; paliad's Fristenrechner is fundamentally proceeding-driven. They serve different jobs. If you want both, that's a sizeable second feature. If you only want timelines, the gap reduces to Tiers 13 (~10 fixable rules).
2. **The CCR adaptive bug (§4.1, recommendation 1) — do we want a UI toggle, or default-CCR-on?** A defendant facing a UPC infringement claim will almost always counterclaim for revocation. Defaulting `UPC_INF` to "with CCR" would silently fix 90 % of users without adding UI complexity. But it's a behaviour change and the result (rejoinder 2mo not 1mo) is subtle.
3. **Rule code format (§4.3) — accept normalisation to `RoP.029.b` style?** Cosmetic but disruptive if any downstream system parses the current strings.
4. **EP_GRANT priority date (recommendation 3)** — paliad doesn't model priority date as a project field today. Should we add a "priority date" input to the EP_GRANT Fristenrechner, or accept that it's an edge case for users with foreign priority claims and document the limitation?
5. **Working-days arithmetic (R.198/R.213, recommendation 10)** — only relevant for evidence-preservation cases. Real users? If never, skip Tier 3 entirely.
6. **The internal KanzlAI proceeding types (INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL) — are they still wired into the matter-attached Fristen UX?** Recent task `t-paliad-080` did a service-layer naming sweep. If those types are still surfaced anywhere, fix recommendation 2 (UPC_APP grounds) needs to be applied to both the public `UPC_APP` rules and the internal `APP` rules.
7. **youpc's deadline calc itself has known gaps** (empty holiday config — §5.3) — should paliad's amendment work also feed back to youpc? Or is youpc intentionally decoupled? (My read: keep them decoupled; paliad serves HLC, youpc serves the public.)
---
## Appendix A — File references
**paliad — source of truth:**
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (52 rules, 9 public types)
- `internal/db/migrations/009_seed_deadline_rules.up.sql` (44 rules, 7 internal types — ported from KanzlAI which itself came from youpc `data.proceeding_events`)
- `internal/db/migrations/010_seed_holidays.up.sql` (55 holiday rows)
- `internal/services/deadline_calculator.go` (date math)
- `internal/services/fristenrechner.go` (UI response shape)
- `internal/services/holidays.go` (DB-driven holidays + German federal fallback)
- `frontend/src/fristenrechner.tsx` (the 9-type wizard)
- `docs/design-prozesskostenrechner-fristenrechner.md` (intent — note: UPC_CCR was planned in §4.1 line 386 but never seeded)
**youpc — source of comparison:**
- `youpc-go/internal/services/deadline_service.go` (70-deadline calc service)
- `youpc-go/internal/services/holidays.go` (empty default config — known gap)
- `youpc-go/internal/services/time_relationship_calculator.go` (graph timeline calc — uses 30-day month approximation, **bug in youpc**)
- `youpc-go/internal/migrations/sql-migrations/039_create_proceeding_events.sql` (timeline tree schema)
- `youpc-go/internal/migrations/sql-migrations/040_adaptive_reply_deadline.sql` (CCR-conditional duration columns — origin of paliad's `condition_rule_id` pattern)
- `frontend/templates/mgmt/deadline-calculations.html` (UI shell — the actual logic is in handler/service, not template)
**Production data verified via Supabase:**
- youpc: 70 deadlines · 102 events · 140 deadline-event relations · 36 proceeding events · 6 proc types · 72 deadline-rule-code links · 0 deadline-law links
- paliad: 96 deadline_rules · 16 proc types (9 fristenrechner) · 55 holidays

View File

@@ -0,0 +1,259 @@
# Paliad Polish Audit — Triage 2 — 2026-04-29
**Source audit:** `docs/audit-polish-2026-04-27.md` (50 findings, 41 screenshots).
**Method:** for every BATCH-tagged finding, re-verified against the current
codebase (post-PRs B/D/E + the brand palette sweep + firm-name sweep).
**Goal:** mark each finding KEEP / OBSOLETE / RESCOPED / DEFER, group the keeps
into 23 ship-ready PRs, and rank what m would notice first on Monday morning.
**What already shipped since the original audit:**
- t-paliad-060 (PR-B): F-05, F-06 — `lang` attr on date/time inputs.
- t-paliad-061 (PR-D): F-11, F-17, F-18, F-19, F-26, F-44, F-45.
- t-paliad-062 (PR-E): F-02, F-03, F-08, F-09.
- t-paliad-063 (palette): F-14, F-30, F-31 (explicitly superseded per commit).
- t-paliad-064 (reminder redesign): the new settings UI uses inline-flex
`caldav-toggle-label` blocks → incidentally fixes F-22.
- t-paliad-065 (firm-name): F-01.
- Pre-audit (t-paliad-049 modal/breadcrumb polish): /projects/new already has
a Cancel button (`btn-cancel`) → F-34 OBSOLETE.
---
## Verification table
| ID | Status | Notes |
|---|---|---|
| F-01 | OBSOLETE | Stripped by t-paliad-065 firm sweep. Curl of `/` and `/login` shows only "Paliad" + "HLC". |
| F-02 | OBSOLETE | t-paliad-062 (search input padding). |
| F-03 | OBSOLETE | t-paliad-062 (migration 024 column rename). |
| F-04 | KEEP | `frontend/src/client/deadlines-new.ts:45` still references `fristen.field.project.choose`; `i18n.ts` only has `fristen.field.akte.choose` → renders raw key. Either rename in deadlines-new.ts to `fristen.field.akte.choose`, or add the new key. |
| F-05 | OBSOLETE | t-paliad-060. |
| F-06 | OBSOLETE | t-paliad-060. |
| F-07 | KEEP | `internal/services/project_service.go:625` emits `project_type_changed` with English title "Project type changed"; description embeds raw English values (`case → litigation`). Dashboard activity widget renders these verbatim. Plus mixed-language nouns ("Note zu deadline hinzugefügt"). Same class as the t-paliad-037 sweep but for newer events. |
| F-08 | OBSOLETE | t-paliad-062. |
| F-09 | OBSOLETE | t-paliad-062. |
| F-10 | KEEP | The `inf.rejoin` raw slug renders because client-side rule lookup falls through when no matching rule label exists. Need a missing-rule label fallback (display "—" or a humanized variant) and/or i18n key for known catalog slugs. |
| F-11 | OBSOLETE | t-paliad-061. |
| F-12 | KEEP | `frontend/src/deadlines.tsx:101` and `frontend/src/appointments.tsx:101` still ship `<th data-i18n="fristen.col.akte">Akte</th>` / `termine.col.akte`; i18n.ts L541/1043 still maps to "Akte". PR-D fixed the *filter* dropdown; the *column header* was deliberately left for PR-A which never ran. |
| F-13 | KEEP | `client/appointments.ts:153` and `client/deadlines.ts:197` render `<span class="frist-project-title">` but `global.css:4716` only styles `.frist-akte-title { display: block; }` — class-name mismatch from the rename → ref + title render inline → "L-2026-001Siemens AG ./." collision. Trivial: rename the CSS or the markup. |
| F-14 | OBSOLETE | t-paliad-063 (palette sweep). |
| F-15 | KEEP | `projects-detail.tsx:334` still uses `className="btn-danger"` (red `#dc2626`) for the "Projekt archivieren" button. The destructive-modal confirm action also uses `btn-danger`, which is fine — it's the entry-point button on the working surface that screams. |
| F-16 | KEEP | `global.css:4089-4093` still ships saturated random colors per type chip (`akten-type-client` lavender, `akten-type-litigation` pink-red, `akten-type-patent` cyan, `akten-type-case` salmon, `akten-type-project` neutral-green). Same classes drive `/projects` and `/admin/team`. |
| F-17 | OBSOLETE | t-paliad-061. |
| F-18 | OBSOLETE | t-paliad-061. |
| F-19 | OBSOLETE | t-paliad-061. |
| F-20 | RESCOPED | Palette sweep harmonized the *colour* (every active tab now points at `--hlc-lime`), but the structural inconsistency remains: `.akten-tab.active` uses `font-weight: 600` + midnight text, while `.login-tab.active` and `.gebuehren-tab.active` use accent-coloured text. Drop to one rule. |
| F-21 | KEEP | `internal/services/deadline_service.go:306` still inserts events with title `"Deadline updated"` (English, in DE narrative on /projects/{id}/history). Same fix lane as F-07. |
| F-22 | OBSOLETE | t-paliad-064 PR-3/4 introduced `caldav-toggle-label` (`display: inline-flex`, `gap: 0.5rem`) for every checkbox row — labels and checkboxes are now adjacent. |
| F-23 | DEFER | Hiding STATUS when single-valued is a design call (some users like the redundancy as a trust signal). Punt to a later "table density" pass. |
| F-24 | KEEP | Mobile filter row still stacks awkwardly. Single-page CSS fix on `/projects` (and adjacent `/deadlines`/`/appointments`). |
| F-25 | DEFER | Card-layout-on-mobile is a design refactor, not a polish edit. Spans `/projects`, `/deadlines`, `/appointments`, `/admin/team`. Out of scope for polish-2; flag as a standalone t-task. |
| F-26 | OBSOLETE | t-paliad-061. |
| F-27 | KEEP | `client/projects-detail.ts:1143` always renders the breadcrumb; no path-depth check. One conditional in `renderBreadcrumb()`. |
| F-28 | KEEP | Cell empty-placeholder is split: `/admin/team` and `/projects/{id}/deadlines` use "—"; `/projects` (REFERENZ, CLIENTMATTER) and `/appointments` (ORT) render blank. Pick one and grep for empty-cell renders. |
| F-29 | KEEP | TSX has a real `<a href="/checklists">` but `i18n.ts` strings (L949/2190) ship plain text "…unter Checklisten angelegt." On runtime translation, the anchor disappears. Fix: store the link in i18n with a placeholder (`{link}`) and substitute at render, or render two strings and inject the anchor. |
| F-30 | OBSOLETE | t-paliad-063 sidebar reskin. |
| F-31 | OBSOLETE | t-paliad-063 (button restyle). |
| F-32 | DEFER | The agenda was redesigned (day-bucket section headings now exist). The per-card urgency pill is still rendered, but it now carries information *only* when the urgency disagrees with the bucket (e.g. an "Überfällig" item appearing under HEUTE). Keeping it is defensible. Mark as design-call, not polish. |
| F-33 | KEEP | One `title=` attribute per project-ref render in `dashboard.ts` and `agenda.ts`. Trivial. |
| F-34 | OBSOLETE | `projects-new.tsx:46` already ships `<a className="btn-cancel" data-i18n="projekte.cancel">Abbrechen</a>`. |
| F-35 | KEEP | `projects.tsx:34` + `i18n.ts:844` still read "Mandanten, Streitsachen, Patente und Fälle …". Actual taxonomy is Mandant / Streitsache / Patent / Verfahren / Projekt. Replace "Fälle" → "Verfahren" (and possibly mention "Projekte"). |
| F-36 | KEEP | `ProjectFormFields.tsx:19` still ships `<option value="client">Mandant</option>` first → implicit default. Switch to a "Bitte wählen…" placeholder option, or default to `case`. |
| F-37 | KEEP | `client/notes.ts` textarea has no Strg+Enter / Cmd+Enter hint, no character counter, no markdown hint. Add a small footer line. |
| F-38 | DEFER | Bottom-nav badge semantics is a design decision — needs to match the agenda urgency definition. Tackle alongside any future agenda-redesign task. |
| F-39 | KEEP | Tree view shows "11" while flat shows "11 / 11"; pick one format. One client edit. |
| F-40 | DEFER | Glossary chip language ("Litigation" / "Prosecution" vs "Allgemein") is a product decision, not a polish fix. m to call. |
| F-41 | OBSOLETE | Was tagged OK in the audit. |
| F-42 | KEEP | Same fix as F-13 (`frist-project-title` CSS class) plus a monospace ref pill style + ellipsis on title. Bundle with F-13. |
| F-43 | KEEP | Empty state on `/projects/{id}/parties` is a single line — add an empty-state CTA card (matches the pattern used elsewhere). |
| F-44 | OBSOLETE | t-paliad-061. |
| F-45 | OBSOLETE | t-paliad-061. |
| F-46 | KEEP | `i18n.ts:1906` still maps `dashboard.greeting.prefix` to "Good day". Change to "Hello" (or "Hi"). One-line. |
| F-47 | KEEP | `/settings` profile placeholder "z.B. Associate, Partner, PA" still mixed EN/DE in i18n. One-line. |
| F-48 | DEFER | `/projects/{id}/sub-projects` would 404, but the canonical `/children` URL works and tabs auto-resolve to it. Aliasing is low-value; flag the canonical path in docs instead. |
| F-49 | DEFER | Tagged DEFER in original audit. |
| F-50 | KEEP | One CSS rule on `<main>` (or `body`) — bottom-padding equal to bottom-nav height on `<768px`. |
**Summary:** 18 OBSOLETE (already shipped), 26 KEEP, 1 RESCOPED (F-20), 7 DEFER (F-23, F-25, F-32, F-38, F-40, F-48, F-49). The KEEP set is the polish-2 backlog.
---
## PR plan — 3 bundles
### PR-1 — i18n leak sweep + activity log narrative 🟡
Single concern: text rendered to a German narrative that's still English or
raw-keyed. Ship as one PR — they're touched in adjacent files and the
reviewer can verify them together by walking the dashboard and the activity
tab.
**Includes:** F-04, F-07, F-10, F-12, F-21, F-29, F-35, F-46.
- F-04 (deadlines-new picker key) — i18n key add or rename. *Pure i18n.ts.*
- F-07 (dashboard activity event types + narrative nouns) — needs three
edits: (a) Go service-side, switch event titles from "Project type changed"
/ "Note added to deadline" to neutral identifiers; (b) i18n.ts add
`dashboard.action.project_type_changed`, `…note_added`, etc.; (c) frontend
dashboard renderer translates the event_type and the dynamic values
(`case` → t("projekte.type.case")) before joining into the narrative.
*This is the only Go-side change in the bundle.*
- F-10 (raw `inf.rejoin`) — frontend rule-label lookup falls back to "—"
when no label exists; can be done in `client/deadlines.ts` only. Optional
follow-up: backfill `fristen.rule.<slug>` keys.
- F-12 (AKTE column header) — flip `fristen.col.akte` and `termine.col.akte`
to "Projekt" / "Matter" (DE/EN), plus the `data-i18n` attribute label.
- F-21 ("Deadline updated" in Verlauf) — same shape as F-07; one Go edit
(`deadline_service.go:306` title → neutral identifier) plus i18n key add.
- F-29 (checklists empty-state link) — store the empty-state copy as two
i18n strings or a `{link}` placeholder; render with a real `<a>`.
- F-35 (subtitle taxonomy) — flip "Fälle" → "Verfahren" in
`projekte.subtitle` (DE+EN) and the SSR fallback in `projects.tsx:34`.
- F-46 (Good day) — one-line i18n change.
**Risk:** medium. Go-side event-emission edits need a smoke test of the
activity feed (dashboard widget + project Verlauf tab) post-deploy. Existing
events in the DB carry the *old* English titles — the renderer should
translate the event_type, not the stored title (so historical rows benefit
too). Worth calling out explicitly in the PR description.
### PR-2 — visual residue + small per-page polish 🟢
Single concern: small CSS/markup edits, mostly self-contained per page.
**Includes:** F-13, F-15, F-24, F-27, F-28, F-33, F-36, F-39, F-42, F-43,
F-47, F-50.
- F-13 (appointments AKTE collision) — rename CSS rule
`.frist-akte-title``.frist-project-title` (or add the new selector).
Same rule fixes F-42 partially.
- F-15 (red archive button) — change `className="btn-danger"`
`btn-secondary` (or introduce `btn-archive` with neutral/outline styling).
Modal confirm button stays red.
- F-24 (mobile filter row wrapping) — `/projects` filter container CSS:
`flex-direction: column` + `align-items: stretch` at `<480px`; each
filter as its own labelled block.
- F-27 (single-child breadcrumb) — `renderBreadcrumb` early-return when
the chain has length ≤ 1.
- F-28 (placeholder consistency) — grep cell renderers; render "—" for
empty REFERENZ, CLIENTMATTER, ORT, REGEL, WEITERE STANDORTE.
- F-33 (truncated ref tooltip) — `title=` attr on
`.dashboard-upcoming-project-ref` and `.agenda-item-project`.
- F-36 (Mandant default) — add a `<option value="" disabled selected>` /
`Bitte wählen…` first row in `ProjectFormFields.tsx:18`.
- F-39 (search counter format) — match flat ("11 / 11") to tree view, or
the other way, by editing the tree-view counter.
- F-42 (deadlines AKTE wrapping) — same CSS rename as F-13 + `text-overflow:
ellipsis` on `.frist-project-title` with a `title=` for the full text.
- F-43 (parties empty state) — add an empty-state CTA card with a "Partei
hinzufügen" call.
- F-47 (settings placeholder mixed) — pick all-DE or all-EN; one i18n edit.
- F-50 (mobile bottom-nav overlap) — `<main>` `padding-bottom: var(--bottom-nav-h)`
at `<768px`.
**Risk:** low. Each change is local; the only cross-cutting bit is the F-13
+ F-42 CSS rename, but the class is referenced in exactly two TS files.
### PR-3 — visual consistency: tabs + chips + Notiz hint 🟡
Single concern: harmonization that touches several pages with one change
each.
**Includes:** F-16, F-20, F-37.
- F-16 (type pill saturated colors) — neutralize to a single chip background
with the type icon for differentiation; reserve color-as-signal for the
*Mandant* root (or for `/admin/team` STANDORT). Touches `global.css:4089-4093`
+ `admin-team.tsx` chip render.
- F-20 (tab styling) — collapse the three active-tab rules
(`.akten-tab.active`, `.login-tab.active`, `.gebuehren-tab.active`) to one
shared style. Could simply make the two minority rules `@extend` the
canonical lime-underline + midnight-text + 600-weight pattern.
- F-37 (Notiz textarea hint) — small footer line under the textarea with
"Strg+Enter zum Speichern" (DE) / "Cmd+Enter to save" (EN).
**Risk:** medium. Tab rule consolidation is the riskiest edit in the
backlog — it touches every `.login-tab` and `.gebuehren-tab` consumer
(login page + Gebührentabellen page). Verify both visually post-edit.
The chip change is visually larger but lower risk because the saturated
colors carry no behaviour.
---
## Top 5 — what m notices first on Monday morning
1. **F-07 — Dashboard activity log English event types.** The activity
widget is on the landing page, every login. Reading "Test Tester
project_type_changed" or "Note zu deadline hinzugefügt" makes the app
feel half-baked in 30 seconds. Sibling F-21 lives on the project Verlauf
tab (next-most-read). High visibility, medium effort.
2. **F-15 — "Projekt archivieren" red button.** Wrong affordance changes
user behaviour: real lawyers will hesitate to archive routine matters
because "red = scary". Trivial fix, biggest behaviour delta.
3. **F-04 — Raw `fristen.field.project.choose` key on /deadlines/new.**
Visible, plainly broken text in a primary form. One-line fix.
4. **F-12 — AKTE column header on /deadlines and /appointments.** The
filter dropdown was renamed to "Projekt" in PR-D; the *column header* still
says "Akte" in the same row. Side-by-side inconsistency screams "rename
half-done". Trivial.
5. **F-13 — `L-2026-001Siemens AG ./.` collision on /appointments AKTE
cell.** Looks like a data-corruption bug at first glance. Caused by a
single CSS class rename that was missed; one-line fix.
(All five fit in PR-1 + PR-2 above. Recommend shipping those two first.)
### Honourable mentions (#610)
6. F-46 (Good day → Hello) — one-line warmth fix on EN dashboard.
7. F-29 (empty-state link not real) — feels broken when you click the word
and nothing happens.
8. F-16 (type pill saturated colors) — calmer chip palette makes /projects
feel less alarming.
9. F-35 (subtitle taxonomy "Fälle") — small but visible on /projects intro.
10. F-50 (mobile bottom-nav overlap on dashboard) — only mobile, but
immediately visible to anyone on a phone.
---
## Defer list
Findings where polish-2 isn't the right scope — either they need a design
call, span a redesign-class change, or carry low value-per-effort.
- **F-23** — STATUS column noise. Hiding when single-valued is a usability
call (some users like the redundancy). Defer to a "table density" pass.
- **F-25** — Mobile tables → card layout. Genuine redesign across four
pages. Should be its own t-task with screenshots and an alignment
pass on the mobile pattern. Out of scope for "polish".
- **F-32** — Agenda redundant urgency pill. After the day-bucket redesign,
the pill *can* still differ from the bucket (e.g. an overdue item under
HEUTE). Keeping it is defensible; design call before changing.
- **F-38** — Bottom-nav agenda badge semantics. Needs to match the agenda
redesign decision; tackle there.
- **F-40** — Glossary chip language (EN/DE mix). Product decision (m).
- **F-48** — `/sub-projects` URL alias. The canonical `/children` works;
guessable-URL-alias is low value. Document the canonical path instead.
- **F-49** Already DEFER in original audit (meta-circular changelog
entry).
---
## Recommendation summary
- Ship **PR-1** (i18n leak sweep + activity log narrative) first biggest
user-visible delta, contains 3 of the top-5.
- Ship **PR-2** (small visual residue) right after low-risk, high
per-edit value, contains the other 2 top-5 items + F-46 / F-50.
- **PR-3** (tab + chip consistency) is worthwhile but riskier; OK to land
after PR-1/PR-2 stabilize. Leave for later in the week or punt to a
separate t-task if velocity is constrained.
- **DEFER list** to a later "design-pass" or "mobile-pass" task; do not
bundle them with these PRs.
## Acceptance
- [x] Every BATCH finding (F-01..F-50) classified KEEP / OBSOLETE /
RESCOPED / DEFER against current code state.
- [x] Keeps grouped into 3 PR bundles with effort + risk + finding IDs.
- [x] Top 5 ranked with rationale.
- [x] Defer list with reason per item.
- [ ] Head greenlights individual PRs before any coder shift.

View File

@@ -0,0 +1,440 @@
# Paliad Polish Audit — 2026-04-27
**Scope:** survey-only. Find high-value, low-risk UX improvements across the
authenticated paliad surface. **No fixes in this doc** — head dispatches
implementation tasks separately.
**Method:** Playwright headless against `https://paliad.de`, logged in as
`tester@hlc.de` (admin, Munich, DE), captures at 1280×900 (desktop primary),
spot-checks at 375×900 (mobile) and DE/EN toggle. 41 screenshots in
`tests/screenshots-polish-2026-04-27/`.
**Bias:** what would a HLC patent lawyer notice on Monday morning? Spacing,
copy, brand, i18n leaks, stale firm names, English in DE narrative, and
broken-feeling defaults. Architectural changes, perf, new features — out of
scope.
**Severity legend:** 🔴 broken / 🟠 friction / 🟡 polish.
**Effort legend:** 🟢 ≤30min / 🟡 12h / 🔴 half-day+.
**Scope:** `BATCH` = bundle as one PR with siblings; `STANDALONE` = own task;
`DEFER` = mention but don't ship now.
---
## Findings
### 🔴 Broken — visible bugs / data-incorrect / leaking implementation
**F-01 — Marketing landing still says "Hogan Lovells", page title same** 🟢 STANDALONE
The hero on `/` reads *"Patent Knowledge for Hogan Lovells"* and the subtitle
*"Guides, templates, and documents for the HL patent team."* The browser tab
title is *"Paliad — Patent Knowledge for Hogan Lovells"*. Per CLAUDE.md the
firm rebranded to **HLC** on 2026-04-16; this is the first thing colleagues
see. Same stale firm reference on `/downloads` ("HL Patents Style", "für das
HL Patent-Team"). _Screens: `34-landing.png`, `25-downloads.png`._
**F-02 — `/admin/team` search input has overlapping placeholder text** 🟢 STANDALONE
The search box visibly renders *two* pieces of placeholder copy on top of
each other: *"Nach Name oder E-Mail"* + *"suche"*. Reproducible at desktop
and mobile. Looks like a `data-i18n-placeholder` doubled with another
attribute. _Screens: `30-admin-team.png`, `36-admin-team-mobile.png`._
**F-03 — `/api/departments?include=members` returns 500 on `/team`** 🟡 STANDALONE
Console error every time `/team` loads as the test admin. Page falls back
to `/api/users` so it still renders, but the dept-grouped toggle uses the
500'd endpoint. Smoke 2026-04-25 reported the same error class; t-paliad-037
claimed to fix it via INNER JOIN. Either a regression or a different code
path. Worth a fresh look. _Screens: `03-team-1280.png` + console log._
**F-04 — `/deadlines/new` shows raw i18n key in the project picker** 🟢 STANDALONE
The default option text in the *Akte* `<select>` is literally
`fristen.field.project.choose`. t-paliad-037 fixed `fristen.filter.project.all`
but missed `.choose`. Add the key DE+EN, done. _Screen: `16-deadline-new.png`._
**F-05 — Date pickers show US `mm/dd/yyyy` in German UI** 🟢 STANDALONE
`/deadlines/new` Fälligkeitsdatum field, `/appointments` Von/Bis filters,
and inline date inputs across the app render `mm/dd/yyyy` placeholder text
even though the user's `lang` is `de`. Native `<input type="date">` follows
the *browser* locale, not the page's `lang` attribute. Fix: set `lang="de"`
on the input element (or render the date in a labelled wrapper). The
*output* dates everywhere are correctly `27.04.2026` — only inputs are wrong.
_Screens: `16-deadline-new.png`, `17-appointments-list.png`._
**F-06 — Time pickers show 12-hour `09:00 AM` / `04:00 PM` in DE Settings** 🟢 STANDALONE
`/settings?tab=benachrichtigungen` reminder time fields render in 12h format
with English AM/PM. Same root cause as F-05: native `<input type="time">`.
Set `lang="de"` on the inputs to force 24h. _Screen: `28-settings-notifications.png`._
**F-07 — Activity log leaks raw English event types** 🟡 STANDALONE
Dashboard "Letzte Aktivität" includes:
- *"Test Tester project_type_changed"* — raw English event slug instead of a
translated verb.
- *"Type case → litigation"* — raw English values inside German narrative.
- *"Note zu deadline hinzugefügt"* and *"Deadline „Foo" geändert"* — English
nouns ("Note", "Deadline") inside German prose; should be "Notiz" / "Frist".
Same class as Bug 4 from the 2026-04-25 smoke audit but for events shipped
in t-paliad-056 and the polymorphic notes. Add the missing
`dashboard.action.project_type_changed`, render dynamic value in the
description (translated), and switch the noun. _Screens: `01-dashboard-1280-viewport.png`, `06-project-detail-1280.png`._
**F-08 — Project tabs use `href="#"` (or `…/history#`)** 🟡 BATCH
Every tab on `/projects/{id}` (Verlauf/Team/Untergeordnet/Parteien/Fristen/
Termine/Notizen/Checklisten) is a JS-only navigation: middle-click and
"open in new tab" don't work. The URL *does* update via history.replaceState
when you click them, so the routes are real (`/projects/{id}/team`,
`/projects/{id}/parties`, etc.) — the anchors just aren't pointing at them.
Wire the real path into `href` and let the click handler `preventDefault` for
the SPA flow. _Screens: `06-project-detail-1280.png`, `07-project-team-1280.png`._
**F-09 — `?view=tree` URL parameter is silently ignored on `/projects`** 🟢 STANDALONE
Visiting `https://paliad.de/projects?view=tree` shows the flat list with the
"Ansicht" dropdown set to "Flache Liste". The `?view=` query param has no SSR
effect. Bookmarks, dashboard links, and shareable filtered views don't work.
_Screens: `04-projects-list-1280.png`, `05b-projects-tree-actual.png`._
**F-10 — REGEL column shows raw rule slug `inf.rejoin` on `/deadlines`** 🟢 BATCH
Two rows show "inf.rejoin" instead of a human label like "Replik (Patent)" or
similar. Either a missing i18n key or a missing display lookup against the
Fristenrechner regel-catalog. _Screen: `15-deadlines-list.png`._
**F-11 — Office values render lowercase, no umlauts** 🟢 BATCH
`/projects/{id}/team` lists members with `· duesseldorf`, `· munich`
slugs from the DB, not the localized labels. The offices module already has
`LabelDE` / `LabelEN`; just look up by key. _Screen: `07-project-team-1280.png`._
**F-12 — AKTE column header + filter still says "Akte" on `/deadlines`** 🟢 BATCH
The column header on the deadlines table is "AKTE", and the filter dropdown
shows "Alle Akten". The rest of the app uses Projekt/Projekte after the
rename. Same on `/appointments`. _Screens: `15-deadlines-list.png`,
`17-appointments-list.png`._
**F-13 — `L-2026-001Siemens AG ./.` collision on `/appointments` AKTE cell** 🟢 BATCH
The reference code and the project title are concatenated with no separator
in the AKTE column ("`L-2026-001Siemens AG ./. Huawei Technologies`"). Need
either a space, a delimiter, or two visual lines. _Screen: `17-appointments-list.png`._
### 🟠 Friction — visibly inconsistent / awkward
**F-14 — Two greens fight everywhere (forest dark green vs lime brand)** 🟡 STANDALONE
Brand inconsistency is the most pervasive issue in the app. Lime
`--accent (#c6f41c)` is the brand; but a darker forest green is used for many
primary CTAs and active filter chips. Same page often has both:
- Lime: "Neue Frist", "Neuer Termin", "Neues Projekt", "Mitglied hinzufügen",
"Frist hinzufügen", "Hinzufügen" (Notizen), "Zurück zum Dashboard" CTA on
404, "Vergleichen" preset chips, year tab on Gebührentabellen ("2025
(Aktuell)").
- Forest dark green: "Vergleichen" submit, "Begriff vorschlagen", "Korrektur
vorschlagen", "Link vorschlagen", "Sign In", "Bestehendes Konto onboarden",
"Neue:n Kolleg:in einladen", "Nachschlagen", filter "Alle" chips on
Checklisten/Gerichte/Links/Glossar, year-bucket tabs, GKG/RVG/UPC/EPA tabs
on Gebührentabellen, the active sidebar tab indicator on Settings.
Pick one (lime is the brand) and convert. Touches lots of files but each edit
is trivial. _Screens: most of them — see `19-kostenrechner.png`,
`20-gebuehrentabellen.png`, `22-glossary.png`, `23-courts.png`, `24-links.png`,
`30-admin-team.png`, `33-login.png`._
**F-15 — "Projekt archivieren" button is bright red** 🟢 STANDALONE
Bottom of every project-detail tab. Archiving is reversible — red signals
*destructive* and will make real lawyers hesitate to archive routine matters,
defeating the affordance. Make it neutral/outline (or amber if you want
*caution* without *danger*). Reserve red for Löschen. _Screens: `06-project-detail-1280.png`,
`07-project-team-1280.png`, `09-project-parties-1280.png`, `10-project-deadlines-tab.png`._
**F-16 — Type pills on `/projects` use saturated random colors** 🟡 STANDALONE
Mandant=lavender, Streitsache=pink-red, Patent=cyan, Verfahren=salmon-orange,
Projekt=neutral. The colors aren't carrying meaning (they're not
ordered/ranked) and red-pink looks alarming for a routine type label.
Recommend: single neutral chip with the type icon (project-tree.ts has
icons), use color only when the type *is* the salient signal (e.g. Mandant
to mark a client root). Same critique for STANDORT pills on `/admin/team`
which random-color per office. _Screens: `04-projects-list-1280.png`,
`05b-projects-tree-actual.png`, `30-admin-team.png`._
**F-17 — "Lead" role label is English in German UI** 🟢 BATCH
`/projects/{id}/team` ROLLE column shows literal "Lead". Subtitle on
`/projects/new`: *„Sie werden als „Lead" automatisch hinzugefügt"*. Should
be "Leitung" or "Verantwortlich" in DE; keep "Lead" in EN. _Screens:
`07-project-team-1280.png`, `14-projects-new.png`._
**F-18 — "Berechtigung" column on `/admin/team` shows "Global Admin", "Standard"** 🟢 BATCH
Half-translated. Either translate both ("Globaler Admin" / "Standard" — both
fine in DE) or display a localized label keyed off the role enum. _Screen:
`30-admin-team.png`._
**F-19 — German DOM IDs lingering on `/projects`** 🟢 BATCH
Dropdowns have `id="projekt-type"`, `id="projekt-view"`, `id="akten-status"`
even though everywhere else this app is now English-coded. Stale rename
sweep — flag for the next pass. _Screen: `04-projects-list-1280.png` (DOM)._
**F-20 — Tab styling is inconsistent across the app** 🟡 BATCH
Three different tab styles in current use:
- Lime underline on active tab: `/settings`, `/projects/{id}` — the canonical
pattern.
- Color-only no underline: `/tools/gebuehrentabellen` (GKG/RVG/UPC/EPA tabs).
- Card grid with badges: `/admin`.
Pull all top-level tab navs into the lime-underline pattern; the card grid
on /admin is fine because it's a launcher, not nav. _Screens:
`20-gebuehrentabellen.png`, `27-settings.png`, `06-project-detail-1280.png`._
**F-21 — "Deadline updated" English event title in Verlauf** 🟢 BATCH
On `/projects/{id}/history`, an event row reads *"Deadline updated"* + DE
description. Same i18n class as F-07 — different code path. _Screen:
`06-project-detail-1280.png`._
**F-22 — Settings notification checkboxes are far from their labels** 🟢 BATCH
`/settings?tab=benachrichtigungen` lays out checkboxes flush-right and
labels flush-left with a wide gap between. Hard to scan which checkbox
belongs to which option. Pull them together (label + checkbox in the same
row, justify-start). _Screen: `28-settings-notifications.png`._
**F-23 — Status column noise on `/deadlines` and `/projects`** 🟡 BATCH
`/deadlines` shows STATUS=Offen on every row (filter default is "Alle
offenen"). `/projects` shows STATUS=Aktiv on every row (filter default is
visible status). When the filter constrains the value, the column adds
nothing. Either hide the column when single-valued or move it to a small
tag in the title cell. _Screens: `04-projects-list-1280.png`, `15-deadlines-list.png`._
**F-24 — Dropdowns in `/projects` filter row wrap awkwardly on mobile** 🟢 BATCH
At 375 the Typ/Status/Ansicht filter row stacks oddly: each label floats on
its own line, selects on the next, no clean grouping. Should stack each as
a labelled block (label above select, full-width). _Screen: `35-projects-mobile.png`._
**F-25 — Mobile project + admin tables overflow horizontally** 🟡 BATCH
At 375 the `/projects` table shows TITEL + TYP only (4 other columns clipped
right). `/admin/team` shows NAME + E-MAIL only. No horizontal scroll
indicator, and important columns (Status, last-modified, Standort, Rolle)
just disappear. Card layout on mobile is the standard fix. _Screens:
`35-projects-mobile.png`, `36-admin-team-mobile.png`._
**F-26 — "Akte" filter dropdown label on `/deadlines`/`/appointments` is "Akte"** 🟢 BATCH
Already covered by F-12 but worth flagging: filter label literally says
"Akte" while the rest of the app says "Projekt".
**F-27 — Single-child breadcrumb is redundant** 🟢 BATCH
On `/projects/{root-id}/{tab}` the breadcrumb shows just the project title
in a pill, then below it the H1 shows the same title. When path-depth=1,
hide the breadcrumb. _Screens: `10-project-deadlines-tab.png`,
`12-project-notizen-tab.png`._
**F-28 — Empty placeholder inconsistency: "—" vs blank cell** 🟢 BATCH
- `/projects` REFERENZ + CLIENTMATTER cells render blank when empty.
- `/admin/team` WEITERE STANDORTE renders "—".
- `/projects/{id}/deadlines` REGEL renders "—".
- `/appointments` ORT renders blank when empty.
Pick one (recommend "—") and apply consistently. _Screens: `04-projects-list-1280.png`,
`17-appointments-list.png`, `30-admin-team.png`._
**F-29 — `/projects/{id}/checklists` empty state references "Vorlagen-Seite" as plain text** 🟢 BATCH
Empty-state copy says *"Instanzen werden auf der Vorlagen-Seite unter
Checklisten angelegt."* — but "Checklisten" is just text, not a link. Make
it a real `<a href="/checklists">` so the user can jump there. _Screen:
`13-project-checklists-tab.png`._
**F-30 — Email cells on `/admin/team` are default-blue underlined links** 🟢 BATCH
Inconsistent with the lime accent system used everywhere else. Either
restyle as a normal text + small icon, or keep `<a href="mailto:">` but use
the Paliad link styling. _Screen: `30-admin-team.png`._
### 🟡 Polish — small wins
**F-31 — `/deadlines` "Kalenderansicht" link is underlined plain text next to a styled button** 🟢 BATCH
Inconsistent click affordance with the adjacent "Neue Frist" filled button.
Make it a secondary outline button (or vice-versa). _Screen: `15-deadlines-list.png`._
**F-32 — `/agenda` redundant status pill below each card** 🟡 BATCH
Cards already carry an urgency stripe on the left edge (red/orange/green).
The pill ("HEUTE", "MORGEN", "IN 2 TAGEN", "DIESE WOCHE", "SPÄTER") sits as
a separate row below each card and duplicates the visual signal. Move to a
small tag inside the card next to the title, or drop it. _Screen:
`02-agenda-1280-viewport.png`._
**F-33 — `/dashboard` upcoming-list project refs truncate without tooltip** 🟢 BATCH
"C-UPC-0002 · UPC-CFI München — Klage Siemens ./. Hu…" — ellipsis but no
title attribute, so hovering doesn't reveal the full reference. Add `title=`
on the project-ref element. _Screen: `01-dashboard-1280-viewport.png`._
**F-34 — `/projects/new` has no Cancel button (just back-link)** 🟢 BATCH
`/deadlines/new` shows a standard "Abbrechen" + primary submit pair;
`/projects/new` only has the form with submit at the bottom — back to list
is via the breadcrumb only. Add the Abbrechen button for parity. _Screens:
`14-projects-new.png`, `16-deadline-new.png`._
**F-35 — "Mandant, Streitsache, Patente und Fälle" subtitle on `/projects` does not match the type taxonomy** 🟢 BATCH
The actual types are Mandant/Streitsache/Patent/Verfahren/Projekt — no
"Fälle". Subtitle copy is stale. _Screen: `04-projects-list-1280.png`._
**F-36 — "Mandant" type is the default on `/projects/new`** 🟢 BATCH
Most projects created in production are Verfahren (per current data). A
Mandant project is created rarely (one per client). Better default:
Verfahren, or "Bitte wählen…" with required validation. _Screen: `14-projects-new.png`._
**F-37 — Notiz textarea has no formatting/length hint** 🟢 BATCH
`/projects/{id}/notes` textarea has no character counter, no markdown hint,
no Strg+Enter shortcut hint. Add a small footer with at least the keyboard
hint. _Screen: `12-project-notizen-tab.png`._
**F-38 — Bottom-nav agenda badge "2" semantics unclear** 🟢 STANDALONE
Mobile bottom nav shows "2" badge on the Agenda icon. Not clear if that's
"2 today", "2 unread", "2 overdue", "2 this week". Add a `title=` or limit
to overdue-only. _Screens: `35-projects-mobile.png`, `36-admin-team-mobile.png`._
**F-39 — Search counter inconsistency between flat and tree views** 🟢 BATCH
`/projects` flat list shows "11 / 11" in the search box; tree view shows
just "11". Match the format. _Screen: `05b-projects-tree-actual.png`._
**F-40 — Glossar filter chips mix English + German** 🟡 BATCH
Filter chips: "Alle / Litigation / Prosecution / UPC / EPA / SEP/FRAND /
Allgemein". "Litigation" / "Prosecution" are English while "Allgemein" is
German. Decide: are these jargon kept in EN intentionally (defensible —
patent lawyers use them in EN), or convert all to DE? At least make this
decision explicit and consistent. _Screen: `22-glossary.png`._
**F-41 — Date input on `/deadlines/new` defaults to today (good)** 🟢 OK
Not a finding — observed-good behaviour worth keeping.
**F-42 — `/deadlines` table AKTE column line-wrapping** 🟢 BATCH
Project ref + title is one long string ("C-UPC-0001 UPC-CFI München — Klage
Siemens ./. Huawei (EP3456789)") that wraps to 2 lines per row, ballooning
row height. Split into a small monospace ref pill + a `text-overflow:
ellipsis` title with a tooltip. _Screen: `15-deadlines-list.png`._
**F-43 — `/projects/{id}/parties` empty state is bare** 🟢 BATCH
Just "Noch keine Parteien eingetragen." — the "Partei hinzufügen" button is
at the top. Add an empty-state CTA card below the message. _Screen:
`09-project-parties-1280.png`._
**F-44 — "Departments / Dezernate" admin card uses slash-mixed languages** 🟢 BATCH
Just "Dezernate" suffices. _Screen: `29-admin.png`._
**F-45 — "Dezernat / Partner" settings field uses slash separator unusual in DE** 🟢 BATCH
Reads more naturally as "Dezernat oder Partner". _Screen: `27-settings.png`._
**F-46 — "Good day, Test Tester" greeting on EN dashboard** 🟢 BATCH
"Good day" is correct German→EN literal but stiff. "Hello" / "Hi" reads
warmer. _Screen: `32-dashboard-EN.png`._
**F-47 — `/settings` profile placeholder mixes EN/DE** 🟢 BATCH
*"z.B. Associate, Partner, PA"* — Associate is EN, Partner is both, PA is
abbrev. Either keep EN-only as legal-jargon convention, or move to all DE.
Currently inconsistent. _Screen: `27-settings.png`._
**F-48 — `/projects/{id}/sub-projects` is 404, but tab label is "Untergeordnet"** 🟢 BATCH
URL slug for the Untergeordnet tab is something else (the JS handles it
client-side). If a user types `/sub-projects` from intuition they hit the
404 page. Either alias the slug or document the canonical URL. _Screen:
`08-project-untergeordnet-1280.png`._
**F-49 — `/changelog` first entry is meta-circular** 🟡 DEFER
Top entry titled "Neuigkeiten" describes the page itself. Cute on first
load, weird as the entry ages. Drop or replace with content news. _Screen:
`26-changelog.png`._
**F-50 — Mobile bottom-nav overlaps last list item on `/dashboard`** 🟢 BATCH
At 375 the lime "Anlegen" FAB sits over the "Lecker Frist" list item in
"Kommende Fristen" — the bottom-nav background gradient covers but doesn't
fully obscure. Add bottom-padding to `<main>` equal to the bottom-nav
height. _Screen: `01-dashboard-375.png`._
---
## Top 10 — best value-per-effort
Ranked by visible impact on the first-5-minutes experience of a HLC patent
lawyer. Each is small enough to land in one focused PR.
1. **F-01 — Strip "Hogan Lovells" / "HL" from the public surface** 🟢
Stale firm name on the marketing landing, page title, downloads section.
First impression for any new colleague. **The single most embarrassing
defect right now.**
2. **F-14 — Pick lime as the only primary green; retire forest-green** 🟡
The pervasive brand inconsistency. Lime is the brand; forest-green leaks
from old design tokens onto every primary CTA. One swap, every page
feels coordinated.
3. **F-15 — "Projekt archivieren" red → neutral/outline** 🟢
Wrong affordance for a reversible action. Will visibly change real-user
behaviour (more confident archiving). Trivial CSS change.
4. **F-02 — `/admin/team` search input overlapping placeholder bug** 🟢
Plainly broken text. Fix in admin-team.tsx.
5. **F-04 — `fristen.field.project.choose` raw key on `/deadlines/new`** 🟢
Leaking key in a primary form. Add the i18n key.
6. **F-07 — Activity log `project_type_changed` / "Type case → litigation"** 🟡
Dashboard is the landing page; the activity widget is the most-read
surface. Same-class fix as t-paliad-037, just for newer events.
7. **F-05 + F-06 — `lang="de"` on date and time inputs** 🟢
`mm/dd/yyyy` and `09:00 AM` in a German UI is jarring. Single attribute
fix, two places.
8. **F-12 + F-26 — "Akte" → "Projekt" on /deadlines + /appointments
filters/columns** 🟢
Last residue of the rename. Quick relabel, tightens the vocabulary.
9. **F-11 — Office values lowercased no-umlaut on /projects/{id}/team** 🟢
`duesseldorf`, `munich` rendered raw; offices module already has the
localized labels.
10. **F-08 — Project tabs use `href="#"`** 🟡
Tabs aren't real links. Middle-click + open-in-new-tab don't work.
Common power-user gesture; fix is one change in the tab component.
### Honourable mentions (#1115)
11. **F-16 — Type pills calmer colors** 🟡
12. **F-22 — Settings notification checkbox layout** 🟢
13. **F-09 — `?view=tree` URL parameter respected** 🟢
14. **F-13 — `L-2026-001Siemens AG ./.` separator on `/appointments`** 🟢
15. **F-03 — `/api/departments?include=members` 500 regression** 🟡
(Functional bug, not pure polish — flagged because it's recurring.)
### Suggested batching
- **PR-A "stale firm name + activity log + i18n leaks"** — F-01, F-04, F-07,
F-12, F-21, F-35.
- **PR-B "format + locale"** — F-05, F-06.
- **PR-C "brand consistency sweep"** — F-14, F-15, F-31, F-30.
- **PR-D "rename residue + small i18n cleanups"** — F-11, F-17, F-18, F-19,
F-26, F-44, F-45.
- **PR-E "single-page bug fixes"** — F-02 (standalone), F-09 (standalone),
F-08 (standalone), F-03 (standalone).
Everything else (F-23 onwards) can land alongside whichever batch is most
adjacent.
---
## Cross-cutting observations
- **Two greens** is the single biggest visual gain available right now. F-14
alone makes the app feel "designed" rather than "drifting".
- **i18n leak class** keeps reappearing (Bug 4 in 2026-04-25 smoke fixed
some keys; t-paliad-037 fixed `.all` keys; this audit finds `.choose` keys,
`project_type_changed` event types, "Deadline updated" event titles, and
"Note zu deadline" mixed-language narrative). Worth a one-time scan that
greps every component for raw template strings without i18n wrapping —
could surface a dozen others I missed.
- **Date/time format leakage** comes from native HTML5 inputs ignoring the
page's `lang`. One attribute set in one shared component fixes it
everywhere.
- **Mobile tables** clip silently. Card layout on `<768px` is the canonical
fix and would help `/projects`, `/deadlines`, `/appointments`, `/admin/team`
all at once.
- **Brand: lime is the brand color**, but most "primary" CTAs in the codebase
use a darker green. The lime-vs-forest split is roughly: lime = "create new"
actions on the working surface (Akte, Frist, Termin, Notiz); forest =
knowledge-platform "submit" actions (vorschlagen, suchen, Login). The
split is implicit and surprising — pick one and document the rule.
## Out of scope — flagged for separate work
- **CLAUDE.md doc drift**: project doc says Phase I (Notizen) is "pending"
but `/projects/{id}/notes` ships a working textarea + list. Either Phase I
shipped without doc update, or the placeholder ships ahead of full Phase I.
Worth a quick verification + doc fix.
- **`/api/departments?include=members` 500** is a functional regression, not
pure polish — flagged to head as a side-channel bug to triage outside this
audit's batches.
- **Empty-state CTAs** (F-43 and others) could be a separate "empty state
pass" task across the app.
## Acceptance
- [x] Doc committed.
- [x] 41 screenshots in `tests/screenshots-polish-2026-04-27/`.
- [x] Top 10 ranked with rationale and effort buckets.
- [ ] Head greenlights individual implementation tasks separately.

View File

@@ -0,0 +1,828 @@
# Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments
**Author:** cronus (inventor)
**Date:** 2026-05-06
**Task:** t-paliad-138 (Gitea m/paliad#3)
**Branch:** `mai/cronus/inventor-dual-control`
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift.
---
## 0. TL;DR
Add a 4-eye principle to `paliad.deadlines` and `paliad.appointments`. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved".
Six locked design decisions from m (2026-05-06):
| # | Question | Locked answer |
|---|---|---|
| Q1 | Where does the qualification level live? | **Reuse `project_teams.role` per-project** (no new firm-wide column). New value `senior_pa` added to the role enum. |
| Q1+ | Strict-ladder default? | **Default approval-eligible = {lead, associate}**. Per-project / per-event setting can extend to `senior_pa` or `pa` (so PAs can approve other PAs in some projects). |
| Q2 | Hierarchy semantics | **Strict ladder.** Higher level always satisfies lower. |
| Q3 | Policy granularity | **Per-(project, entity_type, lifecycle_event)** \— up to 8 settable rows per project. |
| Q4 | Edit-trigger fields | **Only date-changing fields.** Deadline: `due_date`, `original_due_date`, `warning_date`. Appointment: `start_at`, `end_at`. All other field changes bypass approval. |
| Q5 | Pending-state architecture | **Write-then-approve.** Field changes apply immediately; the entity carries `approval_status='pending'` until an approver flips it. (Delete is the one exception — see §5.4.) |
| Q6 | Inbox surface | **Bell icon (sidebar header) + dedicated `/inbox` page** with two tabs: "Zur Genehmigung" / "Meine Anfragen". |
| Q7 | Revocation | **Pending-only revoke.** After approval, only path back is a new request. |
| Q8 | Single-qualified-approver deadlock | **Refuse + global_admin override.** UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). |
| Q9 | Audit / chronology | **Both** \— operational `paliad.approval_requests` table + new event types in `paliad.project_events`. Both creator and approver names persist on the entity row. |
| Q10 | RLS | **Visible to project team, action gated by service.** Same `can_see_project()` predicate; service layer checks "caller has required role tier AND caller_id != requested_by". |
| Q11 | Migration of existing rows | **Mark legacy + skip backfill.** All existing rows get `approval_status='legacy'`. New lifecycle events on legacy rows trigger normal approval flow. |
Plus m's explicit interjection: **pending state must be visualised everywhere the entity normally surfaces** — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending.
Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only).
---
## 1. Context — what's already in the code
What this design slots into:
- **Three-axis principle (m, t-paliad-051, sacrosanct).** "Firm roles ≠ project roles ≠ tool roles."
- `paliad.users.job_title` — free-text display. Never gates anything.
- `paliad.users.global_role``standard` | `global_admin`. Tool-admin gate only.
- `paliad.project_teams.role``lead | associate | pa | of_counsel | local_counsel | expert | observer`. Per-project membership role.
- **Visibility:** `paliad.can_see_project()` SQL function (migration 023) + Go mirror `services.visibilityPredicate()` — global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth.
- **Audit:** `paliad.project_events` (created in migration 005 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102).
- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status text CHECK IN ('pending','completed','cancelled','waived')`. Appointments have no status column.
- **Service layer:** `DeadlineService.{Create,Update,Complete,Reopen,Delete}`, `AppointmentService.{Create,Update,Delete}`. Each goes through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx.
- **Existing patterns this design reuses:**
- `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_events`.
- `paliad.event_types` + `paliad.deadline_event_types` (migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page.
- `services.visibilityPredicate(alias)` — same shape for the new `approvalEligibleInProject(userID, projectID, requiredRole)` helper.
This design adds **no new auth/permission axis**. It reuses `project_teams.role` for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one.
---
## 2. Approval ladder
### 2.1 Strict ladder over `project_teams.role`
```
level | role | approval-eligible by default?
------+------------------+-------------------------------
5 | lead | yes — partner-tier on this project
4 | of_counsel | yes — senior tier
3 | associate | yes ← default required level
2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below
1 | pa | only if project policy lowers required to 'pa'
0 | local_counsel | ineligible — external attorney, not in approval scope
0 | expert | ineligible — technical witness role
0 | observer | ineligible — read-only audit role
```
`senior_pa` is added to the `paliad.project_teams.role` CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum.
**Strict-ladder rule:** a user with project_teams.role `R` can approve any request whose `required_role` is at level ≤ `level(R)`. So:
- Default `required_role = 'associate'` (level 3) → eligible approvers: lead, of_counsel, associate.
- Override to `required_role = 'senior_pa'` (level 2) → eligible: lead, of_counsel, associate, senior_pa.
- Override to `required_role = 'pa'` (level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for.
- Override to `required_role = 'lead'` → only the project lead can approve.
**Hard rules:**
1. **Self-approval is hard-blocked.** `caller_id = requested_by` always returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (`approved_by != requested_by`).
2. **Eligible level 0 = ineligible.** A user with role=local_counsel/expert/observer **cannot** approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button.
3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_kind='admin_override'`.
### 2.2 Why not introduce a firm-wide qualification column?
The issue listed candidates `partner / senior_attorney / attorney / senior_pa / pa / paralegal` and asked whether roles should be global, per-team, or per-project. m chose **per-project** (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record):
A firm-wide rank column would have:
- Cleanly separated from `job_title` (display) and `global_role` (tool admin).
- Made authoring rules trivial — one column on `users`, one int compare.
- Worked even before a project's team was fully populated.
But it would have:
- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds.
- Forced a firm-wide ladder onto a project context where seniority is already encoded — `lead` on a project IS the partner-tier on that project.
- Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as `observer` on a small case) without a clean answer.
m's per-project choice is consistent with how the rest of paliad treats authority: the `lead` role on `project_teams` is the source of truth for "who is the partner running this case", and approvals naturally cluster around that.
### 2.3 What about local_counsel / expert / observer?
Default: ineligible to approve. Rationale:
- **local_counsel** is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain.
- **expert** is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines.
- **observer** is explicitly a read-only role.
**Escape hatch:** if a project genuinely wants its local_counsel to approve, the team admin can re-add them with `role='associate'` (or whatever tier is intended). The role on `project_teams` is a per-project assignment; the same human can be `local_counsel` on Project A and `associate` on Project B if that's the correct authority on each.
**Out of scope (follow-up if needed):** a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter.
---
## 3. Policy grammar — `paliad.approval_policies`
### 3.1 Schema
```sql
CREATE TABLE paliad.approval_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
UNIQUE (project_id, entity_type, lifecycle_event)
);
CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id);
```
Design choices:
- **Up to 8 rows per project.** `(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete)`. UNIQUE composite key enforces this.
- **No row = no approval needed for that event.** A project with zero policy rows is in the same operational state as today — no 4-eye anywhere.
- **`required_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering.
- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and the migration becomes lighter.)
### 3.2 Inheritance
**No automatic inheritance from parent project.** A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons:
- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics).
- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny.
- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value.
**UI affordance:** project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut.
### 3.3 Authoring permission
**v1: global_admin only.** Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1.
**Reasoning:** approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change.
**Out of scope follow-up:** lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships.
### 3.4 Service-layer helpers
```go
// internal/services/approval_levels.go
// levelOf maps a project_teams.role value to the strict-ladder level used
// for approval gating. Returns 0 (ineligible) for roles outside the
// approval ladder (local_counsel, expert, observer).
func levelOf(role string) int {
switch role {
case "lead": return 5
case "of_counsel": return 4
case "associate": return 3
case "senior_pa": return 2
case "pa": return 1
default: return 0 // local_counsel, expert, observer, anything new
}
}
// canApprove returns true iff:
// - caller is not the requester (self-approval blocked)
// - caller's project_teams.role on this project has level >= required level
// OR caller is global_admin (which is always allowed and audit-marked separately).
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) {
if callerID == requesterID {
return false, "", ErrSelfApprovalBlocked
}
user, err := s.users.GetByID(ctx, callerID)
if err != nil { return false, "", err }
if user.GlobalRole == "global_admin" {
return true, "admin_override", nil
}
membership, err := s.projects.MembershipFor(ctx, callerID, projectID)
if err != nil || membership == nil {
return false, "", nil // not on team, cannot approve
}
if levelOf(membership.Role) >= levelOf(requiredRole) {
return true, "peer", nil
}
return false, "", nil
}
```
`decision_kind` values: `peer` (normal in-team sign-off), `admin_override` (global_admin used override path). Stored on `approval_requests.decision_kind`.
---
## 4. Lifecycle flow (write-then-approve)
### 4.1 The four lifecycle events
For each entity (deadline, appointment), four lifecycle events trigger an approval check:
1. **create** — new row submitted by user.
2. **update** — change to one or more date-bearing fields (allowlist in §4.5).
3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment.
4. **delete** — request to remove the row.
### 4.2 Submission
User clicks Save / Complete / Delete on the entity. The service layer:
1. Looks up `paliad.approval_policies(project_id, entity_type, event)`.
2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done.
3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally:
- Set `approval_status = 'pending'` and `pending_request_id = <new uuid>` on the entity row.
- Insert one `paliad.approval_requests` row with `lifecycle_event`, `pre_image jsonb` (a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4), `payload jsonb` (echo of what was submitted, for audit), `requested_by = caller`, `requested_at = now()`, `required_role = policy.required_role`, `status = 'pending'`.
- Emit `paliad.project_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = <uuid>`. The Verlauf shows the lifecycle inline.
- All four writes happen in **one transaction** (entity update + request insert + event emit).
4. **Single-qualified-approver deadlock check.** Before committing, the service runs a count: how many users on this project's team have `levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller`? If 0, the submission **fails with HTTP 409** and a structured error: `{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }`. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7).
### 4.3 Delete is special — stage-then-write
m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable.
**Resolution:** for `lifecycle_event = 'delete'`, the entity row stays in place. We set `approval_status = 'pending'` and link to an `approval_requests` row carrying `lifecycle_event = 'delete'`. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from `approval_requests`). On reject: clear `approval_status` back to `'approved'` and `pending_request_id` to NULL. The deletion never happened.
This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise.
### 4.4 Approval / rejection
Approver opens `/inbox`, picks a request, clicks Approve (or Reject with optional reason).
**Approve:**
1. Service-layer `canApprove(caller, project, request)` check (see §3.4).
2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`.
3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`.
4. For `delete`: hard-delete the entity (cascade clears the request FK).
5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06".
6. Tx commits.
**Reject:**
1. Same `canApprove` check.
2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver).
3. **Revert entity** — restore from `pre_image`:
- `create`: hard-delete the entity (it never should have been live).
- `update`: write `pre_image` field values back over the row.
- `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands).
- `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before.
4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best."
5. Tx commits.
### 4.5 Edit-trigger field allowlist (per Q4)
The service layer only enters the approval-request flow when an `update` touches the date-bearing fields. All other edits apply immediately as `approval_status='approved'` writes — no request row, no pending state.
**Deadlines — date-bearing (gates approval):**
- `due_date`
- `original_due_date`
- `warning_date`
**Deadlines — bypass (no approval):**
- `title`, `description`, `notes`
- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below)
- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction)
- `status` other than via the `complete` lifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive)
**Appointments — date-bearing (gates approval):**
- `start_at`
- `end_at`
**Appointments — bypass (no approval):**
- `title`, `description`
- `location` (m's Q4 choice excludes location; documented trade-off below)
- `appointment_type`
**Trade-off (m's call):** the looser allowlist means a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp.
If the team finds this allowlist too loose in practice, the constants in `internal/services/approval_fields.go` (proposed location) are a one-PR widening — no schema change.
### 4.6 Optimistic-concurrency / superseded requests
Race scenario: User A submits an `update` request with `pre_image = {due_date: 2026-05-10}`. Before it's approved, user B submits another `update` with their own pre-image. Now there are two pending requests on the same row.
**Rule:** a row can have at most one pending request at a time. The submission service-layer does:
```sql
UPDATE paliad.deadlines
SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID
WHERE id = $entityID
AND approval_status = 'approved' -- only mutate if currently clean
RETURNING id;
```
If the UPDATE returns 0 rows (because `approval_status != 'approved'`), the submission fails with HTTP 409 `{ "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }`. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor."
Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle.
### 4.7 Single-qualified-approver deadlock — global_admin override path
Per Q8, the default behaviour is **refuse to submit** when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer.
**Override mechanism:** any `global_admin` (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as:
```
SELECT COUNT(*) FROM paliad.project_teams pt
WHERE pt.project_id = $proj
AND pt.user_id <> $caller
AND pt.role IN (eligible roles for required_role)
+
SELECT COUNT(*) FROM paliad.users u
WHERE u.global_role = 'global_admin'
AND u.id <> $caller
```
If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case.
When global_admin signs off, the `decision_kind` on the approval_request row is `'admin_override'` (vs `'peer'`). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on `decision_kind`.
### 4.8 Revocation (per Q7)
- **Requester revokes:** while `request.status = 'pending'`, the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request `'rejected'`, marks it `'revoked'`. New `paliad.project_events` event_type `'deadline_approval_revoked'`.
- **Approver revokes after approval:** **not supported** per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate.
---
## 5. UI surfaces
### 5.1 The pending pill — visible everywhere
Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment:
- **Pending CREATE** — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von <required_role>+". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class.
- **Pending UPDATE** — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12".
- **Pending COMPLETE** — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance.
- **Pending DELETE** — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request.
CSS classes (proposed, in `frontend/src/styles/global.css`):
```css
.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); }
.entity-row--pending-update { /* solid border, chip handles the signal */ }
.entity-row--pending-complete { background: linear-gradient(...striped...); }
.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; }
.approval-pill { display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 9999px;
background: var(--bg-warn-soft); color: var(--fg-warn);
font-size: 12px; }
.approval-pill::before { content: "⚠ "; }
```
i18n keys (DE primary, EN secondary):
- `approvals.pending_create.label` — "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)"
- `approvals.pending_update.label` — "Änderung wartet auf Genehmigung" / "Awaits approval (change)"
- `approvals.pending_complete.label` — "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)"
- `approvals.pending_delete.label` — "Zur Löschung beantragt" / "Awaits approval (deletion)"
- `approvals.required_role.<role>` — "Lead", "Of Counsel", "Associate", "Senior PA", "PA"
- `approvals.requested_by` — "Eingereicht von {name}" / "Submitted by {name}"
- `approvals.no_approver_dialog.*` — full deadlock dialog strings
- `approvals.approve.button` — "Genehmigen" / "Approve"
- `approvals.reject.button` — "Ablehnen" / "Reject"
- `approvals.revoke.button` — "Zurückziehen" / "Revoke"
- `approvals.decision_kind.peer` — "Genehmigt von {name}" / "Approved by {name}"
- `approvals.decision_kind.admin_override` — "Admin-Sign-off von {name}" / "Admin sign-off by {name}"
Surfaces that show the pending pill:
- `/deadlines` and `/appointments` table rows (one pill per row).
- `/agenda` timeline (per-row pill).
- `/dashboard` traffic-light card-list previews.
- `/projects/{id}` details — Fristen + Termine sections.
- `/deadlines/{id}` and `/appointments/{id}` detail pages — full diff display.
- CalDAV: pending entries sync to the user's external calendar with title prefix `[PENDING] ` (e.g. `[PENDING] Frist Erwiderung`). Approved entries sync clean.
- Email reminders (`internal/services/reminder_service.go`): pending entries get a banner in the mail body and a `[PENDING] ` subject prefix.
### 5.2 Bell + `/inbox` page (per Q6)
**Bell** in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → `/inbox`. Refreshes via the existing dashboard-polling pattern (60s interval; `Last-Modified` ETag if cheap to add).
**`/inbox` page**, two tabs:
1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where:
- `status = 'pending'`
- `requested_by != me`
- I have eligible role on the project (or I'm global_admin)
Sorted by `requested_at` ASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create).
2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_by = me`. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button.
URL structure: `/inbox?tab=pending-mine|mine&status=pending|...&project_id=...`. Back-button friendly.
Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes.
### 5.3 Policy authoring — `/projects/{id}/settings/approvals`
Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table:
```
CREATE UPDATE (date) COMPLETE DELETE
Frist [select] [select] [select] [select]
Termin [select] [select] [select] [select]
```
Each `<select>` offers: "Keine Genehmigung erforderlich (default)" / "Lead" / "Of Counsel" / "Associate" / "Senior PA" / "PA". Submitting upserts/deletes rows in `paliad.approval_policies`.
Helpers:
- "Aus Eltern-Projekt übernehmen" button — copies the parent project's policy rows in one click. One-shot copy, no live link.
- "Alle auf Associate setzen" button — fills all 8 cells with `associate` for fast onboarding of a new project.
### 5.4 Diff rendering
For `update` requests, the `pre_image` jsonb captured at submission and the entity's current values let the UI render a clean diff. For deadlines: a 1-3 line comparison ("Datum: 2026-05-10 → 2026-05-12 · Warnung: 2026-05-08 → 2026-05-10"). Done in pure TS in `frontend/src/client/inbox.ts` consuming the request payload.
---
## 6. Schema changes (migration 054)
### 6.1 Add `senior_pa` to `project_teams.role`
```sql
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead','associate','pa','of_counsel',
'local_counsel','expert','observer',
'senior_pa'
));
```
i18n labels for the new role (in DE+EN per existing `team.role.*` keys).
### 6.2 `paliad.approval_policies`
See §3.1 — full DDL.
### 6.3 `paliad.approval_requests`
```sql
CREATE TABLE paliad.approval_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
-- entity_id is the deadline.id / appointment.id this request operates on.
-- For 'create' lifecycle, this is the id of the just-inserted entity row
-- (so the request can reference back to it). For 'delete', it's the row
-- being requested for removal.
entity_id uuid NOT NULL,
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
-- For 'update'/'complete'/'delete': pre_image carries the field values
-- needed to revert on rejection. For 'create': pre_image IS NULL.
pre_image jsonb,
-- For audit/visibility, payload echoes the diff or new values that were
-- written. Read-only after insert.
payload jsonb,
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
requested_at timestamptz NOT NULL DEFAULT now(),
-- Snapshot of policy.required_role at request time. Even if the policy
-- changes mid-flight, the request honours the level it was submitted under.
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','revoked','superseded')),
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
decided_at timestamptz,
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer','admin_override')),
decision_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Hard CHECK: an approver is never the requester.
CHECK (decided_by IS NULL OR decided_by <> requested_by)
);
CREATE INDEX approval_requests_project_status_idx
ON paliad.approval_requests (project_id, status);
CREATE INDEX approval_requests_entity_idx
ON paliad.approval_requests (entity_type, entity_id);
CREATE INDEX approval_requests_requested_by_idx
ON paliad.approval_requests (requested_by, status);
CREATE INDEX approval_requests_pending_idx
ON paliad.approval_requests (status, requested_at)
WHERE status = 'pending';
```
RLS on `approval_requests`: per Q10, mirror `paliad.deadlines` policy — visible if `paliad.can_see_project(project_id)`. RLS does NOT gate the approve/reject action; that's enforced at the service layer.
```sql
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY approval_requests_all ON paliad.approval_requests
FOR ALL USING (paliad.can_see_project(project_id));
```
### 6.4 New columns on `paliad.deadlines` and `paliad.appointments`
```sql
-- deadlines: approval state + approver tracking
ALTER TABLE paliad.deadlines ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved','pending','legacy'));
ALTER TABLE paliad.deadlines ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
ALTER TABLE paliad.deadlines ADD COLUMN approved_by uuid
REFERENCES paliad.users(id) ON DELETE SET NULL;
ALTER TABLE paliad.deadlines ADD COLUMN approved_at timestamptz;
CREATE INDEX deadlines_approval_status_idx
ON paliad.deadlines (approval_status) WHERE approval_status = 'pending';
-- appointments: same triple
ALTER TABLE paliad.appointments ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved','pending','legacy'));
ALTER TABLE paliad.appointments ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
ALTER TABLE paliad.appointments ADD COLUMN approved_by uuid
REFERENCES paliad.users(id) ON DELETE SET NULL;
ALTER TABLE paliad.appointments ADD COLUMN approved_at timestamptz;
-- appointments need a completed_at for the 'complete' lifecycle event to land
ALTER TABLE paliad.appointments ADD COLUMN completed_at timestamptz;
CREATE INDEX appointments_approval_status_idx
ON paliad.appointments (approval_status) WHERE approval_status = 'pending';
```
**`appointments.completed_at`** is new. Today appointments don't have a completion concept — they just sit on the calendar. The `complete` lifecycle event for appointments is meaningful when m wants to mark hearings/meetings as actually-happened (e.g. "Mündliche Verhandlung am 2026-05-15 — abgehalten"). If m prefers to drop appointment-complete from the lifecycle list (deadline-complete only), the `completed_at` column drops out and the policy CHECK constraint excludes `(appointment, complete)`.
This is a clean place for m to make a smaller call: keep appointment:complete (and add `completed_at`), or drop it.
### 6.5 Backfill
```sql
-- Mark all existing rows as legacy (predates 4-eye).
UPDATE paliad.deadlines SET approval_status = 'legacy';
UPDATE paliad.appointments SET approval_status = 'legacy';
```
`approved_by`/`approved_at` stay NULL on legacy rows. `created_by` is already populated since migration 005 (the column has been required from day one).
**No retroactive approval** — m's Q11 choice. Legacy rows are read-only-clean. The next mutation on a legacy row that hits an active policy follows the normal flow (e.g. editing a date on a legacy deadline triggers `update` approval; the row becomes `approval_status='pending'` and goes through the gate; once approved, `approval_status='approved'`).
### 6.6 Down migration
The down migration drops the four new columns + `completed_at` + `approval_policies` + `approval_requests` + restores the `project_teams.role` CHECK without `senior_pa`. If any user has been re-roled to `senior_pa`, the down migration will fail loudly until they're migrated to another role — intentional, mirrors the t-paliad-051 down strategy.
---
## 7. Service-layer integration
### 7.1 New service: `ApprovalService`
```go
// internal/services/approval_service.go
type ApprovalService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
// SubmitCreate is invoked by DeadlineService.Create / AppointmentService.Create
// inside the existing entity-write tx. If a policy applies, it inserts the
// approval_requests row and sets entity.approval_status = 'pending' + entity.
// pending_request_id. Returns (requestID, isPending, err).
func (s *ApprovalService) SubmitCreate(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
// Same shape for Update / Complete / Delete. Update takes a preImage map.
func (s *ApprovalService) SubmitUpdate(ctx, tx, projectID, entityType, entityID, requesterID, preImage map[string]any) (uuid.UUID, bool, error)
func (s *ApprovalService) SubmitComplete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
func (s *ApprovalService) SubmitDelete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
// Approve / Reject / Revoke — invoked by the inbox handler.
func (s *ApprovalService) Approve(ctx, requestID, callerID, note string) error
func (s *ApprovalService) Reject(ctx, requestID, callerID, note string) error
func (s *ApprovalService) Revoke(ctx, requestID, callerID string) error
// ListInbox returns the pending-mine and my-submitted views.
func (s *ApprovalService) ListPendingForApprover(ctx, callerID, filter) ([]ApprovalRequestView, error)
func (s *ApprovalService) ListSubmittedByUser(ctx, callerID, filter) ([]ApprovalRequestView, error)
```
### 7.2 Wiring into existing services
**`DeadlineService.Create`** today:
1. ProjectService.GetByID gate (visibility check)
2. Begin tx
3. INSERT into paliad.deadlines
4. Attach event_types junction rows
5. insertProjectEventWithMeta(deadline_created)
6. Commit
After integration:
1. ProjectService.GetByID gate
2. Begin tx
3. INSERT into paliad.deadlines (approval_status defaults to 'approved')
4. **`approvals.SubmitCreate(ctx, tx, projectID, "deadline", id, userID)`** — if policy applies, this:
- Updates approval_status='pending', pending_request_id=… on the just-inserted row
- INSERTs approval_requests row
- Performs deadlock count, fails the tx if 0 qualified approvers exist
5. Attach event_types junction rows
6. insertProjectEventWithMeta(deadline_created) — unchanged
7. **insertProjectEventWithMeta(deadline_approval_requested)** if approval is pending
8. Commit
Same shape for `Update`, `Complete`, `Delete` on both DeadlineService and AppointmentService. The `Complete` call site is `MarkComplete`/`Reopen` in DeadlineService (today); reopen would be modelled as a fresh "create-style" approval if it lands on a legacy row, or as part of "update" lifecycle on the `status` field — but `status` is not in the date-bearing allowlist so reopen goes through immediately. **Reopen does NOT trigger 4-eye** under this design (Q4 = date-fields-only). If m wants reopen-needs-approval, add `status` to the allowlist or treat reopen as its own lifecycle event.
### 7.3 Read-path changes
Existing list/summary queries (`ListVisibleForUser`, `SummaryCounts`) need to:
- Hydrate `approval_status`, `approved_by`, `approved_at`, and the linked `approval_requests.lifecycle_event` (via JOIN) for each row.
- Pass these through to the frontend so the pending pill and traffic-light styling can render.
Bucket math (t-paliad-106 5-bucket harmonisation) is **unchanged** — pending CREATEs still bucket by `due_date` like any other; the visual just adds the pending pill. Pending DELETEs still appear in their bucket until the delete is approved.
`/api/inbox/pending-mine` and `/api/inbox/mine` are new endpoints, served by `internal/handlers/inbox.go`.
### 7.4 Visibility gating for the inbox
The pending-mine list is gated by:
```sql
SELECT ar.* FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by != $callerID
AND <visibilityPredicate>(p) for callerID
AND (
-- caller is global_admin
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
OR
-- caller has eligible role on this specific project
EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $callerID
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND levelOf(pt.role) >= levelOf(ar.required_role))
)
ORDER BY ar.requested_at ASC;
```
`levelOf` in SQL is a small immutable function:
```sql
CREATE FUNCTION paliad.approval_role_level(role text) RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END
$$;
```
Stable values; mirrors the Go `levelOf`. Used in the inbox SQL and in any future RLS policy. Migration ships both.
---
## 8. Audit / chronology integration
Per Q9, the existing `paliad.project_events` audit gains four new event_type values per entity:
- `deadline_approval_requested` — a request was submitted. Metadata: `{ approval_request_id, lifecycle_event, required_role }`.
- `deadline_approval_approved` — request approved. Metadata: `{ approval_request_id, decision_kind, decided_by_email }`.
- `deadline_approval_rejected` — request rejected. Metadata: `{ approval_request_id, decision_note }`.
- `deadline_approval_revoked` — requester revoked their own pending. Metadata: `{ approval_request_id }`.
Same four for appointments (`appointment_approval_*`).
These appear in:
- The `paliad.project_events` Verlauf card on `/projects/{id}` (via existing render path; new translateEvent cases needed in `frontend/src/client/projects-detail.ts`).
- The `paliad.project_events` Verlauf card on `/deadlines/{id}` and `/appointments/{id}` (same pattern).
- The cross-project `AuditService.ListEntries` timeline at `/admin/audit-log` (already unions project_events; new event types ride along automatically).
- Dashboard recent-activity rail (filter through existing `translateEvent` to render the correct sentence).
**Both names persist on the entity** per the issue's m-locked requirement: `created_by` (already there) + `approved_by` (new). Verlauf renders for an approved deadline:
```
Frist erstellt — eingereicht von Anna 2026-05-06 14:23
· genehmigt von Bert 2026-05-06 14:31
```
This is two project_events rows rendered as a paired card in the Verlauf. The frontend pairs them by `metadata.approval_request_id`.
---
## 9. RLS / security plan
Per Q10:
1. **`approval_requests`** — RLS = `paliad.can_see_project(project_id)`. Same predicate as `deadlines`/`appointments`. Anyone on the project can read pending requests (transparency).
2. **`approval_policies`** — RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to `global_role = 'global_admin'` (consistent with /admin/team / /admin/partner-units precedent).
3. **The `approve`/`reject`/`revoke` action** — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens in `ApprovalService.canApprove()` (§3.4). RLS provides defense-in-depth for any future direct-DB query path.
4. **Self-approval block** — enforced both at the service layer and via a CHECK constraint on `approval_requests` (`decided_by IS NULL OR decided_by <> requested_by`). Two layers because either alone is insufficient (a SQL bug bypasses the service; a service bug bypasses the CHECK).
The path-walking team-membership + global_admin predicate (`visibilityPredicate`) extends naturally to "approvable-by-me" via the inline JOIN shown in §7.4. No new SQL function needed; the inline form is read-only on the inbox query path.
**Out of scope follow-up:** if any future direct-DB tooling needs to query "approvable by me", extract a `paliad.can_approve_in_project(user_id, project_id, required_role)` SQL function. For v1, the inline JOIN is sufficient and avoids adding a function that no migration currently calls.
---
## 10. Migration plan
### 10.1 Single migration, single PR
Migration 054 (`054_approvals.{up,down}.sql`):
1. Add `senior_pa` to `project_teams.role` CHECK (§6.1).
2. Create `paliad.approval_role_level(text) RETURNS int` SQL function.
3. Create `paliad.approval_policies` table (§6.2) + indexes + RLS.
4. Create `paliad.approval_requests` table (§6.3) + indexes + RLS.
5. Add new columns on `paliad.deadlines` and `paliad.appointments` (§6.4) + indexes.
6. Mark all existing rows `approval_status='legacy'` (§6.5).
No data move. No FK hijinks. ms-level apply on a 200-ish-row deadlines table.
### 10.2 Implementation phasing
The PR is large but clean. Recommended split into commits (single branch, single PR):
1. **Commit 1 — Migration 054.** Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
2. **Commit 2 — `ApprovalService` core.** Submit / Approve / Reject / Revoke, deadlock check, pre_image capture, request lifecycle. Unit tests (table-driven over the strict-ladder + self-approval rules, deadlock count edge cases).
3. **Commit 3 — Wire into `DeadlineService` + `AppointmentService`.** Mutation paths gain the SubmitCreate/Update/Complete/Delete hooks. Read paths hydrate approval_status. Adds new event_types to project_events emit path. Live-DB integration test: TEST_DATABASE_URL covering submit→approve / submit→reject / submit→revoke / single-approver-deadlock / global-admin-override.
4. **Commit 4 — Policy authoring page.** `/projects/{id}/settings/approvals` tab + handler + frontend. global_admin-only gate.
5. **Commit 5 — Inbox.** `/inbox` page + bell icon + `/api/inbox/*` endpoints + frontend list rendering with diff display.
6. **Commit 6 — Pending pills + traffic-light variants.** CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
7. **Commit 7 — CalDAV `[PENDING] ` prefix + email-reminder pending banner.** Updates `caldav_service.go` and `mail_service.go` formatting. Integration tests on iCal output and rendered email body.
8. **Commit 8 — Verlauf rendering of approval lifecycle.** translateEvent cases for the four new event_types. Pair-card rendering for request+decision events.
Each commit is testable in isolation; commits 13 are merge-safe even before the UI lands (legacy rows + pending state hidden by default = no behaviour change on existing flows because no project has policies until commit 4 ships).
### 10.3 Roll-out
Suggested:
1. Migration 054 lands → no behaviour change (no policies exist yet).
2. Pick one pilot project, set policy `(deadline,*)=associate`. Smoke through one CREATE / UPDATE / COMPLETE / DELETE cycle as a non-admin user. Verify pending pills, inbox, approver flow, audit chronology.
3. Once validated, m authors policies on real client projects. Each project opts in by adding rows.
4. Backfill any free-form leftover later if needed (admin scripts).
---
## 11. Trade-offs and known limitations
### 11.1 Write-then-approve dilution risk
Per Q5 m chose write-then-approve. This means a pending CREATE is "live" in lists / dashboard / agenda / CalDAV / email reminders before approval. A wrongful create that's eventually rejected briefly polluted the user's mental model and external calendars.
**Mitigations:**
- Pending pill is highly visible (striped border, ⚠ icon).
- CalDAV title prefix `[PENDING] ` makes external surfaces honest.
- Rejected creates emit `*_approval_rejected` event in Verlauf so the "what happened to that deadline" question has a paper trail.
- Approval flow surfaces immediately in inbox (bell badge), so latency between submit and approve is short.
The alternative (stage-then-write) was strictly safer but m rejected it; the strict-safer architecture would have forced each Frist to live in `approval_requests` until approved, which means views had to UNION the entity table with the requests table — heavy read-path changes and the kind of complexity that compounds into bugs.
### 11.2 Date-fields-only edit allowlist
m chose Q4 = "Only date-changing fields". Trade-off: a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) bypasses 4-eye. The ladder-based approval-fatigue argument (every metadata edit triggering approvals causes rubber-stamping) is the case for the looser scope.
If the team finds this too loose in practice, extending the allowlist is a one-line constants change in `internal/services/approval_fields.go` — documented as the place to widen.
### 11.3 No inheritance from parent project
§3.2 — a child project doesn't auto-inherit its parent's policy. Trade-off: explicit per-project authoring (more control, more clicks). The "Aus Eltern-Projekt übernehmen" button in the authoring UI (§5.3) reduces the friction.
### 11.4 v1 is global_admin-only for policy authoring
Per §3.3, only global_admins can create/edit policies. Project leads cannot edit their own project's policy. Trade-off: tighter governance vs. lead self-service. Lifting to "lead can edit" is a one-line gate change (file as t-paliad-139).
### 11.5 senior_pa is the only new role enum value
§6.1 only adds `senior_pa`. Other firm-rank candidates from the issue (`partner`, `senior_attorney`, `attorney`, `paralegal`) were redundant: `lead` already represents partner-tier on a project, `of_counsel` covers senior-attorney, `associate` covers attorney, and paralegal sits below pa (mapped to `observer` in v1). If those distinctions matter later, additional values can be added without breaking existing rows.
### 11.6 Reopen is not a separate lifecycle
Today reopening a deadline (revert from `completed` to `pending`) is a status-only change. With Q4 = date-fields-only, reopen does NOT trigger 4-eye. If m wants reopen-needs-approval, it can be modelled as a 5th lifecycle event or as a special-case status-field entry in the allowlist. Documented for future tightening.
### 11.7 Approval timeout
No automatic timeout on pending requests. A request can sit pending forever. UI surfaces age ("vor 4 Tagen") to nudge approvers. Future addition: nightly digest email to approvers with a list of pending items > 24h old. Out of scope for v1.
---
## 12. Implementation recommendation
Recommended implementer: **cronus** (this same worktree). Rationale: shipped t-paliad-088 (Event Types — schema + service + handlers + frontend, similar shape), t-paliad-110 (events unification — read-path with new columns hydrated and rendered), t-paliad-122 (courts entity with role-tier-like ladder over countries+regimes). Pattern fluency is high.
Alternative: split — cronus does commits 13 (schema + service core + service-layer wiring) on `mai/cronus/approvals-impl-1`. Then a fresh coder (curie or fritz) does commits 48 (UI + inbox + pills + CalDAV + email) on a sibling branch. Trade-off: smaller PRs, but two coordination handovers.
Head decides.
---
## 13. End-of-design checklist
- [x] Locked constraints summarised (§0)
- [x] Existing-code grounding (§1)
- [x] Role taxonomy / hierarchy (§2)
- [x] Rule grammar (§3)
- [x] Lifecycle flow + edit allowlist + deadlock + revocation (§4)
- [x] UI surfaces (§5)
- [x] Schema (§6)
- [x] Service-layer integration (§7)
- [x] Audit / chronology (§8)
- [x] RLS / security (§9)
- [x] Migration plan + phasing (§10)
- [x] Trade-offs (§11)
- [x] Implementation recommendation (§12)
**Inventor stays parked.** Design committed; awaiting m's go/no-go before any coder shift starts. No `/mai-coder` self-load. The `DESIGN READY FOR REVIEW` signal is sent via `mai report completed` so the head can gate.

View File

@@ -0,0 +1,504 @@
# Cmd/Ctrl+K Command Palette — Design
**Task:** t-paliad-044
**Author:** cronus (inventor)
**Date:** 2026-04-26
**Status:** Design — awaiting m's go/no-go before coder shift
---
## Decision: Option B — full command palette (with strict scope guardrails)
The brief offered two scopes. Rationale for picking B:
1. **`pwa-baseline.md` is explicit** — multi-entity sites ship a command palette;
single-entity sites can skip it. Paliad has 8 entity types (projects, deadlines,
appointments, glossary, courts, checklists, links, users), so it is squarely in
the "ship a palette" bucket.
2. **80% of the infrastructure already exists.** `frontend/src/client/search.ts`
has sectioned grouped results, keyboard navigation (↑↓ / ↵ / Esc), i18n
group headers, debounce + AbortController, an in-flight cancellation pattern,
and language-switch re-render. Adding an *Actions* section on top is
incremental, not a rewrite.
3. **Patent lawyers are heavy keyboard users on desktop.** The HLC / HLC-Munich
audience drafts long documents; Cmd+K → "Neue Frist" without leaving the
keyboard is genuinely valuable. Sidebar nav is already always-visible on
desktop, so the *navigate-to* actions are quality-of-life — but the *create*
actions ("Neue Frist", "Neuer Termin", "Neues Projekt") are real time saves.
4. **Going A first feels like a half-step.** A "/" key alias for Cmd+K is five
lines of code, but everyone who hits Cmd+K and sees only entity search will
wonder where "Gehe zu Dashboard" / "Neue Frist anlegen" are. We'd be back
here in two weeks anyway.
5. **Template value.** Paliad is the first paliad-stack PWA to fully implement
the pwa-baseline `SearchPalette` reference. Doing it right here makes the
pattern reusable for the next mAi PWA project.
---
## Scope guardrails (what is NOT in this design)
- ❌ Fuzzy matching library — substring match on DE+EN labels is sufficient
for ~20 actions and small entity result sets. Add `fuse.js` only if the catalog
passes ~50 entries, which is unlikely to happen in 2026.
- ❌ Recently-used persistence / localStorage MRU — defer. We can add a
`paliad-palette-recent` key later if telemetry shows users repeating the same
3-4 actions.
- ❌ Action groups beyond Aktionen / Projekte / Fristen / … — no
meta-categories like "Werkzeuge", "Wissen". Keep flat.
- ❌ Extension API or plugin registry — the action catalog is a single static
array in `palette-actions.ts`. Future sections can be added by editing that
file; no need for a registration callback.
- ❌ Cross-project search — out of scope per task brief.
- ❌ AI-powered ranking — out of scope per task brief.
- ❌ Action shortcut keys beyond Cmd+K itself (e.g. `g d` to go to Dashboard).
Maybe later; not now.
- ❌ Recent entities — show entity results only when the user types.
---
## Trigger surface
| Trigger | Behavior | Platform |
|---------------------|----------------------------------------------|----------|
| `Cmd+K` (Mac) | Open palette + focus input. `preventDefault`. | desktop |
| `Ctrl+K` (Win/Lin) | Same. | desktop |
| `/` (slash) | Same — kept for muscle memory (shipped t-paliad-026). | desktop |
| Click sidebar input | Same — focuses the input directly. | desktop |
| `Esc` | Close + clear input. | all |
| BottomNav menu → drawer → search input | Existing path on mobile. | mobile |
### Why not a dedicated mobile slot
The BottomNav (5 slots: Start / Projekte / Anlegen / Agenda / Menü) is full.
Replacing one would degrade an established pattern. The mobile sidebar drawer
(opened via Menü or hamburger) already contains the same `#global-search-input`,
so a tap-search path exists. **Mobile users get the palette via the drawer, not
a dedicated button.** Revisit if telemetry shows mobile users searching often
enough to justify a topbar search icon.
### Browser-native Ctrl+K suppression
`Ctrl+K` in Firefox/Chrome focuses the URL bar's "search engine" submenu (rare
but exists). In Safari, `Cmd+L` focuses the URL bar but `Cmd+K` is unbound.
We `preventDefault()` on the document-level keydown handler whenever the key
combo matches and **only** when a textarea / input is not already focused with
a non-`#global-search-input` element — same skip-rule as the existing `/`
shortcut.
---
## UX shape
### Empty state (Cmd+K just pressed, input empty)
Show all actions, sectioned under "Aktionen". Don't fetch entity search.
The user can see the catalog at a glance — this is the "discoverability mode"
of the palette.
```
┌──────────────────────────────────────────────────┐
│ 🔍 ____________________________________________ │ ← #global-search-input
├──────────────────────────────────────────────────┤
│ AKTIONEN │
│ 📊 Gehe zu Dashboard │
│ 📁 Gehe zu Projekte │
│ ⏰ Gehe zu Fristen │
│ 📅 Gehe zu Termine │
│ 🗓 Gehe zu Agenda │
│ 📖 Gehe zu Glossar │
│ 🏛 Gehe zu Gerichte │
│ 🔗 Gehe zu Links │
│ ✓ Gehe zu Checklisten │
│ ⬇ Gehe zu Downloads │
│ ⚙ Gehe zu Einstellungen │
Neue Frist anlegen │
Neuer Termin anlegen │
Neues Projekt anlegen │
│ 🌐 Sprache umschalten (DE → EN) │
│ 📌 Sidebar anheften / lösen │
│ ✉ Kolleg:in einladen │
│ ↪ Abmelden │
├──────────────────────────────────────────────────┤
│ ↑↓ Navigieren · ↵ Öffnen · Esc Schließen │ ← footer hint
└──────────────────────────────────────────────────┘
```
### Filtered state (user typed at least 1 char)
Both Actions (filtered by substring on DE+EN labels) AND entity search results
(via existing `/api/search?q=...`) render together, Actions on top.
```
Query: "frist"
┌──────────────────────────────────────────────────┐
│ 🔍 frist │
├──────────────────────────────────────────────────┤
│ AKTIONEN │
│ ⏰ Gehe zu Fristen │
Neue Frist anlegen │
│ FRISTEN │
│ ⏰ Klagebeantwortung — UPC-2024-0042 │
│ ⏰ Replik einreichen — Patent EP1234567 │
│ GLOSSAR │
│ 📖 Frist (Definition + Berechnung) │
└──────────────────────────────────────────────────┘
```
### Footer keyboard hints
A small `<div class="palette-footer">` below the overlay results, visible
whenever the palette is open. Key hints in DE/EN:
- DE: `↑↓ Navigieren · ↵ Öffnen · Esc Schließen`
- EN: `↑↓ Navigate · ↵ Open · Esc Close`
---
## Action catalog (initial set)
20 actions, divided across two sub-types under "Aktionen" but rendered as one
flat list (no sub-headers):
### Navigate
| ID | DE | EN | URL |
|-----------------------|-------------------------------|-----------------------------|------------------------|
| `nav.dashboard` | Gehe zu Dashboard | Go to Dashboard | `/dashboard` |
| `nav.projects` | Gehe zu Projekte | Go to Projects | `/projects` |
| `nav.deadlines` | Gehe zu Fristen | Go to Deadlines | `/deadlines` |
| `nav.appointments` | Gehe zu Termine | Go to Appointments | `/appointments` |
| `nav.agenda` | Gehe zu Agenda | Go to Agenda | `/agenda` |
| `nav.team` | Gehe zu Team | Go to Team | `/team` |
| `nav.glossary` | Gehe zu Glossar | Go to Glossary | `/glossary` |
| `nav.courts` | Gehe zu Gerichte | Go to Courts | `/courts` |
| `nav.links` | Gehe zu Links | Go to Links | `/links` |
| `nav.checklists` | Gehe zu Checklisten | Go to Checklists | `/checklists` |
| `nav.downloads` | Gehe zu Downloads | Go to Downloads | `/downloads` |
| `nav.settings` | Gehe zu Einstellungen | Go to Settings | `/settings` |
### Create
| ID | DE | EN | URL |
|-----------------------|-------------------------------|-----------------------------|------------------------|
| `create.deadline` | Neue Frist anlegen | New deadline | `/deadlines/new` |
| `create.appointment` | Neuer Termin anlegen | New appointment | `/appointments/new` |
| `create.project` | Neues Projekt anlegen | New project | `/projects/new` |
### Toggle / action
| ID | DE | EN | Behavior |
|-----------------------|-------------------------------|-----------------------------|-------------------------------|
| `toggle.lang` | Sprache umschalten | Toggle language | Click `data-lang-toggle` for the OTHER language. |
| `toggle.pin` | Sidebar anheften / lösen | Pin / unpin sidebar | Click `.sidebar-pin`. |
| `app.invite` | Kolleg:in einladen | Invite a colleague | Click `#sidebar-invite-btn`. |
| `app.logout` | Abmelden | Logout | Navigate to `/logout`. |
The catalog lives as a single `const ACTIONS: PaletteAction[]` array in a new
file `frontend/src/client/palette-actions.ts`. Adding/removing actions = editing
the array. Keep the file small and obvious.
### Filtering rule
Substring match (case-insensitive, language-agnostic — match against BOTH the
current-language label AND the other-language label, so an English-speaker who
types "Frist" still finds the deadline action). No fuzzy distance, no token
permutations. Sort filtered results by `prefix-match > substring-match`, ties
broken by catalog order.
---
## Component architecture
### Files touched / created
| File | Change |
|-------------------------------------------------|-------------------------------------|
| `frontend/src/client/search.ts` | Extended: Cmd+K binding, empty-state action render, footer hint, action filtering. |
| `frontend/src/client/palette-actions.ts` | **NEW.** Static action catalog + `runAction(id)` dispatcher. |
| `frontend/src/components/Sidebar.tsx` | Add palette footer markup inside `#global-search-overlay`. Update `<kbd>` shortcut hint to show both `/` and `⌘K` (or just keep `/` — minor UX choice). |
| `frontend/src/client/i18n.ts` | Add ~20 i18n keys: `palette.action.*`, `palette.section.actions`, `palette.footer.*`. |
| `frontend/src/styles/sidebar.css` (or wherever overlay styles live) | Add `.search-group-actions` (subtle accent), `.palette-footer` styles, action-icon styles. |
### Decision: extend `search.ts`, don't create a new `palette.ts`
- Single source of truth for the overlay's keyboard / focus / debounce logic.
- Avoids two files racing to mutate `#global-search-overlay`.
- The mental model is "search.ts owns the palette" — palette is just search
with actions on top.
- `palette-actions.ts` is a **data file** (catalog + dispatcher), not a
controller. Keeps the action catalog easy to browse and edit.
### High-level flow
```
[Cmd+K pressed]
search.ts: focus input, openPalette(empty=true)
render(): show all actions, no entity fetch, footer hint visible
[user types "f"]
input handler debounces 200ms → runSearch("f")
runSearch():
1. filteredActions = filterActions(query) ← synchronous, instant
2. entityResults = fetch /api/search?q=f ← async
3. render({ actions: filteredActions, entities: entityResults })
[user presses ↵]
openActive():
if active.kind === "action": runAction(active.id)
else : window.location.href = active.url
[user presses Esc] → closeOverlay()
```
### `PaletteAction` type
```ts
type PaletteAction = {
id: string; // stable id, used for icon lookup
i18nKey: string; // e.g. "palette.action.nav.dashboard"
fallbackLabel: { de: string; en: string };
iconKey: ActionIconKey; // small svg, see below
group: "navigate" | "create" | "toggle"; // for sort priority only
run: () => void; // dispatcher closure
};
```
### `runAction(id)` dispatcher
A switch statement that maps action id → DOM action. Examples:
```ts
function runAction(id: string): void {
switch (id) {
case "nav.dashboard":
window.location.href = "/dashboard";
break;
case "create.deadline":
window.location.href = "/deadlines/new";
break;
case "toggle.lang": {
const cur = document.documentElement.getAttribute("lang") || "de";
const next = cur === "de" ? "en" : "de";
document.querySelector<HTMLButtonElement>(`[data-lang-toggle="${next}"]`)?.click();
break;
}
case "toggle.pin":
document.querySelector<HTMLButtonElement>(".sidebar-pin")?.click();
break;
case "app.invite":
document.getElementById("sidebar-invite-btn")?.click();
break;
case "app.logout":
window.location.href = "/logout";
break;
// …
}
}
```
The dispatcher reuses existing DOM handlers (lang toggle, pin, invite modal)
so we don't duplicate state. If the underlying button moves, the dispatcher
breaks — that's acceptable: each action is one line of indirection, and the
file is small enough to grep.
### Keyboard binding for Cmd+K
```ts
document.addEventListener("keydown", (e) => {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (!isCmdK) return;
// Allow inputs to consume Cmd+K when they want to (e.g. a future rich-text
// editor's link insert) ONLY if they explicitly handle it. By default, we
// intercept globally — paliad has no Cmd+K conflict elsewhere today.
e.preventDefault();
e.stopPropagation();
input.focus();
input.select();
openPaletteEmpty();
});
```
`/` keeps its existing skip-when-typing rule (don't fire if user is mid-edit).
`Cmd+K` does NOT skip — power users explicitly intend to open the palette
even from inside a text input.
---
## Render changes inside `search.ts`
### `render()` becomes `renderResults({ actions, entities, query })`
```ts
function renderResults(opts: {
actions: PaletteAction[],
entities: SearchResponse | null,
query: string,
}, overlay: HTMLElement): void {
flatResults = [];
activeIndex = -1;
const sections: string[] = [];
if (opts.actions.length > 0) {
sections.push(renderActionsSection(opts.actions, opts.query));
}
if (opts.entities) {
for (const group of GROUP_ORDER) {
const items = opts.entities[group.key] as SearchResult[];
if (!items || items.length === 0) continue;
sections.push(renderEntityGroup(group, items, opts.query));
}
}
if (sections.length === 0) {
overlay.innerHTML = `<div class="search-empty">…</div>`;
} else {
overlay.innerHTML = sections.join("") + renderFooter();
bindResultClicks(overlay);
}
overlay.style.display = "block";
}
```
The `flatResults` array becomes `Array<{ kind: "action" | "entity", … }>` so
`openActive()` can dispatch correctly.
### Footer
```html
<div class="palette-footer">
<span><kbd>↑↓</kbd> <span data-i18n="palette.footer.navigate">Navigieren</span></span>
<span><kbd></kbd> <span data-i18n="palette.footer.open">Öffnen</span></span>
<span><kbd>Esc</kbd> <span data-i18n="palette.footer.close">Schließen</span></span>
</div>
```
---
## Mobile considerations
- BottomNav stays as-is (5 slots, full).
- Hamburger / drawer path to search remains the existing pattern — no change
required.
- The palette overlay's `position: fixed` already handles small viewports.
Verify the Actions section + footer fit without scrolling on a 360×640
test viewport during implementation; if not, drop the footer on mobile via
`@media (max-width: 480px) { .palette-footer { display: none; } }`.
- `Cmd+K` is desktop-only; mobile users never see the keybind.
---
## i18n additions
```ts
// de
"palette.section.actions": "Aktionen",
"palette.action.nav.dashboard": "Gehe zu Dashboard",
"palette.action.nav.projects": "Gehe zu Projekte",
"palette.action.nav.deadlines": "Gehe zu Fristen",
// … (all 20 actions)
"palette.footer.navigate": "Navigieren",
"palette.footer.open": "Öffnen",
"palette.footer.close": "Schließen",
// en
"palette.section.actions": "Actions",
"palette.action.nav.dashboard": "Go to Dashboard",
// …
"palette.footer.navigate": "Navigate",
"palette.footer.open": "Open",
"palette.footer.close": "Close",
```
Use the existing `t(key)` helper from `i18n.ts`. Match the pattern from
`GROUP_ORDER` in search.ts.
---
## Acceptance criteria (re-stated from task brief)
-`Cmd+K` (Mac) opens palette
-`Ctrl+K` (Win/Lin) opens palette
-`/` shortcut still works (existing behavior preserved)
-`preventDefault()` suppresses browser-native Ctrl+K
- ✅ Actions filter live as user types
- ✅ Entity results render alongside actions when query non-empty
- ✅ ↑↓ navigates the merged result list (actions + entities)
- ✅ ↵ opens / runs active item
- ✅ Esc closes
- ✅ Footer shows kbd hints, switches language with global toggle
- ✅ Console clean (no errors, no warnings beyond what's already there)
-`go build/vet/test ./...` clean
-`bun run build` clean
- ✅ Mobile fallback documented (no new BottomNav slot)
---
## Implementation plan (for the coder shift)
1. Add `frontend/src/client/palette-actions.ts` with the catalog + dispatcher.
2. Extend `frontend/src/client/search.ts`:
- Add Cmd+K binding (separate from `/` binding).
- Change `runSearch()` to also produce filtered actions; render actions
section first.
- Add empty-state branch (open palette → show all actions, no fetch).
- Update `flatResults` to be `Array<{ kind, … }>` so Enter dispatches.
- Render footer with kbd hints.
3. Update `frontend/src/components/Sidebar.tsx` `#global-search-overlay`
markup if needed (probably none — overlay is built dynamically).
4. Add i18n keys in `frontend/src/client/i18n.ts` (DE + EN, ~25 keys).
5. Add CSS for `.search-group-actions`, `.palette-footer`, action icon
colors. Reuse existing `.search-result` / `.search-group` styles where
possible.
6. Add a small SVG icon for each action `iconKey` (reuse sidebar nav icons
where they map 1:1 — `ICON_GAUGE` for dashboard, `ICON_FOLDER` for
projects, etc.).
7. Manual smoke (local `bun run build` + `go run ./cmd/server`):
- Cmd+K with empty input → all actions visible
- Type "frist" → action filter + entity search both render
- ↑↓ wraps; ↵ on action runs; ↵ on entity navigates
- Esc clears; clicking outside clears
- DE/EN toggle re-renders labels
- `/` still works
- Browser URL bar does NOT focus on Cmd+K
8. `go build ./...`, `go vet ./...`, `go test ./...`, `bun run build`.
9. Commit: `feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044)`.
10. Push to `mai/cronus/cmd-ctrl-k-command`, self-merge into main with
`--no-ff` (regression cleanup is in, no conflicts expected).
11. Verify on prod paliad.de via Playwright after Dokploy auto-deploy
(test creds: `tester@hlc.de` / `xdMmC7iCeDSTFmPXAlAyY0`).
---
## Risks + mitigations
| Risk | Mitigation |
|-------------------------------------------------------|----------------------------------------------------------------------|
| Cmd+K conflicts with future rich-text editor | Single global handler; if a real conflict appears, gate by `closest('.no-cmdk')` opt-out. |
| Action labels wrong language on first render | Reuse `t()` + `onLangChange()` handler that already exists in search.ts. |
| Browser URL-bar focus on Ctrl+K (Firefox) | `preventDefault()` + `stopPropagation()` in document keydown handler. |
| Action dispatcher breaks if sidebar button moves | One-line indirection per action; trivial to fix; covered by manual smoke. |
| Mobile BottomNav doesn't expose palette | Documented decision — drawer path exists; revisit if usage shows need. |
| Existing `flatResults` consumers (Enter handler) | Updated in same change — single file controls navigation. |
| Action catalog grows beyond ~50 | Add fuzzy match later; not now. |
---
## Implementer recommendation
cronus (myself) is a good fit to implement this:
- Designed it; minimum context-loading cost.
- Single-file-cluster change (search.ts + 1 new + 1 i18n + a few markup tweaks).
- No DB / backend touch; pure frontend client-side change.
- Can self-merge once m approves and t-paliad-043 stays green.
Alternative: knuth (frontend-strong, shipped t-paliad-026 global search).
Either works; this design doc carries enough detail that the implementer choice
is fungible.
**Decision is m's. I will not start coding until the head signals approval.**

View File

@@ -0,0 +1,160 @@
# Courts entity + per-country holiday computation
**Task:** t-paliad-122
**Status:** ON-HOLD until trigger condition met (see "When to do it"). Design locked by m on 2026-05-05 18:51; this doc archives the design plus live-codebase findings so a future implementer doesn't have to re-derive them.
**Inventor:** cronus (2026-05-05 23:46)
## TL;DR
Today, every `paliad` deadline calculation gates on one combined holiday list (DE federal hardcoded + whatever rows happen to be in `paliad.holidays` for that year, regardless of country). That works while every active user is in a German jurisdiction. It silently breaks the moment a UPC LD outside DE (Paris, Helsinki, Milan, Den Haag, …) or an EPO closure-day calendar comes into scope, because the right calendar to apply depends on **the court the proceeding is filed in**, not on the proceeding type.
m's locked model:
1. Holidays are scoped per country (`paliad.holidays.country` already exists).
2. A new `paliad.courts` entity carries `country` and is FK'd from anything that needs jurisdiction-aware deadline math.
3. The Fristenrechner takes a `court_id` (not a `country_code` directly), resolves court → country, and gates `IsNonWorkingDay` on that country's holidays.
4. Jurisdiction lives on the **court (forum)**, not on the proceeding type. UPC_INF can sit in München LD (DE), Paris LD (FR) or Helsinki LD (FI) — same proceeding, three calendars.
## What's true today (live-code verification, 2026-05-05)
Verified against the worktree at `mai/cronus/inventor-holidays-per` and the `paliad.*` schema on the youpc Supabase. The design rests on these:
- **`paliad.holidays.country`** — exists, NOT NULL, default `'DE'`. Verified via `information_schema.columns`. m's claim "(already shaped this way)" stands.
- **`paliad.courts`** — does **not** exist. The schema has `holidays`, `proceeding_types`, `deadline_concepts`, `deadline_rules`, `event_types`, `projects` etc., but no `courts` table. Confirmed via `information_schema.tables`. The migration must create it.
- **`paliad.proceeding_types.jurisdiction`** — exists as a `text` column with values `'UPC' | 'DE' | 'EPA' | 'DPMA'`. **This is the legal regime, not the country.** It answers "which procedural law applies" (UPC RoP vs. ZPO vs. EPC vs. PatG); the new `courts.country` will answer "which national holiday calendar applies". The two are orthogonal: `UPC_INF` has `jurisdiction='UPC'` (regime) and could be filed in any of nine countries (calendar). The existing column is **not redundant** under the new model and should not be removed; it should be renamed to `regime` in a follow-up if and only if the dual meaning starts confusing future readers (see "Optional rename" below).
- **Static court catalog already exists** — `internal/handlers/courts.go` carries 41 hand-curated `Court` entries (Gerichtsverzeichnis knowledge tool) with stable `ID` (kebab-case, e.g. `upc-ld-paris`, `upc-cd-munich`, `de-bgh`), `Country` (ISO-3166 alpha-2), and `Type` (`UPC-LD`, `DE-BGH`, …). These ARE the seeds for `paliad.courts`. No new curation work needed for the initial migration.
- **`HolidayService.IsNonWorkingDay(date time.Time) bool`** — current signature, no country/court param. Lives at `internal/services/holidays.go:124`.
- **`HolidayService.loadYear(year int)`** — does **not** filter the SQL by country. Returns every row for that year, regardless of country. Latent bug if anyone seeds non-DE rows ahead of the courts entity arriving — they'll silently apply to DE-jurisdictional calculations. See "Latent bugs" below.
- **`germanFederalHolidays(year)`** is hardcoded as a merge in `loadYear` (`internal/services/holidays.go:92`) so a misconfigured DB never silently drops Christmas. Under per-country holidays, this merge must become country-conditional.
- **`Holiday` cache struct** drops the `Country` field — `dbHoliday` reads it but the public `Holiday` (built at `internal/services/holidays.go:77`) doesn't carry it forward. The cache shape must grow `Country` for per-country lookup to work without re-querying.
- **`FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts)`** — current signature at `internal/services/fristenrechner.go:116`, no court param.
## Right data model
### `paliad.courts`
```sql
CREATE TABLE paliad.courts (
id text PRIMARY KEY, -- kebab-case stable ID, e.g. 'upc-ld-paris'
code text NOT NULL, -- short code, e.g. 'UPC-LD-Paris', for display / log lines
name_de text NOT NULL,
name_en text NOT NULL,
country text NOT NULL, -- ISO-3166 alpha-2, FK target for paliad.holidays.country
court_type text NOT NULL, -- 'UPC-LD' | 'UPC-CD' | 'UPC-CoA' | 'UPC-RD' | 'DE-LG' | 'DE-OLG' | 'DE-BGH' | 'DE-BPatG' | 'DE-DPMA' | 'EPA' | 'NAT'
parent_id text REFERENCES paliad.courts(id), -- e.g. all UPC LDs → 'upc-cfi'; UPC CoA stands alone
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX courts_country_idx ON paliad.courts(country);
CREATE INDEX courts_type_idx ON paliad.courts(court_type);
```
Seeds come straight from `internal/handlers/courts.go` (the 41 entries already there). The Gerichtsverzeichnis handler stays as-is and continues to be the source for the rich `Court{}` metadata (address, phone, languages, source URLs); `paliad.courts` is the **deadline-computation slice** of that catalog and only carries what holiday math needs. Follow-up admin UI can promote courts to authoritative-in-DB later if maintenance friction warrants.
`court_type` is denormalised text (matching the existing `CourtTypes` enum in Go) rather than a separate `paliad.court_types` table — the type set is small, stable, and already canonicalised in `internal/handlers/courts.go:CourtTypes`.
### `paliad.holidays.country` keeps its current shape
No schema change. We just start writing rows for FR / FI / IT / NL / AT / etc. as those courts come into scope, and the merge of `germanFederalHolidays(year)` becomes conditional on `country='DE'`.
### Court FK on existing rows
Touch points in order of impact:
- **Project rows that today carry a free-text `court` field** (per `docs/design-data-model-v2.md`, the `verfahren`-typed projekte have a `court text` column). On migration, attempt to map `court``courts.id` heuristically (lower-case match, kebabify), backfill `court_id`, leave `court` text in place for legacy reads, and gate the Fristenrechner picker on the new FK only.
- **`deadlines` rows** — currently no court FK. Add `court_id` nullable, populate on creation from the parent project's resolved court. Existing rows can stay NULL; the Fristenrechner UI re-resolves at calc time.
- **`event_deadlines`, `event_types`, `deadline_rules`** — none gain a court column. The court is a property of the *proceeding the deadline is computed for*, not of the rule template.
## Right service shape
### `HolidayService` becomes country-aware
```go
// IsNonWorkingDay returns true on weekends or closure-type holidays
// applicable to the given country. countryCode is ISO-3166 alpha-2.
func (s *HolidayService) IsNonWorkingDay(date time.Time, countryCode string) bool
// AdjustForNonWorkingDays / AdjustForNonWorkingDaysWithReason gain the same param
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, countryCode string) (...)
```
Internally:
- `loadYear` adds a `country = ANY($2)` filter. The cache key becomes `(year, country)` — or, slightly slicker, the cache stays keyed by `year` but the `Holiday` struct grows a `Country` field and the lookup helpers filter on it. Cache-by-year wins on hit rate (one fetch per year regardless of how many countries are touched in a request), so prefer growing the struct.
- `germanFederalHolidays(year)` merge becomes `if countryCode == "DE" { merge(...) }`. Belt-and-braces: also seed `paliad.holidays` properly with German federal entries via migration so the Go fallback can eventually be retired.
- A two-letter country code `""` is treated as a hard error — callers must always say which country they mean. Don't paper over an unknown court with a silent DE default; that's how the bug class this task fixes recurs.
### `FristenrechnerService.Calculate` takes `courtID`
```go
func (s *FristenrechnerService) Calculate(
ctx context.Context,
proceedingCode, triggerDateStr string,
courtID string, // NEW — required when proceeding can land in multiple courts; empty for unambiguous DE-only proceedings (BPatG nullity etc.)
opts CalcOptions,
) (*UIResponse, error)
```
Resolution path inside `Calculate`:
1. If `courtID == ""`: look up the proceeding type, find its single canonical court (e.g. `DE_NULL_BGH``de-bgh`); error if the proceeding can land in multiple courts.
2. Resolve `courtID → countryCode` via `paliad.courts`.
3. Pass `countryCode` to every `IsNonWorkingDay` / `AdjustForNonWorkingDays` call inside the calculator and the rule walker.
### UI: court picker on the Fristenrechner form
Show a court dropdown only when the selected proceeding type has more than one possible court (today: every UPC-flavoured proceeding type — `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_PI`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`). For DE-only proceedings (`DE_NULL`, `DE_NULL_BGH`, `DE_INF_BGH`, `DPMA_*`, `EPA_*`) keep the form as-is and resolve the court server-side.
The picker pulls from `paliad.courts` filtered by court type compatible with the proceeding code, ordered by a hand-curated importance score (HLC offices first → München LD, Düsseldorf LD, Paris LD, …). When `proceeding_types.jurisdiction='UPC'`, valid court types are `UPC-LD | UPC-CD | UPC-CoA | UPC-RD`; when `'DE'`, `DE-LG | DE-OLG | DE-BGH`; etc. The mapping `(jurisdiction → []court_type)` is compact enough to live in Go alongside the existing `CourtTypes`.
## Out of scope
- ~~`jurisdictions text[]` column on proceeding_types~~ — m: "We can't map jurisdictions to proceeding types" (2026-05-05 18:51).
- ~~`proceeding_type_jurisdictions` join table~~ — same.
- ~~Hard-coded `switch proceedingCode` in Go~~ — same.
- **Per-court rule overrides** — already handled by the t-131 `deadline_concepts.per_context` jsonb. Out of scope here; lean on it if a court has a non-standard duration. Don't replicate that mechanism.
- **Promoting the static `[]Court` slice in `internal/handlers/courts.go` to DB-authoritative** — orthogonal. The static slice continues to back the Gerichtsverzeichnis page; `paliad.courts` is the deadline-computation slice. They're sibling artefacts, not master/replica.
## Latent bugs to fix as part of this work
1. **`loadYear` doesn't filter by country.** Today, if anyone seeds a non-DE row into `paliad.holidays` (e.g. trying out FR holidays for an upcoming Paris LD case), it will silently apply to every DE deadline calculation that year. Fix: SQL-filter by country and require a country argument to `loadYear` / `IsNonWorkingDay`.
2. **Vacation-block walker is country-blind.** `findVacationBlock` (`internal/services/holidays.go:259`) walks across the merged year list with no country filter. After this work, vacation entries have a country too — the walker should only consider vacations for the active country. (Today the only vacations are UPC summer/winter, country-stamped DE in seeded data; harmless until non-DE court vacations land.)
3. **`paliad.holidays` has no FK on country.** A typo'd country code (`'De'` instead of `'DE'`) would silently create an orphan calendar that no court resolves to. Fix: either CHECK constraint listing the alpha-2 codes paliad supports, or a `paliad.countries (code text PRIMARY KEY)` lookup table. Lean toward the lookup table because the courts table will FK to the same set anyway.
## Optional rename — defer
`proceeding_types.jurisdiction``proceeding_types.regime` would make the dual meaning unambiguous (regime = procedural law, country = holiday calendar). **Not part of this task.** It's a wide rename across migrations, models, services, handlers, and frontend payloads, with no functional benefit until someone gets confused. Leave the column name; document the dual meaning in the column comment when the migration ships.
## When to do it (trigger conditions, restated for the implementer)
This task unlocks the moment any of the following becomes real:
- A user files a deadline-bearing event in a non-DE UPC LD (Paris, Helsinki, Milan, Den Haag, Brussels, Wien, Lisboa, Ljubljana, Kopenhagen) or in the UPC RD Stockholm.
- The user wants EPO closure days modelled separately from German federal holidays (today they overlap heavily but diverge for things like the EPO-internal "shut between Christmas and New Year" rule).
- Cross-border practice picks up to the point that a DE firm regularly files in NL LD or FR LD.
Until then — don't pre-build. The schema change is small and the verification surface is large; doing it ahead of demand wastes a coder shift and adds another migration to the rollback story without buying anything users feel.
## Implementation outline (when triggered)
Order matters — each step is a self-contained, RoP-safe slice that an implementer can ship and merge before starting the next.
1. **Migration `053_courts.up.sql`** — create `paliad.courts`, seed from `internal/handlers/courts.go` (one INSERT per static-list entry, bilingual names from NameDE/NameEN, type from existing Type field, country direct, parent_id linked where the static list expresses hierarchy). Add `paliad.countries` lookup with the eight ISO codes paliad needs initially: DE, FR, IT, NL, BE, FI, PT, AT, SI, DK, SE, LU.
2. **Migration `054_holidays_country_fk.up.sql`** — add `holidays.country REFERENCES countries(code)`. CHECK that every existing row's country is in the lookup (must be true; default 'DE' is already in the seed list).
3. **Migration `055_proceedings_court_fk.up.sql`** — add nullable `projects.court_id REFERENCES courts(id)` for `verfahren`-typed rows; backfill via heuristic match against the legacy free-text `court` column; flag unmapped rows in a `\paliad.unmapped_courts` view for manual triage. Don't drop the legacy `court` text yet.
4. **HolidayService refactor** — grow `Holiday` struct with `Country`, change cache shape, add country param to `IsNonWorkingDay` / `AdjustForNonWorkingDays` / `findVacationBlock`. Keep the German-federal merge but gate on country. Update every call site (deadline_calculator, fristenrechner, deadline_service, event_deadline_service); the compile error is your checklist.
5. **FristenrechnerService refactor** — add `courtID` parameter to `Calculate`; resolve court → country at the top of the function; thread country through every helper.
6. **API + UI** — extend the calc endpoint to accept `courtId`; add the picker to the Fristenrechner form (only renders when proceeding type has multiple compatible courts); persist court choice on calc-result bookmarks.
7. **Seed data for at least one non-DE country** — pick whichever triggered the unlock (FR if Paris LD; NL if Den Haag LD; etc.); seed both public holidays and any UPC vacation entries country-stamped to that code.
8. **Test coverage** — table-driven test in `internal/services/holidays_test.go` covering: DE court → DE holidays; FR court → FR holidays; UPC LD München (DE) → DE holidays; UPC LD Paris (FR) → FR holidays; unknown court → error; missing country argument → error. Plus a Go coverage test asserting every active proceeding type resolves to at least one court.
## Reference
- t-paliad-119 — adjustment-reason explainer (what a user sees today when a deadline shifts).
- t-paliad-121 — UPC court vacations are informational, not closure-type. Same precedent: vacation entries stay in DB but `IsNonWorkingDay` excludes them.
- t-paliad-131 — `deadline_concepts.per_context` jsonb already supports per-context overrides; if a court demands a non-standard duration, use that mechanism rather than a new column on `courts`.
- m's design call: 2026-05-05 18:51 — courts own jurisdiction (country), not proceeding types.
- `internal/handlers/courts.go` — static court catalog (41 entries) with `(ID, NameDE, NameEN, Country, Type)` ready to seed `paliad.courts`.
- `internal/services/holidays.go` — current HolidayService; country-blindness lives at lines 6398 and 116130.
- `internal/services/fristenrechner.go:116` — current `Calculate` signature.

View File

@@ -0,0 +1,920 @@
# Data Model v2 — Clients, nestable Projects, Teams
**Author:** cronus (inventor)
**Date:** 2026-04-20
**Task:** t-paliad-023
**Status:** Design draft for review. Supersedes the flat `paliad.akten` model in `design-kanzlai-integration.md` §2§3.
**Scope:** Schema + migration plan. No implementation in this change.
---
## Executive summary
**Recommendation.** Replace the flat `paliad.akten` table with a two-table core:
1. `paliad.mandanten` — clients (companies or people who instruct HLC).
2. `paliad.projekte` — a **single, self-referential, typed tree** of all work. Every row has a `project_type` (`mandat`, `litigation`, `patent`, `verfahren`, `projekt`), an optional `parent_project_id`, and a required `client_id` that points to the Mandant at the root of the tree. Fristen, Termine, Notizen, Dokumente and Parteien all hang off a **single polymorphic `project_id`** — "polymorphic" only in the sense that any node in the tree can own them, not in the sense of multi-table FKs.
**Teams** become explicit rows. `paliad.teams` holds both Dezernate (structural, one partner-led unit per row) and — as a separate concept — Project Teams (ad-hoc, per-project roster). The `paliad.users.dezernat` free-text field is superseded by `paliad.team_mitglieder`. The `paliad.akten.collaborators uuid[]` array on every Akte is replaced by `paliad.projekt_mitglieder`, giving us per-user roles inside a project and a sane target for audit + invitations.
**Visibility** stays office-scoped, but the predicate now walks the tree: seeing *any* node in a project grants the viewer access to the whole connected tree (root, siblings, descendants). This matches how lawyers actually use the data — a Munich associate put on one UPC Case of a Siemens litigation must see the parent litigation and the sibling Cases to do their job, and a lead partner put on the litigation root must see everything below.
**Naming.** Stay German, matching everything shipped so far — `mandanten`, `projekte`, `teams`, `team_mitglieder`, `projekt_mitglieder`. German plural for table names, singular for Go structs (`Mandant`, `Projekt`, `Team`). `User` stays English (Supabase concept).
**Migration** is phased and non-destructive. Existing `paliad.akten` rows survive as `projekte` rows with `project_type='verfahren'` (best match for the flat-Akte pattern), the same primary key UUIDs, and `client_id` nullable during a cleanup window (the partner UI collects real Mandant assignment). Every child table (`fristen`, `termine`, `notizen`, `dokumente`, `parteien`, `akten_events`, `checklist_instances`) gets its FK column renamed from `akte_id` to `project_id` in a follow-on migration — no data move, just a DDL rename. The existing `/akten` URLs stay live as aliases of `/projekte`.
**This is the foundation for Paliad v2.** It is opinionated. The trade-offs are called out inline.
---
## 1. Entity-Relationship
### 1.1 Mermaid
```mermaid
erDiagram
USERS ||--o{ TEAM_MITGLIEDER : "belongs to"
USERS ||--o{ PROJEKT_MITGLIEDER : "staffed on"
TEAMS ||--o{ TEAM_MITGLIEDER : "has members"
MANDANTEN ||--o{ PROJEKTE : "owns"
PROJEKTE ||--o{ PROJEKTE : "parent of"
PROJEKTE ||--o{ PROJEKT_MITGLIEDER : "has team"
PROJEKTE ||--o{ FRISTEN : "1:N"
PROJEKTE ||--o{ TERMINE : "1:N (nullable)"
PROJEKTE ||--o{ NOTIZEN : "1:N (polymorphic parent)"
PROJEKTE ||--o{ DOKUMENTE : "1:N"
PROJEKTE ||--o{ PARTEIEN : "1:N"
PROJEKTE ||--o{ AKTEN_EVENTS : "1:N (audit)"
PROJEKTE ||--o{ CHECKLIST_INSTANCES : "1:N (nullable)"
FRISTEN ||--o{ NOTIZEN : "parent (optional)"
TERMINE ||--o{ NOTIZEN : "parent (optional)"
AKTEN_EVENTS ||--o{ NOTIZEN : "parent (optional)"
TEAMS {
uuid id PK
text type "dezernat | project_team"
text name
uuid partner_id FK "for dezernat; NULL for project team"
uuid projekt_id FK "for project team; NULL for dezernat"
text office "seat of the dezernat; NULL for project team"
bool is_active
}
MANDANTEN {
uuid id PK
text name
text legal_form "e.g., AG, GmbH, Einzelerfinder"
text industry
text country
text billing_reference
jsonb key_contacts
text owning_office "Default office for new Projekte"
uuid[] collaborators "Firm-wide people with explicit Mandant access"
bool firm_wide_visible
text status "active | archived"
}
PROJEKTE {
uuid id PK
uuid client_id FK "nullable during migration"
uuid parent_project_id FK "self"
text project_type "mandat|litigation|patent|verfahren|projekt"
text title
text reference "human-readable ref (Aktenzeichen)"
text external_ref "EP no., UPC docket, etc."
text court
text court_ref
text status "active | pending | closed | archived"
text owning_office
bool firm_wide_visible
uuid created_by FK
jsonb metadata
ltree path "materialised ancestor path"
int depth "0 = root"
}
PROJEKT_MITGLIEDER {
uuid projekt_id PK FK
uuid user_id PK FK
text role "lead|associate|pa|of_counsel|local_counsel|expert|observer"
timestamptz added_at
uuid added_by FK
}
TEAM_MITGLIEDER {
uuid team_id PK FK
uuid user_id PK FK
text role "partner|associate|pa|trainee|of_counsel|secretariat"
timestamptz added_at
}
```
### 1.2 ASCII worked example
Showing the Siemens portfolio from the brief. `[P]` = partner, `[A]` = associate, `[LC]` = local counsel. Node IDs are illustrative.
```
MANDANTEN: M1 "Siemens AG" (industry=industrial, owning_office=munich)
└── PROJEKTE (client_id=M1)
└── P0 project_type=mandat "Siemens — Overall relationship" (path=P0, depth=0)
│ owning_office=munich
│ Team: [P Lead=partner_a@munich] [A associate_b@munich]
└── P1 project_type=litigation "Siemens v. Huawei — SEP Portfolio" (path=P0.P1, depth=1)
│ owning_office=munich, firm_wide_visible=false
│ Team: [P Lead=partner_a@munich] [A associate_c@duesseldorf] [LC local_uk@london]
├── P2 project_type=patent "EP 1 234 567" (path=P0.P1.P2, depth=2)
│ │ external_ref=EP1234567
│ │
│ ├── P3 project_type=verfahren "UPC Infringement UPC_CFI_123/2026"(path=P0.P1.P2.P3, depth=3)
│ │ court=UPC_CFI_Munich, court_ref=UPC_CFI_123/2026
│ │ └─ Fristen, Termine, Notizen, Dokumente all project_id=P3
│ │
│ ├── P4 project_type=verfahren "EPO Opposition W 0001/26"
│ └── P5 project_type=verfahren "BPatG Nullity 3 Ni 45/26"
├── P6 project_type=patent "EP 2 345 678"
│ └── P7 project_type=verfahren "UPC Infringement UPC_CFI_456/2026"
└── P8 project_type=patent "EP 3 456 789"
└── P9 project_type=verfahren "LG München I 21 O 12345/26"
```
The key insight: **every box is a row in `paliad.projekte`**. Fristen/Termine/Notizen live at whichever node is the right home. Dashboard aggregates across all nodes the user can see by walking the visibility predicate.
---
## 2. Table schemas
### 2.1 `paliad.mandanten`
```sql
CREATE TABLE paliad.mandanten (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
legal_form text, -- 'AG', 'GmbH', 'Inc.', 'Einzelerfinder', …
industry text, -- free text, not an enum
country text, -- ISO 3166-1 alpha-2 ('DE','US',…)
billing_reference text, -- matches the firm-wide billing system
key_contacts jsonb NOT NULL DEFAULT '[]'::jsonb,
-- [{"name":"…","role":"…","email":"…","phone":"…"}]
owning_office text NOT NULL CHECK (owning_office IN (
'munich','duesseldorf','hamburg',
'amsterdam','london','paris','milan')),
-- Visibility knobs, analogous to paliad.akten today:
collaborators uuid[] NOT NULL DEFAULT '{}',
firm_wide_visible boolean NOT NULL DEFAULT false,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived')),
metadata jsonb NOT NULL DEFAULT '{}',
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX mandanten_owning_office_idx ON paliad.mandanten (owning_office);
CREATE INDEX mandanten_firm_wide_idx ON paliad.mandanten (firm_wide_visible) WHERE firm_wide_visible = true;
CREATE INDEX mandanten_collaborators_gin ON paliad.mandanten USING GIN (collaborators);
CREATE INDEX mandanten_name_trgm ON paliad.mandanten USING GIN (name gin_trgm_ops);
```
Notes:
- `key_contacts` is JSONB not a child table. Contacts don't have their own identity (they're denormalised name/email/phone); pulling them into `paliad.contacts` would be overkill and block nothing. If HLC later wants CRM-style contact records, promote to a table in a follow-on.
- `owning_office` on `mandanten` is the **default** for new Projekte; individual Projekte can override.
- Duplicated visibility knobs (`collaborators`, `firm_wide_visible`) are intentional: a user can have Mandant-level visibility without being on any particular Projekt (e.g., the relationship partner who hasn't been staffed on a specific case yet). The predicate OR-fans these in.
### 2.2 `paliad.projekte`
```sql
-- Enable the ltree extension in Supabase (first migration to use it).
CREATE EXTENSION IF NOT EXISTS ltree;
-- project_type is a text + CHECK, not an enum type. Enums are painful to extend
-- in Postgres migrations; text + CHECK gives us the same validation with room
-- to add a new type by replacing the constraint.
CREATE TABLE paliad.projekte (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid REFERENCES paliad.mandanten(id) ON DELETE RESTRICT,
-- NULLABLE during migration;
-- enforced NOT NULL in a later phase.
parent_project_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE,
project_type text NOT NULL CHECK (project_type IN
('mandat','litigation','patent','verfahren','projekt')),
title text NOT NULL,
reference text, -- firm-internal human ref (Aktenzeichen)
external_ref text, -- EP no., UPC docket, BPatG ref, …
court text,
court_ref text,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','pending','closed','archived')),
owning_office text NOT NULL CHECK (owning_office IN (
'munich','duesseldorf','hamburg',
'amsterdam','london','paris','milan')),
firm_wide_visible boolean NOT NULL DEFAULT false,
-- Materialised tree state. Maintained by a BEFORE INSERT/UPDATE trigger.
-- `path` is the ltree of ancestor ids ending with this row's id.
path ltree NOT NULL,
depth int NOT NULL CHECK (depth >= 0),
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
metadata jsonb NOT NULL DEFAULT '{}',
ai_summary text, -- unused today; kept from akten
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Sanity: a project's client must match its parent's client (and root of
-- tree carries the single source of truth).
CONSTRAINT projekte_parent_self_differs CHECK (parent_project_id IS NULL OR parent_project_id <> id)
);
-- Trees are navigated by path. GiST over ltree makes ancestor / descendant
-- lookups <@ / @> work in O(log n) — critical for the visibility predicate.
CREATE INDEX projekte_path_gist ON paliad.projekte USING GIST (path);
CREATE INDEX projekte_parent_idx ON paliad.projekte (parent_project_id);
CREATE INDEX projekte_client_idx ON paliad.projekte (client_id);
CREATE INDEX projekte_client_type_idx ON paliad.projekte (client_id, project_type)
WHERE status <> 'archived';
CREATE INDEX projekte_owning_office_idx ON paliad.projekte (owning_office);
CREATE INDEX projekte_firm_wide_idx ON paliad.projekte (firm_wide_visible) WHERE firm_wide_visible = true;
CREATE INDEX projekte_status_idx ON paliad.projekte (status);
CREATE INDEX projekte_reference_trgm ON paliad.projekte USING GIN (reference gin_trgm_ops);
CREATE INDEX projekte_title_trgm ON paliad.projekte USING GIN (title gin_trgm_ops);
```
**Why ltree and not a recursive CTE?** RLS is called once per candidate row on every SELECT. A recursive CTE per row is O(depth) repeated per predicate call. `path @> ancestor_path` uses the GiST index and collapses to one index scan. This is the biggest performance decision in the doc; ltree is the right tool.
**Why a materialised path *and* `parent_project_id`?** The parent FK is the source of truth for the tree (used by `ON DELETE CASCADE`). The `path` + `depth` columns are derived state maintained by a trigger. Keeping both is redundant on purpose — the FK guarantees referential integrity; the path gives us fast traversal. Updates to `parent_project_id` re-compute path for the subtree in the trigger.
**Tree trigger sketch:**
```sql
CREATE FUNCTION paliad.projekte_sync_path() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
parent_path ltree;
BEGIN
IF NEW.parent_project_id IS NULL THEN
NEW.path := text2ltree(replace(NEW.id::text, '-', '_'));
NEW.depth := 0;
ELSE
SELECT path, depth + 1 INTO parent_path, NEW.depth
FROM paliad.projekte
WHERE id = NEW.parent_project_id;
IF parent_path IS NULL THEN
RAISE EXCEPTION 'parent project % not found', NEW.parent_project_id;
END IF;
NEW.path := parent_path || text2ltree(replace(NEW.id::text, '-', '_'));
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER projekte_sync_path_ins
BEFORE INSERT ON paliad.projekte
FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path();
-- On parent change, re-path both this row and every descendant.
CREATE FUNCTION paliad.projekte_rewrite_subtree() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.parent_project_id IS DISTINCT FROM OLD.parent_project_id THEN
PERFORM paliad.projekte_sync_path() FROM paliad.projekte WHERE id = NEW.id;
UPDATE paliad.projekte
SET path = NEW.path || subpath(path, nlevel(OLD.path) - 1),
depth = NEW.depth + (nlevel(path) - nlevel(OLD.path))
WHERE path <@ OLD.path
AND id <> NEW.id;
END IF;
RETURN NEW;
END;
$$;
```
(Sketch — implementer will refine. The constraint that uuid-hyphens aren't valid in ltree labels means we encode UUIDs with `-``_`. Alternative: use `hashtext(id::text)::text` to keep labels short — discuss with implementer.)
### 2.3 `paliad.teams`
**Two kinds, one table.** The shape is similar enough that splitting into `dezernate` + `project_teams` would mostly duplicate columns. A `type` column + partial CHECK constraints is cheaper.
```sql
CREATE TABLE paliad.teams (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
type text NOT NULL CHECK (type IN ('dezernat','project_team')),
name text NOT NULL,
-- For type = 'dezernat':
partner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
office text CHECK (office IS NULL OR office IN (
'munich','duesseldorf','hamburg',
'amsterdam','london','paris','milan')),
-- For type = 'project_team':
projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE,
is_active boolean NOT NULL DEFAULT true,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Shape invariants per type.
CONSTRAINT teams_dezernat_shape CHECK (
type <> 'dezernat'
OR (partner_id IS NOT NULL AND office IS NOT NULL AND projekt_id IS NULL)
),
CONSTRAINT teams_project_team_shape CHECK (
type <> 'project_team'
OR (projekt_id IS NOT NULL AND partner_id IS NULL AND office IS NULL)
)
);
-- One project_team per Projekt (if any).
CREATE UNIQUE INDEX teams_one_team_per_projekt
ON paliad.teams (projekt_id) WHERE type = 'project_team';
CREATE INDEX teams_partner_idx ON paliad.teams (partner_id) WHERE type = 'dezernat';
CREATE INDEX teams_office_idx ON paliad.teams (office) WHERE type = 'dezernat';
CREATE INDEX teams_projekt_idx ON paliad.teams (projekt_id) WHERE type = 'project_team';
```
**Critique already anticipated.** Mixing two entities in one table is a smell. I accept the smell because: (a) the queries we actually run split cleanly by `type`; (b) the join from a user to "every team I'm on" wants a single table; (c) project teams and dezernate both feed the same `team_mitglieder` roster table and the same per-team role enum overlap is ~80%. If we discover real divergence (project teams grow a `stage` field, dezernate grow a `parent_dezernat_id`), split then.
### 2.4 `paliad.team_mitglieder`
Roster for **both** kinds of team. Dezernat and project-team memberships coexist — a user is typically in exactly one Dezernat *and* on multiple project teams.
```sql
CREATE TABLE paliad.team_mitglieder (
team_id uuid NOT NULL REFERENCES paliad.teams(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN (
'partner','associate','pa','trainee','of_counsel',
'secretariat','lead','local_counsel','expert','observer'
)),
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX team_mitglieder_user_idx ON paliad.team_mitglieder (user_id);
```
Role values overlap intentionally: `partner`, `associate`, `pa`, `trainee`, `of_counsel`, `secretariat` are the typical Dezernat roles; `lead`, `associate`, `pa`, `of_counsel`, `local_counsel`, `expert`, `observer` are the typical project-team roles. The union is finite and small — don't over-engineer with separate role enums per team type.
### 2.5 `paliad.projekt_mitglieder` (replaces `collaborators uuid[]`)
I recommend **flattening project-team rosters into a dedicated junction table** instead of going through `teams` + `team_mitglieder` for the project-team case. Reason: project-team membership is the hot path for RLS. A dedicated two-column junction with a covering index beats any indirection through `teams`.
```sql
CREATE TABLE paliad.projekt_mitglieder (
projekt_id uuid NOT NULL REFERENCES paliad.projekte(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN (
'lead','associate','pa','of_counsel','local_counsel','expert','observer'
)),
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
PRIMARY KEY (projekt_id, user_id)
);
CREATE INDEX projekt_mitglieder_user_idx ON paliad.projekt_mitglieder (user_id);
```
**So what's `paliad.teams` type='project_team' for?** Two things the junction can't express alone: (a) the *identity* of the team as a first-class object (naming, status, metadata, invitations); (b) a place to attach team-level settings (e.g., a project-team Slack channel URL, a CalDAV group calendar id). If we decide we don't need either, `teams` shrinks to dezernate only and `project_team` rows go away. That's fine too — implementation can start with junction-only and add `teams` rows on demand.
**Opinion:** start with `projekt_mitglieder` only. Add `teams` rows for project teams if/when we ship team-scoped features (project Slack channel, group calendar). For the purposes of visibility and role, the junction table is sufficient.
### 2.6 Child tables — single `project_id` polymorphic FK
No polymorphic magic. Every node in the tree is a `projekte` row. A Frist attached to "the Siemens SEP litigation" just FKs to the litigation-level projekt. A Frist on the root Mandat-level projekt is rare but expressible. Polymorphic multi-FK tables (with multiple nullable parent columns + a CHECK) are a pattern we have today on `notizen`, and they create RLS pain — we avoid extending it.
Revised child tables (columns that change only):
| Table | Today | v2 |
|---|---|---|
| `paliad.parteien` | `akte_id NOT NULL` | `project_id NOT NULL` |
| `paliad.fristen` | `akte_id NOT NULL` | `project_id NOT NULL` |
| `paliad.termine` | `akte_id NULL` | `project_id NULL` (personal stays NULL) |
| `paliad.dokumente` | `akte_id NOT NULL` | `project_id NOT NULL` |
| `paliad.akten_events` | `akte_id NOT NULL` | `project_id NOT NULL` (table stays `akten_events` for history; see §10) |
| `paliad.checklist_instances` | `akte_id NULL` | `project_id NULL` (personal stays NULL) |
| `paliad.notizen` | 4 nullable FKs (akte/frist/termin/event), 1-of CHECK | **Keep as-is** — still polymorphic across frist/termin/event/*project*; `akte_id``project_id`. |
`notizen` stays polymorphic because notes attach to three different kinds of entity (Projekt, Frist, Termin, AktenEvent) — a note on a Frist is *not* the same thing as a note on the owning Projekt. The alternative (always attach at Projekt and store `frist_id` / `termin_id` in metadata) loses referential integrity; reject it.
### 2.7 `paliad.users` changes
- **Drop:** `dezernat text` (free-text, only introduced in migration 015).
- **Keep:** `office`, `role`, `practice_group`, `lang`, `email_preferences`.
- **New:** nothing — Dezernat membership moves to `team_mitglieder` with `team_id` pointing at a `teams` row of type `dezernat`.
Migration 015 left `dezernat` as free text precisely because the partner might not have registered yet. v2 keeps the freedom differently: during onboarding, the user either (a) picks an existing Dezernat from a dropdown (fed from `paliad.teams WHERE type='dezernat'`) or (b) types a new one, and the onboarding service auto-creates a `teams` row with `partner_id = NULL` and a note "claim this partnership by signing up with role=partner". The partner claims on their own first-login.
---
## 3. Polymorphic FK strategy (the explicit recommendation)
> **Single `project_id` on all child tables (except `notizen`).**
Rationale:
- Everything a Frist/Termin/Dokument/Partei could "attach to" is now a row in `paliad.projekte`. A Mandant-level Frist FKs to the `project_type='mandat'` root. A verfahren-level Frist FKs to the `project_type='verfahren'` leaf. No discriminator column, no CHECK constraint juggling.
- RLS becomes one predicate: `paliad.can_see_project(project_id)`. Today's `can_see_akte()` + `notiz_is_visible()` split goes away for 6 of 7 child tables.
- Client visibility (a Mandant-level Frist like "send yearly renewal reminder") is uniform: it just lives on the Mandant-level projekt — no `client_id` FK on child tables, no third polymorphic branch.
- Query aggregation across a client's work ("show me all deadlines in the next 30 days for Siemens") is a single JOIN: `fristen JOIN projekte ON fristen.project_id = projekte.id WHERE projekte.path <@ (SELECT path FROM projekte WHERE id = <mandat-projekt-id>)`.
Alternatives I considered and rejected:
- **Multiple nullable FKs (`client_id`, `litigation_id`, `patent_id`, `verfahren_id`) with a 1-of CHECK.** Reproduces the notizen pain for every child table. Harder to index, harder for RLS. Rejected.
- **`parent_type text` + `parent_id uuid` (classic polymorphic)**. Kills foreign-key integrity. Rejected.
- **Separate child tables per level (`mandat_fristen`, `litigation_fristen`, …)**. Absurd proliferation. Rejected on sight.
`notizen` is the one exception because a note genuinely attaches to one of four *kinds of entity*, not to four different positions in the same tree. Keep the 4-FK-one-nullable shape (akte_id → **project_id**, frist_id, termin_id, akten_event_id; CHECK = 1-of-4).
---
## 4. Visibility model
### 4.1 Design principles
1. Visibility is **tree-connected**: if you can see one node, you can see the whole tree (root → all descendants). Mimics how litigation teams actually work.
2. Office-scoping stays **at the project level**, not the Mandant level, because different Projekte under one client may legitimately belong to different offices (e.g., the client's Munich patent prosecution vs. their Düsseldorf enforcement).
3. Project-team membership **grants visibility**, including for users outside `owning_office`.
4. Mandant-level visibility (`mandanten.collaborators`, `mandanten.firm_wide_visible`) grants visibility to the **Mandant** and its **entire project tree**. This is the firm-wide or relationship-partner override.
5. `admin` role sees everything.
### 4.2 The predicate, in English
A user U can see a Projekt P iff **any** of the following:
- `P.firm_wide_visible = true`, **or**
- `P.owning_office = U.office`, **or**
- U is in `projekt_mitglieder` for P, **or**
- U is in `projekt_mitglieder` for **any ancestor or descendant of P** (tree-connected visibility), **or**
- `P.client_id` points to a Mandant M where:
- `M.firm_wide_visible = true`, **or**
- U's uuid ∈ `M.collaborators`, **or**
- `U.role = 'admin'`.
A user U can see a Mandant M iff **any** of:
- `M.firm_wide_visible = true`, **or**
- `M.owning_office = U.office`, **or**
- U's uuid ∈ `M.collaborators`, **or**
- U can see **any** Projekt under M (inductive), **or**
- `U.role = 'admin'`.
### 4.3 SQL predicate
```sql
-- Canonical visibility predicate for projects. Used in RLS and mirrored at
-- the service layer (AkteService.ListVisibleForUser equivalent).
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
WITH me AS (
SELECT id, office, role FROM paliad.users WHERE id = auth.uid()
),
tgt AS (
SELECT id, client_id, owning_office, firm_wide_visible, path
FROM paliad.projekte
WHERE id = _project_id
)
SELECT EXISTS (SELECT 1 FROM tgt WHERE tgt.firm_wide_visible)
OR EXISTS (SELECT 1 FROM tgt, me WHERE tgt.owning_office = me.office)
OR EXISTS (SELECT 1 FROM me WHERE me.role = 'admin')
OR EXISTS (
-- membership at target, or at any ancestor/descendant
SELECT 1 FROM paliad.projekt_mitglieder pm, tgt
WHERE pm.user_id = auth.uid()
AND pm.projekt_id IN (
SELECT id FROM paliad.projekte
WHERE path @> tgt.path OR path <@ tgt.path
)
)
OR EXISTS (
SELECT 1 FROM paliad.mandanten m, tgt
WHERE m.id = tgt.client_id
AND (m.firm_wide_visible
OR auth.uid() = ANY (m.collaborators))
);
$$;
```
The `path @> tgt.path OR path <@ tgt.path` clause is the tree-connected check: any project whose path is an ancestor or descendant of the target's path. Uses the GiST index on `projekte.path`.
**Performance note.** For a litigation tree of ~20 nodes with ~100 members, the predicate runs a handful of index probes per row. Measurably worse than today's flat `can_see_akte()` (one EXISTS), but still sub-millisecond in Postgres. If we ever see RLS cost dominate a listing page, the follow-on is to cache "visible project ids for user X" in a session-scoped CTE at the application layer (same trick we use in `ListVisibleForUser`).
### 4.4 Cross-office teams — concretely
Example: Munich partner (Dezernat A) leads; Düsseldorf associate and London local counsel are staffed in.
- The Litigation-level Projekt is created with `owning_office = 'munich'`.
- The Munich partner, the Düsseldorf associate, and the London local counsel all get rows in `projekt_mitglieder` (roles `lead`, `associate`, `local_counsel`).
- Result: the Düsseldorf associate can see the whole litigation tree even though `owning_office <> 'duesseldorf'`. Munich-office colleagues not on the team can still see it (office-scope). London colleagues not on the team cannot see it.
**Edge case: "Chinese-walled" cases.** If a single Akte needs to be hidden from the rest of `owning_office` (conflict of interest), `owning_office` can't carry the day. Add a boolean `restricted` column in a later iteration that flips the predicate to team-only. Don't build now — wait for the first real conflict.
---
## 5. Migration plan
Non-destructive, phased. Data survives at every step.
### Phase 1 — Add new tables (no FK rewrites)
Migration `018_v2_core_tables`:
- `CREATE EXTENSION IF NOT EXISTS ltree;`
- Create `paliad.mandanten`, `paliad.projekte`, `paliad.teams`, `paliad.team_mitglieder`, `paliad.projekt_mitglieder`.
- Create the path trigger.
- No change to `paliad.akten` or its children yet. Old code keeps running.
Acceptance: `\dt paliad.*` shows the new tables. Smoke test: insert a Mandant + one Projekt tree, query via `path @>`.
### Phase 2 — Backfill `paliad.projekte` from `paliad.akten` (synthetic Mandanten)
Migration `019_v2_backfill_projects_from_akten`:
- For each distinct `owning_office` with any Akte, create one synthetic Mandant: `name = 'Unbekannter Mandant (<office>)'`, `status = 'active'`, `owning_office = <office>`, `metadata = {"synthetic": true}`.
- Insert a `paliad.projekte` row for every `paliad.akten` row. **Same UUID** (`projekte.id = akten.id`), `client_id = synthetic mandant of matching office`, `parent_project_id = NULL`, `project_type = 'verfahren'` (best-match to current flat Akte semantics — most are single proceedings), `title`, `reference = aktenzeichen`, `owning_office`, `firm_wide_visible`, `created_by` copied 1:1. The path trigger populates `path` and `depth`.
- Also backfill `projekt_mitglieder` from `paliad.akten.collaborators` (array → rows with `role='associate'`).
Acceptance: `(SELECT COUNT(*) FROM paliad.projekte) = (SELECT COUNT(*) FROM paliad.akten)`; every akten id survives as a projekte id. Visibility-predicate returns the same answers as `can_see_akte` for every (user, akte) pair (spot-check).
### Phase 3 — Rename FK columns on child tables to `project_id`
Migration `020_v2_rename_akte_id_to_project_id`:
- For `parteien`, `fristen`, `termine`, `dokumente`, `akten_events`, `checklist_instances`: `ALTER TABLE … RENAME COLUMN akte_id TO project_id;` and `ALTER TABLE … RENAME CONSTRAINT <akte_fk> TO <project_fk>;` plus rewrite the REFERENCES target from `paliad.akten` to `paliad.projekte`.
- For `notizen`: `RENAME COLUMN akte_id TO project_id;` similarly; keep `frist_id/termin_id/akten_event_id` intact.
- Because of the shared UUID trick in Phase 2, no data moves. Indexes are renamed in the same migration.
Acceptance: `\d paliad.fristen` shows `project_id uuid NOT NULL REFERENCES paliad.projekte(id)`. Existing SELECTs joining to `paliad.akten` now break — that's the signal to cut the application code over in Phase 4.
### Phase 4 — Cut application code over to `paliad.projekte`
- Rename Go types: `models.Akte``models.Projekt`. Keep `models.Akte` as a deprecated type alias for one release for external API compatibility if needed.
- `services.AkteService``services.ProjektService`. Preserve method signatures; internals switch to `paliad.projekte`.
- Update handlers. `/api/akten` becomes an alias to `/api/projekte` (same handler, same JSON shape during transition — `projekte` additionally exposes `client_id`, `parent_project_id`, `project_type`, `path` fields).
- Update the dashboard query to aggregate by `projekte` (tree-walk already shown in §4.3).
Acceptance: the app runs end-to-end on the new schema; old routes still resolve; old JSON shapes still accepted (new fields additive).
### Phase 5 — New Mandant UI + partner cleanup
- `/mandanten` list + detail pages. Partners assign every synthetic-Mandant project to a real Mandant row (bulk "Change Mandant" on the project-detail page, or a dedicated migration UI at `/einstellungen/migration`).
- After every `projekte.client_id` in `metadata->>'synthetic'=true` has been reassigned, drop the synthetic Mandanten and enforce `paliad.projekte.client_id SET NOT NULL` (migration 021).
Acceptance: no synthetic Mandanten remain. `client_id` is NOT NULL.
### Phase 6 — Decommission `paliad.akten`
- `DROP TABLE paliad.akten` (migration 022). Everything that referenced it has been rewritten.
- Drop the legacy `paliad.can_see_akte()` function; the one-to-one function becomes `can_see_project()`.
Acceptance: `\dt paliad.akten` → not found. All FK constraints still satisfy.
### Rollback
Every migration has a `down`:
- Phases 3 and 6 are destructive DDL (drop column rename → rename back; drop table → re-create). Data preservation in those down-migrations is **not guaranteed** after the migration completes; the safe rollback window is "before Phase 3 runs in production". Document loudly.
- Phases 1, 2, 4, 5 are additive or app-level, rollback by reverting code or running `DELETE FROM paliad.projekte WHERE …`.
---
## 6. RLS policy updates
### 6.1 New policies
`paliad.projekte` — enable RLS. Policies:
```sql
CREATE POLICY projekte_select ON paliad.projekte
FOR SELECT TO authenticated
USING (paliad.can_see_project(id));
-- Non-admins can only create Projekte rooted in an office they belong to
-- (or a tree whose existing parent they can already see).
CREATE POLICY projekte_insert ON paliad.projekte
FOR INSERT TO authenticated
WITH CHECK (
-- Creating under an existing parent? — must already see it.
(parent_project_id IS NOT NULL AND paliad.can_see_project(parent_project_id))
OR
-- Root project: own office, or admin.
(parent_project_id IS NULL
AND (owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'))
);
CREATE POLICY projekte_update ON paliad.projekte
FOR UPDATE TO authenticated
USING (paliad.can_see_project(id))
WITH CHECK (paliad.can_see_project(id));
-- Delete: partner/admin only. Cascades down the tree.
CREATE POLICY projekte_delete ON paliad.projekte
FOR DELETE TO authenticated
USING (
paliad.can_see_project(id)
AND (SELECT role FROM paliad.users WHERE id = auth.uid()) IN ('partner','admin')
);
```
`paliad.mandanten` — enable RLS. Visibility: any user who can see at least one of the Mandant's Projekte, or who is in `collaborators`, or `firm_wide_visible`, or admin.
```sql
CREATE OR REPLACE FUNCTION paliad.can_see_mandant(_mandant_id uuid)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = paliad, public AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.mandanten m, paliad.users u
WHERE m.id = _mandant_id
AND u.id = auth.uid()
AND (
m.firm_wide_visible
OR m.owning_office = u.office
OR auth.uid() = ANY (m.collaborators)
OR u.role = 'admin'
OR EXISTS (
SELECT 1 FROM paliad.projekte p
WHERE p.client_id = _mandant_id
AND paliad.can_see_project(p.id)
)
)
);
$$;
CREATE POLICY mandanten_select ON paliad.mandanten
FOR SELECT TO authenticated
USING (paliad.can_see_mandant(id));
CREATE POLICY mandanten_insert ON paliad.mandanten
FOR INSERT TO authenticated
WITH CHECK (
owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'
);
-- Update/Delete policies analogous; delete is partner/admin-gated.
```
### 6.2 Child-table policies — converge on `can_see_project`
Every child table's policy changes from `paliad.can_see_akte(akte_id)` to `paliad.can_see_project(project_id)`. `notizen` loses its dedicated `notiz_is_visible()` helper in favour of an inline check that dispatches by which FK is set:
```sql
CREATE POLICY notizen_all ON paliad.notizen
FOR ALL TO authenticated
USING (
CASE
WHEN project_id IS NOT NULL THEN paliad.can_see_project(project_id)
WHEN frist_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.fristen WHERE id = frist_id))
WHEN termin_id IS NOT NULL THEN
CASE
WHEN (SELECT project_id FROM paliad.termine WHERE id = termin_id) IS NULL
THEN (SELECT created_by FROM paliad.termine WHERE id = termin_id) = auth.uid()
ELSE paliad.can_see_project(
(SELECT project_id FROM paliad.termine WHERE id = termin_id))
END
WHEN akten_event_id IS NOT NULL THEN paliad.can_see_project(
(SELECT project_id FROM paliad.akten_events WHERE id = akten_event_id))
ELSE false
END
)
WITH CHECK (...same...);
```
(We may extract a helper `notiz_is_visible(project_id, frist_id, termin_id, akten_event_id)` again — symmetric to today's.)
### 6.3 Admin bootstrap
Unchanged. The `pg_advisory_xact_lock(7346298141)` onboarding gate that lets the first user self-assign `role='admin'` still works. Nothing to do.
### 6.4 Defense-in-depth at the service layer
Same pattern as today: every service mirrors the predicate in `ListVisibleForUser` for indexed performance. The SQL is wordier for the tree variant — we do it once in `ProjektService.listVisibleIDsForUser` (returns a `map[uuid.UUID]struct{}`) and re-use across list endpoints.
---
## 7. API surface changes
### 7.1 New endpoints
| Method + path | Purpose |
|---|---|
| `GET /api/mandanten` | List Mandanten the user can see. |
| `POST /api/mandanten` | Create Mandant. |
| `GET /api/mandanten/{id}` | Detail. |
| `PATCH /api/mandanten/{id}` | Update. |
| `DELETE /api/mandanten/{id}` | Delete (partner/admin only). |
| `GET /api/mandanten/{id}/projekte` | List root-level Projekte under this Mandant. |
| `GET /api/projekte` | List top-level visible Projekte (flat root list). |
| `POST /api/projekte` | Create a Projekt (optionally nested via `parent_project_id`). |
| `GET /api/projekte/{id}` | Detail + immediate children. |
| `GET /api/projekte/{id}/tree` | Full subtree (depth-first). |
| `PATCH /api/projekte/{id}` | Update. |
| `DELETE /api/projekte/{id}` | Cascade delete subtree (partner/admin). |
| `GET /api/projekte/{id}/kinder` | Direct children (for lazy-loaded UI). |
| `POST /api/projekte/{id}/team` | Add project team member. |
| `DELETE /api/projekte/{id}/team/{user_id}` | Remove member. |
| `GET /api/teams` | List Dezernate (+ optionally project teams). |
| `POST /api/teams` | Create Dezernat (admin-gated). |
| `GET /api/teams/{id}/mitglieder` | List members. |
### 7.2 Aliased endpoints
During transition:
- `GET /api/akten` → alias of `GET /api/projekte?project_type=verfahren` (or all, with `akte_type`/`court` fields surfaced) for clients still using the old shape.
- `POST /api/akten` → alias of `POST /api/projekte` with `project_type='verfahren'` default.
- `/api/akten/{id}/fristen` → alias of `/api/projekte/{id}/fristen`.
Remove aliases after 60 days + 1 green deployment.
### 7.3 Retired endpoints
None immediately. Keeping aliases means no 404s for the UI during the Phase 4 cutover.
### 7.4 New query parameters
- `?client_id=<uuid>` on `GET /api/projekte` — scope to one Mandant's tree.
- `?project_type=<type>` — filter by type.
- `?ancestor=<uuid>` — subtree of a given root (uses `path <@`).
- `?include_children=true` on `GET /api/projekte/{id}` — one-shot detail + subtree (cheap because of the path index).
---
## 8. UI implications
### 8.1 Sidebar
Insert "Mandanten" above "Akten" in the "ARBEIT" group. Rename "Akten" → "Projekte" in a second phase once partners get used to the concept — initially, keep "Akten" as the label and add the Mandanten entry only.
```
— ARBEIT —
Dashboard
Mandanten ← NEW
Projekte ← renamed from "Akten" (phase 2)
Fristen
Termine
```
### 8.2 New pages
- `/mandanten` — list. Columns: Name, Büro, #Projekte, #aktive Fristen, letzte Aktivität.
- `/mandanten/neu` — create form.
- `/mandanten/{id}` — detail with tabs: Übersicht, Projekte (the tree at this root), Fristen (aggregated), Termine (aggregated), Notizen (aggregated), Team (aggregated from all child projekt_mitglieder).
- `/projekte` — flat list of root projects + filter by Mandant, type, office.
- `/projekte/{id}` — detail. Tabs today (`verlauf`, `parteien`, `fristen`, `termine`, `dokumente`, `notizen`, `checklisten`) stay. **New first tab: "Untergeordnet"** — renders the subtree of child projekte as a collapsible list. A "Neues Untervorhaben" button under each node creates a child.
- `/projekte/neu` — create. Form adapts to `project_type`: `mandat`/`litigation`/`patent` surface no `court` / `court_ref`; `verfahren` does.
### 8.3 Detail-page tree rendering
On `/projekte/{id}` the sidebar (left sub-nav) renders the ancestor path (breadcrumbs: Mandant → Litigation → Patent → Verfahren). Children render in the Untergeordnet tab as a collapsible tree. Keep click-depth low: clicking a child navigates to that child's detail page; siblings render as flat siblings on the current page.
### 8.4 Team editor
On `/projekte/{id}` under the Team tab: list of team members with role, "Mitglied hinzufügen" modal with autocomplete fed by `GET /api/users`. Removing the `collaborators uuid[]` array means the multi-pick UI gets a proper role column and an "added_by/at" audit line, which today's model can't show.
### 8.5 Dashboard impact
Dashboard queries aggregate across **every** Projekt the user can see (all tree levels). Fristen summary widget: `SELECT * FROM paliad.fristen f JOIN paliad.projekte p ON f.project_id = p.id WHERE can_see_project(p.id) AND f.status='pending' AND f.due_date <= now() + interval '30 days'`. Same tree-agnostic pattern for Termine.
"Neu auf ..." — add a Mandanten-level aggregate: "Siemens AG: 3 neue Fristen diese Woche, 1 Verhandlung am Donnerstag". Requires a `client_id` join via `projekte` — same index pattern.
### 8.6 Fristenrechner "Save to Akte"
Rename button to "Zur Verfahren-Akte speichern" (or simpler: "Zur Akte speichern" still works because Verfahren are the most common target). The target picker is now a two-step autocomplete: Mandant → Projekt (scoped to that Mandant's tree). Or skip Mandant picker entirely and autocomplete across all visible projekte — simpler, lets the user jump straight to the right leaf by typing the court-ref.
### 8.7 Checklisten Akten-link
Minimal change: `akte_id``project_id` on the service layer and UI picker. Picker is the same cross-tree autocomplete.
### 8.8 CalDAV sync
No material change. Today each Termin's iCal includes the Akte's `aktenzeichen` in the DESCRIPTION. v2 includes the full path: `Siemens AG · Siemens v. Huawei · EP 1 234 567 · UPC_CFI_123/2026`. Lawyers will find events in their calendar far more easily.
---
## 9. Impact on existing features
| Feature | Change |
|---|---|
| Dashboard | Queries shift from `akten` to `projekte`; widgets aggregate tree-wide. Mandanten-level "Neu auf ..." widget added. |
| Fristenrechner | Save-to-Projekt instead of save-to-Akte. Autocomplete is cross-tree (all visible projekte). |
| Fristen list | Columns show `Mandant · Projekt` chain instead of flat Aktenzeichen. Filter by Mandant. |
| Termine list | Same. |
| Notizen | FK rename only (akte_id → project_id). Polymorphic shape preserved. |
| Checklisten | FK rename only. Picker widened to cross-tree autocomplete. |
| CalDAV | Richer DESCRIPTION (full path). Termin ↔ calendar event mapping unchanged. |
| Akten detail page | Gains Untergeordnet tab + tree sub-nav. Existing tabs keep working. |
| Audit trail (Verlauf) | `akten_events` stays as a table name (history); `akte_id``project_id`. New event types: `projekt_nested`, `projekt_reparented`, `mandant_assigned`, `team_member_added`, `team_member_removed`. |
| Dokumente (placeholder) | No change today (still placeholder). Future implementation attaches to the right level of the tree. |
---
## 10. Naming conventions — German, with one English holdover
**Decision: German throughout. Match everything shipped so far.**
| Concept | DB table | Go struct | Go service | URL | German UI |
|---|---|---|---|---|---|
| Client | `paliad.mandanten` | `Mandant` | `MandantService` | `/mandanten` | "Mandant" / "Mandanten" |
| Project (generic) | `paliad.projekte` | `Projekt` | `ProjektService` | `/projekte` | "Projekt" / "Projekte" |
| Project sub-type "Mandat" | (row in projekte) | — | — | (row variant) | "Mandat" (Gesamtbeziehung) |
| Project sub-type "Litigation" | (row in projekte) | — | — | (row variant) | "Streitsache" |
| Project sub-type "Patent" | (row in projekte) | — | — | (row variant) | "Patent" |
| Project sub-type "Verfahren" | (row in projekte) | — | — | (row variant) | "Verfahren" |
| Project sub-type "Projekt" | (row in projekte) | — | — | (row variant) | "Projekt" (generisch) |
| Team (structural / project) | `paliad.teams` | `Team` | `TeamService` | `/teams` (admin) | "Team" / "Teams" |
| Team member | `paliad.team_mitglieder` | `TeamMitglied` | — | (sub) | "Teammitglied" |
| Project roster | `paliad.projekt_mitglieder` | `ProjektMitglied` | — | (sub of Projekt) | "Projektmitglied" |
| Party | `paliad.parteien` | `Partei` | `ParteienService` | (sub of Projekt) | "Partei" / "Parteien" |
| Deadline | `paliad.fristen` | `Frist` | `FristService` | `/fristen` | "Frist" / "Fristen" |
| Appointment | `paliad.termine` | `Termin` | `TerminService` | `/termine` | "Termin" / "Termine" |
| Document | `paliad.dokumente` | `Dokument` | `DokumentService` | (sub) | "Dokument" |
| Audit event | `paliad.akten_events` | `AkteEvent` | (in `ProjektService`) | n/a | "Verlauf" |
| Note | `paliad.notizen` | `Notiz` | `NotizService` | cross-cutting | "Notiz" / "Notizen" |
| User | `paliad.users` | `User` | `UserService` | n/a | n/a |
**The one English holdover:** `paliad.akten_events` table name. Rationale:
- It's the audit-trail table name already shipped.
- "Verlauf" is the UI label; the table name is invisible to users.
- Migrating to `projekt_events` would churn migration history for no gain, and any historical tools (Grafana, ad-hoc SQL) keep working. Preserve the name; update the comment and the Go struct semantics.
**Why not `projekt_*` everywhere?**
- `projekt_mitglieder` reads cleanly; kept.
- `projekt_events` would be clean too but see above — churn:benefit ratio unfavourable.
**URL aliases.** `/akten` and `/akten/{id}` keep redirecting to `/projekte` and `/projekte/{id}` indefinitely (bookmark preservation). No hard break. 301 from `/akten/neu``/projekte/neu`.
---
## 11. Open questions & deferrable decisions
1. **Patent registry.** Should `external_ref` on a `patent`-type projekt be a FK to a firm-wide `paliad.patente` table (EP number + metadata, shared across Mandanten)? Not today — a single litigation's view of a patent is legitimately separate from the firm-wide "patents we've ever seen" list. Revisit when HLC asks for firm-wide IP inventory. If/when built, add `patent_registry_id uuid` on `projekte` where `project_type='patent'`.
2. **Conflict of interest / Chinese walls.** Partner-level override to restrict a single project to its team roster only (strip office-scope). Not built now; add `restricted boolean` in a follow-on migration, and extend the predicate to skip the office-scope branch when `restricted = true`.
3. **Practice-group scoping.** `paliad.users.practice_group` is already free-text. If HLC splits Patents Litigation vs. Patents Prosecution and wants wall-like isolation, add `visible_to_groups text[]` on `projekte` + predicate extension. Not now.
4. **Matrix management.** A user belongs to one Dezernat (structural) today. If partners share associates (e.g., a "tax-patent" associate is on both the Patents and the Tax Dezernate), relax `team_mitglieder` — it already allows multiple memberships. The onboarding UI currently picks one Dezernat; relax when needed.
5. **Billing hooks.** `mandanten.billing_reference` is provisioned but not wired. Deliberate: HLC has firm-wide billing; Paliad does not compete. Field exists so the UI can show it and the future Outlook/Exchange integration can look it up.
6. **External collaborators.** A Milan boutique working on a case today would go into `projekt_mitglieder` only if they have Supabase accounts. Building external-party access (email-only, scoped, audit-logged) is a post-foundation feature; deferred.
7. **Hard delete vs. archive.** `status='archived'` on Mandanten and Projekte exists; hard-delete is cascade via FK. Consider a `archived_at` + soft-delete semantics once we have retention-policy rules. Not now.
8. **ltree label encoding.** UUIDs with hyphens aren't valid ltree labels. Replace `-` with `_`, or hash. Implementer's call; both work, hash is shorter but loses traceability.
---
## 12. Trade-off summary (for the head)
| Choice | Alternative | Why I picked this | Cost |
|---|---|---|---|
| Single `projekte` tree with type enum | Separate tables per type (mandate/litigation/patent/case) | Polymorphic FK pain, cross-tree queries, UI shared components | `project_type` CHECK has to grow carefully |
| ltree materialised path | Recursive CTE | RLS is the hottest call site; O(log n) tree queries matter | Extension dependency; label encoding quirk |
| Single `project_id` on child tables | Multi-level polymorphic FKs | RLS simplicity, uniform service code | Discipline: every Fristen/Termin has a Projekt, even "client-level" rare cases |
| `mandanten` as a separate table | Project with `type='mandat'` as the conceptual client | Clients have no deadlines/termine/parteien; they're a different shape. Also: Mandant outlives any specific matter. | One extra table |
| `teams` shared between Dezernat + project_team | Two separate tables | Single roster table (`team_mitglieder`), UI/service reuse | Partial CHECK constraints are a minor smell |
| `projekt_mitglieder` junction in addition to `teams` | Route project-team membership through `teams` | Hot path for RLS wants a dedicated two-column junction | Small duplication of concept |
| German naming | Mixed EN/DE | Continuity with everything shipped; audience speaks German | German plural forms (`mandanten`, `projekte`) in URLs |
| Tree-connected visibility | Downward-only (seeing ancestor grants ancestor+descendants only) | Matches how associate-on-one-case actually needs parent context | Slightly bigger RLS query |
| Phased non-destructive migration with preserved UUIDs | Dump-transform-reload | Zero downtime; every child-table row survives untouched | Requires discipline: match UUIDs in the backfill exactly |
---
## 13. Who implements?
Recommendation: **I (cronus) can implement the foundation** — migrations 018022, the predicate function, the new `ProjektService`, and the API alias shim. Reasons:
- I wrote the design; I know the edge cases.
- The schema work is security-critical (RLS policy + path trigger); having design-context on the implementer cuts review cycles.
- The pragmatic split: cronus does schema + services + aliases + RLS (Phase 14). A parallel coder worker does the Mandanten UI (`/mandanten` list + detail + create + partner cleanup wizard) in Phase 5. Cronus does Phase 6 decommission.
If the head prefers to keep cronus on design duty and hand implementation to a coder, the design is detailed enough to hand off — every schema has columns, constraints, triggers, RLS snippets, and migration acceptance criteria. I'd still want to review the RLS + path trigger PR before merge.
---
## 14. Acceptance criteria for the design itself
A "yes" on this design means head agrees to:
- [ ] Mandanten as a first-class table (not just a Projekt type).
- [ ] Single `projekte` tree with 5-value type enum.
- [ ] ltree materialised path + GiST index.
- [ ] Single `project_id` FK on fristen/termine/dokumente/parteien/akten_events/checklist_instances; `notizen` keeps its polymorphic shape with `akte_id` renamed to `project_id`.
- [ ] Tree-connected visibility predicate (ancestors + descendants both reachable from any team node).
- [ ] `paliad.teams` as a single table for Dezernat + project-team, with the two-kind shape CHECK.
- [ ] `projekt_mitglieder` as a hot-path junction, *and* optional `teams` rows of type `project_team` for team-level features.
- [ ] Phased migration with preserved UUIDs between `akten` and `projekte` rows.
- [ ] German naming throughout; `akten_events` table name preserved for continuity.
- [ ] `/akten` URLs alias to `/projekte` indefinitely.

View File

@@ -0,0 +1,593 @@
# Admin Email-Templates editor — design
**Task:** t-paliad-072 (ritchie, inventor)
**Date:** 2026-04-29
**Status:** design — awaiting m's go/no-go before coder shift
## Problem statement
Today the three email templates Paliad sends (`invitation`, `deadline_digest`,
plus the shared `base.html` wrapper) live in `internal/templates/email/*.html`,
embedded into the binary at build time and rendered by `MailService`
(`internal/services/mail_service.go`). Editing copy — even fixing a typo in
the German `Heute fällig` heading — requires a code change, PR, merge to main,
Dokploy redeploy.
The `/admin` landing page already advertises an "Email-Templates" card as
"Kommt bald" (`frontend/src/admin.tsx:36-42`). This task fills it in: an
admin can read each template, edit subject + body, preview against sample
data, save without a deploy, and roll back if a save was wrong.
---
## 1. Storage decision
**Decision: DB-backed, with the embedded files as the fallback default.**
The task brief recommends DB unless m explicitly wants filesystem-only, and
the whole rationale for surfacing a card on `/admin` is in-place editing. A
filesystem-only "preview + variable docs" page would be a different feature
(more like `/admin/email-templates/docs`) and doesn't fit the card we
promised.
The embedded files **stay**. They are:
1. The seed source (initial DB rows are populated from them).
2. The render-time fallback when a DB row is missing or malformed (so a
broken save can never wedge an entire send path — see §3).
3. The "Reset to default" target (always available, always parseable).
### Schema
Two new tables in a single migration `026_email_templates.up.sql`. RLS
enabled with no policies — service-only access, same pattern as
`paliad.invitations` / `paliad.reminder_log`.
```sql
-- Active template body per (key, lang). Exactly one row per pair, kept
-- current by UPSERT on save. Absence == use embedded fallback.
CREATE TABLE paliad.email_templates (
key text NOT NULL,
lang text NOT NULL CHECK (lang IN ('de', 'en')),
subject text NOT NULL, -- text/template source
body text NOT NULL, -- html/template source ({{define "content"}})
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
PRIMARY KEY (key, lang)
);
-- Append-only version log. Captures every save (and every reset). The
-- service garbage-collects to the most recent VERSION_RETENTION rows per
-- (key, lang) inside the same transaction as the save.
CREATE TABLE paliad.email_template_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text NOT NULL,
lang text NOT NULL CHECK (lang IN ('de', 'en')),
subject text NOT NULL,
body text NOT NULL,
saved_at timestamptz NOT NULL DEFAULT now(),
saved_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
note text NOT NULL DEFAULT '' -- '', 'reset', 'restore from <version_id>'
);
CREATE INDEX email_template_versions_key_lang_idx
ON paliad.email_template_versions (key, lang, saved_at DESC);
ALTER TABLE paliad.email_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.email_template_versions ENABLE ROW LEVEL SECURITY;
```
`key` is one of `invitation`, `deadline_digest`, `base` — the existing
template-name set. `lang` is `de` or `en`. The pair `('base', 'de')` is the
shared wrapper (it has lang-conditional bits in it today, but those become
language-specific copies after the split — see §1.3).
`VERSION_RETENTION = 20` per (key, lang). After 20 the oldest is deleted on
save. Trade-off: 20 saves per language per template means at most
3 templates × 2 languages × 20 = 120 rows total in steady state. Negligible
storage; gives admins room to recover from "I edited this five times in a row
to get the wording right and then realised the third version was correct".
### Migration plan
1. **Migration 026 (this PR)** — creates both tables. Does **not** seed any
rows. First-render-after-deploy reads embedded fallbacks; first save from
the editor inserts the active row.
2. **Embedded file split (this PR)** — replace each existing
`<name>.html` with `<name>.de.html` and `<name>.en.html`. The current
bilingual files use `{{if eq .Lang "en"}}…{{else}}…{{end}}` blocks; we
split each branch into its own file. After this migration **no template
contains a `Lang` conditional** — language is selected by file (or DB
row) lookup, not by an in-template branch. This makes the editor UX
simple ("you're editing the German invitation" — one file, no nested
conditionals to confuse the reader).
3. **MailService refactor**`RenderTemplate` looks up `EmailTemplateService.GetActive(key, lang)` first; on miss reads the embedded `<key>.<lang>.html`. `base` is loaded the same way (so the wrapper is editable). The render itself stays `html/template` over the cloned base.
4. **Subject becomes data, not code.** The hard-coded `inviteSubject` and `buildDigestSubject` in Go go away. Each template's DB row has a `subject` column whose contents are a `text/template` source (not `html/template` — subject lines aren't HTML). Caller passes the same `Data` map; service renders subject and body from the same payload.
*Trade-off*: today's subject logic for `deadline_digest` is conditional
("SYSTEMAUSFALL" vs "URGENT" vs plain count). It moves into the template
syntax verbatim. Admins editing the subject see the conditional clearly
and can adjust the framing. **Mitigation against admin breakage**: save
validates the subject template parses cleanly *and* renders without error
against the same sample data the body preview uses (§3.4).
### Why not split DE/EN at the file level only (no DB)?
Considered. Rejected because the rejected version is "the editor card is a
preview + docs page, no editing". That removes the only feature the card was
named after. If m vetoes DB-backed editing, the fallback is to swap this card
for "Email-Templates (Vorschau + Variablen)" — but that's a different
product decision and I'd want to confirm before building toward it.
---
## 2. Editor UX
### Page layout
`GET /admin/email-templates` — gated identically to `/admin/team`:
`auth.RequireAdminFunc(users, gateOnboarded(handleAdminEmailTemplatesPage))`.
```
┌──────────────────────────────────────────────────────────────────────┐
│ Sidebar │
│ │
│ Email-Templates [Vorschau ↻]│
│ Vorlagen für Einladungen, Erinnerungen und Layout-Wrapper. │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Einladung │ │ Fristen- │ │ Basis │ │
│ │ invitation │ │ Sammelmail │ │ base │ │
│ │ │ │ deadline_… │ │ Layout- │ │
│ │ Zuletzt: │ │ │ │ Wrapper │ │
│ │ 2026-04-12 │ │ Standard │ │ Standard │ │
│ │ Standard │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
```
Three template cards. Each card shows: human title, internal key,
"Zuletzt geändert" date (or "Standard" if no DB override), language badges
(DE/EN — clicking enters the editor for that language).
### Editor view
`/admin/email-templates/{key}?lang=de` (lang query, defaults to `de`).
```
┌────────────────────────────────────────────────────────────────────────┐
│ ← Zurück Einladung — Deutsch [DE] [EN] │
│ │
│ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │
│ │ Betreff │ │ VORSCHAU │ │
│ │ ┌───────────────────────────┐ │ │ ┌─────────────────────────┐ │ │
│ │ │ Einladung von {{.Inviter…│ │ │ │ <iframe rendered HTML> │ │ │
│ │ └───────────────────────────┘ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ HTML-Body │ │ └─────────────────────────┘ │ │
│ │ ┌───────────────────────────┐ │ │ │ │
│ │ │ {{define "content"}} │ │ │ Betreff (gerendert): │ │
│ │ │ <h1>{{.InviterName}}… │ │ │ Einladung von Maria Schmidt │ │
│ │ │ … │ │ │ │ │
│ │ └───────────────────────────┘ │ │ [Vorschau aktualisieren] │ │
│ │ │ │ │ │
│ │ Verfügbare Variablen ⓘ │ │ │ │
│ │ ┌───────────────────────────┐ │ │ │ │
│ │ │ .InviterName Maria S… │ │ │ │ │
│ │ │ .InviterEmail maria@hl… │ │ │ │ │
│ │ │ .ToEmail neu@hlc.de │ │ │ │ │
│ │ │ .Message "Komm rein"│ │ │ │ │
│ │ │ .RegisterURL https://… │ │ │ │ │
│ │ │ .Firm HLC │ │ │ │ │
│ │ └───────────────────────────┘ │ │ │ │
│ │ │ │ │ │
│ │ [Speichern] [Auf Standard │ │ [Versionen ▾] │ │
│ │ zurücksetzen] │ │ │ │
│ └─────────────────────────────────┘ └───────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
```
Three columns at desktop width, stacked on mobile (mobile-edit will be rare;
acceptable to deprioritise).
#### Editing controls
- **Subject input** — single-line text input. Holds a `text/template` source.
Variable hints from the variable list autocomplete on `{{` (v1 can skip the
autocomplete and just rely on the variable list).
- **Body textarea** — full raw HTML. Tall (~24 rows). Monospace. No syntax
highlighting in v1 (textarea is fine — task brief).
- **Variable list** — read-only list of available `{{.Foo}}` placeholders for
this template, with the sample value next to each so the admin can see
what they're substituting. List is hard-coded server-side per template
key (§5).
- **Lang toggle [DE] [EN]** — switching prompts "Ungespeicherte Änderungen
verwerfen?" if the editor is dirty. Saves remain per-language.
- **Preview pane** — iframe, sandboxed (`sandbox="allow-same-origin"` only —
no scripts, no top-nav). Server renders with sample data and returns
HTML; client `srcdoc=`s it. Updates on debounce (500ms after typing
stops) **and** on explicit "Vorschau aktualisieren" click. Subject
rendered above the iframe.
- **Save button** — disabled until dirty. POSTs subject + body to
`PUT /api/admin/email-templates/{key}/{lang}`. Server validates parse
and a render against sample data before accepting; bad templates return
422 with the parse error.
- **Reset button** — confirm modal, then `POST /api/admin/email-templates/{key}/{lang}/reset` deletes the active row (versions stay). Editor reloads with embedded fallback content.
- **Versionen dropdown** — opens a side panel listing the most recent 20
versions for (key, lang). Each row: timestamp, who saved, optional note,
"Vorschau" + "Wiederherstellen" buttons. Restoring is a save with note
`restore from <version_id>`.
#### State machine on the client
```
loading -> ready (active row + sample variables fetched)
ready -> dirty (any input change)
dirty -> previewing (debounce / button click → POST preview)
previewing -> ready (success — but stays dirty until save)
dirty -> saving (Save click → PUT)
saving -> ready (success: clear dirty, reload active row)
saving -> save_error (4xx → show parse error inline above subject input)
ready -> resetting (Reset confirm → POST reset)
resetting -> ready (success: reload, clear dirty)
```
No autosave. Patent lawyers will edit, preview, edit, preview, then commit
intentionally — autosave would clutter the version log with intermediate junk.
---
## 3. Preview surface design
### Sample data per template
Hard-coded server-side in `internal/services/email_template_samples.go`. One
function per template key, returning a `map[string]any` plus a "sample
subject context" for `text/template` rendering. Not user-editable in v1
(deferred — task brief out-of-scope is silent on this, but customising
sample data is a lot of UI for marginal value).
**`invitation`** sample:
```go
{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"Message": "Hallo Kolleg:in, ich glaube Paliad würde dir gefallen — schau es dir an.",
"RegisterURL": "https://paliad.de/login",
"Firm": "HLC",
}
```
**`deadline_digest`** sample (morning slot, 1 overdue + 2 today + 1 weekly):
```go
{
"Slot": "morning",
"IsEvening": false,
"Overdue": []map[string]any{{
"DueDate": "2026-04-27", "Title": "Beschwerde gegen EP-Anmeldung",
"ProjectReference": "HL-2024-0083", "ProjectTitle": "Acme vs Beta GmbH",
"OwnerName": "Maria Schmidt", "IsOtherOwner": true,
"URL": "https://paliad.de/deadlines/sample-1",
}},
"DueToday": []map[string]any{
{ "DueDate": "2026-04-29", "Title": "Klageerwiderung einreichen", ... },
{ "DueDate": "2026-04-29", "Title": "Vollmacht prüfen", ... },
},
"DueWarning": []map[string]any{
{ "DueDate": "2026-05-06", "Title": "Stellungnahme vorbereiten", ... },
},
"OverdueCount": 1, "DueTodayCount": 2, "DueWarningCount": 1,
"DeadlinesURL": "https://paliad.de/deadlines",
"Firm": "HLC",
}
```
A `?slot=evening` toggle on the preview endpoint flips `IsEvening: true` so
the admin can see how the same body renders for the evening DRINGEND slot.
**`base`** sample — minimal: `Subject: "Beispielbetreff", Lang: "de", Firm: "HLC"`, plus a placeholder content block (`<p>Inhalt der spezifischen Mail …</p>`).
### Endpoint
```
POST /api/admin/email-templates/{key}/{lang}/preview
Body: { subject, body, slot? }
```
Server:
1. Looks up `samples[key]` (404 if unknown key).
2. Validates `body` parses as an `html/template` (returns 422 + parse error on failure).
3. Validates `subject` parses as a `text/template`.
4. Renders body inside the active `base` (DB row or embedded fallback for the same lang).
5. Renders subject against the same data map.
6. Returns `{ subject_rendered, html_rendered }`. Client `srcdoc=`s the HTML.
Latency budget: < 100ms for sample rendering. No external I/O all
in-process `html/template` execution.
### Why iframe (not innerHTML)
Email HTML uses inline styles aggressively that would otherwise leak into
the editor's chrome (table-resets, `body` background colours, custom font
stacks). Iframe gives a clean rendering boundary that matches what an email
client would see. `sandbox` strips JS so a hostile template (impossible in
v1 only admins write but defense-in-depth) can't escape.
---
## 4. Permission model
Identical to `/admin/team` (the existing precedent):
- **Page route**: `protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))`
- **Editor route**: `protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEdit)))`
- **API routes**: all under `/api/admin/email-templates/...`, gated by `adminGate(users, ...)`.
`adminGate` is the existing `auth.RequireAdminFunc(users, h)` from
`internal/auth/require_admin.go`. It checks `paliad.users.global_role =
'global_admin'` (post-migration 023). Non-admins on the API path get 403
JSON; non-admins on the page paths get 302 to `/dashboard?forbidden=admin`.
Unauth gets 302 to `/login` from the outer Middleware before the admin gate
runs. No new auth machinery.
The `updated_by` / `saved_by` audit columns capture the acting admin's
`auth.users.id` so future "who broke the invitation template" questions
have an answer in the version log.
---
## 5. Variable docs per template
Single source of truth: `internal/services/email_template_variables.go`,
shipped alongside the sample-data file. Each template gets a typed list:
```go
type Variable struct {
Name string // ".InviterName"
Type string // "string" | "date" | "url" | "[]Row"
Description string // "Anzeigename der einladenden Person"
Sample string // "Maria Schmidt"
}
var Variables = map[string][]Variable{
"invitation": { },
"deadline_digest": { },
"base": { },
}
```
Served from `GET /api/admin/email-templates/{key}/variables` so the editor
sidebar can render the list with samples without duplicating the schema in
TypeScript.
### Per-template variable contracts
**`invitation`** (lang {de, en}):
| Variable | Type | Sample | Description |
|---|---|---|---|
| `.Lang` | `string` | `"de"` | Sprache der gerenderten Mail. Nicht direkt verwenden wird im Body nicht mehr per `{{if}}` benötigt, da DE/EN getrennte Templates haben. |
| `.Firm` | `string` | `"HLC"` | Firmenname (aus `FIRM_NAME`). |
| `.InviterName` | `string` | `"Maria Schmidt"` | Anzeigename der einladenden Person. |
| `.InviterEmail` | `string` | `"maria.schmidt@hlc.com"` | E-Mail der einladenden Person. |
| `.ToEmail` | `string` | `"neu.kollege@hlc.de"` | Empfänger:in der Einladung. |
| `.Message` | `string` | `"Hallo Kolleg:in …"` | Optionale persönliche Nachricht; leer wenn nichts angegeben. `{{if .Message}}…{{end}}` umschliesst den Block. |
| `.RegisterURL` | `string` | `"https://paliad.de/login"` | Zielseite für den Anmelde-Button. |
| `.Subject` | `string` | `"Einladung von Maria Schmidt zu Paliad"` | Vom System aus dem `subject`-Feld gerendert; der Body verwendet ihn typischerweise nicht, das `<title>` der `base` schon. |
**`deadline_digest`** (lang {de, en}):
| Variable | Type | Sample | Description |
|---|---|---|---|
| `.Lang`, `.Firm` | `string` | wie oben | wie oben |
| `.Slot` | `string` | `"morning"` / `"evening"` | Trigger-Slot. Im Body meist über `.IsEvening` benutzt. |
| `.IsEvening` | `bool` | `false` | True wenn Abend-Slot steuert die DRINGEND-Headline. |
| `.Overdue` | `[]Row` | siehe unten | Überfällige Fristen. |
| `.OverdueCount` | `int` | `1` | Länge von `.Overdue`, vorgerechnet für die Überschrift. |
| `.DueToday` | `[]Row` | | Heute fällig. |
| `.DueTodayCount` | `int` | `2` | |
| `.DueWarning` | `[]Row` | | In 1 Woche fällig. |
| `.DueWarningCount` | `int` | `1` | |
| `.DeadlinesURL` | `string` | `"https://paliad.de/deadlines"` | Ziel des Alle Fristen" Buttons. |
`Row` (innerhalb `range`):
| Feld | Typ | Sample | Beschreibung |
|---|---|---|---|
| `.DueDate` | `string` | `"2026-04-29"` | Fälligkeitsdatum, ISO. |
| `.Title` | `string` | `"Klageerwiderung einreichen"` | Frist-Titel. |
| `.ProjectReference` | `string` | `"HL-2024-0083"` | Akten-/Projekt-Aktenzeichen. |
| `.ProjectTitle` | `string` | `"Acme vs Beta GmbH"` | Projekt-Titel; kann leer sein. |
| `.OwnerName` | `string` | `"Maria Schmidt"` | Eigentümer:in der Frist. |
| `.IsOtherOwner` | `bool` | `true` | True wenn die Frist *nicht* dem:der Empfänger:in gehört (Anzeige der Eigentümer-Zeile). |
| `.URL` | `string` | `"https://paliad.de/deadlines/<uuid>"` | Direktlink zur Frist. |
**`base`** (lang {de, en}):
| Variable | Type | Sample | Description |
|---|---|---|---|
| `.Lang` | `string` | `"de"` | `<html lang="…">` Attribut. |
| `.Subject` | `string` | `"Einladung von Maria Schmidt"` | Wird ins `<title>` der Mail eingesetzt. |
| `.Firm` | `string` | `"HLC"` | Footer-Branding. |
`base` rendert via `{{block "content" .}}{{end}}` den Body des spezifischen
Templates. Diese Block-Direktive **darf nicht entfernt werden** der
Editor muss sie validieren (siehe §6 Test plan).
---
## 6. Test plan
### Unit tests — service
`internal/services/email_template_service_test.go` (new):
- `GetActive` returns embedded fallback when no DB row.
- `GetActive` returns DB row when present.
- `Save` parses subject + body with `text/template` / `html/template`.
- `Save` rejects bad template syntax (`{{ .Foo` unterminated 422 path).
- `Save` rejects body that doesn't redefine `{{define "content"}}` for non-base keys (otherwise the `base` block wouldn't fill).
- `Save` rejects `base` body that removes the `{{block "content" .}}{{end}}` directive (would silently produce an empty inner body).
- `Save` writes one row to `email_template_versions` per call.
- `Save` triggers retention GC: after 21 saves to the same (key, lang), only 20 rows remain.
- `Reset` deletes the active row but leaves versions intact.
- `RestoreVersion` copies a historical row into active and adds a new version with note `restore from <id>`.
### Unit tests — handlers
`internal/handlers/email_templates_test.go` (new):
- `GET /admin/email-templates` and `/admin/email-templates/{key}` both return 302 to `/login` for unauth, 403 for non-admin, 200 for admin.
- `GET /api/admin/email-templates` returns the canonical key list with active/lang info.
- `GET /api/admin/email-templates/{key}/variables` returns the variable contract.
- `POST /api/admin/email-templates/{key}/{lang}/preview`:
- 200 with rendered subject + body for valid input.
- 422 with parse error for bad subject template.
- 422 with parse error for bad body template.
- 404 for unknown key.
- `PUT /api/admin/email-templates/{key}/{lang}` saves and returns the new version row id; rejects bad templates with 422.
- `POST /api/admin/email-templates/{key}/{lang}/reset` deletes active row.
- `GET /api/admin/email-templates/{key}/{lang}/versions` returns the version log, newest first.
- `POST /api/admin/email-templates/{key}/{lang}/restore/{version_id}` restores.
### Integration test
`internal/services/mail_service_db_test.go` (new):
- With a DB-backed `EmailTemplateService`: insert a custom invitation row `MailService.RenderTemplate(invitation, de)` returns the custom row, not the embedded fallback.
- Delete the row next render falls back to embedded.
- Insert a syntactically broken row directly via SQL (bypassing the service validation that would normally reject it) `RenderTemplate` falls back to embedded and logs an error. **This is the core safety property**: a corrupt DB row never breaks email delivery.
### Manual smoke test (Playwright, optional v1)
1. Login as `tester@hlc.de` / `xdMmC7iCeDSTFmPXAlAyY0` (admin).
2. Visit `/admin` see "Email-Templates" card now linked, not "Kommt bald".
3. Click land on `/admin/email-templates` with three template cards.
4. Click "Einladung" editor with German content.
5. Edit subject to `Test {{.InviterName}}` preview pane shows `Test Maria Schmidt`.
6. Save success toast, "Zuletzt geändert" date updates.
7. Send a real test invitation via the sidebar invite modal to `m@flexsiebels.de` verify the new subject lands.
8. Open Versionen restore previous invitation reverts.
9. Reset to default DB row deleted, fallback restored.
10. Logout, login as a non-admin `/admin/email-templates` 302s to `/dashboard?forbidden=admin`.
### Smoke gate before merge
`go test ./internal/services/... ./internal/handlers/...` clean,
`go build ./...` clean, `cd frontend && bun run build` clean.
---
## 7. Implementation order
Six logical chunks. Coder shift implementer's call whether to land them as
one PR or split.
1. **Migration 026** + embedded file split (DE/EN). Server still uses
embedded files; nothing else changes. Verifies the split renders
identically to the bilingual originals (golden tests in
`mail_service_test.go` already exercise both languages keep them).
2. **EmailTemplateService** `GetActive`, `Save`, `Reset`, `Versions`,
`Restore`, retention GC, sample data + variable docs.
3. **MailService refactor** replace embedded-only render with service
lookup; subject moves from Go-built strings to template render. Update
the two callers (`invite_service.go`, `reminder_service.go`) to pass
subject *data* instead of the formatted subject string. Verify
`buildDigestSubject` is fully removed.
4. **Handlers + API routes** both page handlers (`/admin/email-templates`,
`/admin/email-templates/{key}`) plus the eight API endpoints.
5. **Frontend** `frontend/src/admin-email-templates.tsx` +
`frontend/src/admin-email-templates-edit.tsx` + their `client/*.ts`
counterparts; `admin.tsx` flips the placeholder card; `i18n.ts` gains
the new strings.
6. **Smoke** manual Playwright run with `tester@hlc.de`.
---
## 8. Open questions for m — RESOLVED 2026-04-29
1. **DB-backed editing confirmed?** **YES, DB.**
2. **Subjects move into templates (admin-editable)?** **YES, customisable.**
Mitigation kept: seeded `deadline_digest` subject ships with a
`{{/* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */}}`
comment so the next admin who edits the SLO-critical framing sees the
rationale.
3. **`base.html` editable, or locked?** **A. Editable like the others.**
Version log + reset-to-default + render-time fallback on parse error
are the safety net.
4. **Versioning depth** **20 per (key, lang). Confirmed.**
5. **`note` field on version rows + optional "Notiz" input on save?**
**YES, keep.**
All decisions baked into §1–§7. No remaining blockers from the inventor
side; coder shift can start once head greenlights.
---
## 9. Out of scope (deferred, per task brief)
- New template types beyond the existing three (password reset, account
locked, etc.) defer until those flows exist.
- Per-firm overrides `FIRM_NAME` already templates "HLC" "anything"
but per-firm full-template branching is not needed today (paliad serves
one firm per deployment).
- A/B testing not justified for transactional mail at this volume.
- WYSIWYG editor explicit out-of-scope. Plain textarea is the v1.
- Editable sample data admins use a fixed sample set in v1.
- Side-by-side DE / EN editing language toggle in v1, not a split view.
- Plain-text body editing text fallback is auto-derived by `htmlToText`;
exposing it as an editable field is a future-feature.
---
## 10. Coder fit
The implementation is mostly straight-line: migration, service, handlers,
frontend. The interesting risks are (a) the embedded-file DE/EN split must
golden-match the existing bilingual render byte-for-byte where possible
(or with explainable diffs), and (b) the MailService fallback path must be
provably safe bad DB row embedded render, never a 500 inside the
reminder ticker. Both are testable.
Suggested coder for the implementation shift: same role/skill that landed
t-paliad-021 (knuth) or whoever currently has the warmest cache on
`mail_service.go` and `reminder_service.go`. I'm fine to implement this
myself if head wants but no strong preference; head decides.
---
## 11. Files (for the implementing coder)
### New
- `internal/db/migrations/026_email_templates.up.sql`
- `internal/db/migrations/026_email_templates.down.sql`
- `internal/services/email_template_service.go`
- `internal/services/email_template_service_test.go`
- `internal/services/email_template_samples.go`
- `internal/services/email_template_variables.go`
- `internal/services/mail_service_db_test.go`
- `internal/handlers/email_templates.go`
- `internal/handlers/email_templates_test.go`
- `internal/templates/email/invitation.de.html` + `invitation.en.html`
- `internal/templates/email/deadline_digest.de.html` + `.en.html`
- `internal/templates/email/base.de.html` + `base.en.html`
- `frontend/src/admin-email-templates.tsx`
- `frontend/src/admin-email-templates-edit.tsx`
- `frontend/src/client/admin-email-templates.ts`
- `frontend/src/client/admin-email-templates-edit.ts`
### Edit
- `internal/templates/email.go` embed pattern stays; embed glob already covers `*.html` so the per-lang split files come for free.
- `internal/services/mail_service.go` `RenderTemplate` consults `EmailTemplateService` first, falls back to embedded; `SendTemplate` accepts a subject template + data, stops requiring a pre-formatted subject string.
- `internal/services/invite_service.go` drop `inviteSubject`; pass subject via the data map.
- `internal/services/reminder_service.go` drop `buildDigestSubject`; pass slot/counts via the data map.
- `internal/services/mail_service_test.go` adjust for new subject path.
- `internal/handlers/handlers.go` register the new routes alongside the existing `/admin/team` block.
- `cmd/server/main.go` wire `EmailTemplateService`, pass it to `NewMailService` (or set on `MailService` post-construct).
- `frontend/src/admin.tsx` flip the "Email-Templates" placeholder card from `admin-card-soon` to a real `card card-link` pointing at `/admin/email-templates`. Re-sequence `PLANNED` so it drops to three entries.
- `frontend/src/client/i18n.ts` drop "kommt bald" framing from email_templates; add new strings: `admin.email_templates.title`, `.heading`, `.subtitle`, `.list.last_modified`, `.list.default`, `.editor.subject`, `.editor.body`, `.editor.variables`, `.editor.preview`, `.editor.save`, `.editor.reset`, `.editor.reset_confirm`, `.editor.versions`, `.editor.restore`, `.editor.restore_confirm`, `.editor.dirty_warn`, `.editor.parse_error`, `.editor.note_optional`, all DE + EN.
- `frontend/build.ts` add `renderAdminEmailTemplates` + `renderAdminEmailTemplatesEdit` entry points and bundle the two client TS files.

View File

@@ -0,0 +1,461 @@
# Event Types for deadlines + submissions — design
**Task:** t-paliad-088
**Author:** cronus (inventor)
**Date:** 2026-04-30
**Status:** RESOLVED 2026-04-30 12:23 — m greenlit all 7 questions. See §12 for the resolution table. Awaiting head's coder assignment.
m's directive (2026-04-30 11:56):
> "let's add an inventor for 'Event Types', in particular deadlines and submissions — I want to be able to select from existing Event Types when creating a deadline but also add a new custom one if it does not exist. This also needs to be filterable in the overview"
## TL;DR — resolved decisions
1. **Concept:** "Event Type" is the **categorization tag** on a deadline. **Event Types lead**, with an optional bridge FK to `paliad.trigger_events` for the seeded UPC rows (m's call: "event_types should lead and later we can connect things to it"). Submissions are explicitly the primary use case — m's words: *"those are the event types I mean, mainly"*. trigger_events stays as separate calc-engine state (UPC-only verbatim youpc imports).
2. **Schema:** new table `paliad.event_types` + nullable FK `paliad.deadlines.event_type_id`. The bridge `event_types.trigger_event_id bigint NULL REFERENCES paliad.trigger_events(id)` populates only for seeded UPC rows; user customs and non-UPC entries leave it NULL. Broader scope from day one (UPC + EPO + DPMA + DE-national + contract).
3. **Submissions live as Event Types.** No separate `paliad.submissions` table. `event_types.category='submission'` carries the discrimination. A future Schriftsatz-Verwaltung surface pivots on that filter.
4. **Picker:** typeahead `<select>`-flavoured combobox with grouping by category, plus inline "+ Neuen Typ hinzufügen…" → small modal. Reuses the `/tools/fristenrechner` trigger-picker style.
5. **Filter on `/deadlines` is MULTI-SELECT.** Trigger button styled like an `<select>`; click opens a listbox panel with search + "Alle" toggle + checkbox list grouped by category. Backend: `?event_type=uuid1,uuid2,…` (UNION within Event Types, AND-intersected with Status/Projekt). Special value `none` for "Ohne Typ"; combinable with selected types. **Same multi-select filter on `/agenda`**.
6. **Permissions:** any authenticated user can create both private AND firm-wide types. Admins moderate firm-wide via archive after the fact (m's call: lighter-weight onboarding > admin gating).
7. **Backfill:** existing 10 deadlines get `event_type_id=NULL`, render as "Ohne Typ". Migration 030 ships ~40 curated firm-wide seeds (~25 UPC submissions + ~10 UPC decisions/orders + ~10 non-UPC EPO/DPMA/DE-national/contract). Spreadsheet attached to the implementation PR.
## 1 · Concept clarification
### What lives where today
| Surface | Concept | Examples | Cardinality | Origin |
|---|---|---|---|---|
| `paliad.trigger_events` (migration 028) | "What just happened" — anchor for `event_deadlines` calc | `service_of_complaint`, `decision_handed_down`, `statement_of_defence` | 102 rows, UPC only | Imported verbatim from `youpc.data.events` for diffable re-syncs |
| `paliad.event_deadlines` (migration 028) | "After event X, deadline Y fires" rules | RoP.029 1-mo Reply after Defence | 70 rows | Imported verbatim from `youpc.data.deadlines` |
| `/tools/fristenrechner` trigger-picker (PR-2) | UI input over `trigger_events` | "Was kommt nach 'Statement of defence'?" | — | Public knowledge tool |
| `paliad.deadlines` (migration 003+) | Persistent per-project scheduled deadline | Free-text title, due_date, project_id, optional `rule_id``deadline_rules` | 10 rows in production | User-created, sometimes Fristenrechner-seeded |
### What m is asking for
A **categorization on `paliad.deadlines`** so the user can:
- pick from a known taxonomy when creating a deadline,
- add a custom type if missing,
- filter the `/deadlines` list by type.
That is unambiguously a **taxonomy column on `deadlines`**, not a trigger event.
### Are Event Types == trigger_events with a UX rename?
**No.** Three reasons:
1. **Scope.** `trigger_events` is **UPC-only** (102 rows from youpc's UPC corpus — see §verified data below). Paliad's deadlines also cover **EPO opposition/appeal**, **DPMA**, **German national court** (LG/OLG Düsseldorf, München), and **contract/IP-licensing renewal dates**. The trigger_events corpus has zero EPO-opposition events, zero DPMA events, and only a handful of cross-jurisdiction items (`Decision of the EPO` is still in the UPC unitary-effect context). Renaming it would falsely suggest paliad covers all jurisdictions.
2. **Diffability invariant.** Memory note from t-paliad-086: *"IDs preserved verbatim from youpc data.events / data.deadlines / data.deadline_rule_codes for diffable re-syncs."* Letting users insert custom rows into `trigger_events` would either break this (id collisions) or require a separate id range — both compromise the import contract. trigger_events is canonical-imports state, not user-extensible.
3. **Different semantics.** A trigger_event is "the event that just occurred, used as anchor". An Event Type on a deadline is "what this deadline categorically IS". For 70-ish rows they coincide (a deadline whose Type is "Reply to Defence" would naturally have `trigger_event_id` pointing to the same code). For decisions/orders they don't really coincide — `decision_handed_down` as a trigger anchors *future* deadlines, but as an Event Type it labels *the date the decision is expected*. Both views are useful. Conflating them collapses the distinction.
### Are Event Types == "submissions" as a sibling entity?
**No.** Not now.
A submission *is* a deadline-bearing item ("filed Statement of Defence on 2026-08-15" — that's a deadline whose category is submission). Splitting submissions into their own table either duplicates data or forces a migration of existing deadlines, both for negative gain. **The Event Type's `category` column carries the discrimination** (`submission` | `decision` | `order` | `service` | `fee` | `hearing` | `other`). A future Schriftsatz-Verwaltung surface (out of scope here) can pivot on `WHERE event_types.category='submission'`.
Re-evaluate this if/when m wants a true Schriftsatz-Verwaltung with submission-specific fields (file uploads, version tracking, recipient party, language) that don't fit on a generic deadline row. **That's a separate task; flagging here so we don't have to migrate twice.**
### Bridge to trigger_events
`paliad.event_types` carries an optional `trigger_event_id bigint REFERENCES paliad.trigger_events(id)`. For the seeded firm-wide types we populate it; for user customs and non-UPC types it stays NULL. This:
- preserves provenance for the UPC-seeded types,
- enables future polish: "this deadline's Event Type matches a trigger event → offer to compute downstream deadlines via Fristenrechner",
- doesn't force a circular dependency (Event Types can exist without a trigger_event).
## 2 · Schema decision
### Migration 030 — `paliad.event_types` + FK on `paliad.deadlines`
```sql
-- internal/db/migrations/030_event_types.up.sql
CREATE TABLE paliad.event_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL,
label_de text NOT NULL,
label_en text NOT NULL,
category text NOT NULL DEFAULT 'submission'
CHECK (category IN ('submission','decision','order','service','fee','hearing','other')),
jurisdiction text
CHECK (jurisdiction IS NULL OR jurisdiction IN ('UPC','EPO','DPMA','DE','any')),
description text,
trigger_event_id bigint REFERENCES paliad.trigger_events(id) ON DELETE SET NULL,
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
is_firm_wide boolean NOT NULL DEFAULT false,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Slug uniqueness: firm-wide types share one namespace; private types are scoped per user.
CREATE UNIQUE INDEX event_types_firm_slug_idx
ON paliad.event_types(slug)
WHERE is_firm_wide = true AND archived_at IS NULL;
CREATE UNIQUE INDEX event_types_private_slug_idx
ON paliad.event_types(created_by, slug)
WHERE is_firm_wide = false AND archived_at IS NULL;
CREATE INDEX event_types_category_idx ON paliad.event_types(category);
CREATE INDEX event_types_jurisdiction_idx ON paliad.event_types(jurisdiction) WHERE jurisdiction IS NOT NULL;
-- FK on deadlines
ALTER TABLE paliad.deadlines
ADD COLUMN event_type_id uuid
REFERENCES paliad.event_types(id) ON DELETE SET NULL;
CREATE INDEX deadlines_event_type_idx
ON paliad.deadlines(event_type_id)
WHERE event_type_id IS NOT NULL;
-- updated_at trigger (mirrors existing paliad.set_updated_at pattern)
CREATE TRIGGER event_types_set_updated_at
BEFORE UPDATE ON paliad.event_types
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
-- RLS
ALTER TABLE paliad.event_types ENABLE ROW LEVEL SECURITY;
-- Read: firm-wide types visible to all authenticated users; private types only to author.
CREATE POLICY event_types_select ON paliad.event_types
FOR SELECT TO authenticated
USING (
archived_at IS NULL
AND (is_firm_wide = true OR created_by = auth.uid())
);
-- Insert: any authenticated user can insert any row, as long as created_by = self.
-- Firm-wide types are open to all users; admins moderate via archive after the fact.
CREATE POLICY event_types_insert ON paliad.event_types
FOR INSERT TO authenticated
WITH CHECK (created_by = auth.uid());
-- Update: author owns their own rows (private or firm-wide they created).
-- global_admin can update / archive any firm-wide row regardless of authorship.
CREATE POLICY event_types_update_owner ON paliad.event_types
FOR UPDATE TO authenticated
USING (created_by = auth.uid())
WITH CHECK (created_by = auth.uid());
CREATE POLICY event_types_update_admin ON paliad.event_types
FOR UPDATE TO authenticated
USING (
is_firm_wide = true
AND EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- Delete: never. Use archived_at.
```
### Why a separate table (not a `deadlines.event_type` text column)
A free-text column would let users type the same concept three ways ("Reply", "reply", "Erwiderung") and **the filter would silently miss matches**. The whole point of typing is that the same name resolves to one row. A table also lets us:
- ship 30+ curated firm-wide labels with bilingual text,
- carry `category` + `jurisdiction` metadata for grouped picker,
- cross-link to `trigger_events` for the Fristenrechner-handoff polish,
- support archiving (firm renames "Beschwerdebegründung" → leave old rows untouched, archive the type).
### Why optional FK and not NOT NULL on deadlines
Existing 10 deadlines have no event_type. Backfilling automatically would either guess or be wrong. NULL = "Ohne Typ" works fine — the filter row has an "Alle" default and an "Ohne Typ" option. Users tag retrospectively when they edit a deadline.
### Slug strategy
Slug is auto-derived from `label_de` (kebab-case) on insert if not supplied; user-private slugs are scoped per user so two users can each have their own "klage" without colliding. Firm-wide slugs share one namespace — global_admins coordinate.
## 3 · Picker UX
### Where the picker appears
- `/deadlines/new` — new optional field below "Titel", above the date+rule row.
- `/deadlines/{id}` — edit modal, same field shape.
- *(Future polish, NOT in scope)* `/tools/fristenrechner` "Send to deadline" button — pre-fills `event_type_id` from the originating trigger_event.
### Visual shape (matches existing `.akten-form` field-row pattern)
```
┌─────────────────────────────────────────────────────────┐
│ Typ (optional) │
│ ┌─────────────────────────────────────────────────┬───┐ │
│ │ Bitte wählen oder tippen… │ ▼ │ │
│ └─────────────────────────────────────────────────┴───┘ │
│ │
│ When opened (with no input): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Eigene │ │
│ │ ⭐ Mein Mahnschriftsatz-Template │ │
│ │ Eingaben (UPC) │ │
│ │ • Statement of Defence │ │
│ │ • Reply to the Defence │ │
│ │ • Counterclaim for Revocation │ │
│ │ • Statement of Appeal │ │
│ │ ... (~30) │ │
│ │ Entscheidungen │ │
│ │ • Decision on the merits │ │
│ │ • Decision on costs │ │
│ │ Anordnungen │ │
│ │ • Case management order (Service) │ │
│ │ Gebühren │ │
│ │ • Annuity payment (DPMA) │ │
│ │ • EP renewal fee │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ + Neuen Typ hinzufügen… │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
The trigger-event picker on `/tools/fristenrechner` (PR-2) already implements typeahead-over-list. Reuse its filter logic and visual style; differences:
- **Grouped by category** with sticky group headers (the trigger picker is flat).
- **"Eigene" group** at top (private types of the current user) with a star icon.
- **"+ Neuen Typ hinzufügen…" footer row** triggers the add modal.
### Custom-add modal
Lightweight `<dialog>` (matches the existing `.modal` pattern from t-paliad-049):
```
┌─ Neuen Event-Typ anlegen ─────────────────────────┐
│ │
│ Bezeichnung (DE) * │
│ [_______________________________________] │
│ │
│ Bezeichnung (EN, optional) │
│ [_______________________________________] │
│ │
│ Kategorie * │
│ [Eingabe ▼] (Eingabe / Entscheidung / │
│ Anordnung / Zustellung / │
│ Gebühr / Sitzung / Sonstiges) │
│ │
│ Jurisdiktion (optional) │
│ [— ▼] (UPC / EPA / DPMA / DE / —) │
│ │
│ ☐ Firmenweit verfügbar machen * │
│ (* nur für Admins sichtbar) │
│ │
│ [ Abbrechen ] [ Anlegen ] │
└───────────────────────────────────────────────────┘
```
Behaviour:
- If user typed text in the picker before clicking "+ Neuen Typ", that text pre-fills "Bezeichnung (DE)".
- "Firmenweit" checkbox is only rendered for users where `currentUser.global_role === 'global_admin'`. Non-admin private-only.
- On submit: `POST /api/event-types`, on 201 the picker re-fetches its option list and selects the new id.
- Error 409 (slug collision): show inline error "Ein Typ mit diesem Namen existiert bereits".
### Why a modal vs. inline expansion
Inline expansion would push the deadline-create form down by 4 fields and feel cramped. A modal is the existing paliad pattern (project edit, invitation flow). Smaller scope: 1 form, 1 button, escape-to-close.
### Why not free-text fallback that auto-creates
Two reasons:
1. **Typo-driven duplication.** A user types "Klage" → a row is created → next time they type "Klagen" → another row. Within a week the firm has 12 rows for one concept. The deliberate "+ Neuen Typ" affordance forces the user to confirm "yes, this is new" and to set category/jurisdiction.
2. **Permission asymmetry.** Auto-create defaults to private; users who actually want firm-wide need an explicit toggle. The modal makes that visible.
## 4 · Filter UX on `/deadlines` (and `/agenda`)
### `/deadlines` (primary scope) — multi-select
m's call (Q4): match the existing `<select>`-row pattern visually, but make Event Types **multi-select**. Status/Projekt stay single-select. New `EventTypeMultiSelect` component:
```
.akten-filter-row
├─ <label>Typ</label>
└─ <button class="akten-select akten-multi-trigger" aria-haspopup="listbox">
<span class="akten-multi-label">Alle</span> ← / "3 Typen" / single label
<span class="akten-multi-chevron">▾</span>
</button>
opens (popover, anchored to the trigger, click-outside dismisses):
┌──────────────────────────────────────────┐
│ 🔍 [Suche…] │
│ ☐ Alle / ☐ — Ohne Typ — │
│ ─────────────────────────────────────────│
│ Eingaben (UPC) │
│ ☐ Statement of Defence │
│ ☐ Reply to the Defence │
│ ☐ Counterclaim for Revocation … │
│ Entscheidungen │
│ ☐ Decision on the merits … │
│ Anordnungen │
│ Gebühren │
│ Eigene │
│ ☐ Mein Mahnschriftsatz-Template │
│ ─────────────────────────────────────────│
│ [ Zurücksetzen ] [ Anwenden ] │
└──────────────────────────────────────────┘
```
**Behaviour:**
- Default state: "Alle" toggle on, list disabled. Toggling any list item turns "Alle" off and ticks the row.
- "— Ohne Typ —" is a special row separate from "Alle"; ticking it adds `event_type_id IS NULL` rows alongside whatever specific types are ticked.
- Trigger label shows "Alle", "Ohne Typ", "Statement of Defence", or "3 Typen" depending on count.
- Search box filters the list across `label_de` + `label_en`.
- "Anwenden" (or click-outside) commits; "Zurücksetzen" returns to "Alle".
- Mobile: popover becomes a full-width sheet from the bottom (reuses the existing `.modal-mobile-bottom` class if it exists, else a one-CSS-rule bottom sheet).
**Backend / query param:**
- `?event_type=<uuid>,<uuid>,none` — comma-separated UUIDs and/or the literal `none` keyword. Empty / absent = "Alle".
- Service layer parses to `(event_type_id IN (uuid1,uuid2,…) OR event_type_id IS NULL [if 'none' present])`, AND-intersected with the existing status / project predicates.
- State persists in URL — bookmark + back-button preserve the filter.
### Add a `Typ` column to the deadlines table?
Yes — small column showing `label_de` (or `label_en` per current language). Column hides via `.akten-table--hide-status`-style toggle (t-paliad-073 pattern: hide when every visible row shares the same value).
### `/agenda` — same multi-select
m's call (Q5): ship `/agenda` filter in the same task. Same `EventTypeMultiSelect` component, mounted as a second filter row below the existing Type chip row (deadlines/appointments). `AgendaService.List` accepts the same `?event_type=` param; appointments are unaffected (they have no event_type) — they're returned regardless when "appointments" is in the type-of-item filter.
## 5 · Permission model
| Action | Permission |
|---|---|
| Read firm-wide types | any authenticated user |
| Read own private types | only the author |
| Create private type (`is_firm_wide=false`) | any authenticated user (`created_by=self`) |
| Create firm-wide type (`is_firm_wide=true`) | **any authenticated user** (m's Q6: lighter-weight onboarding, admin moderates after the fact) |
| Edit own type (private or firm-wide) | the author |
| Edit / archive any firm-wide type | `global_role='global_admin'` (moderation lever) |
| Archive a type | same as edit |
| Delete a type | never (set `archived_at` instead) |
Enforced at two layers:
- **RLS policies** (§2 above) — the safety net.
- **Service layer** — `EventTypeService.Create` validates the slug shape and uniqueness before insert, rejects `created_by` mismatches with 400.
### Why this looser firm-wide-create policy
m's call. Two consequences worth naming:
1. **Drift risk:** users will create overlapping firm-wide types ("Klage", "Klageeinreichung", "Klage erheben"). Mitigation: search-prevent-duplicate in the add modal — if a fuzzy match (`%label_de%`) already exists firm-wide, surface "Existiert vermutlich schon: …" with a "Trotzdem anlegen" override. Reduces but doesn't eliminate.
2. **Moderation backlog:** admins need a lightweight surface to scan + archive. Not in scope for this task; flag as follow-up *t-paliad-089: admin Event-Type moderation panel*.
### Why allow user-private types at all
Each lawyer has a couple of personal categories that nobody else needs ("Reminder for myself when X", "My standing template for Y"). Private types stay out of others' pickers; the user's own picker shows them under "Eigene".
## 6 · Backfill strategy
### Existing data
`SELECT count(*) FROM paliad.deadlines` → 10 rows (production, 2026-04-30).
All get `event_type_id=NULL` after migration 030. Render in the UI as "Ohne Typ" (separate option in the filter, displayed as a faint chip next to the title in the table).
### Seeded firm-wide types
Migration 030 seeds ~40 firm-wide types in a separate `030b_seed_event_types.up.sql` (or appended to 030 — single-migration ok if it stays readable). Three pools:
1. **UPC submissions (~25 rows)** — picked from `paliad.trigger_events` codes for the most common procedural submissions (Statement of Claim/Defence, Counterclaim, Reply, Rejoinder, Statement of Appeal, Statement of grounds of appeal, Cross-appeal, Application to amend the patent, Defence to revocation, Application for damages, Application for cost decision, Protective Letter, Preliminary Objection). Each row gets `trigger_event_id` populated.
2. **UPC decisions/orders (~10 rows)** — Decision on the merits, Decision on costs, Case management order, Order of the judge-rapporteur, Final decision, Summons to oral hearing, Service of complaint. `trigger_event_id` populated.
3. **Non-UPC (~10 rows, hand-written, no `trigger_event_id`)** — EPO opposition filing, EPO opposition reply, EPO appeal filing, EPO appeal grounds, EP annuity payment, DPMA examination request, DPMA opposition, German national court Klageerwiderung, German national court Beschwerde, IP-licence renewal date.
I will NOT seed all 102 trigger_events as Event Types — most are highly specific procedural sub-events ("Rejoinder to the Reply, Reply to the Defence to an Application to amend the patent" — that level of granularity belongs in the calc engine, not the picker dropdown). The curated subset of ~25 captures the 80 % case; users with niche needs add private types.
The exact seed list lives in the migration; I'll attach the spreadsheet to the implementation PR.
### No automatic title-based backfill
Tempting: parse `paliad.deadlines.title` against seeded `label_de`/`label_en`. **Don't.**
- 10 production rows; not worth the script.
- High false-positive risk ("Reply" matches at least 8 different seed types).
- Better UX: when a user opens a typed-`NULL` deadline in edit mode, the form shows "Typ: — Ohne Typ —" with the picker open, prompting them to tag.
If/when the row count grows past ~200, revisit with a manual reconciliation script run by an admin.
## 7 · API endpoints
```
GET /api/event-types → list (firm-wide own private), filterable by ?category= and ?jurisdiction=
POST /api/event-types → create; body {label_de, label_en?, category, jurisdiction?, is_firm_wide?}
PATCH /api/event-types/{id} → edit (label/category/jurisdiction/archived_at); RLS enforces ownership
GET /api/deadlines?event_type_id= → already handled if we add the param to the list handler
GET /api/agenda?event_type_id= → same on the agenda handler
```
`POST /api/event-types` returns 201 with the created row; 403 for non-admin trying `is_firm_wide=true`; 409 on slug collision; 422 on missing label_de or invalid category. Standard paliad envelope.
The existing `paliad.deadlines` POST handler (`/api/deadlines`) gets a new optional `event_type_id` field — validate it points to a row the user can see (firm-wide or own private).
## 8 · Test plan
### Unit / Go
- `EventTypeService.Create` happy path (private type by regular user).
- `EventTypeService.Create` 403 when regular user tries `is_firm_wide=true`.
- `EventTypeService.Create` 409 on slug collision (firm-wide same slug; per-user same slug).
- `EventTypeService.List` returns firm-wide own-private; not other users' private.
- `DeadlineService.Create` accepts `event_type_id`; rejects FK pointing at someone else's private type.
- `DeadlineService.List` filter by `event_type_id` intersects with status + project filters.
- `AgendaService.List` filter by `event_type_id`.
### Integration / Playwright
Login as `tester@hlc.de`:
1. **Pick existing type:** `/deadlines/new` → fill title + project + due → open Typ picker → select "Statement of Defence" → submit → `/deadlines` shows row with Typ column = "Statement of Defence".
2. **Filter:** `/deadlines` → set Typ filter to "Statement of Defence" → list narrows to 1 row → set to "Alle" → all rows back.
3. **Custom-add (private):** `/deadlines/new` → open Typ picker → click "+ Neuen Typ hinzufügen" → modal → name "Mein Test-Typ", category Eingabe → Anlegen → modal closes, picker re-opens with new option selected → submit deadline → `/deadlines` shows it.
4. **Privacy:** custom private types only visible to creator. Verify with API call as second test account if available; else assert via direct SQL after the test.
5. **No-type filter:** filter "— Ohne Typ —" returns the pre-existing 10 rows that were never tagged.
6. **Mobile snapshot:** filter row wraps cleanly on 375px viewport.
7. **DE/EN switch:** language toggle re-renders both picker and filter labels (`label_de`/`label_en` swap, optgroup labels swap).
8. **Archive flow (admin):** as global_admin, edit a firm-wide type → set archived_at → existing deadlines keep their `event_type_id` (label still renders for legacy rows) but the type no longer appears in the picker for new deadlines.
### Manual smoke
- New deadline reachable through Fristenrechner "Send to deadline" path (if/once that flow exists) carries event_type_id matching the trigger event.
## 9 · Coordination
- **t-paliad-086 (curie, shipped):** trigger_events table is the seed source for the curated firm-wide types. The Fristenrechner trigger picker on `/tools/fristenrechner` STAYS as-is — it's a calc tool, not a categorization tool. No conflict.
- **t-paliad-087 (brunel, in flight):** light-grey BG sweep on `global.css`. Low overlap — this task adds new picker styles + the custom-add modal; brunel touches existing surfaces. Coordinate via merge order: brunel merges first (bigger surface area), then this task rebases.
- **Tier 2 Fristenrechner ports** (damages, cost-appeal, cross-appeal, lay-open, leave-to-appeal): unrelated; their trigger_events rows (already imported in PR-1) become eligible seed candidates if/when the curated list expands.
## 10 · Migration outline (single PR, ~5 commits)
1. **Schema + seeds**`030_event_types.up.sql` (table, indexes, triggers, RLS policies, ~40 seed rows). `030_event_types.down.sql` reverses cleanly.
2. **Models + service**`internal/models/event_type.go`, `internal/services/event_type_service.go`. Wire into `cmd/server/main.go` services bundle.
3. **Handlers + routes**`internal/handlers/event_types.go` (CRUD), update `internal/handlers/deadlines.go` to accept `event_type_id`, update `internal/handlers/agenda.go` to accept `?event_type_id`.
4. **Frontend picker + modal**`frontend/src/components/EventTypePicker.tsx` (shared) + `frontend/src/components/EventTypeAddModal.tsx`. Wire into `deadlines-new.tsx` and `deadlines-detail.tsx` edit modal. ~30 i18n keys (DE+EN) under `event_types.*` and `deadlines.field.event_type.*`.
5. **Frontend filter + table column**`deadlines.tsx` adds the `<select>` filter row + Typ column; `client/deadlines.ts` handles the `?event_type=` query param. `agenda.tsx` adds the pill-row variant; `client/agenda.ts` handles its query param.
Tests live alongside each layer. Verify via `bun run build` + `go test ./...` + Playwright smoke.
## 11 · Alternatives considered, not picked
| Alternative | Why not |
|---|---|
| Reuse `trigger_events` directly with a `created_by` column | Breaks the verbatim-import-for-diffability invariant; mixes calc state with user state; bigint vs uuid id space friction. |
| Free-text `deadlines.event_type` column with self-distinct lookup | Typo-driven duplicates kill the filter UX; no metadata (category/jurisdiction); no privacy boundary. |
| `paliad.submissions` as a sibling entity to deadlines | Forces migration of existing rows; duplicates fields (due_date, project_id, created_by); a submission *is* a deadline-bearing item. Defer until real submission-specific fields (file uploads, recipient party) are needed. |
| Multi-select filter (UNION across multiple Event Types) | **PICKED.** m's Q4 call — Event Types specifically benefits from multi-select (a user often wants "show me all my Replies, Rejoinders, and Defences"). Status/Projekt stay single-select; the asymmetry is intentional. |
| Auto-create on free-text in picker | Generates noise; can't ask the user category/jurisdiction; wrong default permission. Keep the explicit "+ Neuen Typ" affordance. |
| Hierarchical Event Types (parent type → sub-type) | Over-engineered for 40 seeds + handful of customs. Use `category` for the one level of grouping users care about. |
## 12 · Open questions — RESOLVED 2026-04-30 12:23
| # | Question | m's call |
|---|---|---|
| 1 | Concept boundary — broader (UPC + EPO + DPMA + DE + contract) or UPC-only first cut? | **A — broader from day one** |
| 2 | Schema — new `paliad.event_types` table + FK? | **A — yes**, with the bridge `event_types.trigger_event_id → paliad.trigger_events(id)` populated only for seeded UPC rows. *m: "event_types should lead and later we can connect things to it"* |
| 3 | Submissions — defer separate table? | **A — yes, defer**. *m: "those are the event types I mean, mainly"* |
| 4 | Filter style — `<select>` matching existing pattern? | **A, but multi-select.** Custom listbox-panel multi-select component (see §4 above). Status/Projekt stay single-select. |
| 5 | `/agenda` filter — same task? | **A — same task** |
| 6 | Firm-wide type permission floor — global_admin only? | **B — any authenticated user can create firm-wide; admins moderate via archive after the fact.** Mitigation: duplicate-warning in the add modal. Follow-up: *t-paliad-089: admin moderation panel*. |
| 7 | Seed list — ~40 curated rows in migration 030? | **A — yes**, spreadsheet on the implementation PR for review. |
**Status:** all gate-blocking calls answered. Awaiting head's coder assignment. Inventor stays parked.
## 13 · Inventor recommendation on implementer
cronus did this design (data-model area). Either cronus or curie would be a good fit to implement: cronus knows the `trigger_events` corpus from this design pass; curie just shipped the trigger_events import (t-paliad-086) and knows the calc-engine context. Single coder is fine — the surface is one table, one FK, one picker, one filter, one modal. **Head decides**, not me.
---
**End of design.** Awaiting m's go/no-go on §12 #1, #2, #3, #4. Will not begin implementation until greenlit.

View File

@@ -0,0 +1,629 @@
# Design: Unify Fristen + Termine as filtered views of one Events page
**Task:** t-paliad-109
**Author:** cronus (inventor)
**Date:** 2026-05-04
**Status:** DRAFT — awaiting m's go/no-go on §F open questions
**Branch:** `mai/cronus/design-unify-fristen`
---
## 0. Premise check (read this first)
The task brief describes the current state as:
> - `/deadlines` — backend `DeadlineService`, table `paliad.deadlines`
> - `/agenda` — backend `AppointmentService`, table `paliad.appointments`
The first half is correct. **The second half is wrong against the live codebase**, and the design has to start from the real shape:
| Route | What it actually is today | Backend |
|---|---|---|
| `/deadlines` | Fristen list (table + 5 summary cards). Deadline-only. | `DeadlineService` |
| `/appointments` | **Termine list (table + 3 summary cards). Appointment-only.** | `AppointmentService` |
| `/agenda` | **Cross-type timeline (already unified)** — day-grouped feed with chip filters Beides / Nur Fristen / Nur Termine, range chips 7/14/30/90, event-type multi-select. | `AgendaService` (not `AppointmentService`) |
So three list-ish surfaces exist, not two. The two table surfaces (`/deadlines` and `/appointments`) are the ones that diverge cosmetically and structurally; `/agenda` is a genuinely different visual paradigm (timeline grouped by day, no table) and a genuinely different backend (`AgendaService` already unions both event types).
Sidebar today (`frontend/src/components/Sidebar.tsx:111122`):
```
Übersicht: Dashboard, Agenda, Team
Arbeit: Projekte, Fristen (/deadlines), Termine (/appointments)
```
So **Agenda is already a sibling overview-style entry** distinct from the work-day list pair Fristen/Termine. The design below treats the unification target as the **Fristen ↔ Termine list pair**, not the timeline. Whether `/agenda` collapses into the new shape is its own question (Q3 in §F).
This premise correction was caught before locking the design — it determines the shape of A/B/C/D below. m should sanity-check it (Q1 in §F).
---
## 1. m's intent (as I read it)
> "Fristen and Termine should be **two predefined filters of the same Events view**, sharing the same layout. The Dashboard should reflect the same model."
Three things in that sentence:
1. **Predefined filters** — the user-facing names "Fristen" and "Termine" stay; under the hood each is `?type=deadline` / `?type=appointment` of one Events page.
2. **Same layout** — the table chrome, summary cards, filter row, "+ Neu" button all come from one component, not two.
3. **Dashboard reflects the same model** — the deadline summary expands to a unified Events summary (or gains a parallel Termine block keyed off the same backend shape).
The smallest-diff path that delivers that intent is **A1 + B1** below: keep the two URLs, render the same component, share one backend service that returns a discriminated `Event` row.
---
## 2. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Routes** | Keep `/deadlines` and `/appointments` URLs; both render the same `EventsPage` component with `?type=deadline` or `?type=appointment` baked in by the handler. | A2 (introduce `/events`, redirect from old URLs) — louder external change, more deploy-time friction. |
| **Sidebar** | No change. "Fristen" still links to `/deadlines`, "Termine" still links to `/appointments`. | Collapsing to a single "Ereignisse" entry — renames a thing users know; m's phrasing was "two predefined filters", not "one entry". |
| **Page header** | Renders "Fristen" or "Termine" (driven by default type). Below: a 3-chip toggle (Fristen / Termine / Beides) lets users widen. | A header named "Events" — fights m's intent that the names stay. |
| **Backend** | New `EventService.ListVisibleForUser` that union-loads from both tables and returns `[]EventListItem`. Sits next to `AgendaService` (which keeps the timeline shape). | Reusing `AgendaService` directly — its struct is timeline-shaped (no `EventTypeIDs`, no rule fields, hides completed deadlines). Extension is bigger than greenfield. |
| **Endpoint** | New `GET /api/events?type=&status=&project_id=&event_type=&from=&to=`. Existing `/api/deadlines` and `/api/appointments` keep working until ~v2 cleanup. | Folding both old endpoints into `/api/events` — needless break for clients we still ship (calendars, dashboards, project-detail panes). |
| **Detail pages** | **Stay separate** (`/deadlines/{id}`, `/appointments/{id}`). The unification is list-only. | Unify detail pages too — out of scope per task brief §13; deadline-edit and appointment-edit have nearly disjoint forms. |
| **Dashboard** | Add a parallel **Termine summary** rail (Heute / Diese Woche / Später, mirroring `/appointments` summary today). Keep the deadline 5-bucket rail. Both rails read from `/api/events/summary` (new). | Cram appointments into the 5-bucket model — "Überfällig" doesn't really apply to past meetings; degrades meaning. |
| **`/agenda`** | **Out of scope for this round.** Keep the timeline as-is; revisit in a follow-up once Events list is stable. | Retire `/agenda` now — too much UX surface area for one PR; m hasn't asked for it. |
The rest of this doc is the detail behind those rows.
---
## 3. Section A — Information architecture
### Q1. Canonical route
**Recommendation: A1 — keep both URLs, share one component.**
```
GET /deadlines → renderEventsPage({ defaultType: "deadline" })
GET /appointments → renderEventsPage({ defaultType: "appointment"})
```
Both handlers serve the same TSX page, both bundle the same `client/events.ts`. The only difference is a one-line attribute `<body data-default-type="deadline">` (or `"appointment"`) read by `events.ts` on init.
A `?type=` query param can override the default — that lets the 3-chip toggle ("Fristen / Termine / Beides") work without re-routing. The URL on `/deadlines?type=appointment` is mildly weird but harmless; the alternative is full `pushState` to switch routes when toggling, which fights browser history.
Three options considered:
| Option | Smallest diff? | Notes |
|---|---|---|
| **A1** Two routes, one component, default-type per handler | ✅ smallest | No redirect machinery, no broken bookmarks, no sidebar churn. |
| A2 `/events` canonical + redirects | ✗ medium | 302 from `/deadlines` and `/appointments`. Every internal link, every bookmarked URL, every email-template link redirects once. Workable, but louder. |
| A3 `/events` only + sidebar collapse to "Ereignisse" | ✗ largest | Renames a thing users know. Conflicts with m's "two predefined filters" framing. |
### Q2. Branding
**Recommendation: keep "Fristen" and "Termine" as user-facing names.**
The page `<h1>` reads "Fristen" or "Termine" depending on the default type. The 3-chip toggle below the header is labeled `[Fristen] [Termine] [Beides]`. When the user is in "Beides" mode, the `<h1>` stays whichever they came from (don't rewrite it on toggle — would jitter). The page `<title>` follows the same rule.
Why not introduce "Events / Ereignisse" as a top-level label: m said "two predefined filters", not "one new concept". Calling the page "Events" while sidebar entries say "Fristen" / "Termine" creates a two-vocabulary problem; calling everything "Ereignisse" demands users learn a new label.
The internal vocabulary in code (`EventService`, `EventListItem`, `/api/events`) stays English-Events per the system-language convention. User-facing strings stay German Fristen/Termine.
### Q3. Sidebar nav
**Recommendation: no change.**
Sidebar today:
```
Arbeit:
Projekte /projects
Fristen /deadlines
Termine /appointments
```
Both entries continue to point at their existing URLs. The 3-chip toggle on the page is the gateway to "Beides".
Collapsing to a single "Ereignisse" entry means losing the muscle-memory shortcut to the deadline-only or appointment-only view. The 3-chip toggle is one click further than a sidebar entry; for a high-frequency view that's a regression.
If we ever want a single entry, the path is: ship the unification, watch usage, then collapse if telemetry says nobody uses one of the two pre-filtered URLs.
---
## 4. Section B — Data model
### Q4. Backend service shape
**Recommendation: B1 — new `EventService` that delegates internally.**
```go
// internal/services/event_service.go
type EventService struct {
db *sqlx.DB
deadlines *DeadlineService
appointments *AppointmentService
eventTypes *EventTypeService
}
func NewEventService(db, d, a, et) *EventService
type EventListFilter struct {
Type EventTypeFilter // "" | "deadline" | "appointment"
Status DeadlineStatusFilter // applies only to deadlines
ProjectID *uuid.UUID
EventTypeIDs []uuid.UUID // applies only to deadlines
IncludeUntyped bool // applies only to deadlines
AppointmentType *string // applies only to appointments
From *time.Time
To *time.Time
}
func (s *EventService) ListVisibleForUser(ctx, userID, filter) ([]EventListItem, error)
func (s *EventService) SummaryCounts(ctx, userID, filter) (EventSummary, error)
```
Internally, `ListVisibleForUser`:
1. If `filter.Type == "appointment"` → call only `AppointmentService.ListVisibleForUser`, project to `EventListItem`.
2. If `filter.Type == "deadline"` → call only `DeadlineService.ListVisibleForUser`, project to `EventListItem`.
3. If `filter.Type == ""` (Beides) → call both, merge, sort by canonical date.
Status filter only takes effect when the result includes deadlines; when filter.Type=="appointment" with a Status set, the handler should return 400 (or quietly drop it — Q11 in §F).
Three options considered:
| Option | Notes |
|---|---|
| **B1** New `EventService` delegating to existing services | Single ownership, clean API surface. ~150 LoC. The two existing services keep their callers (project detail pages, dashboard subqueries). |
| B2 Union at the handler layer | Filter logic split. Hard to test the merge. Same query gets duplicated for `/api/events/summary`. |
| B3 Extend one of the existing services | Awkward — neither `DeadlineService.ListAllEvents` nor `AppointmentService.ListAllEvents` reads naturally. Adds an unrelated dep (each service would need to know about the other). |
Why not reuse `AgendaService`: it's the right shape for timelines (`AgendaItem` with urgency annotation, completed-deadlines hidden, no rule/event-type fields on the row). Extending it to also feed the table view would require adding `EventTypeIDs`, `RuleCode`, `RuleName`, `Description`, `Notes`, an `IncludeCompleted` flag, and Status filtering — at which point it stops being agenda-shaped. Cleaner to leave `AgendaService` for the timeline and introduce a sibling.
### Q5. The unified row type
**Recommendation: discriminated tagged union with type-specific optional fields.**
```ts
// frontend type — same shape as Go's EventListItem JSON
type EventListItem =
| DeadlineEvent
| AppointmentEvent;
interface EventBase {
id: string;
type: "deadline" | "appointment";
title: string;
description?: string;
date: string; // ISO 8601 — canonical sort key (deadline: due_date 00:00 UTC; appointment: start_at)
date_label: string; // pre-formatted for table cell, e.g. "31.05.2026" or "31.05. 14:0015:00"
urgency: "overdue" | "today" | "tomorrow" | "this_week" | "next_week" | "later" | "completed";
project_id?: string;
project_title?: string;
project_reference?: string;
project_type?: string;
}
interface DeadlineEvent extends EventBase {
type: "deadline";
due_date: string; // YYYY-MM-DD
status: "pending" | "completed";
completed_at?: string;
source: "manual" | "fristenrechner" | "import";
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
event_type_ids: string[];
has_ccr?: boolean; // condition_flag = 'with_ccr' (UPC_INF)
}
interface AppointmentEvent extends EventBase {
type: "appointment";
start_at: string;
end_at?: string;
location?: string;
appointment_type?: "hearing" | "meeting" | "consultation" | "deadline_hearing";
}
```
Go-side mirror:
```go
type EventListItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"` // "deadline" | "appointment"
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Date time.Time `json:"date"`
DateLabel string `json:"date_label"`
Urgency string `json:"urgency"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
// Deadline-only (zero-valued / nil for appointments)
DueDate *string `json:"due_date,omitempty"`
Status *string `json:"status,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Source *string `json:"source,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
HasCCR *bool `json:"has_ccr,omitempty"`
// Appointment-only
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
AppointmentType *string `json:"appointment_type,omitempty"`
}
```
Why a flat struct with optionals instead of `Deadline *DeadlineFields; Appointment *AppointmentFields`: the agenda already proved (in `AgendaItem`) that flat-with-optionals reads cleaner across both Go service code and frontend rendering. The frontend type-narrows on `type === "deadline"` and TS infers the rest.
JSON example — one of each:
```json
[
{
"id": "8c3a…",
"type": "deadline",
"title": "Statement of Defence",
"date": "2026-08-31T00:00:00Z",
"date_label": "31.08.2026",
"urgency": "next_week",
"project_id": "1f…",
"project_title": "Acme v. Foo",
"project_reference": "0001234.0000567",
"project_type": "case",
"due_date": "2026-08-31",
"status": "pending",
"source": "fristenrechner",
"rule_id": "…",
"rule_code": "RoP.023",
"rule_name": "Statement of Defence",
"event_type_ids": ["af…", "bd…"],
"has_ccr": false
},
{
"id": "9d4b…",
"type": "appointment",
"title": "Mündliche Verhandlung — Acme v. Foo",
"date": "2026-09-15T09:00:00Z",
"date_label": "15.09.2026 09:0011:00",
"urgency": "later",
"project_id": "1f…",
"project_title": "Acme v. Foo",
"project_reference": "0001234.0000567",
"project_type": "case",
"start_at": "2026-09-15T09:00:00Z",
"end_at": "2026-09-15T11:00:00Z",
"location": "UPC LD München, Cincinnatistraße 64",
"appointment_type": "hearing"
}
]
```
### Q6. Date semantics
**Recommendation: one `date` column, one `date_label` column.**
- `date` is always the canonical sort key, RFC3339 UTC.
- Deadlines: `2026-08-31T00:00:00Z` (midnight UTC of `due_date`).
- Appointments: `start_at` verbatim.
- `date_label` is the pre-localized human string for the table cell.
- Deadlines: `"31.08.2026"` (no time component — deadlines are date-only).
- Appointments without `end_at`: `"15.09.2026 09:00"`.
- Appointments with `end_at` same-day: `"15.09.2026 09:0011:00"`.
- Appointments with `end_at` next-day: `"15.09.2026 09:00 → 16.09.2026 11:00"`.
The label is computed server-side so the table rows render identically across DE/EN (i18n only swaps date format) without each frontend pass having to special-case start/end vs single-date.
The column header reads "Fällig / Beginn" (Fristen / Termine) in single-type mode and "Datum" in Beides mode (Q11 in §F asks m to confirm).
---
## 5. Section C — UI
### Q7. The 5-bucket summary
**Recommendation: bucket model is type-aware. Deadlines keep 5 buckets, Appointments use 3 buckets, "Beides" shows two rails.**
The deadline 5-bucket model (Überfällig / Heute / Diese Woche / Nächste Woche / Erledigt — t-paliad-106) is genuinely deadline-shaped: "Überfällig" means **a deadline that passed without being completed**. Past appointments are not "overdue" — they've happened, and that's fine. So:
| Mode | Bucket rail |
|---|---|
| `?type=deadline` (Fristen) | 5 cards — Überfällig / Heute / Diese Woche / Nächste Woche / Erledigt (today's behavior, unchanged). |
| `?type=appointment` (Termine) | 3 cards — Heute / Diese Woche / Später (today's behavior on `/appointments`, unchanged). |
| `?type=` (Beides) | **Two rails stacked**: Fristen rail (5 cards, deadlines only) on top, Termine rail (3 cards, appointments only) below. Each card filters to that bucket within its own type. |
This stays honest about what each card means and avoids a stretched 4-column compromise that lies about appointments being "Überfällig". The Beides view does pay a vertical-space cost (~120px for the second rail); the alternative compromise feels worse.
If m wants a *common* 4-bucket compromise (Heute / Diese Woche / Nächste Woche / Erledigt+Vergangen), Q12 asks. My recommendation is the two-rail approach.
### Q8. Filter row
**Recommendation: one filter row that shows the *union* of relevant filters; rules toggle visibility/active-state per type.**
```
[Type chips: Fristen | Termine | Beides ] ← driver
Filters when type=deadline:
Status (single-select) | Projekt (single-select) | Typ (event-type multi-select)
Filters when type=appointment:
Termin-Typ (single-select) | Projekt (single-select) | Von | Bis
Filters when type=Beides:
Projekt (single-select) | Von | Bis | Typ (event-type multi-select, applies to deadlines only with a tooltip)
+ a status selector that's disabled with hint "Nur Fristen"
```
Concretely:
- **Projekt** (single-select) — always visible, always active. Same behavior as today.
- **Status** (deadline-only) — visible in `?type=deadline` and `?type=`; in `?type=appointment` it's hidden. In Beides, it filters deadlines AND silently passes appointments through (with a tooltip explaining).
- **Typ (event-type multi-select)** — visible in `?type=deadline` and `?type=`; hidden in `?type=appointment`. Today's `event_type_id` model is deadline-only.
- **Termin-Typ** (hearing/meeting/consultation/deadline_hearing) — visible in `?type=appointment`; hidden in deadline-only mode and Beides (low value, would mean "only appointments of type X plus all deadlines" which is incoherent).
- **Von / Bis** — already on `/appointments`. Add to the unified view across all type modes (gives users a way to scope deadlines too — currently deadlines don't have a date range filter, only buckets).
Hidden, not greyed-out, when a filter doesn't apply. Greyed-out adds noise and invites confusion. Filters re-appear instantly on chip toggle (no page reload).
### Q9. Columns that differ
**Recommendation: type-conditional columns — visible whenever ≥1 row in the current view has data for that column.**
Single-type mode is straightforward: render exactly today's columns.
Beides mode: render the *union* of columns, but apply the existing **hide-on-uniform** pattern (`.entity-table--hide-event-type` from t-paliad-088, generalized):
```
| Type icon | Datum | Titel | Projekt | Regel¹ | Typ¹ | Ort² | Termin-Typ² | Status |
¹ deadline-only — hidden in pure-appointment view
² appointment-only — hidden in pure-deadline view
```
Cell content per column:
| Column | Deadline row | Appointment row |
|---|---|---|
| Type icon | 🕐 (CLOCK) | 📅 (CALENDAR) |
| Datum | "31.08.2026" | "15.09.2026 09:0011:00" |
| Titel | deadline title | appointment title |
| Projekt | reference + title (or "—" for personal Termine) | same |
| Regel | rule_code (e.g. "RoP.023") | empty (column shown only if any row has it) |
| Typ | event-type chip cluster | empty |
| Ort | empty | location text |
| Termin-Typ | empty | "Verhandlung" / "Besprechung" / etc. |
| Status | "Offen" / "Erledigt" / OVERDUE badge | empty (or maybe "vergangen" — Q14 in §F) |
The CCR flag (UPC_INF condition_flag='with_ccr', t-paliad-086 PR-3) is a deadline detail that today shows as a small "CCR" pill on the deadline detail page. In the list view it stays as a row-level pill in the Titel cell — same as today on `/deadlines`.
### Q10. The "+ Neu" button
**Recommendation: type-aware default with a quick-switch dropdown.**
In `?type=deadline`: button reads "Neue Frist" → `/deadlines/new`.
In `?type=appointment`: button reads "Neuer Termin" → `/appointments/new`.
In `?type=` (Beides): button reads "+ Neu" with a small dropdown caret → opens a 2-option menu (Neue Frist / Neuer Termin) that routes to the existing form pages.
Why not a type-picker modal: it's an extra click for the common case (user knows what they're creating). Why not two side-by-side buttons in Beides mode: button-pair clutters the header and makes the "Beides" mode feel structurally different (it's just a filter view, not a different mode of being).
Detail/create pages stay separate (per task brief §13 + §E13 below). The unification is list + filter, not form.
---
## 6. Section D — Dashboard
### Q11. Termine on the Dashboard
**Recommendation: Add a Termine summary rail; keep the deadline rail.**
Today the Dashboard has:
- 5-card deadline summary (Überfällig / Heute / Diese Woche / Nächste Woche / Erledigt) → links to `/deadlines?status=…`
- "Kommende Fristen" + "Kommende Termine" two-column 7-day list (already cross-type)
- Activity feed
What to add:
- **3-card Termine summary** (Heute / Diese Woche / Später) → links to `/appointments?range=today` etc.
- Both card rails read from a new `GET /api/events/summary` that returns:
```json
{
"deadlines": { "overdue": 3, "today": 2, "this_week": 5, "next_week": 1, "completed_this_week": 2 },
"appointments": { "today": 1, "this_week": 4, "later": 12 }
}
```
The two-column 7-day list stays — it's already cross-type and reads well. The activity feed stays.
Visual ordering on Dashboard:
```
[Greeting]
[Fristen summary — 5 cards]
[Termine summary — 3 cards] ← new
[Meine Akten matter card]
[Kommende Fristen | Kommende Termine]
[Letzte Aktivität]
```
The Termine rail goes directly under the Fristen rail because the two are conceptually the same "what's coming up?" question split by type.
### Q12. Bucket-model translation
**Recommendation: the buckets stay type-specific (no shared 4-bucket compromise).**
Trying to fit appointments into the deadline 5-bucket model:
| Deadline bucket | Appointment fit? |
|---|---|
| Überfällig (past, not completed) | ✗ — appointments either happened or didn't; "past" isn't urgent. |
| Heute | ✓ |
| Diese Woche | ✓ |
| Nächste Woche | △ — `/appointments` today uses "Später" (anything past this week). The bucket is fine but the cutoff is different. |
| Erledigt | △ — "vergangen" maybe, but the semantics differ. |
The honest answer is the two surfaces have different time horizons (deadlines obsess over "overdue", appointments don't) and squeezing them into one bucket grid would erase that. The two-rail approach in §C7 is the cleanest expression.
---
## 7. Section E — Migration & rollout
### Q13. Verlauf / detail-page links
**Recommendation: detail pages stay type-specific. Verlauf links unchanged.**
t-paliad-102 wired `eventDetailHref()` and `activityHref()` to point at `/deadlines/{id}` and `/appointments/{id}` based on event metadata. Those keep working — only the LIST view unifies. No frontend Verlauf change needed.
If a future round wants to unify detail pages too, that's t-paliad-110 territory; the deadline-edit and appointment-edit forms are quite different (event_type chips, rule code, complete/reopen vs CalDAV time pickers, location, type dropdown).
### Q14. Data migration
**Recommendation: none. Both tables stay; only the read side joins.**
`paliad.deadlines` and `paliad.appointments` keep their schemas. `EventService` reads from both and projects to `EventListItem` at request time. Migration 030+ stays untouched.
The only schema-adjacent change worth flagging: when we add **per-row "Erledigt" semantics for appointments** (Q14 in §F asks), we'd need a new column `paliad.appointments.completed_at` or similar. Today there's no such concept (a past appointment is just past). I'd defer this to a follow-up unless m wants it now.
### Rollout (PR shape)
Single feature PR on `mai/<coder>/events-unification`, ~5 commits:
1. **Backend: EventService + endpoint.** New `internal/services/event_service.go` (delegating to existing services), new `internal/handlers/events.go` (`GET /api/events`, `GET /api/events/summary`), wire into `Services` struct.
2. **Backend: EventService tests.** Unit tests for the merge/sort logic, type-filter, status-filter behavior, summary counts.
3. **Frontend: shared EventsPage component + client/events.ts.** New `frontend/src/events.tsx` (the shared TSX), new `frontend/src/client/events.ts` (the runtime). Shared filter row, shared bucket-rail, shared table renderer.
4. **Frontend: rewire `/deadlines` and `/appointments` handlers** to render `EventsPage` with the right `defaultType`. Drop `frontend/src/deadlines.tsx` + `frontend/src/appointments.tsx` (their build entries replaced by `events`). Update `bun build` config + Go template glue.
5. **Frontend: Dashboard Termine summary rail.** Read `/api/events/summary`, render 3 cards under the existing Fristen rail.
Plus i18n keys (DE+EN) for the new strings: type-chip labels, the 3-chip toggle, "+ Neu" dropdown labels, Dashboard Termine rail. Roughly ~12 new keys.
Old endpoints (`GET /api/deadlines`, `GET /api/appointments`) **stay** — they're used by `/deadlines/calendar`, `/appointments/calendar`, `/projects/{id}` detail panes, mobile/PWA. Don't churn callers we don't have to.
Estimated PR scope: ~600 LoC backend + ~900 LoC frontend (most of it consolidation, not new code) + ~150 LoC tests. Numbers approximate.
---
## 8. Mock — unified table layout
ASCII mock of `?type=` (Beides) view, after 3-chip toggle, both rails visible:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Fristen [Kalender] [Neue Frist] │ ← H1 reflects entry route
├─────────────────────────────────────────────────────────────────────────┤
│ [⏰ Fristen] [📅 Termine] [Beides ●] │ ← 3-chip toggle
├─────────────────────────────────────────────────────────────────────────┤
│ Fristen auf einen Blick │
│ ┌────────┬───────┬────────────┬────────────┬──────────┐ │
│ │ 3 │ 2 │ 5 │ 1 │ 2 │ │
│ │Überfäl.│ Heute │Diese Woche │Nächste W. │ Erledigt │ │
│ └────────┴───────┴────────────┴────────────┴──────────┘ │
│ Termine │
│ ┌───────┬────────────┬─────────┐ │
│ │ 1 │ 4 │ 12 │ │
│ │ Heute │Diese Woche │ Später │ │
│ └───────┴────────────┴─────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ Projekt: [Alle ▾] Von: [____] Bis: [____] Typ: [Alle ▾] Status: [—] │
├─────────────────────────────────────────────────────────────────────────┤
│ │ │ Datum │ Titel │ Projekt │ Regel │ Typ│Ort │T-Typ │ Status │
├─┼─┼──────────────────────┼────────────────────┼───────────┼────────┼────┼─────────────────────┼───────────┼────────────┤
│☐│⏰│ 28.05.2026 │ Statement of Def. │ ACM 0001 │RoP.023 │SoD │ │ │ Offen │
│☐│⏰│ 31.05.2026 OVERDUE │ Reply to Defence │ ACM 0001 │RoP.029a│Repl│ │ │ Offen │
│ │📅│ 01.06.2026 09:0011:00│ MV Acme v. Foo │ ACM 0001 │ │ │UPC LD MUC, … │Verhandlung│ │
│☐│⏰│ 03.06.2026 │ Schriftsatz Beweis │ ACM 0001 │ │SoD │ │ │ Offen │
│ │📅│ 05.06.2026 14:00 │ Strategiebespr. │ — │ │ │Zoom │Besprechung│ │
└─┴─┴──────────────────────┴────────────────────┴───────────┴────────┴────┴─────────────────────┴───────────┴────────────┘
```
In `?type=deadline` mode, the Termine summary rail and the Ort/T-Typ columns vanish; in `?type=appointment` mode, the Fristen rail vanishes plus Regel/Typ/Status; the table becomes today's pure deadline / pure appointment table.
The leftmost ☐ column is the deadline-complete checkbox (deadline rows only — appointments don't have a complete affordance today; Q14 in §F asks).
---
## 9. Section F — Open questions for m
These are blocking. I've put a recommendation under each so the decision is small.
**Q1. Premise correction.** The task brief described `/agenda` as the appointment list backed by `AppointmentService`. The live system has `/appointments` as the appointment list and `/agenda` as a third pre-existing cross-type timeline view. The design above treats the unification target as **`/deadlines``/appointments`**, with `/agenda` left alone. **Confirm this read.** *(Reco: confirm.)*
**Q2. Sidebar.** Keep "Fristen" + "Termine" as separate sidebar entries, both pointing at the unified component? Or collapse to one "Ereignisse" entry? *(Reco: keep separate.)*
**Q3. `/agenda` fate.** Out of scope for this round (timeline stays as-is) — confirm? Or do you want the timeline retired in favor of the new Events list + Beides toggle? If retired, the cards on Dashboard linking to `/agenda` need rerouting. *(Reco: leave as-is for this round.)*
**Q4. Page header in Beides mode.** When the user toggles to Beides on `/deadlines`, does the `<h1>` stay "Fristen" (recommended), switch to "Ereignisse", or rewrite to "Fristen & Termine"? *(Reco: stay "Fristen" — the route owns the heading; the chip toggle is a within-page filter.)*
**Q5. URL on type-chip toggle.** When the user toggles to "Beides" on `/deadlines`, the URL becomes `/deadlines?type=` — slightly weird. Acceptable, or should the toggle redirect to a canonical `/events` route? *(Reco: accept the weirdness; bookmarks survive.)*
**Q6. "Neu" button in Beides mode.** Recommended: single button "+ Neu" with a 2-option dropdown (Neue Frist / Neuer Termin). Acceptable, or do you want two side-by-side buttons? *(Reco: dropdown.)*
**Q7. Filter row visibility.** In Beides mode, deadline-only filters (Status, Typ multi-select) are visible-but-mark-deadline-only. Appointment-only filter (Termin-Typ) is hidden. Confirm this asymmetry. *(Reco: confirm.)*
**Q8. Date range filter on deadlines.** Today `/deadlines` has no Von/Bis range — only buckets. Adding it as part of the unified filter row would slightly change deadline UX. OK? *(Reco: yes, gives users another way to scope; doesn't replace buckets.)*
**Q9. Type icon column.** I'm proposing a leftmost type icon column (⏰ vs 📅) in Beides mode for at-a-glance. Useful or noise? *(Reco: useful in Beides; auto-hide in single-type mode.)*
**Q10. Dashboard Termine summary cards.** Add a 3-card Termine rail (Heute / Diese Woche / Später) under the existing 5-card Fristen rail. Confirm. *(Reco: add it.)*
**Q11. Status filter semantics in Beides.** When type=Beides and Status="Erledigt" is set, what should appointments do? Three options:
- (a) Hide all appointments (status filter only matches completed deadlines).
- (b) Show all appointments untouched, plus completed deadlines.
- (c) Disable the Status selector with a tooltip "Status gilt nur für Fristen". *(Reco: c — simplest mental model.)*
**Q12. 5-bucket vs 3-bucket vs shared-4-bucket.** I recommended the two-rail approach in Beides (deadline 5-bucket + appointment 3-bucket stacked). Are you OK with two rails, or do you want a single shared bucket model (e.g. drop "Überfällig" and use a 4-card Heute/Diese Woche/Nächste Woche/Erledigt+Vergangen across both)? *(Reco: two rails — honest about each type's semantics.)*
**Q13. Date column header label in Beides.** "Datum" (generic) vs keeping "Fällig / Beginn" double-header. *(Reco: "Datum"; the type icon column tells users what it means.)*
**Q14. "Erledigt" for appointments.** Today appointments have no completion concept — past appointments just exist. Do you want to add `appointments.completed_at` so users can mark a Verhandlung as "done" and have it leave the active table? Or leave appointments without that — they fall off the active range filter naturally? *(Reco: defer to a follow-up; not part of this unification.)*
**Q15. API endpoint cohabitation.** Keep `GET /api/deadlines` and `GET /api/appointments` alongside the new `GET /api/events`? *(Reco: keep both; the calendar and project-detail pages still call them. Retire on a separate v2 cleanup once confidence is high.)*
**Q16. Detail-page unification.** Out of scope per task brief §13. Confirm — I want to be sure m's "same Events view" framing didn't extend to detail pages. *(Reco: out of scope; deadline-edit and appointment-edit forms have nearly disjoint fields.)*
**Q17. Granularity on event-type filter in Beides.** Event-type filter (multi-select chip cluster) only matches deadlines (appointments don't have event types). When applied in Beides, do appointments get included anyway, or do they get filtered out (logically: "show only events that have one of these types")? *(Reco: appointments pass through unchanged; the filter is a deadline-side narrower, not a global narrower. Tooltip clarifies.)*
---
## 10. Out of scope
- Detail pages (`/deadlines/{id}`, `/appointments/{id}`) — stay separate.
- `/agenda` timeline — stays as-is for this round.
- `/deadlines/calendar` and `/appointments/calendar` — month-grid views; not affected by list unification.
- Forms (`/deadlines/new`, `/appointments/new`) — stay separate.
- Reminder service, CalDAV sync, project-detail panes — read from old endpoints; unaffected.
- Adding `completed_at` to appointments — defer per Q14.
---
## 11. Files the implementer will touch
(For the head's planning; not authoritative.)
**New files:**
- `internal/services/event_service.go`
- `internal/services/event_service_test.go`
- `internal/handlers/events.go`
- `frontend/src/events.tsx`
- `frontend/src/client/events.ts`
**Modified:**
- `internal/handlers/handlers.go` — wire new service + endpoints; rewire `/deadlines` and `/appointments` page handlers to render `events.tsx`.
- `internal/handlers/dashboard.go` — extend payload with appointment summary (or call new `/api/events/summary`).
- `frontend/src/dashboard.tsx` — add Termine 3-card rail.
- `frontend/src/client/dashboard.ts` — fetch + render Termine summary.
- `frontend/src/i18n.ts` (or wherever keys live) — ~12 new DE/EN keys.
- `frontend/build.ts` — drop `deadlines.tsx`/`appointments.tsx` build entries; add `events.tsx`.
**Deleted (replaced):**
- `frontend/src/deadlines.tsx`
- `frontend/src/client/deadlines.ts`
- `frontend/src/appointments.tsx`
- `frontend/src/client/appointments.ts`
**Untouched:**
- `internal/services/deadline_service.go` (still called by `EventService`)
- `internal/services/appointment_service.go` (still called by `EventService`)
- `internal/services/agenda_service.go` (still serves `/agenda` timeline)
- All detail / form / calendar pages.
---
## 12. Inventor stays parked
This is design-only per the inventor → coder gate. After m greenlights §F, head decides whether to load `/mai-coder` on me or assign elsewhere. cronus has the deepest event-types context (t-paliad-088) and bucket math context (t-paliad-106) so cronus or curie are natural fits, but the head decides.
— cronus

File diff suppressed because it is too large Load Diff

View File

@@ -429,7 +429,7 @@ No react-query, no Tailwind v4. Use existing `global.css` patterns.
### Build and deploy
- Existing flow stays: push to `main` on `mAi/paliad` → Gitea webhook → Dokploy auto-deploy.
- Existing flow stays: push to `main` on `m/paliad` → Gitea webhook → Dokploy auto-deploy.
- Dockerfile changes: add migration step to entrypoint (run `migrate up` against `DATABASE_URL` before starting the HTTP server).
- New env vars in Dokploy:
- `DATABASE_URL` (youpc Supabase Postgres conn string)
@@ -710,4 +710,67 @@ Once this design is approved, who implements?
---
## 11. Post-Integration Status (added 2026-04-17)
Recorded after Phase J documentation pass on branch `mai/ritchie/phase-j-roadmap-rewrite`.
### Shipped phases
| Phase | Scope | Status | Merge |
|---|---|---|---|
| A | Database foundation, visibility model, migration tooling | ✅ Shipped | `1b2ef28` (2026-04-16) |
| B | sqlx pool, services, Akten/Frist endpoints | ✅ Shipped | `bcc4939` (2026-04-16) |
| C | Fristenrechner → DB-backed | ✅ Shipped | `d1909c7` (2026-04-16) |
| D | Akten CRUD + onboarding + collaborator UI | ✅ Shipped | `4296da5` (2026-04-16) |
| E | Persistent Frist management UI | ✅ Shipped | `316dc9f` (2026-04-16) |
| F | Termine + CalDAV sync (AES-GCM at rest) | ✅ Shipped | `b56ef66` (2026-04-17) |
| G | Dashboard (server-rendered) | ✅ Shipped | `b79ef25` (2026-04-16) |
| H | AI Frist-Extraktion | ⏸ **Deferred** | branch `mai/ritchie/phase-h-ai-deadline` — not merged |
| I | Notizen (polymorphic service + UI) | ⬜ Pending | Schema only (migrations 005/007); service and UI not started |
| J | Roadmap rewrite + KanzlAI retirement | 🟡 **Docs only** — infra pending | see below |
### Phase H — deferred (not cancelled)
Decision by m on 2026-04-16: *"We don't want Anthropic API. We put this off for a while."* The work on branch `mai/ritchie/phase-h-ai-deadline` (commit `f539102`) covers the extraction path end-to-end, but will not be merged until the Anthropic API decision is revisited. The Dokumente tab on Akten detail stays as a "Kommt bald" placeholder. No `ANTHROPIC_API_KEY` on Dokploy.
Document upload + Supabase Storage alone (without AI) remains an open question — potentially worth shipping as a standalone Dokumente feature even with AI deferred.
### Phase I — pending
`paliad.notizen` table with polymorphic FK + CHECK constraint and RLS is already in place (migrations 005 and 007). The service (`notiz_service.go`), handlers, and the shared `NotizenList` TSX component are not yet built. Picks up as ~4h of focused work when the cross-cutting notes become the next friction point.
### Phase J — partial (documentation done; infra retirement pending)
**Done in this Phase J pass (2026-04-17, branch `mai/ritchie/phase-j-roadmap-rewrite`):**
- `docs/feature-roadmap.md` rewritten per §5 of this doc: all-in-one positioning, Phase 0 Aktenverwaltung section with completed items, "What Paliad Is" replaces "What patholo Is NOT", dropped §2.3 UPC Rechtsprechung (youpc.org link covers it), updated prioritized backlog with done markers, Phase H marked deferred, Architecture Notes data-strategy updated for `paliad` schema + office-scoped RLS.
- `.claude/CLAUDE.md` refreshed with current feature list, env vars (`DATABASE_URL`, `CALDAV_ENCRYPTION_KEY`), and phase status.
- `README.md` refreshed with current feature list, full migration inventory, env vars, and project layout.
- This "Post-Integration Status" section added.
**Still pending — requires head + m coordination (NOT in this Phase J task scope):**
- Add `kanzlai.msbls.de` domain to Paliad Dokploy compose with 301 redirect rule.
- Stop and delete the KanzlAI Dokploy app.
- Archive the `m/KanzlAI-mGMT` Gitea repo (set read-only / archived).
- Merge-or-separate decision for `mai.projects.kanzlai` vs. `mai.projects.paliad`.
- `DROP SCHEMA kanzlai CASCADE` on youpc Supabase after final verification.
- Memory: write a consolidation episode in the `paliad` group and supersede KanzlAI episodes (noted as a followup for m).
These are ops actions with real blast radius (public domain cutover, shared-DB schema drop, repo archival) and should not run unattended from a documentation task.
### Email gate: still hardcoded
The design §2 specified an env-configurable whitelist `[@hoganlovells.com, @hlc.com, @hlc.de]`. Current code (`internal/handlers/auth.go:115`) still hardcodes `hoganlovells.com`. Move to env config before HLC emails come online — trivial change, just hasn't happened yet.
### Visibility model: verified in use across shipped phases
The `paliad.can_see_akte(akte_id)` predicate is the single source of truth and is reused by every RLS policy and mirrored by `AkteService.GetByID` at the application layer. `FristService`, `TerminService`, and `ParteienService` all route through `AkteService.GetByID` before operating on their own rows. No duplication. Architecture invariant held through Phases D, E, F.
### CalDAV: manual Outlook plan still open
Phase F verified CalDAV against `dav.msbls.de` and Apple iCloud. HLC lives on Outlook + Exchange where CalDAV support is limited or off by default. The Phase K plan (EWS / Microsoft Graph backend behind the same sync abstraction) remains the fallback. Reassess after first real HLC user feedback.
---
*End of design.*

View File

@@ -0,0 +1,687 @@
# Partner Units — rename + admin management UI
**Task:** t-paliad-070
**Inventor:** cronus (mai/cronus/partner-units-rename worktree)
**Date:** 2026-04-29
**Status:** DESIGN v2 — m answered the open questions 21:44 Wed 29.04. Revised doc below; awaiting head greenlight before coder shift.
## m's answers (21:44 Wed 29.04.) summarised
1. **Naming**: `partner_unit` everywhere (snake_case for DB/JSON, `PartnerUnit` for Go types, `partner-unit(s)` for kebab-URLs).
2. **Rename API too**: `paliad.departments``paliad.partner_units`, `paliad.department_members``paliad.partner_unit_members`, `/api/departments/*``/api/partner-units/*`. Full consistency.
3. **Settings admin section**: remove (don't duplicate).
4. **Audit emit**: yes, in this PR.
5. **Free-text column drop**: yes — drop `users.dezernat` entirely instead of renaming. Phase 2 collapses into Phase 1.
This dramatically expands the rename scope but produces a single coherent end-state (no transitional German names anywhere, no duplicate-state debt). Single PR is now even more important — splitting would leave the code in an unrunnable mid-rename state for any non-trivial duration.
---
## 1. The two concerns
m wants:
1. The user-facing concept "Dezernate" renamed to **"Partner units"** everywhere.
2. The placeholder card on `/admin` ("Dezernate / Kommt bald") replaced with a real
`/admin/departments` management surface.
These two concerns share the same code surface, so this design treats them as one PR.
---
## 2. Live-state inventory (2026-04-29)
What already exists:
| Layer | Status |
|---|---|
| **DB tables** | `paliad.departments` and `paliad.department_members` already English (renamed in migrations 020 + 024). RLS policies, FKs, indexes already English. |
| **DB column** | `paliad.users.dezernat` — German legacy, free-text `text` column added in migration 015. |
| **Go service** | `internal/services/department_service.go` — full CRUD + member management. Admin-gated via `requireAdmin` (`global_role='global_admin'`). |
| **Go handlers** | `internal/handlers/departments.go` — 8 routes registered under `/api/departments/*`. |
| **Frontend admin CRUD** | Already shipped — but **inside `/settings?tab=dezernat`**, not on a dedicated admin page. Visible only to global_admin (gated client-side via `me.global_role`). |
| **Admin landing** | `/admin` shows a "Geplant / Kommt bald" Dezernate card pointing nowhere. |
| **Admin team page** | `/admin/team` has a "Dezernat" free-text column and edit input bound to `paliad.users.dezernat`. |
| **Onboarding** | Asks for "Dezernat / Partner" as free text, persists to `users.dezernat`. |
| **Settings profile tab** | Asks for "Dezernat oder Partner" free text. |
| **Team directory** | `/team` groups colleagues by `users.dezernat` free-text fallback when `paliad.departments` membership is missing. |
The duplicate-state debt is real: the same concept lives in two places —
the structured `paliad.departments` registry (admin-managed) and the free-text
`paliad.users.dezernat` column (user-typed). Migration 019 backfilled the
former from the latter, but they have been drifting apart since. **Resolving
that drift is out of scope for this task** — flagged as Phase 2.
Counts (`grep -l`):
- 7 Go files mention `dezernat` / `Dezernat`
- 10 frontend files (`.ts` / `.tsx`)
- 2 SQL migrations (015 = column add, 019 = seed function)
- ~80 i18n strings
---
## 3. Naming decisions (per m)
### 3.1 User-facing label (cross-language)
**"Partner unit" / "Partner units"** — same English phrase in DE and EN.
Capitalised loanword in DE strings ("Partner Unit anlegen", "Partner Units
verwalten").
### 3.2 Internal names — full rename to `partner_unit`
Per m's "lets fix departments even in api?!", everything Department-shaped
on the structured side renames too. End state:
| Surface | Before | After |
|---|---|---|
| Table | `paliad.departments` | `paliad.partner_units` |
| Junction table | `paliad.department_members` | `paliad.partner_unit_members` |
| FK column on junction | `department_id` | `partner_unit_id` |
| Constraint names | `departments_*`, `department_members_*` | `partner_units_*`, `partner_unit_members_*` |
| Index names | same prefix | same prefix |
| RLS policy names | `departments_select` etc. | `partner_units_select` etc. |
| Go type | `models.Department` | `models.PartnerUnit` |
| Go type | `services.DepartmentMember` | `services.PartnerUnitMember` |
| Go type | `services.DepartmentWithMembers` | `services.PartnerUnitWithMembers` |
| Go service | `DepartmentService` (`Service.Department`) | `PartnerUnitService` (`Service.PartnerUnit`) |
| Go file | `internal/services/department_service.go` | `internal/services/partner_unit_service.go` |
| Go file | `internal/handlers/departments.go` | `internal/handlers/partner_units.go` |
| API path | `/api/departments` | `/api/partner-units` |
| API path | `/api/departments/{id}/members` | `/api/partner-units/{id}/members` |
| Admin URL | `/admin/departments` | `/admin/partner-units` |
| TSX file | (new) `admin-partner-units.tsx` | same |
| Client TS | (new) `client/admin-partner-units.ts` | same |
| JSON keys | `department_id`, `lead_user_id`, `members[]` | `partner_unit_id`, `lead_user_id`, `members[]` |
| i18n keys | `dezernat.*` | `partner_unit.*` |
| CSS classes | `.dezernat-*` | `.partner-unit-*` |
| CSS classes | (none today) | `.partner-unit-*` |
### 3.3 The `users.dezernat` free-text column
**Drop entirely** (per m's answer 5). Migration also re-runs migration 019's
seed logic immediately before the drop, to capture any drift since 019 ran
(users who edited their `dezernat` value via `/settings` after 019 won't
have a corresponding `partner_unit_members` row). Idempotent
`ON CONFLICT DO NOTHING`.
This means **the onboarding form stops asking for a free-text Dezernat/
Partner field** and **the settings profile tab stops surfacing it**.
Replacement UX (lightweight — same PR):
- **Onboarding**: replace the free-text `dezernat` input with a `<select>`
populated from `GET /api/partner-units` (anonymous-readable; the public
list is fine to expose). First option = "(noch keine zuordnung / not
assigned yet)" maps to no membership. The select writes a
`partner_unit_id` to the create-user payload, and the user-creation flow
inserts a row in `paliad.partner_unit_members` if a unit was picked.
- **Settings profile tab**: drop the field entirely. Membership management
for non-admins lives on the existing "Mein Partner Units" read-only view
(which stays — see §4.4). If a user wants to change their own membership,
they ask an admin (matches the "global_admin only" model in §5).
- **Admin-team table**: drop the "Dezernat" column and the inline-edit input
for it. Admin sees memberships via the dedicated `/admin/partner-units`
page; the team page already has membership chips shown (per F-44 — verify
during smoke). Reduces double-source-of-truth confusion.
- **Team directory grouping**: the `/team` "Nach Dezernat" group keeps its
partner-unit grouping (now reading only from structured `partner_unit_members`),
drops the free-text fallback bucket.
### 3.4 What does NOT rename
- `lead_user_id` (column on partner_units) — generic FK name, not
Department-flavoured.
- `office` (column on partner_units) — generic.
- The 8 HTTP routes' shape — only the path changes; verbs/handler names
rename (`handleListDepartments``handleListPartnerUnits`).
- `paliad.users.office`, `paliad.users.additional_offices` — orthogonal.
### 3.5 URL strategy
- `/settings?tab=dezernat` — tab is removed (admin section moves to
`/admin/partner-units`, "my unit" view becomes a card on the profile tab).
No redirect needed (settings tabs aren't externally bookmarked).
- `/admin/partner-units` is the new admin page. The old placeholder card
was a no-op, no legacy URL to redirect from.
- `/api/departments/*` — no legacy redirect. The API is internal to the
bundled JS (no third-party consumer); a one-shot rename without aliases is
safe. Should there ever be an integration in flight, add a 301 alias in
`internal/handlers/redirects.go` mirroring the existing `/dezernate`
redirect.
---
## 4. The new `/admin/partner-units` page
### 4.1 Surface
A dedicated admin page mirroring `/admin/team`'s aesthetic:
- **Page title:** "Partner Units verwalten" / "Manage Partner Units"
- **Top bar:** count of partner units, plus a primary "Neue Partner Unit anlegen"
button (opens an inline form panel below the table — matches admin-team's
invite/onboard pattern).
- **Table:** columns = Name · Office · Lead (display name + email) · Members
count · Actions. One row per partner unit, ordered by office then name.
- **Inline edit:** click a row → expand below for {edit name / change office /
change lead / view+manage members}. Same disclosure pattern as the existing
settings admin section, but lifted to a top-level admin page with breathing
room.
- **Member management:** typeahead "add member" input (re-uses the same
`/api/users` endpoint `loadUserOptions()` already calls). Each member row
has a remove button with confirmation. Optional "make lead" pin if the
member is a lead candidate (`job_title` containing "Partner" — soft hint,
not a gate).
- **Delete:** danger button with confirm. Cascades memberships (FK on
`department_members`).
Wireframe (ASCII):
```
┌────────────────────────────────────────────────────────────────────────┐
│ Admin > Partner Units [+ Neue Partner Unit] │
├────────────────────────────────────────────────────────────────────────┤
│ Suche: [____________] Office: [Alle ▼] │
├────────────────────────────────────────────────────────────────────────┤
│ Name Office Lead Mitglieder Aktion │
│ Team Müller München Dr. M. Müller 7 ▾ ✏ 🗑 │
│ Team Schmidt München Dr. A. Schmidt 3 ▾ ✏ 🗑 │
│ Team Lopez Düsseldorf J. Lopez 5 ▾ ✏ 🗑 │
│ ... │
└────────────────────────────────────────────────────────────────────────┘
Click ▾ on a row to expand:
┌─ Mitglieder verwalten — Team Müller ────────────────────────────────────┐
│ • Dr. M. Müller muller@hlc.de ★ Lead │
│ • A. Bauer bauer@hlc.de [Entfernen] │
│ • C. Kim kim@hlc.de [Entfernen] │
│ ... │
│ [Mitglied hinzufügen: __________________ ▼] [Hinzufügen] │
└─────────────────────────────────────────────────────────────────────────┘
```
### 4.2 Files to create
- `frontend/src/admin-partner-units.tsx` — page render, mirrors
`admin-team.tsx` shape: container + tool-header + filters + table.
- `frontend/src/client/admin-partner-units.ts` — fetch, render, edit,
delete, member CRUD. Reuses the office list endpoint, `/api/users`
for the typeahead, `t()` for i18n, sidebar + bottom-nav init.
- `frontend/build.ts` entry — `renderAdminPartnerUnits`
`dist/admin-partner-units.html`, `dist/assets/admin-partner-units.js`.
- `internal/handlers/admin_partner_units.go`
`handleAdminPartnerUnitsPage` (one-liner ServeFile, mirrors
`handleAdminTeamPage`).
### 4.3 Files to edit
- `internal/handlers/handlers.go` — register `GET /admin/partner-units`
inside the existing `if svc != nil && svc.Users != nil` block, gated by
`auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPartnerUnitsPage))`.
Re-register the 8 `/api/partner-units/*` routes (renamed from
`/api/departments/*`).
- `frontend/src/admin.tsx` — flip the Partner-Units card from the
"Geplant" section to the "Verfügbar" section, with
`href="/admin/partner-units"`, remove the `admin-card-soon` class and the
"Kommt bald" badge. Icon stays `ICON_BUILDING`.
- `frontend/src/components/Sidebar.tsx` — add a third admin nav item
inside `#sidebar-admin-group`: `navItem("/admin/partner-units",
ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)`.
- `frontend/src/client/i18n.ts` — replace `dezernat.*` and add new keys
(see §6).
### 4.4 Settings page cleanup
The current `/settings?tab=dezernat` has TWO panels:
- "Mein Dezernat" (read-only, shows the user's own units) — **keep** as a
card on the profile tab (no longer needs its own tab; the only reason it
had one was the admin CRUD section). Renamed to "Meine Partner Units".
- "Dezernate verwalten (Admin)" (full CRUD) — **remove**. Replaced by
`/admin/partner-units`. Reduces duplication and matches the "admin tools
live under /admin" convention established by t-paliad-050.
Net code delta in `settings.tsx` + `settings.ts`: removes ~250 lines (admin
CRUD moves to new page; read-only "my units" card moves into profile tab as
~30 lines). The `dezernat` profile-input field is removed entirely (no
replacement on the profile tab; users manage membership via admin requests).
The settings tab list shrinks from 4 to 3: `profil`, `benachrichtigungen`,
`caldav`. URL `/settings?tab=dezernat` 404s gracefully (the tab resolver in
`appointments_pages.go` falls back to `profil`).
---
## 5. Permission model
| Action | Today | After |
|---|---|---|
| List partner units (read) | any authenticated user | unchanged |
| Get partner unit details | any authenticated user | unchanged |
| List members | any authenticated user | unchanged |
| Get own memberships | any authenticated user | unchanged |
| Create | global_admin only | unchanged |
| Update | global_admin only | unchanged |
| Delete | global_admin only | unchanged |
| Add member | global_admin only | unchanged |
| Remove member | global_admin only | unchanged |
No permission-model changes. Service-level `requireAdmin` already enforces
`global_role='global_admin'` for every write.
**Out of scope (defer):** allowing the partner unit's lead user to manage
their own unit's members. m's brief asks "who can assign members? (global_admin
+ the unit's lead/partner?)" — recommendation: defer. Today there are no
real partners with `lead_user_id` set in prod, and m has been actively
pruning permission complexity. Add later when there's a clear request.
---
## 6. i18n strings
**Drop entirely** (no replacement — surfaces are removed):
- `einstellungen.profil.dezernat`, `einstellungen.profil.dezernat.placeholder`
(settings profile field is gone)
- `einstellungen.tab.dezernat` (tab is gone)
- `onboarding.dezernat`, `onboarding.dezernat.placeholder` (free-text input is
replaced with a select; new keys: `onboarding.partner_unit`,
`onboarding.partner_unit.placeholder`, `onboarding.partner_unit.unassigned`)
- `admin.team.col.dezernat` (column removed from admin-team)
- `admin.team.direct_add.dezernat` (input removed from add-form)
- `dezernat.error.user_required`, `dezernat.field.office`, `dezernat.field.name`,
`dezernat.admin.heading`, `dezernat.admin.new`, `dezernat.admin.create`
these belonged to the settings admin section that moves to the new page;
same strings re-keyed under `admin.partner_units.*`.
- `team.dept.unassigned` ("Ohne Dezernat") — replaced with
`team.partner_unit.unassigned` ("Ohne Partner Unit")
**Add (new admin page):**
- `nav.admin.partner_units` = "Partner Units"
- `admin.partner_units.title`, `admin.partner_units.heading`,
`admin.partner_units.subtitle`
- `admin.partner_units.col.name`, `.col.office`, `.col.lead`, `.col.members`,
`.col.actions`
- `admin.partner_units.new`, `admin.partner_units.new.heading`,
`admin.partner_units.create`, `admin.partner_units.cancel`,
`admin.partner_units.delete`, `admin.partner_units.confirm_delete`
- `admin.partner_units.member.add`, `.member.remove`, `.member.confirm_remove`,
`.member.placeholder`, `.member.empty`, `.member.loading`
- `admin.partner_units.error.name_required`, `.error.user_required`
- `admin.partner_units.empty` ("Noch keine Partner Units angelegt.")
**Rename (settings profile-tab "my partner units" card):**
- `dezernat.heading``partner_unit.heading` ("Meine Partner Units")
- `dezernat.subtitle``partner_unit.subtitle`
- `dezernat.none``partner_unit.none`
- `dezernat.members_label``partner_unit.members_label`
**Update copy** (no key change):
- `admin.card.departments.title` → "Partner Units" (was "Dezernate") — and
the key itself renames to `admin.card.partner_units.title` for consistency
- `admin.card.departments.desc` → "Partner Units anlegen und Mitglieder
verwalten." → key renames to `admin.card.partner_units.desc`
- `admin.card.feature_flags.desc` — German body mentions "Dezernat",
rewrite as "Partner Unit"
- `team.subtitle` and `team.group.department` — German bodies say
"Dezernat", rewrite
DE strings use "Partner Unit" / "Partner Units" verbatim (capitalised
loanword). EN uses the same.
---
## 7. Migration plan
### 7.1 Migration 026: rename tables + drop free-text column
One migration, ordered statements, all wrapped in a single tx by migrate.v4:
```sql
-- 026_rename_to_partner_units.up.sql
BEGIN; -- migrate.v4 wraps automatically; explicit BEGIN for psql -1 fallback
-- 1. Best-effort second seed: pick up any users whose dezernat free-text
-- drifted after migration 019 ran. Idempotent.
INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at)
SELECT gen_random_uuid(), btrim(u.dezernat), NULL, MIN(u.office), now(), now()
FROM paliad.users u
WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> ''
GROUP BY btrim(u.dezernat)
ON CONFLICT DO NOTHING;
INSERT INTO paliad.department_members (department_id, user_id, created_at)
SELECT d.id, u.id, now()
FROM paliad.users u
JOIN paliad.departments d ON d.name = btrim(u.dezernat)
WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> ''
ON CONFLICT DO NOTHING;
-- 2. Drop the free-text column.
ALTER TABLE paliad.users DROP COLUMN dezernat;
-- 3. Rename tables.
ALTER TABLE paliad.departments RENAME TO partner_units;
ALTER TABLE paliad.department_members RENAME TO partner_unit_members;
-- 4. Rename column on the junction.
ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_unit_id;
-- 5. Rename constraints (pkey/fkey/check). Postgres auto-renames the
-- underlying index for pkey/uniq constraints.
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey;
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey;
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check;
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey;
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey;
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey;
-- 6. Rename non-pkey indexes.
ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx;
ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx;
ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx;
-- 7. Rename RLS policies.
ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select;
ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write;
ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select;
ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write;
-- 8. Audit table for partner-unit events. Per §8 — minimal schema, no UI yet.
CREATE TABLE paliad.partner_unit_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
partner_unit_id uuid NULL REFERENCES paliad.partner_units(id) ON DELETE SET NULL,
actor_id uuid NOT NULL REFERENCES auth.users(id),
event_type text NOT NULL CHECK (event_type IN (
'created', 'updated', 'deleted', 'member_added', 'member_removed'
)),
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX partner_unit_events_unit_idx ON paliad.partner_unit_events(partner_unit_id, created_at DESC);
CREATE INDEX partner_unit_events_actor_idx ON paliad.partner_unit_events(actor_id, created_at DESC);
-- RLS: any authenticated user can read (matches /api/partner-units read
-- access); only global_admin can write (writes happen inside service
-- methods that already gate with requireAdmin, so RLS is defence-in-depth).
ALTER TABLE paliad.partner_unit_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY partner_unit_events_select ON paliad.partner_unit_events
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY partner_unit_events_write ON paliad.partner_unit_events
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
COMMIT;
```
**Down migration** is the symmetric reverse of steps 8 → 1, with one caveat:
step 1 (the seed) cannot be perfectly reversed. The `paliad.users.dezernat`
column is recreated with NULLs; original values are lost. This is acceptable
because the data is preserved structurally in `partner_unit_members`.
If a true rollback is ever needed and per-user free-text values must be
restored, an admin script can re-seed from `partner_unit_members`:
`UPDATE paliad.users u SET dezernat = (SELECT pu.name FROM ... LIMIT 1)`.
Documented in the down migration as a comment, not auto-run.
### 7.2 Code cutover
migrate.v4 wraps the up migration in a single tx. If anything in the rename
chain fails (e.g. a constraint name mismatch on a freshly-provisioned DB
that didn't go through 020+024), the entire migration aborts and the dirty
flag is set. To minimise that risk, the constraint/index/policy rename
statements are wrapped in `DO $$ ... EXCEPTION WHEN undefined_object THEN
NULL END $$` blocks (same idempotency pattern migration 024 used).
Order of operations:
1. Push code (with migration 026 in `embed.FS`) to main.
2. Dokploy auto-deploys; the new binary's `migrate.Up()` runs migration 026
atomically before binding the listener.
3. Verify `/api/partner-units` returns the renamed table contents; `/admin/partner-units`
renders; `paliad.users.dezernat` no longer exists.
Migration risk is moderate (multi-statement, table rename + column drop +
new audit table) but contained: every statement is idempotent or
exception-trapped, and it all runs inside one tx so a partial apply is
impossible.
### 7.3 Rollback
`migrate down 1` reverses everything. The data loss noted above (free-text
column re-created with NULLs) is acceptable per §3.3 — structured
membership rows are the source of truth post-rename.
---
## 8. Audit logging — emitted in this PR
Per m's "audit emit? sure, why not", this PR ships audit emission. To stay
small and not pre-empt t-paliad-071's eventual cross-cutting audit design,
the emission goes to a dedicated `paliad.partner_unit_events` table (see
migration 026 step 8) rather than a global audit table. t-paliad-071 can
later subsume it (UNION ALL into a global view, or migrate rows into a
unified table).
### Events emitted
Each event is INSERTed in the same tx as the originating mutation.
| Event | When | Payload |
|---|---|---|
| `created` | `Create` succeeds | `{name, office, lead_user_id}` |
| `updated` | `Update` writes ≥1 column | `{before: {…}, after: {…}, fields: ["name","office",…]}` |
| `deleted` | `Delete` succeeds (before cascade) | `{name, office, lead_user_id, member_count}` |
| `member_added` | `AddMember` actually inserts | `{user_id, user_email, user_display_name}` |
| `member_removed` | `RemoveMember` actually deletes ≥1 row | `{user_id}` |
`actor_id` is the `callerID` already passed to every service method.
`partner_unit_id` is set to NULL on `deleted` after the unit row is gone
(FK has `ON DELETE SET NULL`), so the historical event row survives.
### No new endpoint in this PR
The `partner_unit_events` table is queryable via `/api/partner-units/{id}/events`
in a follow-up — keeping that endpoint out of scope here aligns with the
"ship audit emit, defer audit UX" framing. If t-paliad-071 wants to expose
events through a unified audit surface, that's the right home.
### Service-side wiring
A single helper inside `PartnerUnitService`:
```go
func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx,
actorID uuid.UUID, unitID *uuid.UUID, eventType string, payload any) error {
p, err := json.Marshal(payload)
if err != nil { return err }
_, err = tx.ExecContext(ctx,
`INSERT INTO paliad.partner_unit_events
(partner_unit_id, actor_id, event_type, payload)
VALUES ($1, $2, $3, $4)`, unitID, actorID, eventType, p)
return err
}
```
Each mutating method opens a tx (currently they don't — they use
`db.ExecContext` directly), runs the mutation + emit, commits. Adds ~5
lines per method × 5 methods = ~25 lines of audit plumbing.
---
## 9. Test plan
### 9.1 Build gauntlet
- `go build ./...`
- `go vet ./...`
- `go test ./...` (existing user_service_test.go uses `dezernat` test name —
rename to `department` to match)
- `cd frontend && bun run build`
### 9.2 Manual smoke (paliad.de as `tester@hlc.de`)
1. Log in as global_admin.
2. Visit `/admin` — confirm "Partner Units" card under "Verfügbar" (not
"Geplant"), no "Kommt bald" badge.
3. Click → land on `/admin/partner-units` — confirm table renders existing
units (with names migration 019 + the second seed produced).
4. Create a new unit "Test Unit Cronus" (Munich, no lead). Confirm a
`created` row appears in `paliad.partner_unit_events`.
5. Edit name → "Test Unit Cronus (renamed)". Confirm `updated` event row.
6. Add tester@hlc.de as member; confirm `member_added` event; chip appears
on `/team` directory grouping.
7. Remove member. Confirm `member_removed` event.
8. Delete the test unit; confirm row disappears from the table; confirm
`deleted` event row exists with `partner_unit_id IS NULL` (orphaned by
ON DELETE SET NULL).
9. Visit `/settings` — confirm tab list is `Profil | Benachrichtigungen |
CalDAV` (no Dezernat tab). Profile tab has "Meine Partner Units" card;
no free-text dezernat input.
10. Visit `/team` — confirm grouping by Partner Unit (not Dezernat) and
"Ohne Partner Unit" fallback label.
11. Visit `/admin/team` — confirm Dezernat column is gone; add-form has no
Dezernat input.
12. Visit `/onboarding` (with a fresh auth.users-only account) — confirm
the free-text Dezernat input is replaced with a partner-unit `<select>`.
13. Sign out, sign back in as a non-admin — confirm `/admin/partner-units`
returns 302 to `/dashboard?forbidden=admin`, sidebar admin section is
hidden.
### 9.3 Playwright (optional — confirm with head)
If Playwright smoke is desired, mirror t-paliad-050's admin-team pattern:
navigate, create, edit, delete, screenshot. Add an SQL assertion step that
checks `partner_unit_events` row counts after each action.
---
## 10. PR strategy
**Single PR, single merge to main.**
Reasoning:
- The rename touches the same files as the new admin page (admin.tsx,
i18n.ts, settings.tsx, admin-team.tsx, sidebar.tsx, onboarding.tsx,
team.tsx). Splitting forces ugly rebases.
- The migration is multi-statement but single-tx — no risk of partial apply.
- The user-facing label change is consistent only after the WHOLE diff lands.
A split would land "internal rename" with old labels still saying "Dezernat",
then "label change" — confusing during the gap.
- Settings has a redirect dependency (`/settings?tab=dezernat` 404 fallback)
that's only safe once the entire dezernat surface is gone.
Branch already in place: `mai/cronus/partner-units-rename`.
Estimated diff size: ~2200 lines net. Heavier than v1 because the
structured-side rename (Department → PartnerUnit) cascades through
service/handler/types/SQL/tests, plus onboarding form rebuild, plus audit
table + emit plumbing. No new dependencies.
---
## 11. Out of scope (deferred)
- **Hierarchical partner units** — flat list only. Per brief.
- **Per-partner-unit branding** (logo, colour) — defer.
- **Non-admin permission model** (lead manages own unit's members) — defer.
- **Audit UI** (a viewer for `partner_unit_events`) — defer to t-paliad-071.
Emission lands here; consumption + a unified events surface lands there.
- **Other entities' audit emission** — only partner units in this PR.
Projects already have `project_events`; deadlines/appointments already
emit. No global cross-entity audit yet.
- **Onboarding "create new partner unit" inline** — the new select offers
existing units + "(noch keine zuordnung)". A user wanting a new unit asks
an admin or self-promotes via `/admin/partner-units` post-onboarding (only
global_admin sees that page). Inline create-during-onboarding is a small
follow-up if friction surfaces.
---
## 12. Open questions — RESOLVED 21:44 Wed 29.04. (m's answers)
| # | Question | m's answer |
|---|---|---|
| 1 | Column rename target | **partner_unit** (became "drop entirely" after Q5) |
| 2 | API + URL rename | **yes — fix departments in api too** |
| 3 | Settings admin section removal | **yes** ("you do you") |
| 4 | Audit emit in this PR | **yes** ("sure why not") |
| 5 | Drop free-text column | **yes** ("makes sense") |
No remaining open questions. Design is now greenlit pending head's gate
review of this v2 doc.
---
## 13. Files (final)
### New
- `internal/db/migrations/026_rename_to_partner_units.up.sql`
- `internal/db/migrations/026_rename_to_partner_units.down.sql`
- `internal/services/partner_unit_service.go` (renamed from
`department_service.go` via `git mv` so blame survives — content rewritten
for type + SQL renames + audit emit)
- `internal/handlers/partner_units.go` (renamed from `departments.go`)
- `internal/handlers/admin_partner_units.go` — page-serve handler
- `frontend/src/admin-partner-units.tsx`
- `frontend/src/client/admin-partner-units.ts`
### Edit (Go)
- `internal/services/services.go` — wire `PartnerUnit *PartnerUnitService`.
- `internal/services/user_service.go` — drop `Dezernat` field from struct,
drop dezernat from SQL columns, drop dezernat from CreateUserInput +
UpdateUserInput, etc.
- `internal/services/user_service_test.go` — drop dezernat assertions;
add partner_unit_id + member-row assertions if onboarding/admin-create
paths now insert membership.
- `internal/models/models.go` — drop `User.Dezernat`; rename
`Department` → `PartnerUnit`, `DepartmentMember` → `PartnerUnitMember`.
- `internal/handlers/admin_users.go` — drop dezernat from admin
create/update payloads.
- `internal/handlers/handlers.go` — re-register `/api/partner-units/*`,
add `GET /admin/partner-units`, drop `dbSvc.department` field, add
`dbSvc.partnerUnit`.
- `internal/handlers/redirects.go` — drop the `/dezernate` → `/departments`
entry (the path is dead post-rename) OR keep for one cycle; flag in PR
description.
- `internal/handlers/appointments_pages.go` — drop the `"dezernat"` /
`"department"` tab aliases entirely (tab is gone). Default fallback handles
`/settings?tab=dezernat` gracefully.
### Edit (frontend)
- `frontend/src/admin.tsx` — flip the Partner-Units card from "Geplant" to
"Verfügbar".
- `frontend/src/admin-team.tsx` — drop the "Dezernat" column and the
add-form input.
- `frontend/src/client/admin-team.ts` — drop dezernat from payload + render.
- `frontend/src/onboarding.tsx` — replace free-text input with `<select>`
populated from `/api/partner-units`, plus an "(noch keine zuordnung)"
option. Label is `onboarding.partner_unit`.
- `frontend/src/client/onboarding.ts` — submit `partner_unit_id` instead of
`dezernat`. The user-create endpoint now accepts an optional `partner_unit_id`
and inserts a membership row in the same tx.
- `frontend/src/settings.tsx` — drop the dezernat tab, drop the dezernat
profile-field input, add a "Meine Partner Units" card on the profile tab.
- `frontend/src/client/settings.ts` — drop `dezernat` from `TabName` and
`TABS`, drop ~250 lines of admin CRUD + free-text plumbing, replace with
~40 lines for the read-only "my units" card.
- `frontend/src/team.tsx` + `frontend/src/client/team.ts` — labels and
drop the free-text fallback bucket; group only by structured
`partner_unit_members`.
- `frontend/src/components/Sidebar.tsx` — add `/admin/partner-units` nav
item with `nav.admin.partner_units` label.
- `frontend/src/client/i18n.ts` — drop ~30 dezernat keys × 2 langs;
add ~25 partner_unit keys × 2 langs.
- `frontend/src/styles/global.css` — `.dezernat-*` → `.partner-unit-*`.
- `frontend/build.ts` — new `renderAdminPartnerUnits` entry.
---
## 14. Inventor → coder gate
Stop after this design + a `mai report completed "DESIGN READY FOR REVIEW…"`.
Awaiting m's go/no-go on the open questions in §12 before any code change.
Recommended implementer: **cronus** (this same worktree, already on
`mai/cronus/partner-units-rename`). Mechanical rename + one new page is
straightforward Sonnet work; the design context doesn't need to transfer
to a fresh worker.

View File

@@ -0,0 +1,340 @@
# Design: Separate Job Title from Global Permissions
**Status:** Draft for review (m + head)
**Author:** cronus (mai inventor)
**Date:** 2026-04-27
**Task:** t-paliad-051
## Problem
Three orthogonal concepts share one column today:
1. **Job title** — Partner / Counsel / Associate / PA / Trainee / Sekretariat / "Counsel Knowledge Lawyer" / … . Free-text since migration 015. Display only.
2. **Global permissions** — currently "is the user a global admin?" piggy-backed on the same column with `paliad.users.role = 'admin'` checks across Go, SQL, and JS.
3. **Per-project role**`paliad.project_teams.role` ∈ {lead, associate, pa, of_counsel, local_counsel, expert, observer, admin}. Already separated, fine, **out of scope**.
The collision bites whenever someone tries to record their real job title without losing admin access. Concrete trigger: m's job title is "Counsel Knowledge Lawyer". If he sets that as his `role`, he loses every `role='admin'` gate in the codebase. Today the admin-team page (t-paliad-050) actually hard-rejects setting `role='admin'` from the UI (`AdminUpdateUser` raises `ErrAdminBootstrapOnly`), so any UI-driven edit of m's row would silently demote him.
Live state confirmed 2026-04-27 14:25 against `100.99.98.201:11833`:
| email | role | display_name |
|---|---|---|
| matthias.siebels@hoganlovells.com | admin | Matthias |
| tester@hlc.de | admin | Test Tester |
| 29 stub colleagues | associate | … |
So: 31 rows total, 2 admins, 29 associates. No `partner` rows in production today even though the gate exists in code.
## Goal
Split `paliad.users.role` into:
- **`paliad.users.job_title`** — free text, display only. Replaces today's `role`.
- **`paliad.users.global_role`** — enum-via-CHECK, currently `'standard' | 'global_admin'`. New column, drives every `role='admin'`-style permission check.
Per-project `paliad.project_teams.role` is untouched.
After the change m can carry `job_title='Counsel Knowledge Lawyer'` AND `global_role='global_admin'` simultaneously.
## Decisions
### 1. Naming
- Rename `paliad.users.role``paliad.users.job_title`.
- Add `paliad.users.global_role text NOT NULL DEFAULT 'standard'` with `CHECK (global_role IN ('standard','global_admin'))`.
### 2. Why enum-via-CHECK over a `permissions text[]`
| | text-with-CHECK | text[] permissions |
|---|---|---|
| New permission | edit one CHECK constraint | none |
| Code surface | `u.global_role = 'global_admin'` | `'global_admin' = ANY(u.permissions)` |
| Multi-grant | impossible by construction | natural |
| Validation | DB-level | service-level only |
| Today's needs (1 permission) | trivial | over-engineered |
We have one permission today and m's brief says "possibly more later, design with that in mind". An enum keeps every call site short and DB-validated; growing the CHECK to add `billing_admin` is a one-line migration and `IN (...)` checks compose fine. If we ever genuinely need to grant 2+ permissions to one user, swap the column type to `text[]` in a future migration — call sites change from `=` to `ANY(...)`, mechanical. Nothing about today's choice forecloses that.
**Lean: enum-via-CHECK.** Matches m's stated lean. Ships smaller. Easy to widen later.
### 3. Why not "global_admin is just another job_title value"
Considered: keep `role` free-text, just normalize so `job_title='global_admin'` means both the title and the permission. Rejected because (a) the title `Counsel Knowledge Lawyer` and the permission `global_admin` are independent — a user can have one without the other (m wants both); (b) the admin-team UI restriction (`ErrAdminBootstrapOnly`) is exactly the symptom of trying to overload one column for two concerns. We're solving the conflation, not preserving it under a new name.
### 4. The "partner" gate — DROPPED entirely (m's three-axis principle)
Mid-implementation m clarified the principle: "firm roles are not project roles are not tool roles". Several places gated on `user.Role IN ('partner','admin')`:
- `internal/services/party_service.go:100` — delete party
- `internal/services/note_service.go:195` — note ops
- `internal/services/appointment_service.go:199` — appointment update/delete
- `internal/services/project_service.go:617` — project ops
- `internal/services/checklist_instance_service.go:301` — checklist ops
- `internal/services/deadline_service.go:437` — delete deadline
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects_delete`, `project_teams_delete`
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:194,208`, `deadlines.ts:69`, `notes.ts:104` — UI gates
These conflate "Partner" (a firm role / job title) with permission-to-mutate (a tool role). m: firm role and tool role must be orthogonal; the firm role is **display only** and **must never gate ops**.
**Decision:** drop the partner half of every gate. Each of these checks becomes "global_admin only". Production impact is zero — no prod row has `role='partner'` today, so nobody loses a capability they actually had.
After-rename gate shape:
```go
// before
if user.Role != "partner" && user.Role != "admin" { return ErrForbidden }
// after
if user.GlobalRole != "global_admin" { return ErrForbidden }
```
If a future tool-role system grants partner-level mutations to specific users, it adds a fresh dimension cleanly (text[] permissions or named enums) without touching `job_title`. YAGNI for now — there are no rows that need it.
**Helper deleted:** the design's first draft kept an `IsPartnerOrGlobalAdmin(u)` helper. It's gone — the gate is just `user.GlobalRole == "global_admin"` everywhere.
### 5. Data migration
Single up migration (023). Idempotent. No backfill data shape worth keeping in code beyond:
```sql
-- 023_split_job_title_and_global_role.up.sql
BEGIN;
-- Add new column with default so existing rows pick up 'standard'.
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS global_role text NOT NULL DEFAULT 'standard';
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_global_role_check;
ALTER TABLE paliad.users
ADD CONSTRAINT users_global_role_check
CHECK (global_role IN ('standard','global_admin'));
-- Promote anyone who currently has role='admin'.
UPDATE paliad.users SET global_role = 'global_admin' WHERE role = 'admin';
-- Wipe role='admin' to NULL (admins no longer carry a job title — they didn't
-- pick one, the column was overloaded). Real job titles for the 2 current
-- admins (m + tester) get fixed up by a separate manual UPDATE inside the
-- same transaction, since we know them and the migration ran end-to-end is
-- the right place to do it.
UPDATE paliad.users SET role = NULL WHERE role = 'admin';
UPDATE paliad.users
SET role = 'Counsel Knowledge Lawyer'
WHERE email = 'matthias.siebels@hoganlovells.com';
-- tester@hlc.de stays role=NULL — it's a synthetic admin account, no real
-- job title. Admin-team UI will render NULL as "—".
-- Rename the column. Doing this last so the explicit UPDATEs above stay
-- readable; if the rename were first, every UPDATE would refer to job_title
-- and the diff is harder to review.
ALTER TABLE paliad.users
RENAME COLUMN role TO job_title;
-- The CHECK (role <> '') from migration 015 must come along to job_title,
-- but with a tweak: NULL is now allowed (admins without a job title).
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE paliad.users
ADD CONSTRAINT users_job_title_check
CHECK (job_title IS NULL OR job_title <> '');
-- can_see_project must follow.
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;
CREATE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
-- Rebuild RLS policies that the CASCADE drops. Identical to migration 021
-- except every `u.role = 'admin'` becomes `u.global_role = 'global_admin'`
-- and every `u.role IN ('partner','admin')` becomes
-- `(u.job_title = 'partner' OR u.global_role = 'global_admin')`.
-- (Full body in the migration file; not reprinted here for brevity.)
COMMIT;
```
Down migration is the symmetric reverse: rename `job_title``role`, copy `global_admin` rows back to `role='admin'`, drop `global_role`, restore the original `can_see_project` body. Ugly but tractable; ~31 rows, no realistic reason to roll back.
### 6. Code surface — where every `'admin'` lives
Inventoried `grep -rn "'admin'\\|\\\"admin\\\"\\|user.Role" --include="*.go" --include="*.sql" --include="*.ts" --include="*.tsx"` and bucketed:
#### A. Global-admin gates (these MUST migrate)
**Go services**
- `internal/services/dashboard_service.go:154,211,241,270` — SQL `$2 = 'admin'`, callers pass `user.Role` as `$2` at lines 186, 219, 250, 279
- `internal/services/agenda_service.go:135,201` — same pattern
- `internal/services/appointment_service.go:487` — same pattern, caller line 492
- `internal/services/project_service.go:725,736,747` — three `visibilityPredicate*` helpers
- `internal/services/deadline_service.go:405``if user.Role == "admin"` short-circuit
- `internal/services/department_service.go:304``requireAdmin`
- `internal/services/user_service.go:149,232,346,388,500,638,641` — bootstrap + IsAdmin + assignment guards
**Go handlers**
- `internal/handlers/onboarding.go:63` — error message
- `internal/handlers/users.go:91` — error message
- `internal/handlers/admin_users.go:75,113` — error message (twice)
**Auth**
- `internal/auth/require_admin.go:19` — comment only
**SQL migrations (existing — for awareness; only migration 023 touches these)**
- `internal/db/migrations/006_visibility.up.sql:33` — historical, superseded
- `internal/db/migrations/007_rls_policies.up.sql:44,58` — historical, superseded
- `internal/db/migrations/018_projects_v2.up.sql:414,497,530,544,548,560,565` — historical, superseded
- `internal/db/migrations/021_fix_function_bodies_after_rename.up.sql:46,126,155` — current live state of `can_see_project` + RLS; migration 023 replaces
**Frontend**
- `frontend/src/client/sidebar.ts:299,309` — sidebar admin-section reveal
- `frontend/src/client/settings.ts:572,582` — admin-only controls on settings page
- `frontend/src/client/admin-team.ts:117,156,177,179,220,221` — table render + sort
- `frontend/src/client/deadlines-detail.ts:190,193,207` — admin/partner gate on UI
- `frontend/src/client/projects-detail.ts:555,1206` — admin/partner gate on UI
#### B. Job-title labels (these stay as `job_title`)
- `frontend/src/admin-team.tsx:74,121` — table header / form label "Rolle"
- `frontend/src/admin-team.tsx:122` — direct-add input still says "role" (rename to `job_title` server-side, label stays "Rolle / Job title")
- `frontend/src/onboarding.tsx:48,52,53,57` — onboarding form label / field name
- `frontend/src/client/admin-team.ts:170,179,309,365,372` — datalist + form handling
- `frontend/src/client/i18n.ts` — every `admin.team.col.role` / `onboarding.role.*` string
The form FIELD NAMES on the wire (`{display_name, office, role, ...}`) become `job_title` after the rename. Both client and server change in one commit.
#### C. Per-project role (NOT touched)
- `internal/services/deadline_service.go:417``pt.role IN ('admin', 'lead')` — this is `project_teams.role`, unrelated.
- All other `pt.role` references in handlers/services.
### 7. API surface
`/api/me` payload before:
```json
{ "id": "…", "email": "…", "role": "admin", }
```
After:
```json
{ "id": "…", "email": "…", "job_title": "Counsel Knowledge Lawyer", "global_role": "global_admin", }
```
`/api/admin/users` and `/api/admin/users/{id}` similarly expose both fields. `PATCH /api/me` accepts `{job_title}` (no `role`); `PATCH /api/admin/users/{id}` accepts `{job_title, global_role}` — server enforces that only existing global_admins can change `global_role`, and refuses to demote the last global_admin (mirror of the existing last-admin protection in `AdminDeleteUser`).
`POST /api/admin/users` accepts `{email, display_name, office, job_title, dezernat, lang}` only — `global_role` defaults to `'standard'`. Promotion is a separate `PATCH` action so it can't be smuggled into create.
Self-service `POST /api/onboarding` accepts `{display_name, office, job_title, dezernat}``global_role` defaults to `'standard'`. The bootstrap path (first row of `paliad.users`) flips `global_role='global_admin'` instead of setting `role='admin'`. Same `pg_advisory_xact_lock(7346298141)` guard.
### 8. UI surface
#### Onboarding (`/onboarding`)
- Field label: "Berufsbezeichnung / Job title" (was "Rolle")
- Field name in DOM: `job_title`
- Datalist suggestions stay (Partner / Associate / PA / Of Counsel / Referendar/in / Trainee / wiss. Mitarbeiter/in / Sekretariat). Add: Counsel, Knowledge Lawyer, Counsel Knowledge Lawyer.
- No `global_role` field — that defaults to 'standard'.
#### Settings (`/einstellungen`)
- "Rolle" → "Berufsbezeichnung / Job title", same input.
- New read-only "Berechtigung / Permission" line below: shows `Standard` or `Global Admin`. Not editable from settings (must use admin page).
#### Admin team (`/admin/team`)
- New column header (after Office, before Dezernat): "Berechtigung / Permission".
- Cell content: badge — `Standard` (neutral) or `Global Admin` (lime, the brand accent).
- Cell behavior: click toggles a dropdown with the two enum values. Saving issues `PATCH /api/admin/users/{id}` with `{global_role}`.
- "Rolle" column heading + cell content stays — but the cell now renders `job_title` (free text, may be NULL → render as "—").
- Direct-add modal: rename "Rolle" input to "Berufsbezeichnung / Job title", drop the special "Associate" default (keep the placeholder), bind `name="job_title"`.
- Sort: existing "admins first" sort key flips to `global_role='global_admin'` first.
- Last-global_admin protection: dropdown disabled (with tooltip) when the row is the last surviving global_admin.
#### Sidebar
- The admin-section reveal in `sidebar.ts:initAdminGroup()` flips the predicate from `me.role === "admin"` to `me.global_role === "global_admin"`.
#### Deadline / project detail pages
- The `me.role === "admin" || me.role === "partner"` gates become `me.global_role === "global_admin" || me.job_title === "partner"` (preserving today's broken-but-harmless behavior; see §4).
### 9. Bootstrap rule
Today: first `paliad.users` row may self-assign `role='admin'`, guarded by `pg_advisory_xact_lock(7346298141)`.
After: first `paliad.users` row may set `global_role='global_admin'`. Same lock, same constant, new column. Onboarding payload includes no `global_role` — the service decides based on the row count and overrides the default 'standard' for the first inserter.
### 10. Backwards compat
**Decision: clean rename, no compat shim.** Justification:
- 31 production rows, all in our control.
- Wire format changes (`role``job_title`, new `global_role`) cross client + server simultaneously in one merge. No staged rollout needed for an internal tool.
- Old session cookies with cached `me.role` values get refreshed on the next `/api/me` call, which the client makes on every page load.
- The `paliad.users.role` column stops existing after migration 023. Any ad-hoc query / report keyed on `role='admin'` breaks loudly — that's the point.
If future me wants compat-during-deploy: add a generated column `role text GENERATED ALWAYS AS (job_title) STORED` for one release, drop in the next. Not doing that now.
### 11. Test plan
**Unit**
- `internal/services/user_service_test.go`
- `Create` with `count=0``global_role='global_admin'` AND `job_title=<input>` (or NULL if empty)
- `Create` with `count>0``global_role='standard'`
- `UpdateProfile` cannot set `global_role` (field absent from `UpdateProfileInput`)
- `AdminUpdateUser` can set `global_role`; rejects when caller is not global_admin (handler-level test); rejects demotion of last global_admin (service-level test, mirror of `AdminDeleteUser`'s last-admin protection)
- `IsAdmin` reads `global_role`
- `internal/auth/require_admin_test.go` — already covers the `IsAdmin` surface; no changes needed beyond the swap of test fixture's seeded column.
**Integration / smoke**
- Manual: log in as tester@hlc.de — confirm sidebar `/admin/team` entry appears, page loads, table shows m as global_admin + "Counsel Knowledge Lawyer" job title.
- Manual: set m's `job_title` via admin page to something else, confirm `global_role` is unchanged.
- Manual: try to demote tester (last global_admin in this case if you've already demoted m) — expect rejection.
- DB-level: `SELECT email, job_title, global_role FROM paliad.users` after migration. Expected:
- 2 rows global_admin (m, tester), m.job_title='Counsel Knowledge Lawyer', tester.job_title IS NULL
- 29 rows standard with job_title='associate'
- `go build ./... && go vet ./... && go test ./...` clean.
- `cd frontend && bun run build` clean.
## Out of scope (recap)
- Fine-grained permissions (`partner`, `billing_admin`) — design leaves room (CHECK can grow; or migrate to `text[]` later) but ships only `global_admin`.
- Cleaning up the "partner" gate conflation (§4) — gate stays job-title-driven for now. File follow-up.
- Permission inheritance from `project_teams` to global — explicitly orthogonal.
- Role-based UI customization beyond hide/show — defer.
## Open questions for m
1. **m's `job_title` value** — task brief says "Counsel Knowledge Lawyer". Confirmed? (Migration writes that exact string.)
2. **tester's `job_title`** — migration sets NULL. Alternative: 'Admin' literal. Lean: NULL — tester is a synthetic admin without a real title; "Admin" as a job title perpetuates the conflation we're solving.
3. **Default `global_role` on new sign-ups beyond the bootstrap** — confirmed `'standard'`.
4. **The 'partner' job-title gate** — leave as-is for this PR? (My recommendation: yes, file follow-up.)
## Implementation phase plan (after greenlight)
Single mai/cronus/separate-job-title-from branch, single PR, single merge to main.
1. `internal/db/migrations/023_split_job_title_and_global_role.{up,down}.sql` — schema + data + can_see_project + RLS rebuild.
2. `internal/models/models.go``User.Role``User.JobTitle` (keep `db:"job_title"` `json:"job_title"`); add `User.GlobalRole string \`db:"global_role" json:"global_role"\``. Make `JobTitle` a `*string` since admins may have NULL.
3. `internal/services/user_service.go` — every `role` reference, including `userColumns`, the bootstrap branch (assign `global_role`), `IsAdmin` (reads `global_role`), `UpdateProfileInput`/`AdminUpdateInput` (drop `Role`, add `JobTitle *string` and `GlobalRole *string` for admin-only).
4. `internal/services/{dashboard,agenda,appointment,project,deadline,department,party,note,checklist_instance}_service.go` — swap the SQL `$N = 'admin'``$N = 'global_admin'` and the call sites pass `user.GlobalRole` instead of `user.Role`. Partner gates change to `user.JobTitle != "partner" && user.GlobalRole != "global_admin"` (per §4).
5. `internal/handlers/{onboarding,users,admin_users}.go` — update error messages; payload field renames.
6. `internal/auth/require_admin.go` — comment update only (the `AdminLookup.IsAdmin` interface is unchanged because it abstracts behind the boolean).
7. `frontend/src/admin-team.tsx`, `frontend/src/onboarding.tsx` — labels + field names (`role``job_title`); add Permission column on admin-team.
8. `frontend/src/client/admin-team.ts`, `frontend/src/client/onboarding.ts`, `frontend/src/client/sidebar.ts`, `frontend/src/client/settings.ts`, `frontend/src/client/deadlines-detail.ts`, `frontend/src/client/projects-detail.ts` — every `me.role === "admin"``me.global_role === "global_admin"`; every form-field `role``job_title`; add the global_role dropdown widget.
9. `frontend/src/client/i18n.ts` — DE+EN strings for "Berufsbezeichnung", "Berechtigung", "Standard", "Global Admin".
10. Tests — update fixtures + add the cases in §11.
Self-merge to main authorized once `go build/vet/test ./...` and `bun run build` are clean and a smoke pass against ydb confirms acceptance §1§9 of the task brief.

View File

@@ -0,0 +1,478 @@
# Design: PWA Mobile BottomNav + Drawer
**Author:** cronus (inventor)
**Date:** 2026-04-26
**Task:** t-paliad-041
**Status:** Design complete — awaiting m's go/no-go before implementation
**Reference:** `~/dev/web/docs/pwa-baseline.md` (canonical PWA pattern across m's web surfaces)
---
## 1. Executive Summary
Paliad's current navigation is **desktop-first**: a 64px collapsed / 240px
expanded left **Sidebar** (with hover/pin) on `≥1024px`, a slide-out
drawer with a top-left hamburger on `<1024px`. This works on a laptop. On
a phone it does not — the hamburger is a long thumb-stretch and every
common action (open Agenda, create a Frist) is two taps deep behind it.
This design adds a **bottom navigation bar** for phones (`<768px`) per
the m-stack PWA baseline:
- 5-slot fixed bottom bar with thumb-reach icons.
- Center slot opens a **slide-up Quick-Add sheet** (Frist / Termin / Projekt).
- Right-most slot opens the existing **mobile sidebar drawer** (no new drawer — we reuse what already works).
- Auto-hides when the on-screen keyboard opens (`visualViewport` watcher).
- Honors `safe-area-inset-bottom` so iOS home-indicator doesn't sit on top of the buttons.
The desktop Sidebar (≥1024px) is unchanged. Tablets (768-1023px) keep
the current hamburger-drawer pattern. Only phones gain BottomNav.
PWA shell items split into "do now" (cheap, required) and "defer to a
follow-up task":
- **Now:** `viewport-fit=cover`, `theme-color`, `apple-mobile-web-app-*`
meta tags so iOS draws under the notch and `safe-area-inset` actually
has values.
- **Later (separate task):** `manifest.json` + icon assets, service worker,
add-to-home-screen prompt UI.
---
## 2. Why These Choices (HLC Patent Lawyer Perspective)
The user is a litigator-in-the-hallway — between meetings, on the train,
in court anteroom. The phone use-case is overwhelmingly **read** rather
than create:
1. *"What's coming up this week?"* → Agenda, Dashboard
2. *"What's the status on this matter?"* → Projekte detail
3. *"Quickly capture a Frist I just got told about"* → create Frist
4. *"What's the Frist for replying to office action X?"* → Fristenrechner (rare on phone)
5. *"Settings / Glossar / Kostenrechner"* → desk activities, rare on phone
The bottom slots therefore optimise for read-heavy, with a single
prominent capture path in the center. Tools/Wissen/Settings live in the
drawer because phone use of those is rare and a one-tap detour is fine.
---
## 3. Slot Layout
**5 slots, decided:**
```
┌─────────────────────────────────────────────────────────┐
│ │
│ [page content] │
│ │
├─────────────────────────────────────────────────────────┤
│ │
│ [🏠] [📁] ╔══[+]══╗ [📅] [☰] │
│ Start Projekte Anlegen Agenda Menü │
│ │
└─────────────────────────────────────────────────────────┘
↑ ↑ ↑ ↑ ↑
Dash Projekte Quick-Add Agenda Drawer
(/dashboard) (/projects) (sheet) (/agenda) (toggle)
```
| Slot | Label DE | Label EN | Target | Icon (reuse from Sidebar.tsx) |
|------|----------|----------|--------|-------------------------------|
| 1 | Start | Home | `/dashboard` | `ICON_GAUGE` |
| 2 | Projekte | Projects | `/projects` | `ICON_FOLDER` |
| 3 | Anlegen | New | (opens sheet) | `ICON_PLUS` (new) |
| 4 | Agenda | Agenda | `/agenda` | `ICON_AGENDA` |
| 5 | Menü | Menu | (opens drawer) | `ICON_MENU` |
### Why Dashboard + Agenda over Dashboard + Fristen
Initial brief proposed `[Dashboard / Projekte / + / Fristen / Menu]` or
`[... / Agenda / Menu]`. **Agenda wins** because:
- Agenda merges Fristen *and* Termine into one date-sorted timeline
(shipped in t-paliad-030). On a phone you almost never want one but
not the other — you want "what's next".
- Fristen is reachable from Agenda items (each row deep-links to
`/deadlines/{id}`) and from the drawer.
- Dashboard already gives the high-level "traffic light" overview
(overdue / today / week / later) — Agenda gives the actionable list
underneath. Pairing them in the BottomNav covers ~80% of phone reads.
### Why Projekte not Termine
Termine alone is too narrow for a top-level slot. A patent lawyer's
mental model is "I'm working on matter X" — Projekte is the natural
hub. Termine is reachable from a project's detail page or from Agenda.
### Active-state highlighting
Same rule the Sidebar already uses (`navItem` active logic): a slot is
active when its `href` is a prefix of `currentPath`. So `/projects/abc`
keeps the Projekte slot lit, `/deadlines/{id}` lights nothing in
BottomNav (deadlines aren't a top-level slot — that's fine, the
breadcrumb still works).
---
## 4. Center Slot: Quick-Add Sheet
A **slide-up sheet** (not a navigation) with three options:
```
┌─────────────────────────────────┐
│ ───── │ ← drag-handle
│ │
│ 📅 Frist anlegen │ → /deadlines/new
│ 🗓 Termin anlegen │ → /appointments/new
│ 📁 Projekt anlegen │ → /projects/new
│ │
│ [Abbrechen] │
└─────────────────────────────────┘
```
### Why a sheet, not a deep-link
| Option | Pros | Cons |
|---|---|---|
| **Sheet w/ 3 options** ✅ | One predictable place; works on every page; matches "primary capture/add" idiom from baseline doc | One extra tap vs deep-link |
| Always `/deadlines/new` | Zero-tap deadline creation | Wrong default ~30% of the time (Termin/Projekt also frequent); no escape if user wanted Termin |
| Context-aware (per page) | Smart defaults | Surprising — same button does different things on different pages; harder to learn |
The sheet is also where new capture types can be added later (Quick
Note, Voice memo) without redesign. Cheap to extend.
### Sheet mechanics (mvp — does *not* fully copy otto-pwa)
- Native `<dialog>` element via `dialog.showModal()`.
- Slide-up via CSS `transform: translateY(100%) → translateY(0)`,
`transition: transform 220ms ease-out`.
- Backdrop tap dismisses (`dialog::backdrop` click handler).
- ESC closes (native `<dialog>` behavior).
- **Drag-to-dismiss is NOT in v1.** The full otto-pwa pointer-event
pattern (handle hit-area + pointermove transform + 120px threshold)
is great but adds ~80 lines for a phone-only flourish. Ship without
it; if m wants it, a follow-up task adds it copying otto-pwa
`voice-modal` verbatim.
- `max-height: 60vh` (we have only 3 rows; 92vh from the baseline doc is
for sheets that contain scrollable lists).
### Tapping a sheet row
Just navigates: `window.location.href = "/deadlines/new"` etc. The
existing `/deadlines/new`, `/appointments/new`, `/projects/new` pages
already work on mobile (form layout is single-column). No new endpoints.
Note: `/projects/new` requires admin in current implementation — for a
non-admin user, that row should be hidden (read `window.__PALIAD_ME__`
or whatever the page exposes; if not exposed, just always show and let
the destination page error gracefully — m's call).
---
## 5. Drawer: Reuse What's There
**No new drawer.** The existing `Sidebar.tsx` already renders into a
fixed-left aside that, at `<1024px`, is `transform: translateX(-100%)`
by default and slides to `translateX(0)` when class `mobile-open` is
toggled. The hamburger button + `.sidebar-overlay` already do the open
mechanics.
The BottomNav `[Menü]` slot wires into the same toggle that the
hamburger uses — they call the same `toggleMobileSidebar()`.
### Hamburger fate
At `<768px` (BottomNav visible): the existing top-left hamburger is
**hidden** (the BottomNav menu slot does the same job, in a thumb-reach
spot). At `768-1023px`: hamburger stays visible, BottomNav stays
hidden — current behavior preserved.
### What's in the drawer
It's the existing Sidebar — Dashboard, Übersicht (Dashboard, Agenda,
Team), Arbeit (Projekte, Fristen, Termine), Werkzeuge, Wissen,
Ressourcen, Einstellungen, plus the bottom block (Neuigkeiten, invite,
DE/EN, Logout). Nothing duplicated, nothing pruned. Items already in
the BottomNav (Dashboard, Projekte, Agenda) also still appear in the
drawer — that's intentional, the drawer is the canonical map.
### Drawer trigger options considered
| Trigger | Verdict |
|---|---|
| BottomNav `[Menü]` slot ✅ | Standard, discoverable, thumb-reach |
| Top-left hamburger (legacy) | Hidden on phones; lives on for tablets |
| Edge-swipe from left | **No** — conflicts with project-detail tabs that already overflow-scroll horizontally on mobile |
| ESC closes | Already implemented via `closeMobile()` |
Matches mBrian/otto pattern: button-triggered, no swipe.
---
## 6. Breakpoints
```
≥1024px : Desktop sidebar (hover-expand, pin)
768-1023px : Slide-out drawer + top-left hamburger (current behavior)
<768px : Slide-out drawer + BottomNav (hamburger hidden)
```
Two distinct thresholds because they answer different questions:
- **1024px** = "is there room for a persistent collapsed sidebar?"
- **768px** = "is this a phone — do we need a thumb-reach bar?"
The existing `1023px` breakpoint stays. We add a new `767px` breakpoint
specifically for showing/hiding BottomNav and hiding the legacy
hamburger.
The pwa-baseline doc says 768px throughout — that's the BottomNav
breakpoint. The doc doesn't mandate the 1024 sidebar threshold; that's
a paliad-specific affordance worth preserving.
---
## 7. Visual Spec
### Bar dimensions
- Height: `56px` (`--bottom-nav-height`, matches baseline doc).
- Background: `var(--color-surface)` (`#ffffff`).
- Top border: `1px solid var(--color-border)`.
- Box-shadow: subtle upward `0 -1px 3px rgba(0,0,0,0.04)` — looks
attached to the screen edge, not floating.
- Position: `fixed; bottom: 0; left: 0; right: 0;`
- Padding-bottom: `env(safe-area-inset-bottom)` — additive to the 56px,
so on iPhone X+ the bar effectively grows to account for the home
indicator without overlapping it.
- Width: 100%, slots `flex: 1`.
- Z-index: `30` (above content, below sidebar overlay z=35 so the drawer
always covers BottomNav, below modals z=100).
### Slot
- 56px tall, full-width slot, vertical icon (~22px) + label (10-11px).
- Active slot: lime accent `var(--color-accent)` icon + label, with a
thin lime top-bar (3px tall) at the slot top edge.
- Inactive slot: `var(--color-text-muted)` icon + label.
- Tap target: full slot — no inner padding gymnastics. iOS HIG ≥44pt;
56px height + ~70px wide slot easily clears that.
### Center slot ([+])
- Visually elevated: a 48px circular lime button raised ~4px above the
bar (negative margin-top), white plus-icon, subtle `box-shadow:
var(--shadow-md)`.
- Same width slot underneath; the circle is decoration, the whole slot
is the tap target.
- This is the only "loud" pattern; matches the baseline doc's
"primary capture/add action" emphasis.
### Quick-Add sheet
- Width: 100vw on mobile, max 480px on tablet (the sheet should never
appear on desktop because the [+] slot only exists on phones, but
cap width as belt-and-braces).
- Border-radius: `16px 16px 0 0` (top corners rounded, bottom flush).
- Padding-bottom: `env(safe-area-inset-bottom)` so the cancel row sits
above the home indicator.
- Backdrop: `rgba(0,0,0,0.5)` via `<dialog>::backdrop`.
### Layout reflow
Pages with `body.has-sidebar` need extra bottom padding on mobile so
the BottomNav doesn't cover the last row of content. New CSS rule:
```css
@media (max-width: 767px) {
body.has-sidebar main {
padding-bottom: calc(var(--bottom-nav-height) + 1rem
+ env(safe-area-inset-bottom));
}
}
```
`main` gets the padding rather than `body` so the BottomNav's own
fixed-position remains glued to the viewport edge.
### Keyboard-open hide
```css
body.keyboard-open .bottom-nav {
transform: translateY(120%);
transition: transform 200ms ease-out;
}
```
Toggle from JS via `visualViewport.height` delta > 100px (see §9).
---
## 8. Files to Add / Change
### New files
| File | Purpose |
|---|---|
| `frontend/src/components/BottomNav.tsx` | TSX component, exports `BottomNav({currentPath, role?})` |
| `frontend/src/client/bottom-nav.ts` | `initBottomNav()` — drawer toggle wiring, sheet open/close, visualViewport keyboard watcher |
### Modified files
| File | Change |
|---|---|
| `frontend/src/components/Sidebar.tsx` | Hamburger button gains a class so CSS can hide it `<768px` (`.sidebar-hamburger.hide-on-phone` or just by media query — no markup change needed). Add `id` on the toggle target so bottom-nav.ts can find/share it. |
| `frontend/src/client/sidebar.ts` | Export `toggleMobileSidebar()` so bottom-nav.ts re-uses the exact same open/close/overlay code (don't duplicate). |
| `frontend/src/client/index.ts` | Add `import { initBottomNav } from "./bottom-nav"; initBottomNav();` |
| `frontend/src/styles/global.css` | Add ~120 lines: `--bottom-nav-height` token, `.bottom-nav` + slot styles, `<768px` media query showing BottomNav and hiding hamburger, keyboard-open transform, `body.has-sidebar main` padding-bottom rule, sheet styles. |
| All page `*.tsx` files (~25) | (a) Replace `<meta name="viewport" content="width=device-width, initial-scale=1.0" />` with `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />`. (b) Add `<BottomNav currentPath="..." />` next to existing `<Sidebar currentPath="..." />` in each page. Easiest as a sed for (a); each page is touched once for (b). |
| `frontend/build.ts` | Add `bottom-nav.ts` entry... — actually `bottom-nav.ts` is imported by `index.ts` so it gets bundled into `index.js` — no separate entry needed. |
### Optionally (low-cost, highly recommended)
| File | Change |
|---|---|
| All page `*.tsx` `<head>` | Add `<meta name="theme-color" content="#65a30d" />` (lime, matches accent) so iOS Safari paints the URL bar lime in standalone mode. |
| All page `*.tsx` `<head>` | Add `<meta name="apple-mobile-web-app-capable" content="yes" />` and `<meta name="apple-mobile-web-app-status-bar-style" content="default" />`. Cheap, no asset dependency. |
These three meta-tag rows are a one-time sed across 25 files; adding
them now means we don't need a follow-up just to redo the sweep.
### NOT in this task
- `manifest.json` and icon assets (192/512 maskable PNGs) → follow-up.
- Service worker / `sw.js` / app-shell caching → follow-up.
- `beforeinstallprompt` add-to-home-screen UI → follow-up.
These are tracked under §11 below as proposed `t-paliad-04*` follow-ups.
---
## 9. Behavior Spec (`bottom-nav.ts`)
```ts
// Pseudo-shape (real impl will follow paliad style — no narration comments).
import { toggleMobileSidebar } from "./sidebar";
export function initBottomNav() {
initDrawerSlot(); // [Menü] tap → toggleMobileSidebar()
initQuickAddSheet(); // [+] tap → dialog.showModal(); rows nav
initKeyboardWatcher(); // visualViewport resize → body.keyboard-open
}
```
### Keyboard watcher (the one tricky bit)
```ts
function initKeyboardWatcher() {
if (!window.visualViewport) return; // older browsers: no-op
const baseHeight = window.innerHeight;
const KEYBOARD_THRESHOLD = 100; // px shrink == keyboard
window.visualViewport.addEventListener("resize", () => {
const delta = baseHeight - window.visualViewport!.height;
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD);
});
}
```
`baseHeight` is captured once at init — re-orientation events update it
via a `window.orientationchange` handler. Edge case: a user who rotates
the phone while the keyboard is open will see the bar reappear briefly
until the keyboard re-deploys. Acceptable.
### Active-tab class on navigation
The TSX component renders the active class server-side from
`currentPath`, identical to Sidebar. No client-side recomputation
needed.
---
## 10. Z-index Map (post-change)
| Layer | z-index | Notes |
|---|---|---|
| Page content | auto | |
| Header | 10 | Existing `.header` |
| **BottomNav** | **30** | New |
| Sidebar overlay (drawer backdrop) | 35 | Existing |
| Sidebar drawer | 40 | Existing |
| Top-left hamburger (legacy, tablet) | 50 | Existing — hidden <768px |
| Quick-Add sheet backdrop | 90 | New (or just rely on `<dialog>::backdrop`) |
| Quick-Add sheet card | 100 | New, same tier as `.modal-overlay` |
| Existing modal-overlay (invite, etc.) | 100 | Existing |
When the drawer is open over the BottomNav: the drawer (z=40) is wider
than the BottomNav (z=30), and its overlay (z=35) sits above the
BottomNav so the BottomNav is fully covered.
When the Quick-Add sheet is open over the BottomNav: sheet (z=100)
sits above; backdrop dims everything below including BottomNav.
---
## 11. Rollout Plan
**Single PR, single coherent commit per phase per task convention:**
1. *Phase 1 (this task, after m's go):* land BottomNav + Quick-Add sheet
+ drawer wiring + viewport-fit meta + theme-color meta. One commit
on this worktree's branch (`mai/cronus/pwa-mobile-bottom-nav`),
self-merge to `main` per t-paliad-038/039/040 precedent.
2. *Verify on mobile breakpoint:* Playwright (`browser_resize` to
375×812 iPhone X) confirm: BottomNav renders, sheet opens, drawer
opens from `[Menü]`, no double-hamburger, content padding leaves the
last item visible above the bar. Login as `tester@hlc.de` to test
the authenticated paths.
3. *Build green:* `bun run build` and `go build ./... && go vet ./... && go test ./...`.
### Follow-up tasks proposed (NOT in this task)
- `t-paliad-04X` `manifest.json` + 192/512 maskable icons + `<link rel="apple-touch-icon">` on every page installable PWA.
- `t-paliad-04Y` `sw.js` network-first cache app-shell strategy (copy from mBrian; keep tiny just `/dashboard` and `/assets/global.css`).
- `t-paliad-04Z` `beforeinstallprompt` UI: a one-time toast ("Add Paliad to your Home Screen?") gated by a localStorage `paliad-pwa-prompt-dismissed` flag.
- `t-paliad-04W` Drag-to-dismiss for Quick-Add sheet (otto-pwa pattern verbatim).
- `t-paliad-04V` Project-detail tabs horizontal-overflow polish (already-known tablet/phone problem; surfaced again here but out of scope).
---
## 12. Open Questions for m
1. **Slot 4: Agenda or Fristen?** Design picks Agenda. Brief offered
either. If you prefer the more old-school Fristen (deadlines only,
no Termine), it's a one-line swap. Recommendation: **Agenda**.
2. **Center [+] slot: sheet or deep-link?** Design picks the 3-option
slide-up sheet. If you prefer to skip the sheet and have [+] always
go to `/deadlines/new` (the most-frequent capture), say so
simpler, no `<dialog>`. Recommendation: **sheet**.
3. **PWA shell items:** Add the 3 meta tags now (viewport-fit,
theme-color, apple-mobile-web-app-capable) but defer manifest +
service worker + install prompt to follow-up tasks?
Recommendation: **yes — meta now, manifest/SW/prompt later.**
4. **`/projects/new` quick-add row visibility:** non-admins can't create
projects. Hide the row for them, or always show and let the page
gracefully error? Recommendation: **always show**, defer the
permission-aware row to a follow-up keeps this PR self-contained
and matches what the Sidebar already does (`Projekte` is shown to
everyone; admin-gating happens on the destination page).
5. **Badge counts on BottomNav slots** (e.g. red-dot on Agenda when an
overdue Frist is due today)? Nice-to-have, not in v1. Out of scope
here. Confirm: **defer to follow-up.**
6. **Tablet (768-1023px) behavior:** keep as-is (hamburger drawer, no
BottomNav)? The pwa-baseline doc draws the line at 768 we honor
it on the BottomNav side. Confirm: **yes, no BottomNav on tablet.**
---
## 13. Acceptance Mapping
| Requirement | How design satisfies |
|---|---|
| Design doc at `docs/design-pwa-bottom-nav.md` | This file |
| BottomNav renders <768px, hidden 768px | §6 + media query in §8 |
| Mobile drawer slides out, mirrors desktop Sidebar | §5 reuses existing Sidebar.tsx + slide-out CSS |
| Keyboard-open hides BottomNav | §9 visualViewport watcher + `body.keyboard-open` CSS |
| safe-area-inset-bottom padding on iOS | §7 dimensions + §8 viewport-fit=cover meta |
| No regression in desktop layout | Desktop 1024px untouched; only `<768px` adds BottomNav and hides hamburger; tablet 768-1023px unchanged |
| Single commit per phase | §11 rollout |

View File

@@ -0,0 +1,615 @@
# Reminder system redesign — zero-overdue SLO, escalation, per-user bundling
**Task:** t-paliad-064 (cronus, inventor)
**Date:** 2026-04-28
**Status:** design — awaiting m's go/no-go before implementation
## Problem statement (from m)
> "Our main purpose is to avoid ANY DEADLINE EVER becoming past due. So we
> remind one week before and on the same day. And if it is not done by the end
> of / late in the day, we need to send another urgent reminder and also
> escalate."
Three things going wrong today:
1. **Timezone bug.** m set morning=09:00 Berlin and got 4 reminder emails this
morning at 11:16 Berlin (= 09:16 UTC). Root cause is below — it is **not**
the bug m suspected.
2. **"Überfällig" wording is wrong.** A deadline due *today* triggered the
`overdue` template, which says *"war heute oder früher fällig"*. "Überfällig"
should mean past today, not today.
3. **Schedule doesn't match the SLO.** Today's design treats overdue as a
normal recurring nudge. m wants overdue to be a system-failure exception:
the day-of escalation must be aggressive enough that we engineer overdues
away.
---
## 1. The actual timezone bug
### What the spec hints
> "11:16 Berlin = 09:00 UTC + ~16min ticker phase. Fix: compute
> `now.In(user.tz).Hour()` and compare against `user.reminder_morning_time.Hour()`."
### What the code already does
[`reminder_service.go:177-198`](../internal/services/reminder_service.go#L177-L198):
```go
func inSlot(now time.Time, tz, morning, evening, slot string) bool {
loc, err := time.LoadLocation(tz)
if err != nil { loc = time.UTC } // <-- silent fallback
local := now.In(loc)
...
return local.Hour() == hour
}
```
The conversion to `local` is already there. So the in-process logic is right.
### The actual root cause
[`Dockerfile:13-14`](../Dockerfile#L13-L14) is `alpine:3.21` with only
`ca-certificates` installed. **The runtime image has no `tzdata` package**
(`/usr/share/zoneinfo` doesn't exist on minimal alpine). Therefore
`time.LoadLocation("Europe/Berlin")` returns an error in production, and
`inSlot` silently falls back to UTC. With `local := now.In(UTC)`, the gate
fires when `now.UTC().Hour() == 9` — which on 2026-04-28 (CEST, UTC+2) is
**11:00 Berlin** plus the per-tick ~16min phase. Exactly what m saw.
This bug is invisible in `go test` on a dev box because Linux/macOS dev
machines have system tzdata. It only manifests in the alpine container.
### Fix
Two small changes; the first is the actual fix, the second is defense-in-depth:
1. **Embed Go's tzdata into the binary.** Add one line to `cmd/server/main.go`:
```go
import _ "time/tzdata"
```
This adds ~450 KB to the binary and makes tz lookups work without OS
`/usr/share/zoneinfo`. No Dockerfile change needed; the binary becomes
self-contained. (Equivalent alternative: `apk add --no-cache tzdata` in
the runtime stage — but the embedded approach also covers any future
stripped-down container.)
2. **Stop falling back to UTC silently.** When `time.LoadLocation(tz)` fails,
log a `slog.Error` and **skip the user this tick** instead of pretending
they live in UTC. Combined with the embedded tzdata, the only way to hit
this branch is a corrupt or empty `reminder_timezone` value — which we
should fix at write time, not paper over at read time.
Add validation at the user-update boundary (`UserService.UpdateReminderTimes`
/ settings handler / admin-team handler): reject empty or unparseable IANA
names with HTTP 400 instead of silently storing them. Existing rows are
safe (NOT NULL DEFAULT 'Europe/Berlin' from migration 022).
### Regression test
`TestInSlot_TZDataAvailable` — explicit check that
`time.LoadLocation("Europe/Berlin")` succeeds in the test binary (with the
new `_ "time/tzdata"` import in `main.go`, this is automatic in any test
that imports the services package transitively). Plus the existing
`TestInSlot` cases against `Europe/Berlin` already cover the conversion
path — they pass today only because dev machines have tzdata; with the
embed, they pass everywhere.
Also: a new test that asserts `inSlot` returns **false** (skip) when `tz`
is empty or invalid — i.e. we no longer silently fall back to UTC.
---
## 2. New deadline categorization
Replace the current per-kind framing (`overdue`/`tomorrow`/`due_today_evening`/
`weekly`) with four mutually exclusive categories, computed in the user's
local tz on each tick:
| Category | Predicate (local date) | Wording (DE) | Wording (EN) | Severity |
|----------------|-------------------------------------|----------------------|-----------------|----------|
| `overdue` | `due_date < today` | "Überfällig" | "Overdue" | red, system-failure framing |
| `due_today` | `due_date == today` | "Heute fällig" | "Due today" | amber |
| `due_this_week`| `due_date in [today+1, today+offset]` (default offset=7) | "Diese Woche" | "This week" | informational |
| `upcoming` | `due_date > today + offset` | "Kommend" | "Upcoming" | not in reminder emails |
Key correction: `due_date == today` is **not** overdue. The string *"war heute
oder früher fällig"* is retired. Today-due deadlines render under "Heute
fällig" in normal slots; under "DRINGEND — heute noch zu erledigen" in the
evening escalation slot.
`reminder_warning_offset_days` (new column, default 7) controls the boundary
between `due_this_week` and `upcoming`. Per-user customisation lives on the
Settings → Notifications page.
---
## 3. New reminder schedule
Replace today's four send paths (`overdue` / `tomorrow` / `due_today_evening`
/ `weekly`) with **two slots × bundled emails**, plus an exception path for
overdues:
| Trigger | When (per user, in user's tz) | Audience | Email subject (DE) | Purpose |
|----------------------|----------------------------------------------------------------|--------------------------------------------------------|----------------------------------------------------------|--------------------------|
| **Morning digest** | morning slot, ANY of: `due_date == today+offset`, `due_date == today`, `due_date < today` (status=pending) | Created_by project lead set (global_admins, only for the overdue section) | `[Paliad] Frist-Erinnerung: N offen` (or ` ÜBERFÄLLIG: N` if any overdue) | Day-of awareness + 1-week heads-up + system-failure flag |
| **Evening escalation** | evening slot, ANY of: `due_date == today` (status=pending), `due_date < today` (status=pending) | Created_by project lead set global_admins | `[Paliad] DRINGEND heute noch offen: N` (or ` SYSTEMAUSFALL` if overdue) | Last call before tomorrow's escalation |
| **Overdue carry** | morning + evening slots, while `due_date < today` and status=pending | (same as escalation; flagged as system failure) | `[Paliad] ÜBERFÄLLIG (System-Eskalation): N` | Until completed |
Carry rule: an overdue deadline appears in *every* slot until completed (both
morning and evening, because it has already breached the SLO). Today-due
deadlines appear in the morning, then in the evening escalation if still
pending.
The current per-kind system is fully replaced. The Monday weekly digest
(`weekly`) is dropped — its job (heads-up of upcoming deadlines) is now done
per-deadline by the +`offset_days` warning, which fires exactly N days before
each deadline rather than lumping them on a Monday.
### Per-trigger SQL predicate (deadline-side, in the user's tz)
For a candidate user U with timezone `tz`, on tick `now`:
```sql
WITH local_today AS (
SELECT (now AT TIME ZONE :tz)::date AS today
)
SELECT f.id, f.title, f.due_date,
CASE
WHEN f.due_date < (SELECT today FROM local_today) THEN 'overdue'
WHEN f.due_date = (SELECT today FROM local_today) THEN 'due_today'
WHEN f.due_date = (SELECT today FROM local_today) + :offset_days * INTERVAL '1 day' THEN 'due_warning'
ELSE NULL
END AS category
FROM paliad.deadlines f
WHERE f.status = 'pending'
AND (
f.due_date < (SELECT today FROM local_today)
OR f.due_date = (SELECT today FROM local_today)
OR f.due_date = (SELECT today FROM local_today) + :offset_days
)
AND <visibility predicate for U>
```
In the evening slot, drop the `due_warning` branch (the +N-days heads-up is a
morning-only signal):
```sql
WHERE f.status = 'pending'
AND (f.due_date < today_local OR f.due_date = today_local)
```
### Audience computation
Three audience predicates compose the recipient set:
```sql
-- 1) The deadline's creator
created_by = U.id
-- 2) Project leadership along the project's hierarchy path
EXISTS (
SELECT 1
FROM paliad.project_teams pt
JOIN paliad.projects pp ON pp.id = ANY(string_to_array(p.path, '.')::uuid[])
WHERE pt.user_id = U.id
AND pt.project_id = pp.id
AND pt.role = 'lead'
)
-- 3) Global admin (system escalation channel)
U.global_role = 'global_admin'
```
For a given (slot, deadline, candidate user U), U is a recipient iff:
- **`due_warning` category:** `created_by(U)` OR `project_lead(U)` — early heads-up for the team that owns it, not for global escalation.
- **`due_today` morning:** `created_by(U)` OR `project_lead(U)` — same.
- **`due_today` evening (DRINGEND):** `created_by(U)` OR `project_lead(U)` OR `global_admin(U)` — the day is closing, time to escalate.
- **`overdue` (any slot, system-failure):** `created_by(U)` OR `global_admin(U)` (+ future per-user `escalation_contact_id`) — owner and the escalation channel; project leads no longer help here, the system failed.
The wider audience for the urgent and overdue tiers is intentional:
*one* person forgetting is the failure mode we want to engineer away, so by
the evening of the due day, multiple eyes are on it.
---
## 4. Bundling: one email per user per slot
**Today:** N pending deadlines → N reminder emails (m got 4 this morning).
**New:** 1 email per (user, slot, local date). The email body is grouped by
category, in fixed order:
1. ÜBERFÄLLIG (red banner, system-failure framing) — only if any
2. DRINGEND — heute noch offen (amber, evening only) / Heute fällig (amber, morning) — only if any
3. In einer Woche fällig (informational, morning only) — only if any
Each section is a table of (Frist title, Akte reference, due-date,
"Open in Paliad" link) — the same row shape as today's `deadline_weekly.html`.
If all three sections are empty for a user in a given slot, no email is sent.
### Dedup
Per `(user_id, slot, local_date)`, not per deadline. A new column on
`paliad.reminder_log`:
```sql
ALTER TABLE paliad.reminder_log
ADD COLUMN IF NOT EXISTS slot text, -- 'morning' | 'evening'
ADD COLUMN IF NOT EXISTS slot_date date; -- user-local date
CREATE UNIQUE INDEX reminder_log_slot_dedup_idx
ON paliad.reminder_log (user_id, slot, slot_date)
WHERE slot IS NOT NULL;
```
The unique index — partial on `slot IS NOT NULL` — coexists with the legacy
`(user_id, reminder_type, deadline_id)` rows still on disk. The CHECK
constraint on `reminder_type` widens to allow the new `'morning_digest'` /
`'evening_digest'` values:
```sql
ALTER TABLE paliad.reminder_log
DROP CONSTRAINT IF EXISTS reminder_log_reminder_type_check;
ALTER TABLE paliad.reminder_log
ADD CONSTRAINT reminder_log_reminder_type_check
CHECK (reminder_type IN ('overdue','tomorrow','weekly','morning_digest','evening_digest'));
```
Ordering in the row when inserted by the new code: `slot` is the canonical
field; `reminder_type = slot || '_digest'` is set for backward-compatibility
with anything querying by type. `deadline_id` is NULL on digest rows.
### Local-date math for dedup
The dedup key uses the user's local date, not server-UTC. So a user in
`Pacific/Auckland` whose morning slot fires at 18:00 UTC the previous day
gets dedup'd against the local "tomorrow" — a second tick at 19:00 UTC that
same evening (= local 09:00 next day) is a new local_date and would fire
again only if their morning_time is 09:00 (which it won't be at 19:00 UTC).
The (slot, slot_date) tuple resolves the boundary cleanly.
---
## 5. Email layout (bundled)
Single new template `deadline_digest.html` replaces the three current ones
(`deadline_reminder.html`, `deadline_due_today.html`, `deadline_weekly.html`).
Skeleton:
```
{{define "content"}}
{{if .HasOverdue}}
<h1 style="color:#b91c1c">{{t "ÜBERFÄLLIG" "Overdue"}} ({{.OverdueCount}})</h1>
<p>{{t "Folgende Fristen sind nicht rechtzeitig erledigt worden. Diese E-Mail geht an die Eskalations­kontakte."
"These deadlines were not completed on time. This email goes to the escalation contacts."}}</p>
{{template "deadline-table" .OverdueItems}}
{{end}}
{{if .HasDueToday}}
<h1 style="color:#b45309">
{{if .Slot "evening"}}{{t "DRINGEND — heute noch offen" "URGENT — still open today"}}
{{else}} {{t "Heute fällig" "Due today"}}{{end}}
({{.DueTodayCount}})
</h1>
{{template "deadline-table" .DueTodayItems}}
{{end}}
{{if .HasWarning}}
<h2>{{t "In einer Woche fällig" "Due in one week"}} ({{.WarningCount}})</h2>
{{template "deadline-table" .WarningItems}}
{{end}}
<p style="margin-top:24px;">
<a href="{{.DeadlinesURL}}">{{t "Alle Fristen" "All deadlines"}}</a>
</p>
{{end}}
```
The shared `deadline-table` partial renders one row per deadline, similar to
today's `deadline_weekly.html` table, plus an "owner" column when the
recipient isn't the deadline's `created_by` (so a project lead seeing a
team-mate's deadline can immediately tell whose plate it's on).
### Subject line
```
DE morning, no overdue: [Paliad] Frist-Erinnerung: 3 offen
DE morning, with overdue: [Paliad] ÜBERFÄLLIG: 1 — plus 3 weitere
DE evening, no overdue: [Paliad] DRINGEND — 2 heute noch offen
DE evening, with overdue: [Paliad] SYSTEMAUSFALL: 1 überfällig — plus 2 heute offen
```
Subjects are deliberately scary when overdue is in the bundle — the SLO
*is* "no overdues, ever".
---
## 6. Schema changes — migration **025**
(Note: the task brief says "migration 024", but `024_rename_department_columns.up.sql`
already exists. The new migration is 025.)
```sql
-- 025_reminder_redesign.up.sql
-- 1) Per-user warning offset (default 7).
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS reminder_warning_offset_days
INT NOT NULL DEFAULT 7
CHECK (reminder_warning_offset_days BETWEEN 1 AND 30);
-- 2) Optional escalation contact (deferred wiring; column ships now to
-- avoid a follow-up migration if m says yes within a sprint).
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS escalation_contact_id UUID
REFERENCES paliad.users(id) ON DELETE SET NULL;
-- 3) Slot-based dedup on reminder_log.
ALTER TABLE paliad.reminder_log
ADD COLUMN IF NOT EXISTS slot TEXT,
ADD COLUMN IF NOT EXISTS slot_date DATE;
ALTER TABLE paliad.reminder_log
DROP CONSTRAINT IF EXISTS reminder_log_slot_check;
ALTER TABLE paliad.reminder_log
ADD CONSTRAINT reminder_log_slot_check
CHECK (slot IS NULL OR slot IN ('morning','evening'));
ALTER TABLE paliad.reminder_log
DROP CONSTRAINT IF EXISTS reminder_log_reminder_type_check;
ALTER TABLE paliad.reminder_log
ADD CONSTRAINT reminder_log_reminder_type_check
CHECK (reminder_type IN ('overdue','tomorrow','weekly','morning_digest','evening_digest'));
CREATE UNIQUE INDEX IF NOT EXISTS reminder_log_slot_dedup_idx
ON paliad.reminder_log (user_id, slot, slot_date)
WHERE slot IS NOT NULL;
```
### Backfill
None needed for `reminder_warning_offset_days` (default 7 picks up existing
rows). `escalation_contact_id` is NULL by default → behaves as "use
global_admins" in code.
For `reminder_log`: legacy rows have `slot=NULL` and are ignored by the new
dedup index (partial). The new code only queries via the partial-index
predicate. Old rows are kept for audit; a follow-up housekeeping migration
can prune them after the new path runs for a week.
### Down migration
`025_reminder_redesign.down.sql` drops the index, both columns, the new
constraint variant, and restores the previous CHECK with only the original
three values. Reversible.
---
## 7. Settings UI changes
Settings → Notifications gains one new control. The existing morning/evening
times and DE/EN toggles stay.
```
[ ] Master toggle: Erinnerungen aktiv
Morgen-Slot [09:00]
Abend-Slot [16:00]
Zeitzone [Europe/Berlin ▼]
Vorwarnung [7] Tage ← NEW (130)
"Wir erinnern Sie diese viele Tage vor jeder Frist."
```
Backend: `PATCH /api/me/preferences` already accepts a JSON body for
`reminder_morning_time` etc. ([settings.ts:338-340](../frontend/src/client/settings.ts#L338-L340));
add `reminder_warning_offset_days: number` to the same payload.
Validation: integer in [1, 30]; reject anything else with HTTP 400. The
`reminder_timezone` field also gains stricter validation (reject empty
string, reject anything `time.LoadLocation` can't parse) — the same
validator used by the new tz-fix.
### Escalation contact (deferred)
A `<select>` populated from team users would expose
`escalation_contact_id`. Defer the UI to a follow-up task; the column ships
now so wiring it later doesn't need a second migration.
---
## 8. ReminderService rewrite shape
The existing `sendPerFrist` (per-deadline kind scan) and `sendWeekly`
(Monday digest) are both retired. Replaced by a single
`runSlotForUser(ctx, now, user, slot)` per (user, slot) pair the tick
matches.
Loop shape:
```go
func (s *ReminderService) RunOnce(ctx context.Context) {
now := s.clock()
users, _ := s.users.ListAll(ctx) // small table, untouched today
for _, u := range users {
for _, slot := range []string{"morning", "evening"} {
if !inSlot(now, u, slot) { continue }
if alreadySentToday(ctx, u, slot){ continue }
if !s.preferenceAllows(u, slot) { continue }
s.runSlotForUser(ctx, now, u, slot)
}
}
}
```
`runSlotForUser`:
1. Compute `today_local` from `now` and `u.reminder_timezone` (errors → log + skip; no UTC fallback).
2. Pull `overdue`, `due_today`, `due_warning` deadlines for `u`'s recipient set (one query joining `paliad.deadlines`, `paliad.projects`, audience predicates from §3).
3. If the result is empty → skip.
4. Render `deadline_digest` with the categorized buckets.
5. Send. Insert dedup row (user_id, slot, today_local).
The recipient query unifies all three audience predicates — the user is a
recipient iff *any* of the three matches:
```sql
WHERE
-- created_by
f.created_by = $1
OR
-- project lead on path
EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.role = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]))
OR
-- global admin, but only when category is overdue / urgent
( $2 = TRUE -- :is_global_admin
AND ( f.due_date < $3 -- overdue
OR ($4 = 'evening' AND f.due_date = $3) ) -- urgent today, evening only
)
```
Per-category eligibility is computed in Go after the SELECT, so the
"global_admin only sees overdues / urgent" rule isn't smuggled into SQL.
---
## 9. Test plan
### Existing tests to preserve
- `TestReminderEnabled` — JSON preference parsing, unchanged.
- `TestSlotForKind` — drop, kinds collapse to two slots; replace with `TestSlotMapping` over `('morning','evening')`.
- `TestMatchesLocalDueDate` — replaced by `TestCategorize` over the four categories.
### New unit tests
| Test | What it locks down |
|------|--------------------|
| `TestTZDataEmbedded` | `time.LoadLocation("Europe/Berlin")` succeeds in the test binary — guards against losing the `_ "time/tzdata"` import |
| `TestInSlot_InvalidTzSkips` | `inSlot(_, "", _, _, _) == false` and `inSlot(_, "Mars/Olympus", _, _, _) == false` (no UTC fallback) |
| `TestCategorize_Boundaries` | due_date exactly today → `due_today` (not `overdue`); exactly today+offset → `due_warning`; today-1 → `overdue` |
| `TestBundleEmpty_NoSend` | a user with zero matching deadlines in their slot gets no email and no log row |
| `TestBundleMultiCategory` | one user with overdue + due_today + warning → exactly one email, three sections, one log row |
| `TestDedupBySlotDate` | a second tick in the same slot+local-date → no second send |
| `TestDedupRollsOverAtMidnight` | freezing the clock to advance the user's local date past midnight → next slot fires again |
| `TestRecipientSet_OwnerOnly` | non-admin, non-lead user gets only their own deadlines |
| `TestRecipientSet_ProjectLead` | a project lead sees a team-mate's deadline alongside their own in the same email |
| `TestRecipientSet_GlobalAdmin` | a global_admin sees the overdue section but not the warning section |
| `TestEscalationContactFallback` | when `escalation_contact_id` is NULL, global_admins fill the role; when set, the chosen user receives instead |
| `TestSubjectLine_OverdueFraming` | overdue presence flips the subject from "Frist-Erinnerung" to "ÜBERFÄLLIG"/"SYSTEMAUSFALL" |
### TZ-fix regression test (the headline acceptance)
`TestInSlot_BerlinAt0900_NotAt1100` — set `now = 2026-04-28 07:00:00 UTC`,
`tz = "Europe/Berlin"`, `morning = "09:00"`. Asserts `inSlot(...) == true`
(09:00 Berlin). Same now with `morning = "11:00"` → false. Same now without
the `_ "time/tzdata"` import would (today) fail; with the import it passes.
Plus an *integration* test against the email-send path: with `mailSvc`
disabled (Enabled()=false), `RunOnce` at 07:00 UTC writes a dedup row for
user m at slot=morning, slot_date=2026-04-28 — but does **not** write one
when `now = 09:16 UTC` (= 11:16 Berlin), the bug's signature.
### Smoke (manual)
1. Set `tester@hlc.de`'s morning_time to 09:00, tz=Europe/Berlin.
2. Create three deadlines: due 2026-04-21 (overdue), 2026-04-28 (today), 2026-05-05 (today+7).
3. Trigger `RunOnce` at simulated `now = 2026-04-28 07:05 UTC` (= 09:05 Berlin).
4. Verify: one email to tester@hlc.de with three sections; subject contains
"ÜBERFÄLLIG"; one row in `paliad.reminder_log` with slot=morning,
slot_date=2026-04-28.
5. Trigger again at `08:05 UTC` (= 10:05 Berlin) → no second email
(out-of-slot).
6. Trigger at `14:05 UTC` (= 16:05 Berlin) → evening email arrives,
"DRINGEND" wording on the today-due section, overdue section repeated.
7. Mark the today-due deadline as completed; trigger at next morning's
slot → only the overdue remains (system-failure framing); deadlines
completed in the meantime are gone.
---
## 10. Migration plan
1. **PR 1 (this design + tz fix only):** add `_ "time/tzdata"` to `cmd/server/main.go`; tighten `inSlot` to skip on bad tz; add tz validation on user save. **Ships fast** — fixes m's prod 11:16 surprise without waiting for the full redesign. Existing schedule remains functional.
2. **PR 2 (schema):** migration 025 (warning_offset_days, escalation_contact_id, reminder_log slot/slot_date). Idempotent, additive only. Deployed via Dokploy auto-deploy on merge to main.
3. **PR 3 (service rewrite):** new `runSlotForUser`, new `deadline_digest` template, retire `sendPerFrist`/`sendWeekly` and the three legacy templates. Backward-compatible during deploy: the new code only writes `slot/slot_date` rows; the old code wrote `(reminder_type, deadline_id)` rows. There's no overlap window (old code is replaced, not run in parallel).
4. **PR 4 (settings UI):** expose `warning_offset_days` on Settings → Notifications. Optional: escalation_contact dropdown if scope holds.
5. **Cleanup follow-up (separate task):** prune legacy reminder_log rows older than 30 days; remove old templates; remove `sendWeekly` test scaffolding.
A single PR for #2-4 is also reasonable if the diff stays under ~600 lines.
The tz fix in PR 1 should ship first, isolated.
---
## 11. Open questions for m
1. **What does "project_team admins" mean?** The `paliad.project_teams.role`
enum is `lead | associate | pa | of_counsel | local_counsel | expert |
observer` — there is no `admin` role. My proposal: notify `role = 'lead'`
only. Alternative: notify `role IN ('lead','associate')`. Or: notify
everyone on the team's path (closest to existing visibility semantics,
but spammy for observers).
2. **Drop the Monday weekly digest?** The new per-deadline +7-day warning
covers the same job (heads-up of upcoming deadlines), more precisely
(each deadline gets its warning on its own +7 day, not lumped on Mondays).
Proposal: drop the Monday digest. If you'd like to keep it as an
additional weekly summary (different content from per-deadline warnings,
e.g. "everything in your next 30 days"), say so.
3. **Escalation contact UI in this scope or deferred?** Column ships in
migration 025 either way. UI dropdown pulls in user-search — could fit
in PR 4 or be its own follow-up.
4. **`due_warning` recipients — owner only, or owner + leads?** Spec says
"created_by + project_team admins". I've matched that (owner leads).
Confirm that's wider than you intended, or accept.
5. **Calendar-aware skipping (weekends, holidays).** The spec asks me to
note this as a known gap — it's not in scope. The `paliad.holidays`
table already exists (used by Fristenrechner). A future enhancement
could shift the +7 warning earlier when day 7 falls on a weekend, and
skip the morning slot on weekends/holidays for low-severity categories.
Overdue and DRINGEND should still fire — those are the SLO-critical
ones.
6. **Per-deadline custom reminder offset (defer).** Today the offset is
per-user. m might eventually want per-deadline override (e.g., "this
filing deadline needs a +14 warning"). Out of scope for this round;
noting for the backlog.
7. **One canonical worry:** the morning email when there's *no* overdue
and *no* due-today and *no* +7 warning — i.e. nothing — does **not**
send. Confirm that's what you want (no "everything's quiet" ack
email). I'm proposing yes-skip; an empty-state daily ack email is
noise.
---
## 12. Acceptance criteria (mirrored from task brief)
- `tester@hlc.de` morning=09:00 Berlin → ticker fires at 09:xx Berlin (= 07:xx UTC) and never at 11:xx
- A deadline due today + still pending → "Heute fällig" bundled email at 09:00 (one email even with 4 such deadlines), then "DRINGEND" at 16:00 if still pending
- A deadline that escapes to tomorrow uncompleted → "ÜBERFÄLLIG (System-Eskalation)" framing, sent to created_by + global_admins
- Settings page exposes `morning_time` + `evening_time` + `warning_offset_days`
- `go build/vet/test` clean, `bun run build` clean, regression tests for tz + bundle dedup
- Self-merge to main authorised on PR-by-PR basis
---
## 13. Out of scope (per task brief)
- WhatsApp / SMS / push escalation channels — defer
- Per-deadline custom reminder offset — defer
- Calendar-aware skipping (weekends, holidays) — noted as known gap (§11.5)

View File

@@ -1,486 +1,216 @@
# patholo.de Feature Roadmap
# Paliad Feature Roadmap
**Author:** cronus (inventor) | **Date:** 2026-04-14
**Task:** t-patholo-011
**Author:** cronus (inventor) | **Original date:** 2026-04-14 | **Rewritten:** 2026-04-17 (Phase J, after KanzlAI integration)
**Task:** t-paliad-013 (rewrite); originally t-patholo-011
---
## Strategic Position
patholo.de is a **knowledge platform**, not case management. KanzlAI handles case tracking, deadlines, billing. patholo.de is where HL patent lawyers go to **find tools, templates, guides, and answers** — the internal Wikipedia + toolkit for patent practice.
Paliad is the **all-in-one platform for HLC patent practice**: knowledge tools plus Aktenverwaltung, behind one sidebar, one URL, one login.
The goal: every new associate's first bookmark, every partner's quick-reference, every PA's template source.
It grew out of a pure knowledge platform (patholo.de, Q1 2026) and absorbed the KanzlAI case-management prototype on 2026-04-16 after the HL → HLC merger. The goal stays the same: every new associate's first bookmark, every partner's quick-reference, every PA's template source — and now also the place where a lawyer checks their next Frist before looking up the relevant UPC fee.
### Audience
- Patent lawyers and PAs across 7 offices (Munich, Dusseldorf, Hamburg, Amsterdam, London, Paris, Milan)
- Mix of German and English speakers
- Patent lawyers and PAs across 7 offices (Munich, Düsseldorf, Hamburg, Amsterdam, London, Paris, Milan)
- Mix of German and English speakers — DE/EN toggle on every page
- Range from senior partners to new associates
- Daily work: drafting submissions, calculating costs, tracking deadlines, researching case law
- Daily work: drafting submissions, calculating costs, tracking deadlines, managing matters, researching case law
### What We Have (v1)
### What We Have (shipped — April 2026)
| Feature | Status |
|---|---|
| Supabase auth (@hoganlovells.com) | Live |
| Prozesskostenrechner (DE/UPC/EPA) | Live |
| Fristenrechner (deadlines with holiday adjustment) | Live |
| Downloads (HL Patents Style.dotm) | Live |
| Sidebar navigation | Live |
| i18n DE/EN | Live |
| Lime green branding | Live |
| Feature | Phase | Status |
|---|---|---|
| Supabase auth (@hoganlovells.com gate) | v1 | Live |
| Prozesskostenrechner (DE / UPC / EPA) | v1 | Live |
| Fristenrechner (deadlines with holiday adjustment) | v1 | Live |
| Lime-green branding + DE/EN i18n + sidebar | v1 | Live |
| File proxy + Downloads page (HL Patents Style.dotm) | 1.2 | Live |
| Link Hub with curated categories + youpc.org case-law link | 1.1 / 2.3 | Live |
| Gebührentabellen (GKG / RVG / UPC / EPA / PatKostG) | 1.3 | Live |
| Patentglossar (DE/EN, searchable) | 1.4 | Live |
| Kostenrechner enhancements (PDF export, URL sharing, scenario comparison) | 1.5 | Live (partial — Prozesskostensicherheit pending) |
| Gerichtsverzeichnis (court directory) | 2.2 | Live |
| Checklisten (interactive filing checklists) | 2.4 | Live |
| **Akten** (matter management, office-scoped visibility, collaborators) | 0.1 (AD) | Live |
| **Fristen** (persistent deadline management, traffic-light cards, calendar) | 0.2 (E) | Live |
| **Termine** (appointments, calendar view) + **CalDAV sync** (AES-GCM at rest) | 0.3 (F) | Live |
| **Dashboard** (logged-in landing, server-rendered) | 0.4 (G) | Live |
### Design Principles
1. **Content is king** — tools bring people in, content keeps them coming back
2. **Self-serve over manual curation** — prefer structured data + search over hand-maintained pages
3. **Complement, don't compete** — KanzlAI does case management; patholo does knowledge
4. **Ship incrementally** — each feature is independently useful
5. **German content quality matters** — proper Umlaute, legal precision, no machine-translation feel
1. **Knowledge and practice live together** — tools and content bring people in; Aktenverwaltung keeps them there daily.
2. **Office-scoped by default** — an Akte belongs to one office; cross-office access is explicit via collaborator lists or a partner-toggled firm-wide flag. No "everyone sees everything" and no multi-tenancy machinery. See `docs/design-kanzlai-integration.md` §2.
3. **Self-serve over manual curation** — prefer structured data + search over hand-maintained pages.
4. **Ship incrementally** — each feature is independently useful.
5. **German content quality matters** — proper Umlaute, legal precision, no machine-translation feel.
6. **HTML-first, JS-enhanced** — server-rendered TSX with per-page client TS bundles. No react-query, no heavy client frameworks.
---
## Phase 0: Aktenverwaltung Foundation (shipped April 2026)
Ported and rebuilt from the retired KanzlAI prototype. Detailed phase breakdown and acceptance criteria live in `docs/design-kanzlai-integration.md` §8 (Phases AJ); this roadmap is the user-facing summary.
### 0.1 Akten (Matter Management) — office-scoped ✅ Done (2026-04-16, Phases AD)
Persistent Akten (previously "cases" / "Mandate") with Parteien, audit trail (Verlauf), and per-Akte visibility. Every Akte has an `owning_office`, an explicit `collaborators` list, and a partner-togglable `firm_wide_visible` flag. Visibility is enforced both in Supabase RLS (`paliad.can_see_akte(akte_id)`) and at the application layer for defense in depth.
Shipped in Phases A (schema + RLS), B (services + sqlx pool), C (Fristenrechner → DB), D (Akten CRUD + onboarding + collaborator picker).
### 0.2 Fristen (Persistent Deadline Management) ✅ Done (2026-04-16, Phase E)
Persistent Frist list with traffic-light cards (rot / amber / grün / grau), detail page, month calendar, bulk-import from Fristenrechner ("Als Frist(en) speichern"). Visibility inherits from the parent Akte. Every mutation appends an `akten_events` row.
### 0.3 Termine + CalDAV Sync ✅ Done (2026-04-17, Phase F)
Termine CRUD (dual-mode: Akte-attached or personal), list/detail/calendar views, per-user CalDAV configuration. Bidirectional sync with a per-user goroutine on a 60s tick. Credentials encrypted at rest with AES-GCM keyed off `CALDAV_ENCRYPTION_KEY` (KanzlAI audit §1.3 fix). Passwords never returned in API responses.
### 0.4 Dashboard (Logged-in Landing) ✅ Done (2026-04-16, Phase G)
Server-rendered `/dashboard` for authenticated users: Frist summary (traffic lights), Akten summary, upcoming Fristen and Termine (7d), recent Verlauf. Zero client-side waterfall (audit §2.3 fix).
### 0.5 AI-assisted Frist-Extraktion — Deferred (Phase H)
Anthropic-based extraction of Fristen from uploaded court documents. **Not in current scope** — decision by m on 2026-04-16: "We don't want Anthropic API. We put this off for a while." The Dokumente tab on Akten detail stays as a "Kommt bald" placeholder. No `ANTHROPIC_API_KEY` needed on Dokploy today.
Open when revisiting: document upload + Supabase Storage alone (without AI) may still be worth shipping as a standalone Dokumente feature.
### 0.6 Notizen (polymorphic) — Pending (Phase I)
Schema exists (migration 005: `paliad.notizen` with polymorphic FK + CHECK constraint, RLS inherits from parent). Service, handlers, and UI component not yet shipped. Sized at ~4h in the integration design; pick up when cross-cutting notes become the next friction point.
---
## Phase 1: Foundation (Low effort, High impact)
These features can each be built in a single session. They fill obvious gaps and immediately increase the platform's daily utility.
Feature specs from the original roadmap. Items marked **✅ Done** were shipped in the pre-Aktenverwaltung April 2026 content push.
### 1.1 Link Hub ("Nützliche Links")
### 1.1 Link Hub ("Nützliche Links") ✅ Done (2026-04-14)
**What:** Curated, categorized page of external links relevant to daily patent practice.
Curated, categorized page of external links relevant to daily patent practice. Categories cover Gerichte & Ämter, Recherche, UPC, Gesetze, and HL Intern. Lives at `/links`. Includes the youpc.org case-law entry that replaces the dropped §2.3 item. Users can suggest new links via an inline form.
**Categories:**
- **Gerichte & Ämter** — UPC CMS (cms.unifiedpatentcourt.org), UPC Register, EPO (epo.org), DPMA (dpma.de), BPatG
- **Recherche** — Espacenet, DPMA-Register, DEPATISnet, Google Patents, Patentscope (WIPO)
- **UPC** — Rules of Procedure, Schedule of Fees, Practice Directions, UPC website, Bristows UPC Hub
- **Gesetze** — PatG, EPÜ, UPCA, GKG, RVG, ZPO (dejure.org links)
- **HL Intern** — SharePoint UPC Vault, UPC Playbook, UPC Knowledge Bank, DraftXPress
### 1.2 More Downloads ✅ Done (page shipped — content pending)
**Why:** Lawyers waste time finding the right URL. A curated link page with one-click access is the simplest high-value feature. Every major law firm knowledge platform starts here.
Dedicated `/downloads` page with card-grid layout shipped (2026-04-14, `fd25998`). Current registry still holds only **HL Patents Style.dotm** — adding BuildingBlocks, legal writing templates, and the original Patentprozesskostenrechner.xlsm is a one-line registry edit per file, pending content selection from mWorkRepo.
**Implementation:**
- New page: `/links`
- Static data in Go (map of categories -> links), served as JSON
- TSX page with category sections, each link as a card with icon + title + short description
- Add to sidebar navigation
- i18n for category names and descriptions
### 1.3 Gebührentabellen (Fee Schedule Reference) ✅ Done (2026-04-14)
**Effort:** ~2 hours | **Impact:** High (daily use)
Interactive, tabbed fee schedule reference at `/tools/gebuehrentabellen`: GKG / RVG / UPC / EPA / PatKostG, with Streitwert quick-lookup and sortable tables per schedule version.
---
### 1.4 Patentglossar (DE/EN) ✅ Done (2026-04-14)
### 1.2 More Downloads
Searchable bilingual glossary at `/glossar` with client-side filter, category tags (prosecution / litigation / UPC / EPA), and a "Begriff vorschlagen" feedback form. Loaded from static JSON at server startup.
**What:** Expand the downloads page with more templates and documents from mWorkRepo.
### 1.5 Kostenrechner Enhancements ✅ Partial (2026-04-14)
**Files to add (immediate candidates):**
- HL Patents Style.dotm (already live)
- BuildingBlocks library files (from mWorkRepo/6 - material/Templates/Word/Blocks/)
- Legal writing templates (from mWorkRepo/6 - material/Legal Writing/)
- Patentprozesskostenrechner.xlsm (the original Excel calculator — some people prefer Excel)
**Why:** The downloads page exists but has only one file. The file proxy infrastructure is already built and supports multiple files via the registry. Adding files is literally adding map entries.
**Implementation:**
- Add entries to `fileRegistry` in `internal/handlers/files.go`
- Update downloads page TSX with cards per file
- Group downloads by category (Templates, Rechner, Leitfäden)
**Effort:** ~1 hour | **Impact:** Medium (removes a SharePoint dependency)
---
### 1.3 Gebührentabellen (Fee Schedule Reference)
**What:** Browsable, interactive fee tables for GKG, RVG, PatKostG, and UPC fee schedules.
**Content:**
- GKG fee brackets (2005, 2013, 2021, 2025, Aktuell) — what's the 1.0 fee for a given Streitwert?
- RVG fee brackets (same versions)
- UPC fee schedule (pre-2026 vs. 2026+, with SME discount)
- EPA fees (opposition, appeal, grant)
- Common multipliers reference (3.0x LG, 4.0x OLG, etc.)
- PatKostG fixed fees (BPatG, DPMA)
**Why:** Lawyers regularly need to look up a specific fee without running the full calculator. The Kostenrechner is great for full scenarios, but sometimes you just need "what's the 1.0 RVG fee for Streitwert 2M?" — a reference table answers that in 2 seconds.
**Implementation:**
- New page: `/tools/gebuehrentabellen`
- Reuse data from `internal/calc/fee_tables.go` — expose via new API endpoint
- TSX page with tabbed view (GKG | RVG | UPC | EPA | PatKostG)
- Streitwert input for quick lookup
- Sortable/filterable table per schedule version
**Effort:** ~4 hours | **Impact:** Medium-High (replaces Excel lookup)
---
### 1.4 Patentglossar (Patent Glossary DE/EN)
**What:** Searchable bilingual glossary of patent law terminology.
**Content (examples):**
- Streitwert / Amount in dispute
- Nichtigkeitsklage / Nullity action
- Patentverletzung / Patent infringement
- Unterlassungsanspruch / Injunctive relief
- Schadensersatz / Damages
- Beschwerde / Appeal
- Einspruch / Opposition
- Schriftsatz / Written submission
- Merkmalsgliederung / Feature breakdown
- Vertraulichkeitsklub / Confidentiality club
- Einstweilige Verfügung / Preliminary injunction
- Gebrauchsmuster / Utility model
- ... (50-100 terms initially)
**Why:** Cross-border teams constantly need precise DE<->EN translations for legal terms. Google Translate is dangerous for legal terminology. A curated glossary prevents mistranslations in submissions.
**Implementation:**
- New page: `/glossar`
- Data: JSON file with terms, loaded at startup
- Client-side search (instant filter as you type)
- Show DE term, EN term, optional short definition
- Category tags (prosecution, litigation, UPC, EPA)
**Effort:** ~3 hours (code) + ~2 hours (content curation) | **Impact:** Medium (frequent use for cross-border work)
---
### 1.5 Kostenrechner Enhancements
**What:** Improve the existing Kostenrechner with frequently-requested features.
**Enhancements:**
- **PDF Export** — generate a branded summary PDF of the calculation result (for client budgets, internal memos)
- **Scenario Comparison** — side-by-side comparison of two scenarios (e.g., DE-only vs. UPC, different Streitwerte)
- **URL Sharing** — encode calculator state in URL parameters so calculations can be bookmarked/shared
- **Prozesskostensicherheit** — add the security-for-costs calculation (currently only in the Excel version, and buggy there)
**Why:** The Kostenrechner is patholo's flagship feature. Making it PDF-exportable turns it into a client-facing tool. Scenario comparison is the #1 use case for cost calculators (should we litigate at UPC or LG?). The original Excel version's Prozesskostensicherheit section has bugs — patholo can do it right.
**Implementation:**
- PDF: client-side using window.print() with @media print CSS (simple) or server-side PDF generation (more polished)
- Scenario comparison: duplicate the calculator form side-by-side, show diff
- URL sharing: serialize form state to URL query params
- Prozesskostensicherheit: new calc in Go, formula from Kühnen 16th ed. Rn. E.47 ff. (fix the VAT bug from the Excel version)
**Effort:** ~6 hours total | **Impact:** High (makes the tool client-presentable)
- **PDF Export** — shipped (print CSS).
- **Scenario Comparison** — shipped (side-by-side diff).
- **URL Sharing** — shipped (query-param state).
- **Prozesskostensicherheit** — pending. Calculation logic not yet implemented; only the glossary term exists. Kühnen 16th ed. Rn. E.47 ff. formula is still the target reference.
---
## Phase 2: Content Hub (Medium effort, High impact)
These features require content creation alongside code. They transform patholo from a toolkit into a knowledge platform.
### 2.1 Verfahrensleitfäden (Procedure Guides) — Pending
### 2.1 Verfahrensleitfäden (Procedure Guides)
Step-by-step visual guides for UPC Infringement, UPC Revocation, UPC Provisional Measures, German Infringement, German Nullity, EPA Opposition, EPA Appeal. Timeline + step descriptions + cross-links to Fristenrechner pre-filled for the proceeding type. Content exists in mWorkRepo (UPC Know-How, UPC Training); needs structuring.
**What:** Step-by-step interactive guides for common patent procedures.
**Effort:** ~8h code + ~6h content per guide | **Impact:** Very High
**Initial guides:**
- UPC Infringement Action (Statement of Claim -> Defence -> Reply -> Rejoinder -> Hearing -> Decision)
- UPC Revocation Action
- UPC Provisional Measures
- German Infringement (LG -> OLG -> BGH)
- German Nullity (BPatG -> BGH)
- EPA Opposition
- EPA Appeal
### 2.2 Gerichtsverzeichnis (Court Directory) ✅ Done (2026-04-16)
**Per guide:**
- Visual timeline/flowchart (like the UPC Course of Proceedings Excalidraw in mWorkRepo)
- Step descriptions with party, deadline, rule reference
- Tips and practical notes from experienced practitioners
- Links to relevant model documents, templates
- Cross-links to Fristenrechner (pre-filled for this proceeding type)
Reference page at `/gerichte` with entries for every relevant UPC division, German court (LG / OLG / BGH / BPatG), DPMA, EPA, and national courts in NL / UK / FR / IT. Searchable + filterable by type and country.
**Why:** New associates ask "how does a UPC infringement action work?" constantly. A visual, interactive guide replaces the ad-hoc training that senior lawyers currently provide. The content already exists in mWorkRepo (UPC Know-How, UPC Training sessions) — it just needs to be structured and presented.
### 2.3 UPC Rechtsprechungsübersicht — Dropped
**Implementation:**
- New section: `/guides` with sub-pages per guide
- Data: structured JSON per guide (steps, durations, parties, rules)
- TSX: timeline component with expandable steps
- Deep-link to Fristenrechner with pre-selected proceeding type
- i18n for all content
Explicitly removed 2026-04-16. Rationale: youpc.org already maintains a curated UPC case-law database with 1,600+ decisions. Replaced with a prominent youpc.org entry in the Link Hub under "Recherche" (commit `4526942`). Re-add only if youpc.org shuts down or if HLC needs firm-specific takeaways attached to decisions.
**Effort:** ~8 hours (code) + ~6 hours (content per guide) | **Impact:** Very High (training + daily reference)
### 2.4 Checklisten (Interactive Checklists) ✅ Done (2026-04-16)
---
### 2.2 Gerichtsverzeichnis (Court Directory)
**What:** Reference page with details for every relevant court, division, and office.
**Content per entry:**
- Court name (DE + EN)
- Type (UPC Local Division, UPC Central Division, LG, OLG, BGH, BPatG, DPMA, EPA)
- Address, phone, fax
- Filing details (electronic filing system, accepted formats)
- Key judges (for UPC divisions — public info from UPC website)
- Link to court website / registry
- HL contacts for that jurisdiction
- Practical notes (e.g., "Munich LD prefers oral hearings on Wednesdays")
**Courts to include:**
- UPC: Munich CD, Paris CD (Seat), Luxembourg CD, all Local Divisions (Munich, Düsseldorf, Hamburg, Mannheim, The Hague, Paris, Milan, Brussels, Helsinki, etc.), Court of Appeal (Luxembourg)
- Germany: LG Munich I, LG Düsseldorf, LG Mannheim, LG Hamburg, OLG Düsseldorf (+ Senat), OLG Munich, BGH (X. Zivilsenat), BPatG
- EPO: Boards of Appeal, Opposition Division
- National: NL (The Hague), UK (Patents Court, IPEC), FR (TGI Paris), IT (Milan, Turin)
**Why:** "What's the filing address for the UPC Local Division Hamburg?" — this question gets asked weekly. A central directory with practical filing info saves time and prevents errors.
**Implementation:**
- New page: `/gerichte`
- Data: JSON with structured court entries
- Client-side search + filter by type/country
- Map view (optional, using coordinates)
- Print-friendly format for travel
**Effort:** ~4 hours (code) + ~4 hours (data collection) | **Impact:** Medium-High (weekly use)
---
### 2.3 UPC Rechtsprechungsübersicht (UPC Case Law Dashboard)
**What:** Curated overview of significant UPC decisions with summaries and key takeaways.
**Features:**
- List of decisions, newest first
- Per decision: case number, parties, division, date, topic tags, 2-3 sentence summary, key takeaway
- Filter by: division, topic (infringement, validity, preliminary injunction, costs, procedure), date range
- Search by party name or case number
- Link to full decision (UPC register) and to youpc.org if available
**Content source:** mWorkRepo already has youpc-summaries/ with 8+ recent case summaries. The youpc.org database has 1,600+ decisions. Start with curated highlights, not a full database.
**Why:** UPC case law is developing rapidly (court opened June 2023). Staying current is critical for practitioners. The UPC website's register is hard to navigate. A curated, searchable overview with HL-relevant takeaways is enormously valuable.
**Implementation:**
- New page: `/upc/rechtsprechung`
- Data: JSON file with curated entries (start with 20-30 key decisions, add monthly)
- Admin: simple way to add new entries (could be a JSON file in the repo, updated via PR)
- TSX: filterable card list with topic tags
- Optional: API integration with youpc.org for broader search
**Effort:** ~6 hours (code) + ~8 hours (initial curation) | **Impact:** Very High (weekly/daily for UPC practitioners)
---
### 2.4 Checklisten (Interactive Checklists)
**What:** Interactive, printable checklists for common patent workflows.
**Initial checklists:**
- UPC Statement of Claim — required elements per RoP
- UPC Statement of Defence — required elements per RoP
- UPC Confidentiality Application — requirements
- UPC Registration as Representative — documents needed
- Patent nullity action (BPatG) — filing requirements
- EPA Opposition — formal requirements and deadlines
- nUPCMS filing — step-by-step for electronic submission
**Per checklist:**
- Checkbox items (persistent in localStorage per user)
- Category groupings (formal requirements, content requirements, annexes)
- Notes/tips per item
- Print-friendly layout
- Reset button
**Why:** Filing requirements are complex and vary by court. Missing a formal requirement means rejection or delay. Checklists prevent errors. The HL Model Documents project in mWorkRepo already has checklist content (CHECKLIST.md with 63 BuildingBlock entries).
**Implementation:**
- New section: `/checklisten`
- Data: JSON per checklist
- Client-side state (localStorage) for checkbox persistence
- Print with checked/unchecked state visible
**Effort:** ~4 hours (code) + ~3 hours (content per checklist) | **Impact:** High (prevents costly filing errors)
Interactive checklists at `/checklisten` for UPC Statement of Claim, Statement of Defence, Confidentiality Application, Representative Registration, BPatG nullity, EPA Opposition, nUPCMS filing. Checkbox state persisted in `localStorage` per user; print-friendly layout; feedback form per list.
---
## Phase 3: Platform Features (Higher effort, Transformative)
These features require more architecture but move patholo from "useful tool" to "indispensable platform."
### 3.1 Suchfunktion (Global Search) — Pending
### 3.1 Suchfunktion (Global Search)
Search across all Paliad content — glossary, Gerichte, Leitfäden, Checklisten, links, and eventually Akten (scoped by visibility). Build the index at startup from JSON sources + DB. Expose `GET /api/search?q=...`.
**What:** Search across all patholo content — glossary terms, court entries, case law, guides, checklists, links.
**Effort:** ~6h | **Impact:** High
**Why:** Once patholo has significant content, users need to find things fast. A search bar in the sidebar that searches everything is the difference between "I'll check patholo" and "I'll just Google it."
### 3.2 Vorlagenbibliothek (Template Library) — Pending
**Implementation:**
- Search index built at startup from all JSON data sources
- API endpoint: GET /api/search?q=...
- Client: search input in sidebar, results overlay
- Highlight matching text in results
Evolve `/downloads` from a flat card grid into a proper template library with preview, category filters (Schriftsätze, Vorlagen, Tabellen, Blöcke), and metadata. Distribution channel for the HL Model Documents project and BuildingBlocks.
**Effort:** ~6 hours | **Impact:** High (scales with content)
**Effort:** ~8h | **Impact:** High
---
### 3.3 Schulungsbereich (Training Hub) — Pending
### 3.2 Vorlagenbibiothek (Template Library)
Self-serve onboarding and continuing education at `/schulung`. New-associate guide, UPC training material, video guide links, HL Patents Style tutorial, nUPCMS filing guide, FAQ.
**What:** Browsable library of document templates with preview and download.
**Effort:** ~6h code + ~10h content | **Impact:** Medium-High
**Content:**
- HL Patents Style.dotm (already live)
- BuildingBlocks from mWorkRepo (Tables, Headings, Phrases, Elements — 63 blocks)
- Model documents (Statement of Claim, Defence, etc. — from HL Model Documents project)
- Legal writing templates (research memo, client letter)
- Court-specific cover sheets
### 3.4 Benachrichtigungen (What's New) — Pending
**Per template:**
- Name, description, category
- Preview (rendered HTML or screenshot)
- Download (via existing file proxy)
- Usage instructions
- Version info (last updated)
Changelog + "neu seit letztem Besuch" badge in the sidebar. JSON-backed changelog, `localStorage` last-seen timestamp, optional browser push.
**Why:** The current downloads page is a flat list. As the template library grows, it needs categorization, preview, and proper metadata. This is the distribution channel for the HL Model Documents project.
**Implementation:**
- Expand `/downloads` into a proper library
- Template metadata in JSON (or extend fileRegistry)
- Preview generation (could be pre-rendered screenshots stored in Gitea)
- Category filters (Schriftsätze, Vorlagen, Tabellen, Blöcke)
**Effort:** ~8 hours | **Impact:** High (central template distribution)
---
### 3.3 Schulungsbereich (Training Hub)
**What:** Onboarding and continuing education section for patent team members.
**Content:**
- "Getting Started" guide for new associates
- UPC Training materials (from mWorkRepo UPC Training sessions)
- Video guide links (from mWorkRepo UPC Video Guides)
- HL Patents Style tutorial (how to use the template)
- nUPCMS user guide (filing in the new CMS)
- FAQ section
**Why:** Onboarding a new associate to UPC practice currently requires multiple senior-lawyer meetings. A self-serve training section lets associates learn the basics before those meetings, making the meetings more productive.
**Implementation:**
- New section: `/schulung`
- Static content pages with structured lessons
- Progress tracking (optional, via localStorage)
- Embedded video links
- Quiz/self-check elements (optional)
**Effort:** ~6 hours (code) + ~10 hours (content) | **Impact:** Medium-High (onboarding efficiency)
---
### 3.4 Benachrichtigungen (What's New)
**What:** Simple notification system showing what's been added or updated on patholo.
**Features:**
- "What's New" badge on sidebar when new content exists
- Changelog page with dated entries
- Optional: browser push notifications for major updates
**Why:** Content platforms die when users stop checking for new content. A visible "new content" indicator brings people back.
**Implementation:**
- JSON file with changelog entries
- Last-seen timestamp in localStorage per user
- Badge counter in sidebar
- Simple changelog page
**Effort:** ~3 hours | **Impact:** Medium (retention mechanism)
**Effort:** ~3h | **Impact:** Medium (retention)
---
## Phase 4: Advanced (High effort, Long-term)
These are ambitious features that require significant investment but would make patholo truly indispensable.
### 4.1 KI-Recherche (AI-Powered Research) — Pending (AI features deferred alongside Phase H)
### 4.1 KI-Recherche (AI-Powered Research)
Claude-powered chat grounded in Paliad content (glossary, guides, case law, fee tables, and — with visibility enforcement — a user's own Akten/Fristen). Every answer cites sources. Requires guardrails; requires a solid content foundation (Phases 13). Currently blocked by the same "no Anthropic API" decision as Phase H; revisit when that decision flips.
**What:** Claude-powered research assistant for patent law questions.
### 4.2 Fristenkalender ✅ Done (Phase F)
**Features:**
- Chat interface for patent law questions
- Grounded in patholo content (glossary, guides, case law, fee tables)
- Can answer: "What's the deadline for filing a defence in UPC infringement?" or "What are the court fees for Streitwert 5M at LG?"
- Sources cited for every answer
Originally "export deadlines as .ics / CalDAV sync". Subsumed by Phase 0.3 Termine + CalDAV Sync — bidirectional sync with encrypted credentials at rest. The Fristenrechner's "Als Frist(en) speichern" button is the entry point from quick-calc into persistent Fristen; Fristen themselves appear in the user's CalDAV calendar via Termine linkage.
**Why:** This is the endgame — a patent law assistant that knows HL's practice. But it requires a solid content foundation (Phases 1-3) to be useful, and careful guardrails to avoid hallucination in a legal context.
### 4.3 Collaborative Annotations — Pending (partial via 0.6 Notizen)
**Effort:** ~20 hours | **Impact:** Transformative (if done right)
The polymorphic `paliad.notizen` table already covers per-Akte / per-Frist / per-Termin / per-AkteEvent notes (Phase I). "Annotations on published knowledge content" (e.g., per-glossary-term practitioner tips) is a separate scope and still pending. Requires moderation UI.
### 4.4 Mandantenkosten-Report (Client Cost Report) — Pending
Branded PDF cost estimate generated from Kostenrechner data: HL logo, matter reference, date, scenario comparison, editable cover letter. One-click replacement for today's manual Excel-to-memo workflow.
**Effort:** ~10h | **Impact:** Medium-High (client-facing)
---
### 4.2 Fristenkalender (Deadline Calendar)
## Prioritized Backlog
**What:** Integration between the Fristenrechner and external calendar systems.
Phase 0 (Aktenverwaltung) items are **Done** as of April 2026. Remaining work ordered by priority.
**Features:**
- Export calculated deadlines as .ics file (for import into Outlook/Google Calendar)
- Optional: CalDAV sync (write deadlines directly to a shared calendar)
- Reminders with configurable lead time
**Why:** The Fristenrechner calculates deadlines but doesn't connect to where lawyers actually manage their time. Calendar export closes this gap.
**Effort:** ~8 hours | **Impact:** High (workflow integration)
---
### 4.3 Collaborative Annotations
**What:** Allow users to add notes, tips, and corrections to patholo content.
**Features:**
- Comment/note button on guides, checklists, case law entries
- Notes visible to all patholo users
- Upvoting for useful notes
- Moderation (flag inappropriate content)
**Why:** The best knowledge comes from practitioners. Allowing annotations turns patholo from a one-way publication into a living knowledge base. But this requires a database (Supabase) and moderation.
**Effort:** ~15 hours | **Impact:** High (knowledge capture)
---
### 4.4 Mandantenkosten-Report (Client Cost Report)
**What:** Generate branded PDF cost estimates for clients using Kostenrechner data.
**Features:**
- Kostenrechner -> "Generate Report" button
- Branded PDF with HL logo, date, matter reference
- Scenario comparison in the report
- Customizable cover letter text
- Downloadable as PDF
**Why:** Partners need to send cost estimates to clients. Currently they use the Excel calculator and manually format a memo. A one-click branded PDF saves hours and looks professional.
**Effort:** ~10 hours | **Impact:** Medium-High (client-facing)
---
## Prioritized Backlog (Summary)
| # | Feature | Phase | Effort | Impact | Priority |
|---|---|---|---|---|---|
| 1.1 | Link Hub | 1 | 2h | High | **P0** |
| 1.2 | More Downloads | 1 | 1h | Medium | **P0** |
| 1.3 | Gebührentabellen | 1 | 4h | Med-High | **P0** |
| 1.5 | Kostenrechner Enhancements | 1 | 6h | High | **P1** |
| 1.4 | Patentglossar | 1 | 5h | Medium | **P1** |
| 2.4 | Checklisten | 2 | 7h | High | **P1** |
| 2.1 | Verfahrensleitfäden | 2 | 14h | Very High | **P2** |
| 2.3 | UPC Rechtsprechung | 2 | 14h | Very High | **P2** |
| 2.2 | Gerichtsverzeichnis | 2 | 8h | Med-High | **P2** |
| 3.4 | Benachrichtigungen | 3 | 3h | Medium | **P2** |
| 3.1 | Suchfunktion | 3 | 6h | High | **P3** |
| 3.2 | Vorlagenbibliothek | 3 | 8h | High | **P3** |
| 3.3 | Schulungsbereich | 3 | 16h | Med-High | **P3** |
| 4.2 | Fristenkalender | 4 | 8h | High | **P3** |
| 4.4 | Mandantenkosten-Report | 4 | 10h | Med-High | **P4** |
| 4.3 | Collaborative Annotations | 4 | 15h | High | **P4** |
| 4.1 | KI-Recherche | 4 | 20h | Transformative | **P5** |
| # | Feature | Phase | Effort | Impact | Priority | Status |
|---|---|---|---|---|---|---|
| 0.1 | Akten (matter mgmt) | 0 | — | Foundational | **P0** | ✅ Done |
| 0.2 | Fristen (persistent) | 0 | — | Foundational | **P0** | ✅ Done |
| 0.3 | Termine + CalDAV | 0 | — | High | **P0** | ✅ Done |
| 0.4 | Dashboard | 0 | — | High | **P0** | ✅ Done |
| 1.1 | Link Hub | 1 | 2h | High | **P0** | ✅ Done |
| 1.3 | Gebührentabellen | 1 | 4h | Med-High | **P0** | ✅ Done |
| 1.4 | Patentglossar | 1 | 5h | Medium | **P1** | ✅ Done |
| 2.2 | Gerichtsverzeichnis | 2 | 8h | Med-High | **P1** | ✅ Done |
| 2.4 | Checklisten | 2 | 7h | High | **P1** | ✅ Done |
| 1.5 | Kostenrechner enhancements | 1 | 6h | High | **P1** | ✅ Partial (PDF/URL/compare done; Prozesskostensicherheit pending) |
| 1.2 | More Downloads (content) | 1 | 1h/file | Medium | **P1** | ⬜ Page shipped; content pending |
| 0.6 | Notizen (service + UI) | 0 | 4h | Medium | **P2** | ⬜ Schema done, service pending |
| 2.1 | Verfahrensleitfäden | 2 | 14h | Very High | **P2** | ⬜ Pending |
| 3.4 | Benachrichtigungen | 3 | 3h | Medium | **P2** | ⬜ Pending |
| 3.1 | Suchfunktion | 3 | 6h | High | **P3** | ⬜ Pending |
| 3.2 | Vorlagenbibliothek | 3 | 8h | High | **P3** | ⬜ Pending |
| 3.3 | Schulungsbereich | 3 | 16h | Med-High | **P3** | ⬜ Pending |
| 4.4 | Mandantenkosten-Report | 4 | 10h | Med-High | **P3** | ⬜ Pending |
| 0.5 | AI Frist-Extraktion (Phase H) | 0 | 4h | High | **Deferred** | ⏸ Anthropic API decision pending |
| 4.1 | KI-Recherche | 4 | 20h | Transformative | **Deferred** | ⏸ Tied to Phase H decision |
| 4.3 | Collaborative Annotations (published content) | 4 | 15h | High | **P4** | ⬜ Pending |
---
@@ -488,57 +218,85 @@ These are ambitious features that require significant investment but would make
### Data Strategy
Most Phase 1 and 2 features use **static JSON data** loaded at Go server startup. This keeps the stack simple (no new database tables needed) and content is version-controlled in git. Content updates = git commits = automatic deploy.
The data model is split:
When content grows beyond what's practical in JSON files (Phase 3+), migrate to Supabase tables with a simple admin API.
- **Phase 0 (Aktenverwaltung)** — Supabase tables in the `paliad` schema with office-scoped RLS (`paliad.can_see_akte(akte_id)`). User-generated data lives here: Akten, Fristen, Termine, Parteien, Dokumente, Notizen, Verlauf, User profiles, CalDAV config. Migrations embedded into the Go binary via `embed.FS` and applied by `golang-migrate` at server startup.
- **Knowledge platform (Phases 12)** — static JSON data loaded at server startup. Content lives in git; content updates = git commits = automatic deploy.
- **Feedback tables** (`link_suggestions`, `checklisten_feedback`, `gerichte_feedback`) — `paliad` schema, firm-wide visibility.
When static content grows past what's practical in JSON (Phase 3+), migrate specific content types to Supabase tables with a simple admin API. Don't mass-migrate — move what benefits from search/filtering/mutation.
### Visibility Invariant
The office-scoped visibility predicate is **defined once** in SQL (`paliad.can_see_akte(akte_id uuid)`) and reused by every RLS policy on every table that carries an `akte_id`. `AkteService.GetByID` mirrors the predicate at the application layer for defense in depth; every child service (`FristService`, `TerminService`, `ParteienService`, …) routes through `AkteService.GetByID` before operating on its own row. **Never duplicate the predicate.** See `docs/design-kanzlai-integration.md` §2 and the Phase E memory episode for the architecture invariant.
### Content Pipeline
New content follows this flow:
1. Practitioner identifies need (or new case law / template)
2. Content written/curated (by knowledge lawyer or contributor)
3. Added to patholo repo as JSON/markdown
4. PR reviewed and merged
5. Auto-deploy via Dokploy webhook
New knowledge content follows this flow:
1. Practitioner identifies need (or new case law / template).
2. Content written/curated (by knowledge lawyer or contributor).
3. Added to Paliad repo as JSON/markdown.
4. PR reviewed and merged.
5. Auto-deploy via Dokploy webhook (push to `main` → Gitea webhook → Dokploy).
### Navigation Expansion
### Navigation
The sidebar currently has: Home, Kostenrechner, Fristenrechner, Downloads. With new pages, reorganize into groups:
The sidebar has six grouped sections (see `docs/design-kanzlai-integration.md` §6):
```
Werkzeuge
— ÜBERSICHT —
Dashboard
— ARBEIT —
Akten
Fristen
Termine
— WERKZEUGE —
Kostenrechner
Fristenrechner
Fristenrechner (stateless quick calc; distinct from /fristen)
Gebührentabellen
Wissen
Verfahrensleitfäden
Rechtsprechung
— WISSEN —
Glossar
Checklisten
Gerichtsverzeichnis
Leitfäden (future — Phase 2.1)
Ressourcen
Downloads / Vorlagen
— RESSOURCEN —
Downloads
Nützliche Links
Gerichte
Schulung
— EINSTELLUNGEN —
CalDAV
```
### What patholo Is NOT
### What Paliad Is
- **Not a case management system** — that's KanzlAI
- **Not a document management system** — that's SharePoint/netDocuments
- **Not a billing tool** — that's the firm's practice management system
- **Not a CMS** — content lives in git, not a database with a CMS UI
Paliad is the all-in-one platform for HLC patent practice:
patholo is a **knowledge platform and toolkit**: curated content, practical tools, quick reference. Fast, focused, friction-free.
- **Knowledge platform** — curated content, practical tools, quick reference (Glossar, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Leitfäden, Links, Downloads).
- **Aktenverwaltung** — Akten, Fristen, Termine, Parteien, Dokumente, Notizen, Verlauf (audit trail). Office-scoped visibility with explicit collaborator lists for cross-office teams. Personal calendar sync via CalDAV. AI-assisted Frist extraction is designed but deferred.
What Paliad is *not*:
- **Not a billing tool** — HLC has firm-wide billing infrastructure.
- **Not a beA gateway** — out of scope; lawyers use existing beA software.
- **Not a document management system** — SharePoint / netDocuments stay in their lane.
- **Not a CMS** — content lives in git, not a database with a CMS UI.
---
## Recommendation
## Longer-Term Open Questions
I can implement Phase 1 features myself — I have the deepest context from this design work. The Link Hub (1.1) and More Downloads (1.2) are particularly quick wins that could ship today. The Gebührentabellen (1.3) and Glossar (1.4) need some content curation but the code is straightforward.
- **Outlook / Exchange sync (Phase K).** CalDAV covers Apple iCloud + `dav.msbls.de`. HLC lives on Outlook + Exchange; Exchange's CalDAV support is limited. A follow-on "Phase K" would add an EWS / Microsoft Graph backend behind the same sync abstraction. Decide based on internal feedback to Phase F.
- **Practice-group walls.** Today, `practice_group` is filter-only metadata. If a partner asks for "Patents Litigation can't see Patents Prosecution Akten", the schema is ready to extend the `paliad.can_see_akte` predicate. Don't build until asked.
- **External counsel access.** Bringing in an outside boutique on a specific Akte currently means adding them as a user (not possible without the HLC email domain). A future `external_collaborators` table with scoped RLS would cover it.
- **Read-only archive post-closure.** Add `is_archived` on `paliad.akten`, deny mutations via RLS. Cheap follow-on.
- **AI revisit.** The Phase H / 4.1 pause is a decision, not a technical block. When Anthropic API goes back on the table, both AI extraction (Phase H) and KI-Recherche (4.1) can be unblocked.
- **Supabase Auth SMTP routing.** Confirmation / password-reset / magic-link mails from `ydb.youpc.org` still go through Supabase's default sender. Routing them through Paliad's SMTP (`mail@paliad.de`) is a one-line GoTrue config change, but youpc's Supabase is shared with youpc.org, so the global SMTP settings can't be flipped without rebranding youpc.org's auth mails too. Resolution paths (lowest-effort first):
1. Move Paliad to its own Supabase project and configure SMTP there.
2. Wait until the youpc instance exposes per-project SMTP (Supabase Pro / self-hosted upgrade).
3. Write a custom GoTrue webhook that Paliad's Go server intercepts and re-sends via `MailService`.
Phase 2 features need content collaboration (practitioner input for guides, case law curation). The code can be built by a coder worker, but the content requires domain expertise.
Decision for head: assign implementation to me (with coder role) or hand off to a separate coder?
For now the inbox-facing mails (reminders + invitations) go through Paliad's SMTP; identity-bootstrap mails stay on the default sender — acceptable for the current HLC pilot. Tracked as part of t-paliad-021 completion (2026-04-20).

View File

@@ -0,0 +1,721 @@
# Paliad — Architecture Improvement Audit
Prepared by `ada` (consultant) on 2026-04-30 for task **t-paliad-074**.
Scope: read-only architecture audit after the 9-merge push of t-paliad-066..073.
This doc supersedes neither `docs/improvement-audit.md` (the original
2026-04-18 product audit) nor `docs/audit-polish-2-2026-04-29.md` (UX polish).
It complements them: this is the structural/maintainability lens.
Today is 2026-04-30. The repo is at commit `2c67299` on `main`.
Finding tags:
- **Severity** — 🔴 active risk · 🟠 friction now · 🟡 future-proofing
- **Effort** — 🟢 ≤30 min · 🟡 1-2 h · 🔴 half-day+
---
## Executive summary — if I do nothing else, fix these 3 things
1. **`AdminDeleteUser` queries dropped tables `paliad.department_members` /
`paliad.departments`.** Migration 027 (2026-04-29) renamed those to
`partner_unit_members` / `partner_units`. The code at
`internal/services/user_service.go:768` and `:773` was missed by the
t-paliad-070 rename sweep (last edit 2026-04-27 by `c697fe34`, predates
the rename). Any admin who clicks "Delete user" in `/admin/team` today
will hit `pq: relation "paliad.department_members" does not exist`.
**Live production bug, blast radius: admin-only, but blocks user
off-boarding.** See **F-1**.
2. **Seven live-DB integration tests skip silently when
`TEST_DATABASE_URL` is unset, and the repo has no CI.** This is the
exact pattern that masked the t-paliad-069 reminder placeholder bug
for ~24 h. F-1 above is another bug that a passing
`TestAdminDeleteUser` would have caught the moment migration 027
landed. Fix the visibility of the test gate: either add a Gitea
workflow with an ephemeral Postgres, or convert the silent skips to
`t.Fatal` when `CI=true`. See **F-9**.
3. **Visibility predicate is centralised in
`internal/services/visibility.go` but inlined in 10 hot-path SQL
sites across `dashboard_service.go` (4×), `agenda_service.go` (2×),
`reminder_service.go` (2×), `team_service.go` (1×), and
`deadline_service.go` (1×).** This is the same security-critical rule
that t-paliad-058 already extracted — duplication crept right back
in. Every change to the team-visibility model (Chinese-wall
restrictions in design v2 §8) has 11 places to update, and the
inlined sites quietly skip the `global_admin` shortcut. See **F-2**.
The next ~20 findings are a mix of naming drift, dead code, schema
documentation gaps, and missing tests. None of them are emergencies.
---
## 1 — Findings by lens
### 1.1 Service boundaries
#### F-1. `AdminDeleteUser` writes to dropped tables — live bug
🔴 active risk · 🟢 ≤30 min · stand-alone task
**Files:** `internal/services/user_service.go:768`, `:773`
```go
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.department_members WHERE user_id = $1`, id); err != nil {
return fmt.Errorf("delete department_members: %w", err)
}
// A Department this user led keeps existing — the lead seat just goes empty.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.departments SET lead_user_id = NULL WHERE lead_user_id = $1`, id); err != nil {
return fmt.Errorf("clear dept leads: %w", err)
}
```
Migration 027 (2026-04-29) renamed `paliad.departments``paliad.partner_units` and
`paliad.department_members``paliad.partner_unit_members`. `git blame`
shows lines 763-775 last touched by `c697fe34` on 2026-04-27 — the
t-paliad-070 rename sweep (76785da) missed this site.
**Fix:**
```go
`DELETE FROM paliad.partner_unit_members WHERE user_id = $1`
`UPDATE paliad.partner_units SET lead_user_id = NULL WHERE lead_user_id = $1`
```
Also update the comment on line 725: `project_teams / department_members`
`project_teams / partner_unit_members`. And the comment on line 771:
`A Department this user led``A partner unit this user led`.
**Warrants its own task:** yes — it's a customer-visible production bug
even if the surface (admin-only) is small. `t-paliad-NN` titled "fix
AdminDeleteUser SQL after partner_units rename".
---
#### F-2. Visibility predicate inlined in 10 sites despite central helper
🔴 active risk · 🟡 1-2 h · stand-alone task
**Files:**
- `internal/services/dashboard_service.go:158, 214, 244, 274` (4 sites)
- `internal/services/agenda_service.go:138, 204` (2 sites)
- `internal/services/reminder_service.go:312, 325` (2 sites)
- `internal/services/team_service.go:162` (1 site)
- `internal/services/deadline_service.go:422` (1 site)
All ten sites inline the same path-walk fragment:
```sql
EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $X
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]))
```
Worse, the inlined sites do **not** include the `global_admin` shortcut
that `visibilityPredicate` / `visibilityPredicatePositional` add. A
global_admin without an explicit `project_teams` row is visible to the
RLS-style helper but invisible to the inlined sites — unless the
surrounding query carries `p.firm_wide_visible OR pt.user_id = ...`
elsewhere. Audit each site carefully; some queries might be relying on
project-team auto-membership (every project creator gets a team row,
per `project_service.go:467`) to mask the gap.
The agenda-service ship memory (t-paliad-030, 2026-04-22) explicitly
called this out as deliberate: "We deliberately did not factor this
into a helper because the three call sites have slightly different
JOIN shapes." That justification is weaker now that t-paliad-058
proved the central helper works. The drift cost is real (Chinese-wall
in design v2 §8 will need 11 simultaneous edits).
**Fix:**
1. Extend `visibility.go` with a third variant — `visibilityPredicateLateral(alias string, userArg int)` — that fits the dashboard/agenda LATERAL-JOIN shape (predicate appears inside a sub-SELECT, not the outer WHERE).
2. Convert all 10 sites in one PR. Add a `_test.go` table-driven test that asserts the four variants produce equivalent EXPLAIN plans against a live DB.
3. After the conversion, add `make grep-visibility-inline` to CI that fails on `string_to_array.*\.path` outside `visibility.go`.
**Warrants its own task:** yes — security-critical; one focused PR.
---
#### F-3. `NoteService` is a dependency-shaped diamond
🟡 future-proofing · 🟡 1-2 h · part of broader naming task
**File:** `internal/services/note_service.go:23-31`
```go
type NoteService struct {
db *sqlx.DB
projects *ProjectService
appointment *AppointmentService
}
```
`NoteService` reaches into `ProjectService.GetByID` for visibility and
into `AppointmentService.GetByID` for personal-appointment visibility.
That's ~6 cross-service `GetByID` calls in one file. The pattern is fine
at this scale, but it's the canary for a "domain service" that should
own a `CanSee(ctx, userID, parent)` method on each owning service —
right now `NoteService` is hand-rolling the dispatch.
**Fix:** add `ProjectService.CanSee(ctx, userID, projectID) (bool, error)`
and `AppointmentService.CanSee(ctx, userID, appointmentID) (bool, error)`.
`NoteService.ListForFrist` becomes a one-liner that asks the right
parent service whether the user can see, then `s.list(ctx, ...)`.
Reduces the number of full-row reads (`GetByID`) — currently every
note-list call also pre-fetches the parent's full row.
**Batch with F-4 (naming).**
---
### 1.2 Naming consistency
The Department→PartnerUnit rename (t-paliad-070), the Akten→Projects
data-model rename (t-paliad-024 / migration 018), and the German→English
table rename (migration 020) all left rough edges in identifiers. The
schema is mostly clean now (one bug — F-1 above). The Go code is
half-renamed.
#### F-4. Service layer mixes English types with German parameter and helper names
🟠 friction now · 🔴 half-day+ · single-PR mechanical rename
**Affected files (8):**
| File | Legacy identifier | English equivalent |
|---|---|---|
| `note_service.go` | `CreateNotizInput`, `UpdateNotizInput`, `notizColumns`, `notizSelect`, `ListForProjekt`, `ListForFrist`, `ListForTermin`, `CreateForProjekt`, `CreateForFrist`, `CreateForTermin`, `fristProjectID` | `CreateNoteInput`, …, `noteColumns`, `noteSelect`, `ListForProject`, `ListForDeadline`, `ListForAppointment`, `CreateForProject`, …, `deadlineProjectID` |
| `appointment_service.go` | `CreateTerminInput`, `UpdateTerminInput`, `ListForProjekt`, parameter names `terminID`, `projektID` | `CreateAppointmentInput`, `UpdateAppointmentInput`, `ListForProject`, `appointmentID`, `projectID` |
| `deadline_service.go` | `CreateFristInput`, `UpdateFristInput`, `ListForProjekt`, `isValidFristStatus`, parameters `fristID`, `projektID` | `CreateDeadlineInput`, `UpdateDeadlineInput`, `ListForProject`, `isValidDeadlineStatus`, `deadlineID`, `projectID` |
| `project_service.go` | `CreateProjektInput`, `UpdateProjektInput`, `validateProjektStatus` | `CreateProjectInput`, …, `validateProjectStatus` |
| `party_service.go` | `CreateParteiInput`, `ListForProjekt` | `CreatePartyInput`, `ListForProject` |
| `caldav_service.go` | `OnTerminCreated`, `OnTerminUpdated`, `OnTerminDeleted` | `OnAppointmentCreated`, … |
| `caldav_ical.go` | `formatTermin` | `formatAppointment` |
| `checklist_instance_service.go` | `ListForProjekt`, `listWithProjekt` | `ListForProject`, `listWithProject` |
The mismatch is jarring because the *return types* are already English
(`*models.Note`, `*models.Appointment`, `*models.Deadline`,
`*models.Project`). Every reader does mental translation between
parameter and return.
This also means `note_service.go:38` reads `n.deadline_id = $1` (English
column, correct) bound to a parameter named `fristID` (German).
**Fix:** one branch via gopls "rename symbol". Order: types first
(input structs), then methods, then parameters, then comments. Update
the corresponding `internal/handlers/notes.go`, `appointments.go`,
`deadlines.go` callers in the same PR — gopls handles cross-file
symbol-rename correctly.
The CLAUDE.md convention is unambiguous: "All code, table names, Go
types, service names, URL paths, API endpoints, file names — English."
This is just finishing the work.
**Warrants its own task:** yes — one large-mechanical PR. Ship together
with F-3 (the NoteService cleanup) since they touch the same file.
---
#### F-5. Stale comment in `models.go` claims escalation contact dropdown is deferred
🟡 future-proofing · 🟢 ≤30 min · batch with other doc fixes
**File:** `internal/models/models.go:50-52`
```go
// EscalationContactID is an optional override of the escalation channel
// for overdue / DRINGEND mail. NULL means "fall back to global_admins".
// The Settings UI dropdown is deferred (see CLAUDE.md); set via SQL today.
EscalationContactID *uuid.UUID `db:"escalation_contact_id" ...`
```
The dropdown shipped on 2026-04-29 as t-paliad-066 (commit `bff2ec5`).
The comment is now misleading.
**Fix:** drop the last sentence; reference `Settings → Notifications`
instead.
---
#### F-6. Go module path is still `mgit.msbls.de/m/patholo`
🟡 future-proofing · 🔴 half-day+ · defer until next major touch
**File:** `go.mod:1` plus 67 import statements across `internal/`,
`cmd/`, and tests.
The Gitea repo was renamed to `mAi/paliad` and auto-redirects (per
project CLAUDE.md). So `go build` works. But `go.mod`, every `import
"mgit.msbls.de/m/patholo/..."`, and the binary's debug info still read
as `patholo`.
**Fix:** wait. The cost is touching every Go file in the repo;
t-paliad-018 explicitly punted on this for the same reason. Keep the
finding on the books, do it next time someone is doing a global
formatting pass.
---
#### F-7. CSS class names are still mostly German
🟡 future-proofing · 🔴 half-day+ · separable from Go rename
**File:** `frontend/src/styles/global.css` — 226 class definitions
prefixed with German nouns: `.akten-detail-header`, `.akten-table`,
`.akten-parteien-controls`, `.fristen-wizard`, `.frist-section-heading`,
`.akten-events-empty`, etc.
The TSX usages are even more confusing: `frontend/src/deadlines-detail.tsx:38`
uses `className="akten-detail-header"` despite the page being about
deadlines, not Akten. The CSS class is a generic "detail page header"
shape that got named after the first user.
**Fix:** rename in two passes (1: CSS only, 2: TSX usage), keep both
class names valid (`.akten-detail-header, .detail-page-header`) for one
deploy cycle, then drop the legacy. Conservative because there's no
type-checker to catch missed renames in HTML.
**Defer:** value-per-effort is poor — no functional impact, mostly
churn. Worth doing next time a designer touches the stylesheet.
---
### 1.3 Frontend ↔ backend contract
#### F-8. `i18n.ts` has 1264 keys typed as raw `string` with silent fallback
🟠 friction now · 🟡 1-2 h · stand-alone task
**File:** `frontend/src/client/i18n.ts:2776`
```ts
export function t(key: string): string {
return translations[currentLang][key] ?? translations.de[key] ?? key;
}
```
Three problems compound:
1. The key parameter is `string` — TypeScript will not catch typos.
2. The third fallback (`?? key`) renders the literal i18n key in the
UX when a key is missing, so the bug is silent.
3. There are 681 distinct `data-i18n="..."` attributes in TSX files
that bypass the `t()` function entirely — they're resolved by a
generic DOM walker (`initI18n()`) at page boot. No type-checking
path possible without a JSX preprocessor.
The product audits already caught 4+ leaked keys this month
(`fristen.field.project.choose`, `project_type_changed`, audit-polish
F-04, etc.) and ship docs for t-paliad-067 mention "the i18n leak class
keeps reappearing."
**Fix (cheap step 1 — type the `t()` call):**
```ts
// Generated at build time from the keys in `translations.de`.
export type I18nKey =
| "nav.home"
| "nav.kostenrechner"
| // ... 1264 more
| "bottomnav.badge.deadlines";
export function t(key: I18nKey): string { ... }
```
Add a `frontend/build.ts` step that emits `i18n-keys.ts` from
`translations.de`. TypeScript now catches typos in `t()` calls at
build-time. ~501 call sites across `frontend/src/client/*.ts` get
type-checked for free.
**Fix (more thorough step 2 — typed `data-i18n`):** add a
`bun run check-i18n-keys` CLI that greps every TSX file for
`data-i18n="..."` and `data-i18n-placeholder="..."`, asserting the
referenced key exists in `translations.de`. Run from the build script;
fail the build on unknown keys. This catches the `data-i18n` class of
leaks too.
**Warrants its own task:** yes — 1 PR, two scripts, big returns.
---
#### F-9. i18n key namespace is split between German and English prefixes
🟠 friction now · 🟡 1-2 h · stand-alone task
**File:** `frontend/src/client/i18n.ts`
Counts:
- 444 keys with German prefixes (`fristen.`, `termine.`, `notizen.`)
- 200 keys with German prefixes (`akten.`, `dezernat.`, `partei.`)
- Newer keys use English prefixes (`deadlines.`, `appointments.`,
`notes.`, `parties.`, `partner_units.`)
200+ TSX `data-i18n=` attributes still reference the German keys.
A contributor adding a new "deadline edit form label" today would have
to know whether the existing keyspace uses `fristen.field.title` or
`deadlines.field.title`. Both shapes exist. Future drift is guaranteed.
**Fix:** bulk rename all German-prefix i18n keys to their English
equivalents; update the corresponding TSX `data-i18n=` attributes;
verify with the F-8 build-time check. Do it as a single PR — the
diff is mechanical and reviewable. Nothing here is user-visible.
**Warrants its own task:** yes; depends on F-8 being shipped first
(so the typed checker can validate the renames).
---
### 1.4 Migration management
#### F-10. Migrations 004 / 005 / 006 are obsoleted by 018
🟡 future-proofing · 🟢 ≤30 min · doc-only
**Files:** `internal/db/migrations/004_akten.up.sql`,
`005_akten_children.up.sql`, `006_visibility.up.sql` (~210 lines
combined) are fully superseded by `018_projects_v2.up.sql`. Migration
018 itself does the supersession explicitly:
```sql
-- Replaces paliad.akten with a single self-referential paliad.projects tree
ALTER TABLE paliad.akten_events RENAME TO project_events;
DROP TABLE paliad.akten;
DROP FUNCTION IF EXISTS paliad.can_see_akte(uuid);
DROP FUNCTION IF EXISTS paliad.notiz_is_visible(uuid, uuid, uuid, uuid);
```
Reading the migration history requires mentally diffing 004→018.
First-time contributors will be confused.
**Fix:** add `internal/db/migrations/SCHEMA_NOTES.md` with a one-line
supersession map:
```
004 (akten table) → 018 (projects table)
005 (akten_events table) → 018 (renamed to project_events)
005 (notes parent FK) → 018 + 020 (renamed columns)
006 (can_see_akte function) → 018 (renamed to can_see_project)
015 (users.dezernat column) → 027 (dropped + replaced by partner_unit_members)
019 (seed dezernat strings) → 027 (re-applies seed before drop)
024 (department column rename) → 027 (further renamed to partner_unit)
```
Don't squash. The live DB has a stale `public.paliad_schema_migrations`
artifact (per knuth's t-paliad-071 ship memory) that would block any
re-numbering. Documentation > squashing.
**Batch with F-11 + F-12.**
---
#### F-11. Migration 027 down silently drops the `partner_unit_events` audit table
🟡 future-proofing · 🟢 ≤30 min · doc-only
**File:** `internal/db/migrations/027_rename_to_partner_units.down.sql:19`
```sql
-- 1. Drop the audit table.
DROP TABLE IF EXISTS paliad.partner_unit_events;
```
The header comment honestly admits the `users.dezernat` data loss but
buries the audit-table drop without a warning banner. Operators
running a rollback in 2027 would lose months of audit history without
realising.
**Fix:** prepend to the down file:
```sql
-- DATA LOSS WARNING: this rollback drops paliad.partner_unit_events with no
-- recovery path. All audit log entries (created/updated/deleted/member_added/
-- member_removed) are permanently lost. If you need to preserve them, dump
-- the table before applying this rollback:
-- pg_dump -t paliad.partner_unit_events ... > pue-backup.sql
```
**Batch with F-10 + F-12.**
---
#### F-12. `paliad.link_suggestions` and `paliad.link_feedback` exist in schema, are not used
🟠 friction now · 🟡 1-2 h · stand-alone task
**Files:**
- `internal/db/migrations/011_feedback_tables.up.sql:17-38` — creates
`paliad.link_suggestions` and `paliad.link_feedback` (and RLS
policies, indexes).
- `internal/handlers/links.go:247, 281, 290` — still POSTs to
`public.patholo_link_suggestions` / `public.patholo_link_feedback`
via PostgREST.
Migration 011 (2026-04-16) flagged this explicitly:
```sql
-- A follow-on phase (P1 handler refactor) will:
-- (1) swap handlers from PostgREST to the direct DB connection,
-- (2) copy any meaningful rows from the public tables,
-- (3) drop the public tables and this duplication.
```
That follow-on never landed. Today the platform:
- has dead tables in its own schema,
- still depends on a PostgREST round-trip + Supabase anon key for two
endpoints,
- writes to `public.patholo_*` tables that the rest of the platform no
longer touches and that are invisible to the audit log
(`AuditService.UNION ALL` doesn't see them).
**Fix:** rewrite `handleLinkSuggest` / `handleLinkFeedback` /
`handleLinkSuggestionsPendingCount` to use `dbSvc.db.ExecContext()` /
`QueryContext()` against `paliad.link_suggestions` /
`paliad.link_feedback`. Drop `supabaseInsert` / `supabaseCount`
helpers (they're only used by these three sites). Out-of-band SQL
(not a migration) to copy any non-zero row counts from the public
tables and drop them.
After this, the only `mgit.msbls.de/m/patholo`-named or
`patholo_*`-named runtime artifacts are: the legacy session cookie
fallback (which has a 2026-05-18 sunset, see F-15) and the Go module
path (F-6).
**Warrants its own task:** yes — finishes a P1 from migration 011's
own comment.
---
### 1.5 Tests
#### F-13. Live-DB tests skip silently; no CI exists
🔴 active risk · 🟡 1-2 h · stand-alone task
**Files:** 7 tests across `user_service_test.go`,
`deadline_service_test.go`, `reminder_service_test.go` (×3),
`visibility_test.go`, `audit_service_test.go` all use the same idiom:
```go
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
```
The repo has no `.github/`, `.gitea/`, or any CI configuration. So
in practice **these tests never run unless a developer remembers to
set `TEST_DATABASE_URL` locally**. The t-paliad-069 reminder
placeholder bug masked itself for ~24 h via this exact path. F-1 above
(AdminDeleteUser SQL bug) is another instance — a passing
`TestAdminDeleteUser` against the live DB would have failed the moment
migration 027 was applied.
**Fix (cheapest):** add `.gitea/workflows/test.yml` (Gitea Actions
runs on this repo's host) that:
1. Brings up an ephemeral Postgres 16 (`services:` block).
2. Applies migrations.
3. Sets `TEST_DATABASE_URL` to the ephemeral DB.
4. Runs `go test ./...`.
**Fix (safer):** also flip the skip to `t.Fatal` when `CI=true` is set,
so a misconfigured CI workflow doesn't silently skip again.
**Warrants its own task:** yes — one PR, prevents the recurrence of
two known footguns.
---
#### F-14. Test coverage gaps in core services
🟠 friction now · 🔴 half-day+ per service · sequence after F-13
**Missing tests** (no `_test.go` exists for these services):
- `caldav_crypto.go` — AES-GCM encrypt/decrypt of CalDAV passwords. Pure-Go, no DB needed. Highest priority because crypto bugs are silent and irreversible.
- `caldav_client.go` — iCal parse / WebDAV PROPFIND. Table-driven against canned multi-status XML.
- `caldav_ical.go``formatTermin`, `formatAppointment`. Pure-Go.
- `agenda_service.go``annotateAgendaUrgency` is a calc routine, easily testable without DB.
- `dashboard_service.go` — same logic class as agenda.
- `note_service.go` — polymorphic visibility logic.
- `appointment_service.go` — personal-vs-project visibility branching.
- `partner_unit_service.go` — audit-emit-in-tx ordering (cronus' t-paliad-070 memory says "emit before delete" — that's an invariant worth testing).
- `team_service.go` — team membership query shape.
- `project_service.go` — tree-walking, ancestor/descendant logic, ltree path materialisation.
- `party_service.go` — visibility delegation.
**Effort:** non-trivial. Don't try to do all in one PR.
**Suggested order (value-per-hour):**
1. **`caldav_crypto_test.go`** — table-driven (plain → ciphertext → plain) with known vectors. ~30 min, irreversibly valuable.
2. **`agenda_service_test.go`** — table-driven on `annotateAgendaUrgency`. ~30 min, no DB needed.
3. **`partner_unit_service_test.go`** — verifies audit-emit-in-tx + ON DELETE SET NULL invariant. Live-DB test (so do this *after* F-13 to ensure CI runs it).
4. **Everything else** — one ticket per service, low priority.
**Warrants its own task:** one task per service, so head can dispatch
in priority order.
---
### 1.6 Dead code & doc drift
#### F-15. Legacy `patholo_session` / `patholo_refresh` cookie fallbacks
🟡 future-proofing · 🟢 ≤30 min · scheduled cleanup
**File:** `internal/auth/auth.go:27-28`
```go
LegacySessionCookieName = "patholo_session"
LegacyRefreshCookieName = "patholo_refresh"
```
Per t-paliad-018 ship memory: "Remove legacy fallbacks after
2026-05-18 (30d cookie max age)." Today is 2026-04-30. **Sunset is in
~18 days.** All users who haven't logged out since 2026-04-18 will be
forced to re-authenticate.
**Fix:** add a calendar reminder for 2026-05-19 to delete the legacy
constants and the fallback branch in `Middleware`. The associated
test (`TestMiddleware_LegacyCookieAccepted`,
`TestMiddleware_NewCookiePreferredOverLegacy`) goes away too.
The middleware code that handles the upgrade path is at lines 240-256.
**Warrants its own task:** yes — small one. Schedule for 2026-05-19.
---
#### F-16. Stale comment in `team_pages.go` references dropped API endpoint
🟡 future-proofing · 🟢 ≤30 min · doc-only
**File:** `internal/handlers/team_pages.go:5-7`
```go
// GET /team — directory of all Paliad users grouped by office or department.
// Server-rendered shell; the client (assets/team.js) hydrates from /api/users
// and /api/departments?include=members.
```
`/api/departments` was renamed to `/api/partner-units` in t-paliad-070.
**Fix:** update the comment. Same for `internal/handlers/admin_users.go:123`
("`project_teams / department_members` cleanup") which post-dates t-paliad-070.
---
#### F-17. `internal/db/migrations/_dev/mock_supabase_auth.sql` lives inside the embed root
🟡 future-proofing · 🟢 ≤30 min · already known
**File:** `internal/db/migrations/_dev/`
Already noted in 2026-04-18 audit as **T-4**. Not fixed since. The
embed.FS pattern likely filters by `NNN_*.sql` naming so it doesn't
break, but it's one regex-loosening from a build bug.
**Fix:** move to `internal/db/devtools/`.
---
#### F-18. Project status doc says "Audit polish-2 ~25 BATCH-level findings not yet shipped"
🟡 future-proofing · 🟢 ≤30 min · doc-only
**File:** `docs/project-status.md:20`
The doc was last updated before today's t-paliad-073 cleanup PR shipped
the DEFER list. Open follow-ups should be re-checked.
---
### 1.7 Architecture observations (no concrete fix)
#### O-1. `models.go` is one 358-line file with 14+ types
Already noted in 2026-04-18 as **T-11**. Not bad enough to warrant a
ticket on its own, but next time anyone touches a struct, split by
domain. Effort negligible.
#### O-2. `frontend/build.ts` has 24 hand-maintained render-and-write pairs
Already noted as **T-13**. Adding a page requires edits in 4 places
(build.ts, handlers.go, i18n.ts, global.css). A page-manifest reduces
to 1 place. Not urgent but would speed up new-page work.
#### O-3. `RLS policies exist but never enforced`
Original audit **T-1**. Status unchanged. The Go backend uses a
service-role connection so migration 007's RLS policies never run.
Decision still open: drop, document, or switch to per-request JWT
connections.
The 2026-04-18 audit recommended Option A (document + `SET row_security
= off`). That's still right; one comment block in `internal/db/db.go`
would close this out.
---
## 2 — Top-10 ranked by value-per-effort
| Rank | ID | Description | Severity | Effort |
|---|---|---|---|---|
| 1 | F-1 | Fix `AdminDeleteUser` SQL — wrong table names | 🔴 | 🟢 |
| 2 | F-13 | Add CI for live-DB integration tests | 🔴 | 🟡 |
| 3 | F-2 | Centralise visibility predicate (10 sites → 1) | 🔴 | 🟡 |
| 4 | F-12 | Migrate link suggestions/feedback to `paliad.link_*` | 🟠 | 🟡 |
| 5 | F-8 | Type the i18n key + build-time `data-i18n` check | 🟠 | 🟡 |
| 6 | F-14a | `caldav_crypto_test.go` table-driven | 🟠 | 🟢 |
| 7 | F-14b | `agenda_service_test.go` annotateAgendaUrgency | 🟠 | 🟢 |
| 8 | F-9 | Bulk-rename German-prefix i18n keys to English | 🟠 | 🟡 |
| 9 | F-4 | Service layer naming sweep (German→English) | 🟠 | 🔴 |
| 10 | F-5 + F-16 + F-18 | Comment / doc cleanup batch | 🟡 | 🟢 |
Items 11+: F-10, F-11, F-15, F-17, F-3, O-1, O-2, O-3. Effort/value
drops sharply.
---
## 3 — Coordination notes
- **F-1 first.** It's a live bug. Ship before anything else.
- **F-13 second.** Without CI, we'll keep building features on top of
silent test gates. The placeholder bug from t-paliad-069 and F-1
here are the second and third instances of "live-DB test would
have caught it." Fix the meta-problem.
- **F-12 + F-15 are the last two `patholo_*` runtime artifacts** (after
the cookie sunset). Closing both makes the rebrand complete.
- **F-4 + F-9 are mechanically large but conceptually trivial.** Good
AFK candidates — give to a Sonnet coder with explicit "don't change
semantics" framing.
- **F-2 needs careful eyes.** It's security-adjacent. Pair with a
reviewer or run as a dedicated PR with explicit before/after EXPLAIN
plans on the affected queries.
---
## Appendix — items deliberately out of scope
- **Performance profiling** — different audit kind. The t-paliad-058
`string_to_array(p.path, '.')::uuid[]` pattern is a known wart but
fits in F-2's centralisation work.
- **Security review** — there's a `/security-review` skill for that.
Critical items C-1 through C-5 from the 2026-04-18 audit may have
been addressed (JWT verification was added — see `golang-jwt/jwt/v5`
in `go.mod`); a fresh security pass is its own task.
- **Frontend design / accessibility** — already covered by
`audit-polish-*-2026-04-2*` docs.
- **Phase H AI extraction** — explicitly deferred per CLAUDE.md.
- **Dropping the obsoleted migrations 004-006** — schema-history
integrity beats tidiness; doc the supersession instead (F-10).

649
docs/improvement-audit.md Normal file
View File

@@ -0,0 +1,649 @@
# Paliad — Product Audit & Improvement Roadmap
Prepared by `cronus` (inventor) on 2026-04-18 for task **t-paliad-015**.
Scope: complete code, UX, content, architecture, and ops audit after the
17 000-line KanzlAI → Paliad integration (Phases AJ, April 2026).
Audit posture: first-real-user (HLC patent lawyer, Munich) **and**
long-term architect. Items are prioritised and actionable — each has a
file reference and a suggested fix. No hour estimates; effort is
expressed as **S / M / L** (small / medium / large).
- **S** = <1 day of focused work; single file or localised change
- **M** = multi-file change, migration, or non-trivial design
- **L** = new subsystem, cross-cutting rework, or external dep
---
## TL;DR — the two things to fix today
1. **The session middleware does not verify JWT signatures** and the Go
backend extracts `auth.uid()` from that unverified token to gate every
database read and write. A forged cookie with any `sub` and a future
`exp` gives an attacker access to any user's Akten, including admin.
See **C-1** below. `internal/auth/auth.go:178` +
`internal/auth/user.go:60`.
2. **The dashboard leaks every user's personal Termine to every other
logged-in user** for the next 7 days (title, location, description,
start/end). `internal/services/dashboard_service.go:245`. See **C-2**.
Both are one-file fixes and must ship before this audit reaches a wider
pilot group.
---
## 1 — Critical (fix immediately)
### C-1. Session JWTs are not signature-verified
**File:** `internal/auth/auth.go:178`, `internal/auth/user.go:60`
**Severity:** Critical (authZ bypass)
**Effort:** M
`Client.Middleware` accepts the session cookie, parses `exp` via
`DecodeJWTExpiry`, and calls `next` if the token is unexpired. The
signature is never checked. `WithUserID` then base64-decodes the `sub`
claim straight into the request context. `AkteService.GetByID` and every
other service trusts that UUID as the authenticated user.
Exploit: craft any JWT with `exp` in the future and `sub =
<admin-user-uuid>`. Set it as the `patholo_session` cookie. The Go
backend uses a service-role Postgres connection, so RLS policies do not
run and the app-level visibility check sees the forged UUID as a real
admin. Effectively: anyone with a Paliad cookie can impersonate anyone.
**Fix:**
- Fetch the Supabase project's JWT signing key (JWKS endpoint:
`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`) or the shared
`SUPABASE_JWT_SECRET` and verify the token in `Middleware` before
trusting any claim.
- Cache the JWKS for 1 hour; rotate on kid mismatch.
- `WithUserID` should read the *verified* claims, not re-decode the raw
cookie.
- Consider `github.com/golang-jwt/jwt/v5` already idiomatic for Go.
**Related:** `internal/services/akte_service.go:18-24` explicitly
documents that RLS "does not kick in because the backend does not
provide a JWT-backed auth.uid()" so the app-layer predicate is the
*only* gate. The JWT must therefore be trusted.
---
### C-2. Dashboard leaks every user's personal Termine cross-user
**File:** `internal/services/dashboard_service.go:232-256`
**Severity:** Critical (privacy / confidentiality)
**Effort:** S
```sql
WHERE t.start_at >= $4
AND t.start_at < ($4 + interval '7 days')
AND (t.akte_id IS NULL -- ← ANY personal Termin, any user
OR a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin')
```
Personal Termine (`akte_id IS NULL`) are creator-only by contract
(`TerminService.canSee` enforces `created_by = userID`). The dashboard
forgets this filter and returns up to 10 such rows for *any* user
title, location, description, times included.
In practice an associate's "Bewerbungsgespräch bei Kanzlei X" is visible
to partners and vice-versa.
**Fix:** change the personal-Termin branch to
`(t.akte_id IS NULL AND t.created_by = $2::uuid)` mirrors the already-
correct rule in `termin_service.go:103`.
---
### C-3. Any user with visibility can delete Akte children (Parteien, Termine)
**File:**
- `internal/services/parteien_service.go:93-111` Delete has no role gate.
- `internal/services/termin_service.go:357-398` Akte-linked Termine: only
personal Termine check creator; Akte-linked pass through with just
visibility.
**Severity:** Critical (data loss; inconsistent with Fristen policy)
**Effort:** S
An associate in London, viewing a Munich firm-wide Akte, can
`DELETE /api/parteien/{id}` and erase the Klägerin-record, or delete any
hearing from the Akte's calendar. `FristService.Delete` already scopes
to `partner|admin` only the other child services should follow suit.
**Fix:** require `user.Role in {partner, admin}` (or `created_by =
userID`) for delete on `ParteienService` and `TerminService`. Apply the
same rule to `Update` on both.
---
### C-4. Email gate still pins to `@hoganlovells.com`
**File:** `internal/handlers/auth.go:113-116`
**Severity:** Critical (post-merger lockout)
**Effort:** S
HLC email domains (`@hlc.com`, `@hlc.de`) cannot register or log in.
Login-form placeholders still say `name@hoganlovells.com`.
**Fix:**
- Replace `isHoganLovellsEmail` with an env-configurable whitelist,
default `hoganlovells.com,hlc.com,hlc.de` (design §2 already
requested this).
- Update placeholders in `frontend/src/login.tsx:26,34` + the
`login.hint` i18n key (de + en).
- Error strings (`"Zugang nur für @hoganlovells.com …"`) should become
"… für autorisierte HLC-E-Mail-Adressen."
---
### C-5. `CALDAV_ENCRYPTION_KEY` not wired into production compose
**File:** `docker-compose.yml:4-12`
**Severity:** Critical in effect (feature silently disabled)
**Effort:** S
The compose file declares `SUPABASE_URL`, `SUPABASE_ANON_KEY`,
`GITEA_TOKEN`, `DATABASE_URL`. It does **not** pass
`CALDAV_ENCRYPTION_KEY`. On the Dokploy compose (`Zx147ycurfYagKRl_Zzyo`)
this means `cmd/server/main.go:66` logs
"CALDAV_ENCRYPTION_KEY not set CalDAV endpoints will return 501" and
the entire Termine-sync feature is dead on paliad.de. Users who save
their CalDAV settings see a 501 from `/api/caldav-config` and conclude
the product is broken.
**Fix:** add `- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}` (and
while we're at it, a placeholder `ANTHROPIC_API_KEY` commented out so
Phase H reactivation is one uncomment). Set the actual key on Dokploy.
---
## 2 — Important (fix this week)
### I-1. Dokumente tab on Akten detail is a dead placeholder
**File:** `frontend/src/akten-detail.tsx:215-222`,
`frontend/src/client/i18n.ts:468,1241`
**Severity:** Important (visible UI dead-end; leaks internal phase names)
**Effort:** S
The tab is rendered, clickable, and shows the text "Dokumenten-Upload
folgt in Phase H." a strong UX signal that the product is unfinished.
Phase H is deferred indefinitely.
**Fix (pick one):**
- Hide the Dokumente tab entirely until there is real content drop it
from the VALID_TABS list in `akten-detail.ts:62` and the TSX tabs
strip.
- Or keep it visible but replace the copy with a neutral "Dokumenten-
Upload in Planung" (no phase leak) and a discreet CTA to vote/express
interest (write to `paliad_feature_interest`).
I'd hide it dead tabs are worse than missing features.
### I-2. Office labels in German not translated to English
**File:** `frontend/src/index.tsx:122-128`
**Severity:** Important (i18n regression visible on landing page)
**Effort:** S
Only `index.munich` has a `data-i18n` key. Düsseldorf, Hamburg,
Amsterdam, London, Paris, **Mailand** render raw German in EN mode.
"Mailand" "Milan" is the most obvious miss; Düsseldorf/London/Paris
are correct in both languages but the fact that only one is i18n'd is a
consistency bug waiting for the next new office.
**Fix:** add `index.office.munich|duesseldorf|hamburg|amsterdam|london|
paris|milan` keys (de: "München / Düsseldorf / Hamburg / Amsterdam /
London / Paris / Mailand"; en: "Munich / Düsseldorf / Hamburg /
Amsterdam / London / Paris / Milan").
### I-3. Gerichtsverzeichnis UPC URLs redirect
**File:** `internal/handlers/gerichte.go` (45 occurrences of
`unifiedpatentcourt.org`, no hyphens)
**Severity:** Important (one redirect per link click; flagged as wrong by
link-checkers)
**Effort:** S
Canonical UPC website is `https://www.unified-patent-court.org` (with
hyphens). The no-hyphen form returns HTTP 403 challenge pages from
Cloudflare on naive `curl` but does resolve to the same destination.
`internal/handlers/links.go` uses the canonical form the two files
are inconsistent. Pick one. Canonical (hyphenated) is safer.
**Fix:** sed-replace `unifiedpatentcourt.org` `unified-patent-court.org`
across `gerichte.go` (and re-verify the deep paths still resolve
some `/en/court/court-appeal` style URLs may have moved).
### I-4. `loadRecentActivity` omits personal Termine audit rows (not a bug yet, but will be)
**File:** `internal/services/dashboard_service.go:259-287`
**Severity:** Medium-Important (correctness risk)
**Effort:** S
`loadRecentActivity` joins `akten_events` `akten`, so only
Akte-attached events appear. That is correct today because personal
Termine explicitly skip the audit (per Phase F memory). If a future
contributor adds a personal-Termin event type without reading the
design, this query will silently drop it add a comment or switch to
`LEFT JOIN` + `WHERE a.id IS NULL OR <visibility>`.
### I-5. `/api/akten/{id}/events` has no pagination or size limit
**File:** `internal/services/akte_service.go:368-383`
**Severity:** Important (scalability; long-running Akten)
**Effort:** M
`ListEvents` returns every row ever written to `akten_events` for an
Akte. A three-year litigation with CalDAV sync and daily notizen edits
could easily reach 510 k rows. The Verlauf tab will then ship 2 MB of
JSON on each tab-click.
**Fix:** add `?before=<uuid>&limit=50` cursor pagination and render
"Load more" in the Verlauf tab. Already noted in the Phase E followup
list, never actioned.
### I-6. Glossar is missing FRAND / SEP / related commercial-patent terms
**File:** `internal/handlers/glossar.go:33-118` (~86 entries)
**Severity:** Important (content gap for the target audience)
**Effort:** S
HLC's patent practice is a heavy SEP/FRAND shop; the glossary has zero
entries for: FRAND, SEP, Standard-essentielles Patent, Patentpool, Anti-
Anti-Suit Injunction, Injunction gap, Orange-Book-Verfahren,
Huawei/ZTE-Verhandlungsmuster, RAND, ETSI IPR Policy, Patent-Hold-up,
Patent-Hold-out. These are table-stakes for the intended user.
**Fix:** add ~12 entries under a new `SEP/FRAND` category (or stretch
`Litigation`). Pull definitions from the canonical CJEU/BGH case law.
### I-7. README is out of date
**File:** `README.md:30-43,107`
**Severity:** Important (onboarding drag)
**Effort:** S
- Migration list stops at 013; 014 (`checklist_instances`) is live.
- Line 107 says "Phase I (Notizen) pending service and UI aren't
built yet"; it shipped on `mai/knuth/phase-i-notizen` (commit
`5a9f8e5`, 2026-04-17) with full service + handlers + UI.
**Fix:** refresh the migrations block (`014_checklist_instances …`) and
the status paragraph. Phase I Done, Phase J docs-only done, infra
retirement pending.
### I-8. Legacy "patholo_" names everywhere
**File:** `internal/auth/auth.go:17-18`, `internal/handlers/links.go:315`,
`frontend/src/client/i18n.ts:6` (`STORAGE_KEY = "patholo-lang"`),
`frontend/src/client/*.ts` (other localStorage keys),
`paliad_link_suggestions` / `paliad_link_feedback` table names (compare
vs. the older `patholo_*` references in the code).
**Severity:** Important (brand inconsistency; minor confusion)
**Effort:** M
Cookie name `patholo_session`, storage key `patholo-lang`, Supabase
tables `patholo_link_suggestions` / `patholo_link_feedback`. Memory
notes a deliberate decision to *keep* the cookie name so users aren't
logged out that's fine but storage keys and table names can migrate
without user impact.
**Fix:** plan a single branch that:
- Renames `STORAGE_KEY` `paliad-lang` with a one-shot migration from
the old key (read old, write new, delete old) in `i18n.ts` init.
- Renames tables to `paliad_link_suggestions` / `paliad_link_feedback`
(migration + update callers in `handlers/links.go`).
- Leaves the cookie name alone or migrates it with a dual-read grace
period.
---
## 3 — Polish (nice to have)
### P-1. HL Intern links are stubs
`internal/handlers/links.go:206-219` two entries with `URL: "#"`
render as clickable cards that go nowhere.
**Fix:** either remove the entries until real URLs land, or add a
"Coming soon suggest a URL" flag + disabled styling.
### P-2. Dashboard says "Meine Mandate" but the nav and URL say "Akten"
`frontend/src/dashboard.tsx:82`, i18n key `dashboard.matters.heading`.
The project's naming convention (CLAUDE.md) mandates **Akten**
throughout the term "Mandate" is explicitly historical. The dashboard
heading should read "Meine Akten" / "My Matters".
**Fix:** change i18n text to "Meine Akten" (DE) and leave
"My Matters" (EN). Rename i18n key if desired.
### P-3. Login page placeholder still says `name@hoganlovells.com`
Covered by **C-4**. Mentioned again here because the hint
"Nur für @hoganlovells.com Adressen." is a polish concern even after
the whitelist change update to "Nur für autorisierte HLC-Adressen."
### P-4. Landing page footer reads "Hogan Lovells Patent Practice"
`frontend/src/components/Footer.tsx:7`. Brand reads as *old* on every
page. Switch to "HLC Patent Practice" post-merger (or drop the firm
name entirely since Paliad is supposed to survive renames).
### P-5. No explicit empty-state copy on Fristen-Kalender / Termine-Kalender
Check `frontend/src/fristen-kalender.tsx`, `termine-kalender.tsx`.
Calendars with no events render an empty grid add a subtle "Keine
Fristen / Termine im ausgewählten Zeitraum." string.
### P-6. Office names in `AkteService.isValidOffice` diverge from UI labels
`akte_service.go:403-408` accepts keys `munich, duesseldorf, hamburg,
amsterdam, london, paris, milan`. The `models.go` user has the same
list. But the UI (landing page) writes "München", "Düsseldorf",
"Mailand". Currently fine because admins edit office via internal role
only, but any future user-facing office selector must map label
key add a single source of truth (`internal/calc/offices.go` or
similar: `{key: "munich", labelDE: "München", labelEN: "Munich"}`).
### P-7. `Glossar` CSVs hard-coded in one file (230 lines)
Not a bug, but as the list grows toward 150+ terms it wants to move out
of Go source into `internal/handlers/glossar_data.go` or a JSON blob in
`internal/data/glossar.json`. Easier for non-devs to edit.
### P-8. Dockerfile lacks `HEALTHCHECK` and runs as root
`Dockerfile:13-19`. No `HEALTHCHECK`, no `USER nonroot`. Both are easy
wins for Dokploy's health surface and container hardening.
```dockerfile
# before the CMD
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/ || exit 1
RUN addgroup -S paliad && adduser -S -G paliad paliad
USER paliad
```
Add a `GET /healthz` route that returns 200 without auth; current `/`
redirects and wgets a 200 anyway, but an explicit probe is cleaner.
### P-9. Landing-page copy still frames Paliad as "Paliad — Patentwissen für Hogan Lovells"
`frontend/src/client/i18n.ts:38-48`. With Phase 0 shipped, Paliad is
more than a knowledge hub it's the Aktenverwaltung. The hero section
should call that out. Proposed: "Paliad alles für den Patentalltag:
Akten, Fristen, Termine und Wissen."
### P-10. Kostenrechner PDF and Checklisten print — not verified by audit
The design says PDF export works. I did not test it; please verify
after any CSS change that affects `.tool-page` / `.print\:hide`.
Consider adding a visual-regression snapshot to the build.
---
## 4 — Features (prioritised backlog)
### F-1. Global search (Ctrl-K / Cmd-K)
**Impact:** Very High
**Effort:** M
A single keystroke that searches across Akten, Fristen, Termine,
Parteien, Glossar, Gerichte, Links. Patent lawyers hop between matters
constantly; the current UX requires clicking through 2 sidebar entries
to find anything. Index on the backend with `tsvector` + `pg_trgm`, or
build a client-side Lunr index for the static content and a single
`/api/search?q=…` that fans out for user data. Match by Aktenzeichen,
party name, Frist title.
### F-2. Verfahrensleitfäden (procedure guides)
**Impact:** Very High (per the original roadmap, never shipped)
**Effort:** L
Step-by-step UPC / BPatG / EPO workflows that stitch together
checklists, deadline rules, and recommended templates. This is the
biggest original-roadmap item still missing.
### F-3. Expand the Downloads registry
**Impact:** High (2/10 on the roadmap, partial)
**Effort:** M
Only `HL Patents Style.dotm` is wired up. mWorkRepo has more
templates. Wire `Vollmacht.dotm`, `Unterlassungserklärung.dotm`,
`UPC-Klageschrift-Skeleton.dotm` mirror the existing proxy pattern.
### F-4. Benachrichtigungen (notifications)
**Impact:** Medium-High
**Effort:** L
Email digest of today's overdue/due Fristen + tomorrow's Termine, sent
at 07:00 CET per user. Supabase pg_cron + Edge Functions or a Go
scheduler in the Paliad binary. Opt-in per user.
### F-5. What's New / Changelog in-app
**Impact:** Medium
**Effort:** S
`docs/changelog.md` rendered under `/whatsnew` with a red dot on the
sidebar when there's an entry newer than the user's `last_seen_changelog`
timestamp. Cheap way to communicate feature releases.
### F-6. Akten-Kurzinfo on hover (pop-over)
**Impact:** Medium
**Effort:** S
Hovering an Aktenzeichen in any list (Dashboard activity, Fristen,
Termine) shows a tooltip with matter title, court, next Frist. Reduces
tab-switching.
### F-7. Parteien mit Kontaktkarten (Update endpoint)
**Impact:** Medium
**Effort:** S
Currently `ParteienService` only supports Create + Delete. No Update.
Typos in a party name force a delete + re-add and lose the ID. Add
PATCH `/api/parteien/{id}`.
### F-8. Fristenrechner — "Als Frist speichern" mit Wiedervorlage
**Impact:** Medium
**Effort:** S
When creating a Frist with a Rule, also derive a Wiedervorlage-Frist
(warning-date = due - 14 d) and offer to create both in one click.
### F-9. Dark mode
**Impact:** Low (but free with CSS variables)
**Effort:** S
`global.css` already uses CSS variables heavily. Add a
`@media (prefers-color-scheme: dark)` block + a manual toggle in the
sidebar.
### F-10. Document upload without AI (revisit Phase H)
**Impact:** Medium (still open per memory episode)
**Effort:** M
The Supabase Storage upload path already works; only the AI-extraction
part was rejected. A plain "upload PDF to Akte" with no auto-extraction
is still useful and unblocks the Dokumente tab.
### F-11. Akten-Tags / Labels
**Impact:** Medium
**Effort:** M
Free-form tags on Akten (e.g., `SEP`, `OLG-Düsseldorf`, `Q4-Priority`)
with a filter UI. Cheaper than a full custom-fields system and used
extensively at comparable firms.
---
## 5 — Technical Debt
### T-1. RLS policies exist but are never enforced
`internal/db/migrations/007_rls_policies.up.sql` defines full office-
scoped RLS keyed off `auth.uid()`. The Go backend uses a service-role
connection (`db.OpenPool` with `DATABASE_URL`), so those policies never
evaluate every query bypasses RLS by design.
This is correct today (service-role means app-level checks must be
bulletproof, which they mostly are) but it means the RLS layer is dead
weight that increases surface for a migration bug to produce a
silently-wrong policy. Decide:
- **Option A:** accept the "belt-and-braces" position, keep RLS,
document loudly that it's not active in prod.
- **Option B:** drop the RLS migration and policies less code, less
false sense of security.
- **Option C:** switch to PostgREST-style per-request JWT connections so
RLS actually runs. This is the most defensive but a substantial
rewrite.
My recommendation: Option A, with an `internal/db/RLS_NOTE.md` and a
`SET row_security = off;` (or equivalent) in the service-role
connection so the policies don't quietly cost perf.
### T-2. Go module path still `mgit.msbls.de/m/patholo`
`go.mod:1` + every `import` statement. Not breaking, but a daily
reminder of the old name. Rename on the next big refactor, not now
every file in the repo would churn.
### T-3. `calDAVClient` hand-rolls iCal + WebDAV
Documented in the Phase F memory as a deliberate choice. Fine for now,
but re-evaluate when any of these lands:
- Importing foreign UIDs (currently skipped)
- RRULE / VTIMEZONE / DTSTART-with-tzid
- Multi-calendar support per user
At that point switch to `github.com/emersion/go-ical` +
`github.com/emersion/go-webdav`.
### T-4. `_dev/mock_supabase_auth.sql` lives inside `migrations/`
`internal/db/migrations/_dev/`. Embed.FS includes all files under
`migrations/` the `_dev` directory is probably ignored by
`iofs.New` (directories not matching the pattern `NNN_*.sql` are
filtered) but it's one hop from a build bug. Move to
`internal/db/devtools/` or similar, outside the embed root.
### T-5. Client-side duplicated helpers
Memory's Phase E followup notes: `urgency-color` + `date-format` JS
helpers are duplicated across `fristen.ts`, `fristen-detail.ts`,
`fristen-kalender.ts`, `akten-detail.ts`. Extract to
`frontend/src/client/fristen-shared.ts`. Not urgent, but next time
anyone touches the Fristen UI: do this first.
### T-6. `/api/fristen?status=all` returns everything client-side filters
Per Phase E memory: fine at current volumes, but a partner with 500
completed Fristen will see the full 500 on every calendar load. Add
server-side date-range filtering before that's a support ticket.
### T-7. No error monitoring
The server logs to stdout (which Dokploy captures), but there is no
Sentry / log-based alert wiring. Nobody knows when something breaks in
prod until a user complains. Options:
- Wire Supabase log drain (if Dokploy supports it).
- Self-host a lightweight OpenTelemetry collector on mlake.
- At minimum: `GET /metrics` (Prometheus text) with basic counters, and
a cron that posts to Gotify when error-rate crosses a threshold.
Pick the cheapest now we can evolve later.
### T-8. No structured logging
Mixed `log.Printf` + `slog.Info` across the codebase. Pick one, default
to `slog` with a JSON handler in prod. Pre-populate user-id and
request-id in the context for correlation.
### T-9. No backup verification for `paliad` schema
Supabase's automatic backups cover the whole instance, but
`paliad.paliad_schema_migrations` collision history (memory episode
"paliad migration bootstrap collision") suggests the shared-Postgres
posture is fragile. Add a weekly `pg_dump --schema=paliad` cron on
mlake that writes to an offsite bucket and a monthly restore-smoke-test
(into an ephemeral Postgres container).
### T-10. Test coverage gaps
Only `akte_service_test.go`, `deadline_calculator_test.go`,
`holidays_test.go`, `fees_test.go` exist. Missing: `FristService`,
`TerminService`, `NotizService`, `CalDAVService` (unit tests for
iCal encode / decode, encrypt / decrypt, visibility edge-cases).
The CalDAV crypto in particular should have a table-driven test with
known vectors.
### T-11. `internal/models/models.go` is one big file
Not inherently bad, but it's the dumping ground for 14 types. Split by
domain (`user.go`, `akte.go`, `frist.go`, `termin.go`, …) next time it's
touched.
### T-12. Dockerfile build cache hygiene
Every `COPY . .` invalidates on any source change, forcing a full `go
mod download`. Move `go.mod`/`go.sum` copy + `go mod download` *before*
`COPY . .` already done. But also consider `FROM golang:1.24-alpine
AS backend` `golang:1.24` with `CGO_ENABLED=0` so libc-less `alpine`
runtime doesn't fight any future pgx upgrade. Minor; today it's fine.
### T-13. `frontend/build.ts` has 24 hand-maintained render-and-write pairs
Any new page requires edits in 4 places (build.ts, handlers.go
registration, i18n keys, CSS). Consider a page-manifest an array
`[{slug, render, clientEntry}]` that the build script iterates over.
Less ceremony; harder to forget one of the four places.
### T-14. `Gitea-backed file proxy` has no ETag / If-Modified-Since
`internal/handlers/files.go` caches bytes and the commit SHA in memory
but never sets an `ETag` or `Last-Modified` response header, so every
browser re-downloads a 500 KB .dotm on each click. Add:
`w.Header().Set("ETag", entry.sha)` + handle `If-None-Match` with a
304. Cheap win.
---
## Delivery suggestion
- **Week 1 (critical):** C-1, C-2, C-3, C-4, C-5. All small- to
medium-effort and gates production readiness.
- **Week 2 (important):** I-1, I-2, I-3, I-7, I-8. Polish the
post-merger brand story.
- **Week 3 (features, pick 2):** F-1 (global search) gives the biggest
daily-use win; F-2 (Verfahrensleitfäden) is the biggest content win;
F-3 (Downloads) closes a roadmap item cheaply.
- **Always-on (tech debt):** pick one T-item per sprint until they're
gone.
None of the critical items are theoretical the JWT signature bypass
and the dashboard Termine leak are real exploit-paths I could walk
through today with a curl command and a Paliad cookie. Fix those two
first.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,459 @@
# Fristenrechner v4 — RoP-rigorous tree, working filter, card-click computes a deadline
**Task:** t-paliad-136
**Branch:** mai/cronus/inventor-fristenrechner-v4-design
**Status:** Inventor design — gated. No code changes in this shift.
**Predecessors:** v3 (`unified-fristenrechner-v3.md`, t-paliad-133), B1-result-cards (t-paliad-134), pill ordering + dedup (t-paliad-134 v2)
m's three concerns from 2026-05-05 11:5811:59, **in m's priority order**:
1. **Card-click does nothing.** Should expand into a calculation panel that takes a trigger date (default today), shows the resulting deadline (with t-119 adjustment-reason explainer + t-121 vacation-skip), and exposes an "Add to project" CTA that drops the deadline into an existing Akte. _Most important._
2. **Filter narrowing is broken.** Picking "CMS-Eingang → Gegenseite → UPC Verletzung" still surfaces national submissions. Confirmed bug — see §2.
3. **Decision tree must follow the RoP rigorously.** The seed (migration 049) was rapid first-pass; concept↔leaf mappings have errors. Audit + correction in §3.
The work splits into three independent migrations / phases (see §4) so we can ship the bug-fix without waiting on the taxonomy revision.
---
## 1 — Card-click → compute deadline → add to project
### 1.1 Why this is the headline feature
v3 shipped concept cards that visualise "which deadline applies in which forum" at every B1 leaf — a great discovery surface. But the cards are **terminal**. The user can read the pill ("Klageerwiderung · UPC RoP R.23(1) · 3 Monate"), and then they're stuck. To actually compute a date they have to switch back to Pathway A's Verfahrensablauf, click the matching proceeding button, type the date in step 2, and read the deadline out of the timeline.
That's the round-trip Pathway B was supposed to eliminate. The v2 calculator (CORE Pathway A) had **trigger-date → computed-deadline** as the only feature; the v3 cards lost that.
This phase brings it back, **scoped to the single rule the user clicked**.
### 1.2 UX spec — inline calc panel inside the card
When the user clicks a result card, the card expands inline (no modal, no page navigation). The expanded card has three logical zones, top-to-bottom:
```
┌──────────────────────────────────────────────────────────────┐
│ ▾ Klageerwiderung [ × schließen ] │
│ Statement of Defence │
│ │
│ ┌──────────────── Pill picker (only if N>1) ──────────────┐ │
│ │ ◉ UPC Verletzung · R.23(1) · 3 Mon · Beklagter │ │
│ │ ○ DE Verletzung (LG) · §276 ZPO · 6 Wo · Beklagter │ │
│ │ ○ EPA Einspruch · R.79(1) · 4 Mon · Inhaber │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────── Trigger + Flags ─────────────────────────┐ │
│ │ Datum des auslösenden Ereignisses │ │
│ │ [ 2026-05-05 ▼ ] │ │
│ │ ☐ mit Nichtigkeitswiderklage (R.49.2.a) │ │
│ │ ☐ mit Patentänderungsantrag │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────── Berechnete Frist ────────────────────────┐ │
│ │ ► 04.08.2026 (3 Monate ab 05.05.2026) │ │
│ │ ⚠ Verschoben vom 03.08.2026 wegen UPC-Sommerferien │ │
│ │ (27.7.28.8.) — fällt auf nächsten Werktag. │ │
│ │ │ │
│ │ [ 📌 Zu Akte hinzufügen ] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
State transitions, all client-side:
| Action | Effect |
|---|---|
| Click a card row in `#fristen-b1-results` or `#fristen-search-results` | Card expands. Pill picker shows only if >1 pill survived narrowing. First pill auto-selected. Trigger date defaults to today. Calc fires immediately. |
| Click another pill in the picker | Re-fire calc with the new pill's rule. Flag checkbox visibility re-derived from the new rule's `condition_flag`. |
| Change trigger date | Debounce 200 ms (match B2 search debounce), re-fire calc. |
| Toggle a flag checkbox | Re-fire calc immediately (no debounce — discrete event). |
| Click "Zu Akte hinzufügen" | Open project picker (modal — reuse existing `frist-save-modal` from `client/fristenrechner.ts:332`). On submit, POST `/api/projects/{id}/deadlines/bulk` with a single payload row. Show inline success message inside the card with a link to `/deadlines?project_id=…`. |
| Click "× schließen" or click the card header again | Collapse back to compact view. |
Only **one card at a time** can be expanded — opening a second card collapses the first. This keeps the page short and avoids confusion about which trigger date applies where.
### 1.3 Picking the right pill on multi-pill cards
After §2's narrowing fix lands, most leaves will have 1 pill per card. But some legitimately have several:
- "Frist verpasst → EPA": `wiedereinsetzung` (Art. 122) AND `weiterbehandlung` (Art. 121) — **different rules entirely.**
- "Spätere Schriftsätze → Replik auf Erwiderung zur Nichtigkeitsw.": one pill per proceeding the rule applies in (UPC_INF only after fix).
- "CMS-Eingang → Gericht → Hinweisbeschluss": `response-to-preliminary-opinion` in `DE_NULL` (the only correct mapping after audit) — exactly 1 pill.
Heuristic: if the card has **1 pill** after narrowing, skip the pill picker and use that pill directly. If the card has **2+ pills**, render a radio-chip row preselecting the highest-`proceeding_display_order` pill (most-frequent forum first, t-paliad-134 ordering rule).
### 1.4 The single-rule calculator endpoint
We **do not** want to call `POST /api/tools/fristenrechner` (which renders the entire proceeding timeline) when the user clicks a card. That payload is 515 kB; we only need one rule.
**New endpoint:** `POST /api/tools/fristenrechner/calculate-rule`
Request body:
```json
{
"ruleId": "uuid", // either ruleId, OR
"proceedingCode": "UPC_INF", // (proceedingCode + ruleLocalCode)
"ruleLocalCode": "inf.sod",
"triggerDate": "2026-05-05",
"flags": ["with_ccr"] // optional; only flags applicable to the rule
}
```
Response:
```json
{
"rule": {
"id": "uuid",
"localCode": "inf.sod",
"nameDE": "Klageerwiderung",
"nameEN": "Statement of Defence",
"ruleRef": "RoP.023",
"legalSource": "UPC.RoP.23.1",
"legalSourceDisplay": "UPC RoP R.23(1)",
"durationValue": 3,
"durationUnit": "months",
"party": "defendant",
"isCourtSet": false,
"isMandatory": true
},
"proceeding": {
"code": "UPC_INF",
"nameDE": "Verletzungsverfahren",
"nameEN": "Infringement Action"
},
"triggerDate": "2026-05-05",
"originalDate": "2026-08-05",
"dueDate": "2026-08-05",
"wasAdjusted": false,
"adjustmentReason": null
}
```
When the rule has `condition_flag` and the user supplies all of them AND `alt_duration_value` is set, the response uses the alt values (existing flag-swap semantics from `services/fristenrechner.go:368`). When the rule is `is_court_set` (party='court' OR event_type ∈ {hearing, decision, order}), `dueDate` is empty and `isCourtSet=true` — the UI shows "Gericht-bestimmt" instead of a calc panel and disables the "Add to project" CTA.
**Implementation:** `FristenrechnerService.CalculateRule(ctx, params) (*UIDeadline, error)` reusing the same `addDuration` + `HolidayService.AdjustForNonWorkingDaysWithReason` pipeline as `Calculate()` lines 397404. Crucially it **does not walk the parent chain** — the trigger date is treated as the immediate parent's effective date, since that matches the user's mental model when clicking "Duplik": "I just received the Replik on date X, when's my Duplik due?"
For zero-duration rules (`is_court_set` waypoints, root events): respond with `dueDate=triggerDate` and `isCourtSet=true` for the court-set case. The UI handles both: court-set → no add-to-project CTA, "Add manually" hint instead.
### 1.5 Add-to-project — reuse the existing bulk endpoint
`POST /api/projects/{id}/deadlines/bulk` already exists and takes:
```json
{ "deadlines": [{ "title": "...", "rule_code": "...", "due_date": "...", "original_due_date": "...", "source": "fristenrechner", "notes": "..." }] }
```
The card's "Zu Akte hinzufügen" sends a single-element array with the calc result. We extend the `source` enum: add `"fristenrechner_card"` so we can tell card-click adds apart from full-timeline adds in audit logs (one-line addition to whatever validates the source field today).
### 1.6 Why no "auto-add to project on card click"?
m's wording: "should allow adding that deadline to an existing proceeding" — adding is the explicit step, not the click itself. The click computes; the user reviews the date and adjustment-reason chip; only then do they decide whether the date actually goes into a real Akte. This matters because:
- Vacation-skip in either direction can move a date by ~28 days; users want to **see** the skip before committing.
- The trigger date may be wrong (user typed it, or the matter has multiple receipt dates and they need to pick the right one).
- The flag combinations alter the duration — the user may need to flip a flag once they remember "ah, this is the with_ccr case".
Computing inline is free (single SQL hit + one in-memory holidays scan). Persisting is consequential. Keep them separate.
---
## 2 — Filter narrowing bug — diagnosis & fix
### 2.1 m's repro
> "I chose 'CMS receipt' from opposing party UPC infringement and it still shows national submissions."
URL: `/tools/fristenrechner?path=b&mode=tree&b1=cms-eingang.gegenseite.upc-inf` (or the deeper `…upc-inf.klageerwiderung-mit-ccr` etc.).
Expected: only UPC_INF proceeding pills in result cards.
Actual: cards show pills for DE_INF, DE_NULL, DPMA_OPP, EPA_OPP, UPC_DAMAGES, UPC_DISCOVERY, UPC_PI and UPC_REV alongside UPC_INF — i.e. every proceeding where the underlying concept (e.g. `statement-of-defence`) has a rule.
### 2.2 Root cause
The fix-needed code path lives in two places:
**`internal/services/event_category_service.go:194 ConceptIDsForSlug`** — collapses the `ConceptsForSlug` `(concept_id, proceeding_type_code)` tuple list down to a flat slice of concept IDs by deduplicating and **discarding the proceeding code**:
```go
for _, r := range rows {
if seen[r.ConceptID] { continue }
seen[r.ConceptID] = true
out = append(out, r.ConceptID)
}
```
**`internal/services/deadline_search_service.go:382 + 533 + 466`** — both `browseRanks`, `loadPills` and the `rankConcepts` matched-CTE filter the matview by `s.concept_id = ANY($N::uuid[])` only. There is no per-(concept × proc) constraint anywhere downstream of `ConceptIDsForSlug`.
So when a leaf maps `statement-of-defence | UPC_INF` in the `event_category_concepts` junction, the search service:
1. Resolves slug → concept_ids: `[id-of-statement-of-defence, id-of-reply-to-defence, …]`. Drops `UPC_INF`.
2. Loads from matview every row where `concept_id` matches → all 9 proceedings of `statement-of-defence`, since the matview row exists for every (concept × rule) combo across the corpus (matview 047).
3. Renders 9 pills under one card.
Reproduced live (`cms-eingang.gegenseite.upc-inf` subtree):
| concept | junction proc | matview procs returned |
|---|---|---|
| `statement-of-defence` | UPC_INF | `DE_INF, DE_NULL, DPMA_OPP, EPA_OPP, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_PI, UPC_REV` |
| `rejoinder` | UPC_INF | `DE_INF, DE_NULL, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_REV` |
| `reply-to-defence` | UPC_INF | `DE_INF, DE_NULL, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_REV` |
| `notice-of-defence-intention` | UPC_INF | `DE_INF` (the junction maps to a non-existent UPC rule — see §3) |
| `defence-to-counterclaim-for-revocation` | UPC_INF | `UPC_INF` (correct by coincidence — concept only exists in UPC_INF) |
| `cross-appeal` | UPC_APP | `DE_INF_OLG, UPC_APP, UPC_APP_ORDERS` |
| `response-to-appeal` | UPC_APP | `DE_INF_BGH, DE_INF_OLG, DE_NULL_BGH, EPA_APP, UPC_APP` |
Same mis-narrowing applies to **every leaf with a non-NULL `proceeding_type_code` in the junction** — 49 of them in the current seed. Confirmed by counting how many leaves have a junction-proc-code that the matview returns >1 proceeding for: at least 25 leaves are over-broad today, and several more have the inverse problem (junction maps to a proc that has no rule for that concept — silently dropped to no pill).
Of m's diagnostic options (a)/(b)/(c)/(d): **(b)** is the bug — the leaf-set is computed but the junction's `proceeding_type_code` constraint is dropped between `ConceptsForSlug` and `loadPills`. The frontend (c) is fine; the recursive CTE (a) is fine; (d) is not the cause.
### 2.3 Fix shape
Carry `(concept_id, proceeding_type_code)` as a tuple set, not as two independent lists. Tuple semantics:
- `(c, NULL)` in junction = "all proceeding contexts of this concept apply at this leaf" (used by cross-cutting concepts like `wiedereinsetzung`, `weiterbehandlung`, `versaeumnisurteil-einspruch` — they aren't tied to a specific proceeding).
- `(c, X)` in junction = "ONLY proceeding X applies for concept c at this leaf".
- Trigger pills (`kind='trigger'`) bypass the proc constraint by design (cross-cutting).
The matview filter becomes:
```sql
-- Concept allowed AND (junction had no proc-narrowing for this concept
-- OR the matview row's proc matches one of the narrowing tuples for this concept).
WHERE EXISTS (
SELECT 1 FROM unnest($pairs) AS p(concept_id uuid, proc_code text)
WHERE p.concept_id = s.concept_id
AND (p.proc_code IS NULL OR p.proc_code = s.proceeding_code)
)
OR s.kind = 'trigger'
```
Or equivalently, pre-expand on the Go side: from the junction tuples, build two parallel arrays — `concept_ids_unconstrained text[]` (junction had `(c, NULL)`) and `pairs (concept_id, proc_code) (text, text)[]` (junction had a proc) — then:
```sql
WHERE s.concept_id = ANY($unconstrained_concepts)
OR (s.concept_id, s.proceeding_code) IN (SELECT * FROM unnest($pair_cids, $pair_procs))
OR s.kind = 'trigger'
```
Either form keeps the existing `WHERE concept_id = ANY` query plan happy and adds one bounded set-membership check per row. With the matview ~1k rows and per-leaf tuple sets ≤ ~30, both are sub-millisecond.
### 2.4 Where the fix touches code
| File | Change |
|---|---|
| `internal/services/event_category_service.go` | Add `ConceptOutcomesForSlug` (or rename `ConceptsForSlug` already returns `[]ConceptOutcome` — actually it does; expose it through a new search-friendly accessor that returns the two parallel arrays). Keep `ConceptIDsForSlug` for legacy callers but stop using it from the search service. |
| `internal/services/deadline_search_service.go` | `Search` builds the tuple set from `eventCategory.ConceptOutcomesForSlug(slug)` instead of calling `ConceptIDsForSlug`. Pass tuples down to `browseRanks`, `loadPills`, `rankConcepts`. Update the SQL in all three to filter by tuple, not by concept_id alone. |
| `internal/services/deadline_search_service.go` BrowseAll path | Stays as-is — when the user has picked NO leaf, all (concept × proc) combos are valid. Currently goes through `allMappedConceptIDs`; after the fix, change to "select distinct (concept_id, proceeding_type_code) from event_category_concepts" so we still respect any concept-context narrowing that's encoded in the junction even at the root view. |
The forum filter in `Forums` (`?forum=upc_cfi,upc_coa…`) keeps its current AND semantics — it ANDs against the tuple narrowing, never overrides it. After this fix, picking "UPC Verletzung opposing party" in the tree narrows to UPC_INF; adding a forum chip "EPA Einspruch" produces zero results (the user just contradicted themselves and the empty state is correct).
### 2.5 No migration needed for the bug fix
The seed data is fine — the per-leaf `proceeding_type_code` was always there. The Go-side wiring just dropped it. Fix is pure Go + SQL, no migration. Phase A in §4.
### 2.6 Test plan for the fix
Browser smoke tests (Phase A's PR should ship with these as Playwright cases or a manual checklist):
| Path | Expected pills (post-fix) |
|---|---|
| `b1=cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr` | `defence-to-counterclaim-for-revocation` (UPC_INF), `application-to-amend` (UPC_INF), `reply-to-defence` (UPC_INF) — no DE/EPA/DPMA pills |
| `b1=cms-eingang.gegenseite.de-inf.klageerwiderung` | `reply-to-defence` (DE_INF only) — no UPC/EPA |
| `b1=cms-eingang.gericht.hinweisbeschluss` | `response-to-preliminary-opinion` (DE_NULL only after §3 audit fix; currently shows DE_NULL because matview only has DE_NULL for that concept) |
| `b1=frist-verpasst.epa` | `wiedereinsetzung` (cross-cutting/NULL), `weiterbehandlung` (cross-cutting/NULL) — both no proceeding chip |
| `b1=` (root, browse-all) | every concept × proc tuple in the junction, ordered by sort_order |
DB-level invariant to assert in a unit test of `EventCategoryService`:
```go
for _, leaf := range leaves {
outcomes := svc.ConceptOutcomesForSlug(ctx, leaf.Slug)
pills := svc.searchPillsForOutcomes(ctx, outcomes)
for _, p := range pills {
if p.Kind != "rule" { continue }
// Check: pill's (concept_id, proc_code) was authorised by an outcome.
ok := false
for _, o := range outcomes {
if o.ConceptID != p.ConceptID { continue }
if o.ProceedingTypeCode == nil { ok = true; break }
if *o.ProceedingTypeCode == p.ProceedingCode { ok = true; break }
}
require.True(t, ok, "leaf %s leaked pill (%s, %s)", leaf.Slug, p.ConceptSlug, p.ProceedingCode)
}
}
```
If this had existed in t-paliad-133 the bug would never have shipped. Adding it is part of Phase A.
---
## 3 — RoP-rigorous tree audit
### 3.1 Audit method
For each leaf in the seed (49 leaves with non-NULL `proceeding_type_code`, plus the cross-cutting NULL-coded ones), we cross-checked:
1. Does the proceeding the leaf maps to actually have a rule for that concept? (i.e. matview returns a row.)
2. Does the cited RoP / PatG / EPÜ rule match what a HLC patent lawyer would expect to file in that situation?
3. Are there other concepts that legitimately fire from the same leaf but were missed in the seed?
The tree shape — six root buckets (CMS-Eingang / Mündliche Verhandlung / Beschluss-Entscheidung / Frist verpasst / Ich möchte einreichen / Sonstiges) — is **kept**. m locked it on 2026-05-05 and the structure is sound; the failures are at the leaf-junction level, not in the categorisation.
### 3.2 Confirmed errors in the seed
| Leaf | Junction row (current) | Problem | Fix |
|---|---|---|---|
| `cms-eingang.gericht.hinweisbeschluss` | `response-to-preliminary-opinion \| DE_INF` | DE_INF (LG infringement) has no Hinweisbeschluss step. The Hinweisbeschluss is a BPatG-only mechanism (PatG §83). The matview confirms: this concept exists only for DE_NULL. | DELETE the DE_INF row. Keep the DE_NULL row. |
| `cms-eingang.gegenseite.upc-inf.klageschrift` | `notice-of-defence-intention \| UPC_INF` | UPC has no "notice of intention to defend" rule in the corpus. The closest UPC artefact (R.23 explicit reaction) is captured by `statement-of-defence` directly. The matview has this concept only in DE_INF. | DELETE the UPC_INF row. Add `statement-of-defence \| UPC_INF` (already present at sort 200 — keep). |
| `cms-eingang.gericht.kostenfestsetzung` | `notice-of-appeal \| UPC_COST_APPEAL` | Wrong rule. The actual rule for cost-decision appeal is `cost.leave_app``application-for-leave-to-appeal` (R.221.1), not `notice-of-appeal`. Matview confirms: `application-for-leave-to-appeal` exists only in UPC_COST_APPEAL; `notice-of-appeal` exists in UPC_APP but not UPC_COST_APPEAL. | UPDATE concept slug to `application-for-leave-to-appeal`. |
| `beschluss-entscheidung.kostenfestsetzung` | `notice-of-appeal \| UPC_COST_APPEAL` | Same problem as the row above. | Same fix. |
| `ich-moechte-einreichen.berufung.upc-cost` | `notice-of-appeal \| UPC_COST_APPEAL` | Same problem. | Same fix. |
| `ich-moechte-einreichen.berufung.upc-coa-orders` | `application-for-leave-to-appeal \| UPC_APP_ORDERS` | The UPC_APP_ORDERS proceeding has `app_ord.discretion` (R.220.3 discretionary review) and `app_ord.with_leave` (R.220.2 appeal with leave) — NOT `application-for-leave-to-appeal` (which is the cost-appeal mechanism). | UPDATE the second row to `request-for-discretionary-review \| UPC_APP_ORDERS`. Keep `appeal-with-leave \| UPC_APP_ORDERS` row. |
| `cms-eingang.gericht.anordnung` | `request-for-discretionary-review \| NULL` | Looks correct on its own (R.220.3 review is the response to a court order), but the NULL means it'd surface in every proceeding. The right narrowing is UPC_APP_ORDERS only. | UPDATE proc to `UPC_APP_ORDERS`. |
### 3.3 Coverage-gate exempt list — drop one entry
The migration 049 coverage gate exempts 4 concepts from leaf-reachability:
```
'filing', 'request-for-examination', 'approval-and-translation', 'reply-to-cross-appeal'
```
`reply-to-cross-appeal` was added to the exempt list in commit `ff36528` because it's downstream of cross-appeal. But after this audit, **it should be reachable** from at least:
- `cms-eingang.gegenseite.upc-inf.berufungsschrift` (when the Anschlussberufung filed by the opposing side triggers the user's response — the user IS the appellant who needs to respond to the cross-appeal)
- `cms-eingang.gegenseite.upc-rev.berufungsschrift` (same logic for revocation appeals)
- `cms-eingang.gegenseite.de-inf.berufungsschrift-olg` (DE OLG flavour — `cross-appeal \| DE_INF_OLG` already mapped, the reply has no DE rule in the corpus today, so this is UPC-only)
**Add** `reply-to-cross-appeal \| UPC_APP` rows under the two UPC `…berufungsschrift` leaves AND `reply-to-cross-appeal \| UPC_APP_ORDERS` under appropriate UPC_APP_ORDERS appeal leaves. **Drop** from the exempt list.
The other 3 exempt slugs are correctly cross-cutting (filing / examination / translation are EP_GRANT prosecution steps that don't fit the "what just happened" mental model — leave them exempt).
### 3.4 Bilateral-side coverage gaps
The seed correctly captures the receiving side ("CMS-Eingang" → opposing party / court actions) but the proactive side `ich-moechte-einreichen.spaetere-schriftsaetze` is missing some common UPC paths:
| Missing leaf | Concepts to map |
|---|---|
| `ich-moechte-einreichen.spaetere-schriftsaetze.anschlussberufung-upc` | `cross-appeal \| UPC_APP` (and `\| UPC_APP_ORDERS` if `app_ord.cross` is the right rule per R.237/238 — verify against the corpus rules `app.cross_a` / `app_ord.cross`) |
| `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben` | `r116-final-submissions \| EPA_OPP` and `\| EPA_APP` (matview confirms these exist; user should be able to reach this proactively before an EPA hearing, not only through the "Mündliche Verhandlung → Geladen" leaf) |
| `ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag-upc` (already exists) | Already mapped to `application-for-cost-decision \| UPC_INF`. Verify whether UPC_REV also has it (matview shows only UPC_INF — likely just UPC_INF is correct since R.151 is referenced from infringement context, but flag for m's confirmation). |
### 3.5 The `bescheid-mit-frist` orphan
`cms-eingang.gericht.bescheid-mit-frist` ("Order with court-set deadline") has **no junction rows**. Currently a dead-end leaf. The right mapping is the cross-cutting `schriftsatznachreichung` trigger event (the generic "submit something within the court-set period" event).
Add `(cms-eingang.gericht.bescheid-mit-frist, schriftsatznachreichung, NULL, 100)`.
### 3.6 What we are NOT changing
- **Tree shape** — the six root buckets stay (m's lock).
- **Tree depth** — stays unlimited.
- **Forum bucket map** in `services/deadline_search_service.go:64` — stays the 10-bucket layout (m's lock §10 Q8).
- **`is_bilateral` flag and the perspective selector** — out of scope here. v3 deferred Phase D-2 (party-perspective UI) explicitly; v4 keeps that deferral.
- **Trigger event taxonomy** (`paliad.trigger_events`) — out of scope.
- **`primary_party` semantics** — `'both'` rules continue to use the perspective-selector resolution (v3 §5.1).
### 3.7 Required leaf-by-leaf review during implementation
The audit above is high-confidence on the bugs explicitly listed. But a thorough leaf-by-leaf RoP review would benefit from a fresh pass with the corrected pill set visible — that's a one-hour task for the coder shift, not an inventor task. The deliverable for Phase C is a SQL diff against the current `event_category_concepts` rows, with one comment per row citing the RoP/PatG/EPÜ rule. m can spot-check 510 leaves and approve/reject the diff in one review pass.
---
## 4 — Migration plan — three independent phases
The phases are deliberately decoupled so the bug fix (the user-visible regression) can ship first without waiting on taxonomy revision. Each phase is one or more atomic commits with an integration test.
### Phase A — Filter narrowing fix (no schema change)
- New `EventCategoryService.ConceptOutcomesForSlug` that returns the tuple set with proceeding context preserved.
- `DeadlineSearchService.Search` (and helpers `browseRanks`, `loadPills`, `rankConcepts`) accept and apply the tuple constraint.
- Update `allMappedConceptIDs``allMappedOutcomes` to return tuples for browse-all mode (so the root view also respects per-leaf narrowing).
- Add `internal/services/deadline_search_service_test.go` covering the leaks listed in §2.6.
- One commit, one PR.
No migration. No client-side changes. Pure backend correctness fix. Ships independently of B and C.
### Phase B — Card-click flow
- New `FristenrechnerService.CalculateRule(ctx, params)` in `internal/services/fristenrechner.go`.
- New handler `handleFristenrechnerCalculateRule` in `internal/handlers/fristenrechner.go`.
- New route `POST /api/tools/fristenrechner/calculate-rule` in `handlers.go`.
- Frontend additions in `frontend/src/client/fristenrechner.ts`:
- `expandCard(card, pill)` builds the inline calc panel.
- `runCardCalc(rule, triggerDate, flags)` POSTs to the new endpoint and renders the result.
- Card-row click handler (already wired for pill drill-in via `wirePillClicks`) extended to also handle "click on card body, not on a pill".
- Reuse `openSaveModal` (`client/fristenrechner.ts:332`) with a single-deadline payload variant.
- i18n keys: `deadlines.card_calc.trigger_date`, `…flag.<flag_name>`, `…result.due`, `…result.original`, `…add_to_project`.
- CSS: `.fristen-card.is-expanded` + the panel zones in `global.css`.
No migration, no schema change. Depends on Phase A landing first (otherwise the cards are still showing wrong pills and the calc panel computes correctly but on the wrong rules).
### Phase C — RoP-rigorous tree taxonomy revision
- One new migration `052_event_categories_rop_audit.up.sql` (and `.down.sql`).
- Pure data migration. No DDL. Updates `event_category_concepts` rows per §3.2§3.5.
- **No `RAISE EXCEPTION` coverage gates** — last night's outage was caused by exactly that pattern. Use `RAISE WARNING` at most. Coverage gates that block server boot are an ops failure mode the migration runner should not have. Validation gates can be a separate read-only check (a Go invariant test that runs in CI but doesn't block migrations).
- The migration applies idempotently — every row uses `INSERT … ON CONFLICT DO UPDATE` or `DELETE WHERE …` (idempotent on re-run).
- Drop `'reply-to-cross-appeal'` from the exempt list (it's now reachable). Keep the other 3 exempt slugs.
Ships independently of A and B. Ordering recommendation: A → C → B (because B's UX is best evaluated against a correct tree, but B is not technically blocked on C).
### What we are deliberately not doing in this round
- **No party-perspective UI** (v3 Phase D-2 defer holds).
- **No AI Frist-Extraktion** (Phase H is deferred per m's 2026-04-16 decision).
- **No CalDAV write-back of card-click deadlines** — happens through the existing `POST /api/projects/{id}/deadlines/bulk` which already triggers CalDAV sync via the deadline service.
- **No multi-rule cards calc** — if a card has 2+ pills, the calc panel handles ONE pill at a time (the user picks which). Adding "calculate both at once" is feature creep.
- **No persistent calc state** — collapsing the card discards the trigger date and flags. Users who want to keep working state should "Add to project" first.
---
## 5 — Open questions for m before coder shift
The hardest decisions here are taxonomy and UX, both of which warrant a confirm-before-build:
1. **Card-click compute scope.** When the card has 1 pill, the calc panel works on that pill. When the card has 2+ pills (after §2 fix this should be rare — mostly `wiedereinsetzung`/`weiterbehandlung` cards and a few cross-jurisdictional concepts like `notice-of-appeal`), should the user pick ONE pill and compute, or should the calc panel produce a side-by-side comparison ("DE: 1 month → 5 June 2026; UPC: 2 months → 5 July 2026")? The latter is more powerful but doubles the UI surface. **Recommendation: stick with single-pill picker.** Comparison view is a future feature.
2. **Add-to-project source string.** Use `"fristenrechner"` (existing) or `"fristenrechner_card"` (new tag for card-click adds)? **Recommendation: new tag** so the audit log distinguishes the two flows. One-line addition to whatever validates the source field.
3. **Default trigger date.** Today (`new Date()`) or the user's most-recent trigger date from any prior calc this session? **Recommendation: today.** Prior-date carry-over is surprising; the user's last action in any calc tool is rarely the same as the next.
4. **`bescheid-mit-frist` mapping.** §3.5 proposes mapping to `schriftsatznachreichung` (cross-cutting / NULL proceeding). Is there a more specific concept I'm missing for "court-set period to file something" in the German PatG/ZPO corpus? If so, point me at the rule and I'll map it instead.
5. **`cost-appeal` rule labels.** §3.2 fixes 3 leaves to use `application-for-leave-to-appeal` instead of `notice-of-appeal` for UPC_COST_APPEAL. **Confirmation needed:** under R.221, is `application-for-leave-to-appeal` strictly the *first* step (15 days), with the actual `notice-of-appeal` as a *second* step once leave is granted? If so, should the leaf surface BOTH (sort 100 leave + sort 200 notice, conditional)?
6. **Phase ordering.** A → C → B (correctness first, then taxonomy, then UX) vs. A → B → C (correctness first, then UX so users see card-click immediately, then taxonomy as a follow-up). **Recommendation: A → C → B.** B is most valuable when the cards show the right pills, and C ships without UI risk.
7. **Coverage-gate replacement.** Phase C drops the `RAISE EXCEPTION` block. Should we replace it with a Go-side `services_test.go` unit test that asserts every `category='submission'` concept (less the 3-slug exempt list) is reachable from at least one leaf? **Recommendation: yes.** It's the same gate, just at CI time instead of migration time, and it can be made part of the `make test` target so it gates merges without gating server boots.
8. **Project picker autosuggest.** The existing `frist-save-modal` shows a `<select>` of all the user's visible projects. With 100+ Akten this becomes unwieldy. Worth adding a typeahead? **Defer** — out of scope here, but flag for a future task.
---
## 6 — Sequencing summary
```
┌─────────────────────────────────────────────────────────────┐
│ Phase A: Filter fix (Go + SQL only, no migration) │
│ → ships independently, fixes m's repro │
│ │
│ Phase C: Tree taxonomy revision (migration 052) │
│ → ships independently, fixes m's "RoP-rigorous" concern │
│ → no RAISE EXCEPTION │
│ │
│ Phase B: Card-click → calculate → add-to-project │
│ → new endpoint + frontend panel + reuse save modal │
│ → most valuable after A + C land │
└─────────────────────────────────────────────────────────────┘
```
m's open questions in §5 should be resolved before Phase B begins (UX choices) and Phase C migration is written (taxonomy choices). Phase A can start immediately on m's go-ahead; it has no open questions.
---
## 7 — Changelog vs v3
What this design changes about v3:
- **Card-click is no longer a dead-end.** v3 ended at the result card; v4 makes the card the *entry* to a single-rule calculator + add-to-project flow.
- **Per-leaf proceeding narrowing actually narrows.** v3 had the data right but dropped it in `ConceptIDsForSlug`; v4 carries the tuple end-to-end.
- **One concrete RoP-mapping bug class fixed**: 6 leaves had wrong concept↔proc rows; the bug was masked because the broken filter showed all proceedings anyway. Once §2's fix lands, these leaves would have produced empty cards instead of overbroad cards — surfacing the seed errors. Phase C corrects them.
- **No new schema columns.** Same tables (`event_categories`, `event_category_concepts`, `deadline_rules.is_bilateral`); just data corrections + Go logic.

View File

@@ -0,0 +1,930 @@
# Unified Fristenrechner — Search-by-Concept + Complete Proceeding-Type Coverage
**Author:** cronus (inventor)
**Date:** 2026-05-04 (revised after m's go-direction at 23:10)
**Task:** t-paliad-131
**Mode:** design only — no code, no schema migrations applied
**Branch:** `mai/cronus/unified-fristenrechner-design` (worktree)
**Status:** v2 — m's answers to v1 + v2 open questions both incorporated. Awaiting final go for coder shift.
> *m, 2026-05-04 22:55:* "I want additional possibilities to select / filter the trigger. By Rule, by Proceeding Type, by Party etc... we need to classify each of our deadlines again. In particular we do not have all german patg and zpo procedural deadlines, yet. Let us hire an inventor — and allow research on this — for a unified deadline calculator that is ridiculously easy to use."
---
## 0. m's go-direction (v2 anchor)
m's 10 answers (relayed via head 2026-05-04 23:10) reshape the design materially. They are the binding spec for v2:
1. **Augment, not replace.** Search bar at top **plus** the existing chunky proceeding-type tiles below as browse fallback. The two existing tabs (Verfahrensablauf / Was kommt nach…) **stay**. No subsumption.
2. **Aliases hard-coded** in seed migrations (curated, not user-editable in v1).
3. **Unifier shape (a) — "shared rule with applicable_in"**: one canonical concept ('Klageerwiderung') that adapts duration / legal_source / notes per context. Pick the cleaner of (jsonb on rule) vs (separate context-overrides table).
4. **Counterclaim flag pattern stays**, just add the missing deadlines (R.25, R.30, R.50).
5. **"Full Appeal Chain" checkbox** — default: per-instance pick. Toggle on → render LG → OLG → BGH (or BPatG → BGH for nullity) as one tree.
6. **One result card per concept with proceeding pills inside.** Search "Klageerwiderung" → one card titled "Klageerwiderung" with pills [LG] [OLG] [UPC] [BPatG] [EPA] [DPMA]. Click a pill = drill into that context's specific deadline.
7. **Structured legal_source codes**`DE.ZPO.282`, `DE.PatG.83`, `UPC.RoP.23.1`, `EU.EPÜ.108`, `DPMA.PatG.59`, etc. Parseable, filterable. Document the canonical format.
8. **Forum NOT a filter** — drop. Rules are shared across the court system within a jurisdiction.
9. **Sequence preservation in columns-view** — undated court-set events (Counterclaim → Defence → Reply → Decision) currently collapse into one row in the t-paliad-129 columns view. They must order by `sequence_order` even without dates. Flag in this doc; ship as a separate follow-up.
10. **Court-set placeholders ARE searchable triggers.** "Verhandlung", "Entscheidung", "Zwischenverfügung" etc. surface in search.
The remainder of this document implements those ten constraints.
---
## 1. Executive summary (v2)
The Unified Fristenrechner is **search-first with proceeding-type tiles as fallback**, organised around **concepts** ("Klageerwiderung") rather than per-proceeding rule rows. A new `paliad.deadline_concepts` layer sits above the existing `paliad.deadline_rules` and groups context-specific instances of the same legal idea. The tree-shape calculator stays unchanged in math; it just gains a `concept_id` on each rule for grouping.
**The single search hit is one card per concept**, with proceeding-pills inside. Type "Klageerwiderung" → one card; click [LG] for ZPO §276, [BPatG] for PatG §82(1), [UPC] for RoP R.23, etc.
**Coverage gap closure:** UPC counterclaim cross-flows (R.25 / R.30 / R.49(2) / R.50 / R.51 / R.52 / R.55 / R.56), DE OLG + BGH-Revision + BGH-NZB + BPatG Hinweisbeschluss-Cycle, DPMA Einspruch + Beschwerde, EPA R.116 / R.79(2)(3) / R.106 / Wiedereinsetzung. ≈ +85 new deadline rules, organised into ≈ 30 concepts.
**The Full Appeal Chain toggle** synthesises a multi-instance tree (LG → OLG → BGH for infringement; BPatG → BGH for nullity) when the user wants the whole journey on one timeline.
**The columns-view sequence-preservation fix** is flagged but **deferred to a separate task** as it's a t-paliad-129 follow-up rather than a unified-Fristenrechner concern.
---
## 2. Current state — verified live
### 2.1 Two backends, two shapes
| | `paliad.deadline_rules` (proceeding-tree) | `paliad.trigger_events` + `paliad.event_deadlines` (event-driven) |
|---|---|---|
| Shape | 74 rules in 12 trees, parent_id chain, sequence_order | 102 triggers, 70 deadlines (1:N), flat |
| PK | uuid | bigint |
| Provenance | hand-seeded migrations 012/029/031 (HLC + KanzlAI port) | youpc verbatim port (migration 028, IDs preserved) |
| Rule-code coverage | 5 distinct UPC RoP codes + 4 PatG/ZPO + 5 EPÜ Art./R | 64 distinct UPC RoP codes (UPC only) |
| Conditional logic | `condition_flag text` + `alt_*` columns | implicit (operator picks the matching trigger event) |
| Composite math | none | `combine_op IN ('max','min')` + `alt_duration_*` |
| Working-day arithmetic | no | yes (`duration_unit='working_days'`) |
| Anchor flexibility | `anchor_alt='priority_date'` (single special case) | trigger date only |
| Calculator | `internal/services/fristenrechner.go::Calculate` | `internal/services/event_deadline_service.go::Calculate` |
| UI | proceeding-button grid → date/flag inputs → timeline / Spalten | search-by-name input → trigger picked → flat results |
Both backends stay structurally separate (the math is genuinely different). The Unifier sits *above* them.
### 2.2 Proceeding types currently shipped
12 fristenrechner-category types live (sort_order 101303):
- **UPC:** UPC_INF, UPC_REV, UPC_PI, UPC_APP, UPC_DAMAGES, UPC_DISCOVERY, UPC_COST_APPEAL, UPC_APP_ORDERS
- **DE:** DE_INF, DE_NULL
- **EPA:** EPA_OPP, EPA_APP, EP_GRANT
(Plus 7 internal/KanzlAI-port types under `category='litigation'` for matter-attached fristen — out of scope for this design.)
### 2.3 Verified gaps confirmed against m's vision
m's specific complaint:
> "the checkbox for 'counterclaim' does not actually add the submissions to the timeline; it only changes the rejoinder deadline. But for the counterclaim for revocation we also need the application to amend the patent — and in the revocation action we are lacking a 'counterclaim for infringement'."
**Verified:** the current `with_ccr` flag on `UPC_INF` only swaps `inf.reply` (RoP.029.b → RoP.029.a) and `inf.rejoin` (RoP.029.c 1mo → RoP.029.d 2mo) durations. The 57 additional submissions (Defence to CCR, Application to amend, Defence to App-to-amend, Reply to Defence to CCR, etc.) are missing from the tree entirely. UPC_REV has the same gap — no Application to amend, no Counterclaim for infringement, no R.55 / R.56 cycles.
The trigger-event tab carries labels for "Verletzungswiderklage" (id=10), "Antrag auf Patentänderung" (id=38), "Nichtigkeitswiderklage" (id=101), but with `num_deadlines=0` for many of them (labels without downstream cycles). DE/EPA/DPMA triggers absent entirely from the trigger-event corpus.
---
## 3. UX — search-first, tiles as fallback
### 3.1 The search bar augments, doesn't replace
`/tools/fristenrechner` keeps its current structure:
```
┌──────────────────────────────────────────────────────────────────┐
│ Fristenrechner │
│ Berechnung von Verfahrensfristen — UPC, DE, EPA, DPMA │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 🔍 Tippe Frist, Rechtsgrundlage oder Verfahren… │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ [ Häufig: Klageerwiderung │ Berufung │ Einspruch │ Replik │ … ] │
│ │
│ ───────────────────────────────────────────── │
│ │
│ oder Verfahren wählen: │
│ │
│ UPC │
│ [ Verletzungs- ] [ Nichtigkeits- ] [ Einstw. M. ] [ Berufung ] │
│ [ Schadensbem. ] [ Bucheinsicht ] [ Berufung-K ] [ Anord. ] │
│ │
│ Deutsche Gerichte │
│ [ Verletz. (LG) ] [ Nichtigk. (BPatG) ] [ Berufung (OLG) ] … │
│ │
│ EPA / DPMA │
│ [ Einspruch EPA ] [ Beschwerde EPA ] [ EP-Erteilung ] [ DPMA ] │
│ │
│ ☐ Vollständige Instanzenkette anzeigen (LG → OLG → BGH) │
└──────────────────────────────────────────────────────────────────┘
```
Behaviour changes vs today:
- Search bar at top is new.
- Quick-pick chips are new.
- The proceeding-type grid below is the existing "Verfahrensablauf" entrypoint, slightly reorganised (DE gets 5 tiles now, EPA picks up DPMA).
- The two existing tabs (Verfahrensablauf / Was kommt nach…) stay reachable — when the user clicks a tile, the Verfahrensablauf flow opens. When the user lands on a search hit that's a court-set placeholder ("Verhandlung", "Entscheidung"), the "Was kommt nach…" flow opens.
- "Full Appeal Chain" checkbox at the bottom (3.4).
### 3.2 Search hit — one card per concept
A typed query resolves to **concept cards** (not rule rows). Each card represents one legal idea ("Klageerwiderung") with the contexts where it applies as proceeding pills inside.
```
Search: "Klageerwiderung"
┌──────────────────────────────────────────────────────────────────┐
│ ⚖ Klageerwiderung · Statement of Defence │
│ Erwiderung des Beklagten auf eine Klageschrift, üblicherweise │
│ mit Verteidigungsanträgen und Sachvortrag. │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ UPC │ │ LG │ │ BPatG │ │ EPA │ │ DPMA │ │
│ │ R.23.1 │ │ §276.1 │ │ §82.1 │ │ R.79.1 │ │ §59.3 │ │
│ │ 3 Monate │ │ 6 Wochen │ │ 1 Monat │ │ 4 Monate │ │ 4 Mon. │ │
│ │ Beklag. │ │ Beklag. │ │ Beklag. │ │ PatInh. │ │ PatInh. │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ⚖ Klageerwiderung mit Nichtigkeitswiderklage │
│ (UPC-spezifisch — Trigger für mehrere Folgefristen) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ UPC │ │ UPC │ │
│ │ trigger │ │ R.23.1 │ ← der Trigger versus die Frist │
│ │ (Was nach│ │ + R.25.1 │ │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
- **Top line:** concept name (active locale) + EN translation in muted text.
- **Description line:** 12-sentence concept description.
- **Pills row:** one pill per context. Each pill shows: forum chip · structured legal_source · duration · party (compact). Click → drill into that proceeding's calculator at the matching rule.
- **Multiple cards:** when the search term is genuinely ambiguous (e.g. "Erwiderung" matches Klageerwiderung AND Erwiderung Prüfbescheid AND Erwiderung Patentinhaber Einspruch), each is its own card. Cards are sorted by relevance score.
When a single concept also exists as a *trigger event* (court-set placeholder, e.g. "Mündliche Verhandlung"), it gets a special pill labelled "Was kommt nach…" that opens the trigger-event calculator. Per Q10, court-set placeholders ARE searchable.
### 3.3 Drill-in behaviour
Click `[ UPC R.23.1 ]` pill → `/tools/fristenrechner?proc=UPC_INF&focus=inf.sod` opens the proceeding-tree calculator with UPC_INF preselected, jumping straight to the date input, then renders the timeline with `inf.sod` highlighted.
Click `[ UPC trigger (Was nach…) ]` pill → `/tools/fristenrechner?trigger=1` opens the event-driven calculator with the trigger preselected.
### 3.4 Full Appeal Chain checkbox
A checkbox at the bottom of the proceeding tile grid: "**☐ Vollständige Instanzenkette anzeigen** (LG → OLG → BGH)".
When **off** (default): user picks a single instance (LG, OLG, or BGH separately).
When **on**: the tiles grouped by case-type render as multi-instance tile pairs. E.g. the "Verletzungsklage" tile expands to show the full chain when clicked, rather than just the LG step.
Implementation: a synthetic "compound proceeding" rendering option in the calculator. The data model stays per-instance; the calculator stitches three trees together at render time when the toggle is on. Anchor for OLG = "Urteil des LG"; anchor for BGH = "Urteil des OLG". The user enters one date (the LG Klageerhebung) and the chain unfolds; OR the user enters individual stage anchors (1, 2, 3) for known dates.
Mapping for the chain rendering:
- **DE_INF chain:** `DE_INF` (LG) → `DE_INF_OLG` (Berufung) → `DE_INF_BGH` (Revision/NZB)
- **DE_NULL chain:** `DE_NULL` (BPatG) → `DE_NULL_BGH` (Berufung BGH)
- **DPMA chain:** `DPMA_OPP` (DPMA) → `DPMA_BPATG_BESCHWERDE` (BPatG) → `DPMA_BGH_RB` (Rechtsbeschwerde BGH)
- **UPC:** UPC_INF (CFI) → UPC_APP (CoA). Already linkable via Decision → Notice of Appeal.
- **EPA:** EPA_OPP (Einspruch) → EPA_APP (Beschwerde). Already linkable.
Out-of-scope for v1: the toggle as a default-on shortcut. It's an option, not a forced view.
### 3.5 Filters — slimmer than v1 draft
Per Q8 (forum dropped), filters are now:
```
[ Verfahrensart ▾ ] [ Partei ▾ ] [ Rechtsquelle ▾ ]
```
- **Verfahrensart:** the 12 (then ≈ 18 after coverage migrations) proceeding types.
- **Partei:** Kläger / Beklagte / Beide / Gericht.
- **Rechtsquelle:** UPC RoP · UPC Statute · EPÜ · ZPO · PatG · DPMAV · others (parses the prefix of `legal_source`).
Filters appear only when search returns more than 6 cards. Single-select for v1.
### 3.6 Empty-state and browse
- **Empty search input:** no hits below the input; the tile grid is the natural fallback.
- **No matches for a typed query:** "Keine Treffer für 'xyz' — meintest du …?" with up to 3 trigram-nearest concept suggestions.
- **The two existing tabs** (Verfahrensablauf / Was kommt nach…) stay alive as today, reachable from the tile grid (Verfahrensablauf default) and from event-trigger pills on cards (Was kommt nach…).
### 3.7 Mobile
Search bar full-width, quick-pick chips wrap, concept cards stack vertically with pills wrapping. Below ≈ 600px, the proceeding tile grid switches from 4-wide to 2-wide. No special handling beyond the existing responsive breakpoints.
---
## 4. Data model — the Unifier shape
### 4.1 Goal
One concept, many contexts. The user thinks "Klageerwiderung" — the system knows that's UPC R.23, ZPO §276, PatG §82, EPÜ R.79, PatG §59, all at once.
### 4.2 Two design alternatives (m's Q3)
m named two shapes:
**Option (X): `per_context jsonb` on a unified rule.**
- Single `paliad.deadline_rules` table; each row is one concept.
- New `per_context jsonb` column holds `{"UPC_INF": {duration, unit, legal_source, parent_concept, ...}, "DE_INF": {...}, ...}`.
- Pros: one row per concept, easy to query "where does X apply".
- Cons: jsonb keys don't index well for joins; the calculator extracts the right key at runtime; tree-chain (parent) lives inside jsonb, hard to traverse with normal SQL; condition_flag arrays in jsonb are awkward.
**Option (Y): split into `deadline_concepts` + per-context rule rows (essentially today's `deadline_rules`).**
- New `paliad.deadline_concepts` (concept_id, slug, name_de, name_en, aliases, party, description).
- Existing `paliad.deadline_rules` keeps its shape, gains a `concept_id` FK column.
- Each row in `deadline_rules` is one (concept × proceeding_type) instance. Tree-chain (parent_id) stays where it is.
- Pros: backward-compatible (existing calculator code unchanged); standard relational joins; sequence_order, parent_id, condition_flag, alt_* all live in normal columns; concept layer is thin.
- Cons: two tables; "where does Klageerwiderung apply" needs a 1-row → N-row join (trivial).
**Pick: Option (Y).** Cleanest, lowest-risk migration, no calculator refactor required, search hits are a single GROUP BY on concept_id.
### 4.3 Schema additions
```sql
-- New: concept layer (the canonical legal idea)
CREATE TABLE paliad.deadline_concepts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE, -- 'klageerwiderung', 'replik', 'berufungsfrist',
-- 'berufungsbegruendung', 'einspruch', 'beschwerde',
-- 'rejoinder', 'reply-to-defence', 'application-to-amend',
-- 'counterclaim-for-revocation', 'counterclaim-for-infringement', ...
name_de text NOT NULL,
name_en text NOT NULL,
description text, -- 12 sentences for the card body
aliases text[] NOT NULL DEFAULT '{}',
party text, -- canonical (most contexts agree); per-context override on rule row
category text NOT NULL, -- 'submission' | 'decision' | 'order' | 'hearing' | 'other'
sort_order int NOT NULL DEFAULT 100,
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX deadline_concepts_trgm_de ON paliad.deadline_concepts USING gin (name_de gin_trgm_ops);
CREATE INDEX deadline_concepts_trgm_en ON paliad.deadline_concepts USING gin (name_en gin_trgm_ops);
CREATE INDEX deadline_concepts_aliases ON paliad.deadline_concepts USING gin (aliases);
-- Existing: gain concept linkage + structured legal_source + condition_flag array
ALTER TABLE paliad.deadline_rules
ADD COLUMN concept_id uuid REFERENCES paliad.deadline_concepts(id),
ADD COLUMN legal_source text, -- structured code, see §4.5
ALTER COLUMN condition_flag TYPE text[] -- Q3: scalar → array; semantic: ALL flags must be set
USING (CASE WHEN condition_flag IS NULL THEN NULL ELSE ARRAY[condition_flag] END);
CREATE INDEX deadline_rules_concept_id ON paliad.deadline_rules (concept_id);
CREATE INDEX deadline_rules_legal_source ON paliad.deadline_rules (legal_source);
CREATE INDEX deadline_rules_legal_src_trgm ON paliad.deadline_rules USING gin (legal_source gin_trgm_ops);
-- Existing: trigger_events also gain concept linkage (a court-set placeholder concept like 'oral-hearing' applies in many proc types)
ALTER TABLE paliad.trigger_events
ADD COLUMN concept_id text; -- nullable; by slug for cross-link without uuid FK gymnastics
-- Existing: event_deadlines gain legal_source (concept lives on the rule_codes via event_deadline_rule_codes)
ALTER TABLE paliad.event_deadlines
ADD COLUMN legal_source text;
```
**Why no `aliases text[]` on `deadline_rules` directly anymore?** Because aliases are concept-level, not rule-level. "Klageerwiderung" matches the concept regardless of which proceeding the rule lives in. Per-rule context-specific aliases would be redundant — the legal_source ("UPC.RoP.23.1") and the proceeding code ("UPC_INF") already disambiguate. Per Q5, aliases are concept-only, hard-coded.
**Why text-by-slug on `trigger_events.concept_id` rather than uuid FK?** trigger_events has bigint PK and was imported verbatim from youpc; introducing a uuid FK touches the import-resync invariant. A text slug (e.g. `'oral-hearing'`) is a soft link sufficient for search.
### 4.4 Concept ↔ rule mapping examples
**Slug naming rule (Q1, locked):** **EN slug** for concepts native to UPC/EPC AND for **shared concepts** that exist in both DE and UPC/EPC. **DE slug** only for concepts that exist exclusively in German law (no UPC/EPC equivalent). `name_de` and `name_en` carry both labels for the user-facing surface; the slug is internal/maintenance-facing.
| concept slug | name_de | name_en | maps to deadline_rules in (proceeding_type, code) |
|---|---|---|---|
| `statement-of-defence` | Klageerwiderung | Statement of Defence | (UPC_INF, inf.sod), (DE_INF, de_inf.erwidg), (DE_NULL, de_null.erwidg), (UPC_REV, rev.defence), (EPA_OPP, epa_opp.erwidg), (DPMA_OPP, dpma_opp.erwidg) — shared, EN |
| `reply-to-defence` | Replik | Reply to Defence | (UPC_INF, inf.reply), (DE_INF, de_inf.replik), (UPC_REV, rev.reply), (UPC_DAMAGES, damages.reply), (UPC_DISCOVERY, disc.reply) — shared, EN |
| `rejoinder` | Duplik | Rejoinder | (UPC_INF, inf.rejoin), (UPC_REV, rev.rejoin), (DE_INF, de_inf.duplik), (UPC_DAMAGES, damages.rejoin), … — shared, EN |
| `notice-of-appeal` | Berufungsschrift | Notice of Appeal | (DE_INF, de_inf.berufung), (DE_NULL, de_null.berufung), (UPC_APP, app.notice), (EPA_APP, epa_app.beschwerde) — shared, EN |
| `statement-of-grounds-of-appeal` | Berufungsbegründung | Statement of Grounds of Appeal | (DE_INF, de_inf.beruf_begr), (DE_NULL, de_null.beruf_begr), (UPC_APP, app.grounds), (EPA_APP, epa_app.begr) — shared, EN |
| `opposition` | Einspruch / Einspruchsfrist | Opposition | (EPA_OPP, epa_opp.frist), (DPMA_OPP, dpma_opp.frist) — shared, EN |
| `re-establishment-of-rights` | Wiedereinsetzung in den vorigen Stand | Re-establishment of Rights | event_trigger only — PatG §123, ZPO §233, EPÜ Art.122, DPMA §123 — shared cross-cutting, EN |
| `application-to-amend` | Antrag auf Patentänderung | Application to Amend the Patent | (UPC_INF, inf.app_to_amend), (UPC_REV, rev.app_to_amend) — UPC/EPC-native, EN |
| `defence-to-application-to-amend` | Erwiderung auf den Antrag auf Patentänderung | Defence to Application to Amend | (UPC_INF, inf.def_to_amend), (UPC_REV, rev.def_to_amend) — UPC-native, EN |
| `counterclaim-for-revocation` | Nichtigkeitswiderklage | Counterclaim for Revocation | (UPC_INF, inf.ccr_filing) — UPC-native, EN |
| `counterclaim-for-infringement` | Verletzungswiderklage | Counterclaim for Infringement | (UPC_REV, rev.cc_inf) — UPC-native, EN |
| `request-for-discretionary-review` | Antrag auf Ermessensüberprüfung | Request for Discretionary Review | (UPC_APP_ORDERS, app_ord.discretion) — UPC-native, EN |
| `oral-hearing` | Mündliche Verhandlung | Oral Hearing | (every UPC tree, oral), (DE_INF, de_inf.termin), (DE_NULL, de_null.termin), (EPA_*, oral) — court-set, shared, EN |
| `decision` | Entscheidung | Decision | court-set, shared, EN |
| `nichtzulassungsbeschwerde` | Nichtzulassungsbeschwerde | Complaint Against Denial of Leave | (DE_INF_BGH, …) — DE-only, DE slug |
| `versaeumnisurteil-einspruch` | Einspruch gegen Versäumnisurteil | Objection to Default Judgment | event_trigger only — ZPO §339 — DE-only, DE slug |
| `hinweisbeschluss-stellungnahme` | Stellungnahme zum Hinweisbeschluss | Response to Court's Preliminary Opinion | (DE_NULL, …) — DE-only, DE slug |
≈ 30 concepts cover the entire seed corpus after Phase B coverage migrations (≈ +85 rules grouped into those 30 concepts).
### 4.5 Structured legal_source codes (Q7)
Canonical format: `<JURIS>.<CODE>.<§/Art./R>.<Para>[.<Sub>]`
| Source | Format | Example |
|---|---|---|
| UPC Rules of Procedure | `UPC.RoP.<rule>[.<para>][.<sub>]` | `UPC.RoP.23.1`, `UPC.RoP.29.a`, `UPC.RoP.220.1.c` |
| UPC Agreement | `UPC.UPCA.<art>[.<para>]` | `UPC.UPCA.49.5` |
| UPC Statute | `UPC.Statute.<art>` | `UPC.Statute.21` |
| EPÜ (German abbrev.) | `EU.EPÜ.<art>[.<para>]` | `EU.EPÜ.108`, `EU.EPÜ.99.1`, `EU.EPÜ.122` |
| EPC implementing rules | `EU.EPC-R.<rule>[.<para>][.<sub>]` | `EU.EPC-R.79.1`, `EU.EPC-R.116.1`, `EU.EPC-R.136` |
| German civil procedure | `DE.ZPO.<§>[.<para>]` | `DE.ZPO.276.1`, `DE.ZPO.520.2`, `DE.ZPO.544.1` |
| German patent law | `DE.PatG.<§>[.<para>]` | `DE.PatG.59.1`, `DE.PatG.82.1`, `DE.PatG.111.1` |
| DPMA Verordnung | `DE.DPMAV.<§>` | `DE.DPMAV.5` |
| RPBA Beschwerdeordnung | `EU.RPBA.<art>[.<para>]` | `EU.RPBA.12.1.c`, `EU.RPBA.13` |
**Why dot-separated rather than the human form `§82(1) PatG`?** Parseable. The first dot-segment is the juris (`UPC` / `EU` / `DE`); the second is the law name (`RoP` / `EPÜ` / `ZPO` / `PatG`). The filter dropdown can do `legal_source LIKE 'DE.PatG.%'` or `legal_source LIKE 'UPC.%'`. The display layer renders `UPC.RoP.23.1` as **"UPC RoP R.23(1)"** (a small format-converter on the frontend, mirroring what mbrian conventions do).
**The display form on the result-card pill is the human-rendered version.** Internal storage + index is the structured form. One field, two views.
**Why `EU.EPÜ` and not `DE.EPÜ`?** EPÜ is European, not German law (despite the German abbreviation). `EU` is the closest namespace; alternatives `EP` or `EPO` would also work. Open for m to override.
**Why `DE.PatG` and not `BPatG.PatG`?** PatG is national German patent law applicable across DPMA, BPatG, BGH. The forum (where the law is applied) varies; the source is the same. So `DE.PatG.110` is the BGH appeal rule, and the `proceeding_type_id` of the rule row tells us which forum.
### 4.6 The unified search view
```sql
CREATE MATERIALIZED VIEW paliad.deadline_search AS
-- One row per concept × context (so a card is a GROUP BY concept_id of these rows)
SELECT
'rule'::text AS kind,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dr.id AS rule_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'
UNION ALL
-- Trigger events linked to a concept (court-set placeholders, cross-cutting Wiedereinsetzung, etc.)
SELECT
'trigger',
dc.id, dc.slug, dc.name_de, dc.name_en, dc.description, dc.aliases, dc.party, dc.category,
NULL::uuid, -- no rule_id (trigger lives in event_deadlines)
NULL, -- no proceeding_code (trigger is cross-cutting)
NULL, NULL,
'cross-cutting', -- jurisdiction
te.code, te.name_de, te.name,
NULL, -- legal_source (will join from event_deadline_rule_codes when surfaced)
NULL, -- rule_code
NULL, NULL, NULL,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_src ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_legal_src_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_aliases ON paliad.deadline_search USING gin (concept_aliases);
```
`pg_trgm` already enabled (verified). Refresh:
```sql
REFRESH MATERIALIZED VIEW CONCURRENTLY paliad.deadline_search;
```
Triggered by AFTER INSERT/UPDATE/DELETE on `deadline_rules` / `deadline_concepts` / `trigger_events` / `proceeding_types`. Mat-view remains sub-1k rows, so refresh is < 100 ms.
---
## 5. Coverage gaps to map (UPC + DE + EPA + DPMA)
Same legal substance as v1 §5, retargeted to the concept-layer shape. Citations from `data.laws_contents` (youpcdb, UPCRoP) for UPC; from m's authoritative knowledge (spot-checked) for DE / EPA / DPMA.
### 5.1 UPC counterclaim cross-flows (m's primary complaint)
#### UPC_INF with Counterclaim for Revocation (CCR)
R.29 verbatim from `data.laws_contents`:
| When | Who | What | Source code | Duration | Anchor |
|---|---|---|---|---|---|
| (with CCR) | Claimant | Defence to CCR + Reply to SoD + (opt) Application to amend | `UPC.RoP.29.a` | 2 months | service of SoD |
| | Defendant | Defence to App to amend (when claimant filed amend) | `UPC.RoP.32.1` | 2 months | service of App to amend |
| | Defendant | Reply to Defence to CCR + Rejoinder to Reply to SoD + (opt) Defence to App to amend | `UPC.RoP.29.d` | 2 months | service of Defence to CCR |
| | Proprietor | Reply to Defence to App to amend | `UPC.RoP.32.3` | 1 month | service of Defence to App to amend |
| | Claimant | Rejoinder + (opt) Reply to Defence to amend | `UPC.RoP.29.e` | 1 month | service of Reply to Defence to CCR |
| | Defendant | Rejoinder on Reply to amend | `UPC.RoP.32.3` | 1 month | service of Reply to Defence to amend |
**New rules added to `UPC_INF` tree (new concepts in `deadline_concepts`):**
```
inf.def_to_ccr Defence to Counterclaim for Revocation 2mo UPC.RoP.29.a parent=inf.sod party=claimant condition_flag={with_ccr}
inf.app_to_amend Application to amend the patent 2mo UPC.RoP.30.1 parent=inf.sod party=claimant condition_flag={with_ccr,with_amend}
inf.def_to_amend Defence to App to amend 2mo UPC.RoP.32.1 parent=inf.app_to_amend party=defendant condition_flag={with_ccr,with_amend}
inf.reply_def_ccr Reply to Defence to CCR 2mo UPC.RoP.29.d parent=inf.def_to_ccr party=defendant condition_flag={with_ccr}
inf.reply_def_amd Reply to Defence to amend 1mo UPC.RoP.32.3 parent=inf.def_to_amend party=claimant condition_flag={with_ccr,with_amend}
inf.rejoin_reply_ccr Rejoinder on Reply to Defence to CCR 1mo UPC.RoP.29.e parent=inf.reply_def_ccr party=claimant condition_flag={with_ccr}
inf.rejoin_amd Rejoinder on Reply to amend 1mo UPC.RoP.32.3 parent=inf.reply_def_amd party=defendant condition_flag={with_ccr,with_amend}
```
**Concepts created:**
- `defence-to-counterclaim-for-revocation`
- `application-to-amend` (also referenced by UPC_REV, see below)
- `defence-to-application-to-amend`
- `reply-to-defence-to-counterclaim-for-revocation`
- `reply-to-defence-to-application-to-amend`
- `rejoinder-to-reply-to-defence-to-counterclaim-for-revocation`
- `rejoinder-on-reply-to-amend`
**UI:** the existing single `with_ccr` checkbox keeps its label. A nested checkbox **"☐ Mit Antrag auf Patentänderung"** appears below it (only enabled when `with_ccr` is on, since R.30 application is only available with a CCR). Two checkbox states: ccr-only / ccr-with-amend.
#### UPC_REV with Application to Amend + Counterclaim for Infringement
R.49(2) Defence to revocation may include (a) Application to amend (R.55 = R.32 m.m.) and/or (b) Counterclaim for infringement (R.50, R.56 cycle).
| When | Who | What | Source code | Duration | Anchor |
|---|---|---|---|---|---|
| (with amend) | Defendant (proprietor) | Application to amend (within Defence) | `UPC.RoP.49.2.a` | 0 (filed with Defence) | service of SoR |
| | Claimant | Defence to Application to amend | `UPC.RoP.43.3` (= R.32.1 m.m.) | 2 months | service of Application to amend |
| | Defendant | Reply to Defence to amend | `UPC.RoP.32.3` | 1 month | service of Defence to amend |
| | Claimant | Rejoinder on Reply to amend | `UPC.RoP.32.3` | 1 month | service of Reply to Defence to amend |
| (with CCI) | Defendant (proprietor) | Counterclaim for infringement (within Defence) | `UPC.RoP.49.2.b` | 0 (with Defence) | service of SoR |
| | Claimant | Defence to Counterclaim for infringement | `UPC.RoP.56.1` | 2 months | service of CCI |
| | Defendant | Reply to Defence to CCI | `UPC.RoP.56.3` | 1 month | service of Defence to CCI |
| | Claimant | Rejoinder on Reply on CCI | `UPC.RoP.56.4` | 1 month | service of Reply to Defence to CCI |
**New rules added to `UPC_REV` tree, with two parallel independent flag chains** (per Q3 / m's go-direction):
```
rev.app_to_amend Application to amend 0 UPC.RoP.49.2.a parent=rev.defence party=defendant condition_flag={with_amend}
rev.def_to_amend Defence to Application to amend 2mo UPC.RoP.43.3 parent=rev.app_to_amend party=claimant condition_flag={with_amend}
rev.reply_def_amd Reply to Defence to amend 1mo UPC.RoP.32.3 parent=rev.def_to_amend party=defendant condition_flag={with_amend}
rev.rejoin_amd Rejoinder on Reply to amend 1mo UPC.RoP.32.3 parent=rev.reply_def_amd party=claimant condition_flag={with_amend}
rev.cc_inf Counterclaim for infringement 0 UPC.RoP.49.2.b parent=rev.defence party=defendant condition_flag={with_cci}
rev.def_cci Defence to CCI 2mo UPC.RoP.56.1 parent=rev.cc_inf party=claimant condition_flag={with_cci}
rev.reply_def_cci Reply to Defence to CCI 1mo UPC.RoP.56.3 parent=rev.def_cci party=defendant condition_flag={with_cci}
rev.rejoin_cci Rejoinder on Reply on CCI 1mo UPC.RoP.56.4 parent=rev.reply_def_cci party=claimant condition_flag={with_cci}
```
**UI:** UPC_REV gets two independent flags **"☐ Mit Antrag auf Patentänderung"** and **"☐ Mit Verletzungswiderklage"**. Both can be on. They render parallel cycles that don't interact (no rule has `condition_flag={with_amend,with_cci}` verified per R.49 + R.55 + R.56 reading).
#### Other UPC gaps remaining (lower priority — t-paliad-084 Tier 2/3 follow-ups)
Already-shipped per `031_tier2_fristenrechner_ports`: R.137.2 / R.139 (damages), R.151 / R.221.1 (cost-decision), R.220.2 / R.220.3 (leave-to-appeal), R.237 / R.238 (cross-appeal), R.142 (lay-open books). Verify nothing slipped before Phase B2 lands.
Cross-cutting (best as event_trigger only): R.16(3)(a) / R.27(2) / R.89(2) / R.207.6(a) / R.229(2) / R.253(2) (correction of deficiencies × 6), R.262(2) (confidentiality), R.197(3) / R.198 (evidence preservation), R.245(2)(a)/(b) (rehearing needs compound trigger), R.321(3) (refer central division), R.353 (rectification).
#### UPC_APP grounds-anchor bug (open)
`app.grounds.parent_id = app.notice` wrong per R.224(2)(a). Grounds is 4 months from **service of the decision**, not 2mo + 2mo from notice. Fix as part of Phase B1.
### 5.2 German national — PatG / ZPO procedural deadlines
#### 5.2.1 LG (1. Instanz) — `DE_INF`
| # | Trigger | Source code | Duration | Anchor | Status |
|---|---|---|---|---|---|
| 1 | Klageerhebung | (anchor) | 0 | trigger date | EXISTS |
| 2 | Anzeige der Verteidigungsbereitschaft | `DE.ZPO.276.1` | 2 weeks | service of Klage | **GAP** |
| 3 | Klageerwiderung (schriftliches Vorverfahren) | `DE.ZPO.276.1` | 6 weeks (court-set) | service of Klage | EXISTS (`de_inf.erwidg`) |
| 4 | Replik | `DE.ZPO.282` | court-set, ~4 weeks | service of Klageerwiderung | EXISTS (`de_inf.replik`) |
| 5 | Duplik | `DE.ZPO.282` | court-set, ~4 weeks | service of Replik | EXISTS (`de_inf.duplik`) |
| 6 | Schriftsatznachreichung | `DE.ZPO.296a` | court-set | end of mündl. Verhandlung | **GAP** |
| 7 | Haupttermin | (court event) | court-set | | EXISTS |
| 8 | Urteil | (court event) | court-set | | EXISTS |
| 9 | Einspruch gegen Versäumnisurteil | `DE.ZPO.339` | 2 weeks | service of Versäumnisurteil | **GAP** |
| 10 | Berufungsfrist | `DE.ZPO.517` | 1 month | service of Urteil | EXISTS |
| 11 | Berufungsbegründung | `DE.ZPO.520.2` | 2 months | service of Urteil (NOT from Berufungsschrift) | EXISTS verify anchor |
| 12 | Berufungserwiderung | `DE.ZPO.521.2` | court-set, ~4 weeks | service of Berufungsbegründung | **GAP** |
| 13 | Anschlussberufung | `DE.ZPO.524.2` | until expiry of §521 deadline | (event) | **GAP** |
**New proceeding types needed:** `DE_INF_OLG` (OLG Berufung), `DE_INF_BGH` (BGH NZB / Revision).
| # | Trigger | Source code | Duration | Anchor |
|---|---|---|---|---|
| `DE_INF_OLG` | Berufungsschrift | `DE.ZPO.519` | 1 month from Urteil | (entry trigger) |
| | Berufungsbegründung | `DE.ZPO.520.2` | 2 months | service of Urteil |
| | Berufungserwiderung | `DE.ZPO.521.2` | court-set | service of Begründung |
| | Anschlussberufung | `DE.ZPO.524.2` | until §521 expiry | event |
| | Mündliche Verhandlung | (court) | | |
| | Urteil OLG | (court) | | |
| `DE_INF_BGH` | Nichtzulassungsbeschwerde | `DE.ZPO.544.1` | 1 month | service of OLG-Urteil |
| | NZB-Begründung | `DE.ZPO.544.4` | 2 months | service of OLG-Urteil |
| | Revisionsfrist | `DE.ZPO.548` | 1 month | service of OLG-Urteil |
| | Revisionsbegründung | `DE.ZPO.551.2` | 2 months | service of OLG-Urteil |
| | Revisionserwiderung | `DE.ZPO.554` | court-set | service of Revisionsbegründung |
#### 5.2.2 BPatG (Nichtigkeit) — `DE_NULL`
| # | Trigger | Source code | Duration | Anchor | Status |
|---|---|---|---|---|---|
| 1 | Nichtigkeitsklage | (anchor) | 0 | trigger date | EXISTS |
| 2 | Klageerwiderung | `DE.PatG.82.1` | 2 months (= 1mo base + 1mo typische richterliche Verlängerung; user can override exact date inline per Q4) | service of Klage | EXISTS as 2mo keep |
| 3 | Replik | `DE.PatG.83.2` | court-set, ~2 months | service of Erwiderung | **GAP** |
| 4 | Hinweisbeschluss | `DE.PatG.83.1` | court-issued (~6 mo before mündl. Verhandlung) | (court event) | **GAP** |
| 5 | Stellungnahme zum Hinweis | `DE.PatG.83.2` | court-set, ~3 months | service of Hinweisbeschluss | **GAP** |
| 6 | Duplik | `DE.PatG.83.2` | court-set | service of Replik | **GAP** |
| 7 | Mündliche Verhandlung | (court) | | | EXISTS |
| 8 | Urteil | (court) | | | EXISTS |
**New proceeding type:** `DE_NULL_BGH` (Berufung BGH).
| # | Trigger | Source code | Duration | Anchor | Status |
|---|---|---|---|---|---|
| 9 | Berufungsfrist | `DE.PatG.110.1` | 1 month | service of Urteil | EXISTS (currently in DE_NULL) |
| 10 | Berufungsbegründung | `DE.PatG.111.1` | **3 months** (currently seeded as 1mo likely bug) | service of Urteil | **EXISTS but wrong** |
| 11 | Berufungserwiderung | `DE.PatG.111.3` (verweist auf ZPO §521) | 2 months | service of Begründung | **GAP** |
**Bug to fix as part of Phase B3:** `de_null.beruf_begr` 1 month 3 months per current PatG §111(1).
#### 5.2.3 DPMA — new proceeding types
`DPMA_OPP` (Einspruch DPMA), `DPMA_BPATG_BESCHWERDE`, `DPMA_BGH_RB`. Currently 0 rules.
| Trigger | Source code | Duration | Anchor |
|---|---|---|---|
| Einspruchsfrist DPMA | `DE.PatG.59.1` | 9 months | publication of grant |
| Erwiderung Patentinhaber | `DE.PatG.59.3` (court-set) | typical 4 months | service of Einspruchsschriftsatz |
| Beschwerde BPatG | `DE.PatG.73.2` | 1 month | service of DPMA-Entscheidung |
| Beschwerdebegründung BPatG | `DE.PatG.75.1` | 1 month (often extended +1) | service of DPMA-Entscheidung |
| Rechtsbeschwerde BGH | `DE.PatG.100` | 1 month | service of BPatG-Entscheidung |
| Begründung Rechtsbeschwerde | `DE.PatG.102.3` (verweist auf ZPO §551) | 1 month | service of BPatG-Entscheidung |
#### 5.2.4 ZPO cross-cutting deadlines — event-trigger-only
Per Q8 (forum dropped) and concept-card UX, these become *cross-cutting concepts* (no proceeding-type pill, only a "Was kommt nach…" pill on the card):
- `wiedereinsetzung` `DE.PatG.123.2` (2 months from Wegfall, max 12 months from Fristablauf), `DE.ZPO.233` (2 weeks from Wegfall different!), `EU.EPÜ.122` + `EU.EPC-R.136` (2 months / 12 months), DPMA equivalent.
- `versaeumnisurteil-einspruch` `DE.ZPO.339` (2 weeks).
- `schriftsatznachreichung` `DE.ZPO.296a` (court-set, typical 2-3 weeks).
- `mahnverfahren-widerspruch` `DE.ZPO.345` (2 weeks).
### 5.3 EPA — EPÜ + RPBA gaps
**EP_GRANT** additions:
- `weiterbehandlung` `EU.EPÜ.121` + `EU.EPC-R.135` (2 months from loss-of-rights notice).
- `wiedereinsetzung` `EU.EPÜ.122` + `EU.EPC-R.136` (2 months / max 12 months).
- `teilanmeldung` `EU.EPÜ.76` + `EU.EPC-R.36.1` (until end of pending parent anchor is grant date - 1).
- `pruefbescheid-erwiderung` court-set, typical 46 months.
- `validierungsfrist-national` `EU.EPÜ.65` + national IPÜG (3 months from publication of grant B1).
**EPA_OPP** additions:
- `einspruch-stellungnahmen-weitere` `EU.EPC-R.79.2`/`EU.EPC-R.79.3` (court-set).
- `mvor-eingaben-r116` `EU.EPC-R.116.1` (1 month before oral proceedings, court-set).
- `wiedereinsetzung` (cross-cutting concept).
- `weiterbehandlung` (cross-cutting concept).
**EPA_APP** additions:
- `beschwerdeerwiderung` `EU.RPBA.12.1.c` (4 months from service of grounds).
- `eingaben-vor-mvh` `EU.EPC-R.116.1` + `EU.RPBA.13` (1 month before oral, court-set).
- `antrag-auf-ueberpruefung` `EU.EPÜ.112a` (2 months from service of decision).
### 5.4 Coverage delta after migration
| Forum | Today | After migration | Net new |
|---|---|---|---|
| UPC trees | 8 trees / 39 rules | 8 trees / ~62 rules | +23 (counterclaim cross-flows) |
| DE trees | 2 trees / 13 rules | 5 trees / ~43 rules | +30 (OLG, BGH-Rev, BGH-NZB, Hinweisbeschluss, DPMA, BPatG-Beschwerde) |
| EPA trees | 3 trees / 18 rules | 3 trees / ~35 rules | +17 (R.116, R.79.2/3, R.106, Wiedereinsetzung, Weiterbehandlung) |
| Cross-cutting concepts (event-trigger) | 102 triggers / 70 deadlines (UPC only) | +20 triggers (Wiedereinsetzung × 4, Versäumnis, Schriftsatzfristen) | +20 |
| Concepts | 0 (none today) | ~30 | +30 (the new layer) |
**+90 new rules / triggers** + **30 concepts**.
---
## 6. Search & filter — backend mechanics
### 6.1 The single API endpoint
```
GET /api/tools/fristenrechner/search?q=<phrase>
&party=<claimant|defendant|both|court>
&proc=<proceeding_code>
&source=<UPC|EU|DE|DE.PatG|DE.ZPO|UPC.RoP|EU.EPÜ|...>
&limit=<int, default 12, max 30>
Response 200:
{
"query": "klageerwiderung",
"filters": {"party": null, "proc": null, "source": null},
"cards": [
{
"concept": {
"id": "<uuid>",
"slug": "klageerwiderung",
"name_de": "Klageerwiderung",
"name_en": "Statement of Defence",
"description": "Erwiderung des Beklagten auf eine Klageschrift, üblicherweise mit Verteidigungsanträgen und Sachvortrag.",
"party": "defendant",
"category": "submission"
},
"matched_aliases": ["Statement of Defence", "Erwiderung Klage"],
"score": 0.96,
"pills": [
{
"kind": "rule",
"rule_id": "<uuid>",
"proceeding": {"code": "UPC_INF", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC"},
"rule_local_code": "inf.sod",
"legal_source": "UPC.RoP.23.1",
"legal_source_display": "UPC RoP R.23(1)",
"duration": {"value": 3, "unit": "months", "alt": null},
"party": "defendant",
"drill_url": "/tools/fristenrechner?proc=UPC_INF&focus=inf.sod"
},
{
"kind": "rule",
"rule_id": "<uuid>",
"proceeding": {"code": "DE_INF", "name_de": "Verletzungsklage (LG)", "jurisdiction": "DE"},
"rule_local_code": "de_inf.erwidg",
"legal_source": "DE.ZPO.276.1",
"legal_source_display": "ZPO §276(1)",
"duration": {"value": 6, "unit": "weeks"},
"party": "defendant",
"drill_url": "/tools/fristenrechner?proc=DE_INF&focus=de_inf.erwidg"
},
// … BPatG, EPA, DPMA pills
]
},
// … other cards (e.g. "Klageerwiderung mit Nichtigkeitswiderklage" trigger event)
],
"total_cards": 3,
"total_pills": 7
}
```
### 6.2 Ranking
```sql
WITH hits AS (
SELECT
s.concept_id,
bool_or( -- alias hit anywhere?
s.concept_aliases @> ARRAY[lower($q)]
OR EXISTS (SELECT 1 FROM unnest(s.concept_aliases) a WHERE similarity(a, $q) > 0.4)
) AS alias_hit,
GREATEST(
max(similarity(s.concept_name_de, $q)) * 1.0,
max(similarity(s.concept_name_en, $q)) * 1.0,
max(similarity(s.legal_source, $q)) * 0.9,
max(similarity(s.rule_code, $q)) * 0.9,
max(similarity(s.rule_name_de, $q)) * 0.7,
max(similarity(s.rule_name_en, $q)) * 0.7
) AS field_score
FROM paliad.deadline_search s
WHERE (
s.concept_name_de % $q
OR s.concept_name_en % $q
OR s.rule_name_de % $q
OR s.rule_name_en % $q
OR s.legal_source % $q
OR s.rule_code % $q
OR s.concept_aliases @> ARRAY[lower($q)]
OR EXISTS (SELECT 1 FROM unnest(s.concept_aliases) a WHERE a % $q)
)
AND ($party IS NULL OR s.effective_party = $party)
AND ($proc IS NULL OR s.proceeding_code = $proc)
AND ($source IS NULL OR s.legal_source LIKE $source || '%')
GROUP BY s.concept_id
)
SELECT h.concept_id,
(h.field_score + CASE WHEN h.alias_hit THEN 0.2 ELSE 0 END) AS score
FROM hits h
ORDER BY score DESC
LIMIT $limit;
```
Then for each returned `concept_id` the API does a second, scoped query to fetch all pills for that concept (the JSON shape above). Two queries per request (one for ranked concept ids + one for pills), both indexed.
Tie-break: sort by `concept.sort_order` then alphabetical.
### 6.3 Why mat-view + pg_trgm rather than ES / Meilisearch / Postgres FTS
Same answer as v1: corpus < 1k rows, no new infra, `pg_trgm` already enabled, no tokeniser tax for legal text. The ONE nuance for v2: cards are concept-grouped, so the search query has a GROUP BY rather than a flat ranking. Postgres handles this comfortably at this scale.
### 6.4 i18n of search
Indexes both `concept_name_de` and `concept_name_en` plus `rule_name_de`/`rule_name_en` a search hit anywhere routes to the right concept. The result card displays the concept name in active locale. Pills always show the structured `legal_source` (locale-independent code) plus the display form (locale-rendered: `§276(1) ZPO` in DE, `§276(1) ZPO` or `Section 276(1) ZPO` in EN German law sections stay in German per HLC convention).
---
## 7. Migration path — phases A through D
Per m's "augment, not replace" Phase E (subsumption) from v1 is dropped.
### Phase A — Concept layer + structural additions (purely additive)
- Migration A1: create `paliad.deadline_concepts` + indexes.
- Migration A2: add `concept_id` FK on `deadline_rules`, `legal_source` text on `deadline_rules` and `event_deadlines`, `concept_id` slug-text on `trigger_events`.
- Migration A3: change `deadline_rules.condition_flag` from `text` to `text[]` (Q3); update existing rows. The `Calculate` function gains a small loop change: instead of `if rule.condition_flag matches one flag in flagSet` it becomes `if rule.condition_flag is empty OR all elements of rule.condition_flag are in flagSet`.
- Migration A4: backfill seed the ~30 concepts; UPDATE `deadline_rules` SET `concept_id = …` per row; backfill `legal_source` from existing rule_code mapping (algorithm: `'RoP 23'` `'UPC.RoP.23'`, `'§ 276 ZPO'` `'DE.ZPO.276'`, `'Art. 108 EPÜ'` `'EU.EPÜ.108'`, etc. direct seed, no runtime regex).
- Code A5: extend `CalcOptions` with `AnchorOverrides map[string]string` (rule_code YYYY-MM-DD). The tree-walk in `Calculate` checks `AnchorOverrides[parent.code]` before reading the `computed[parent.code]` map; if present, the override anchors the child. No DB schema change purely calculator-side. Enables the user-set-custom-date capability per Q4 (m's 23:36 simplification) and reuses for any case where the user knows a real date better than the calculator's projection (court extensions, court-set decisions, post-hoc corrections).
- Code A6: per-row editable date affordance on the result UI. Each rule row's date display becomes click-to-edit (or has a small icon); editing fires a re-fetch with the override added to the request. Court-set placeholder rows (`IsCourtSet=true`) get the same treatment user enters the actual decision date once known, downstream reflows.
- No user-visible behaviour change in A1A5 *for existing rules* A6's editable affordance is the only UI delta. Existing rules without overrides compute identically.
### Phase B — Coverage migrations (the bulk of new content)
- B1: UPC counterclaim cross-flows on `UPC_INF` and `UPC_REV` 5.1) with the new condition_flag arrays.
- B2: any remaining UPC rules from Tier 2/3 of t-paliad-084 audit (most already in 031; verify before B2 lands).
- B3: DE_INF/DE_NULL fixes (PatG §111 1mo3mo) + new proceeding types DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH; add Hinweisbeschluss-Cycle.
- B4: DPMA DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB.
- B5: EPA fill EPA_OPP / EPA_APP / EP_GRANT gaps (R.116, R.79.2/3, R.106, Wiedereinsetzung, Weiterbehandlung, Validierungsfristen).
- B6: cross-cutting concept-only rows Wiedereinsetzung (4 contexts), Versäumnisurteil-Einspruch, Schriftsatznachreichung.
Each B-migration is independently shippable; B2-B6 have no ordering dependency (different proceeding types).
### Phase C — Search backend
- Mat-view + indexes per §4.6.
- New `DeadlineSearchService` (or method on existing `FristenrechnerService`).
- New handler `GET /api/tools/fristenrechner/search`.
- Tests: golden table with ~15 well-known queries expected ranked concept cards + pill counts.
### Phase D — Search-bar + concept-card UI
- Add search input to top of `frontend/src/fristenrechner.tsx`.
- New client module `frontend/src/client/fristenrechner-search.ts` debounce, fetch, render concept cards with pills.
- Drill-in: pill click `?proc=...&focus=...` URL change calculator opens with proceeding pre-selected, scrolling to focused rule.
- Quick-pick chips above the proceeding tile grid.
- "Vollständige Instanzenkette anzeigen" checkbox below the tile grid.
- URL state for shareable searches.
- The proceeding tile grid stays in place below the search bar (per "augment not replace").
### Out of scope — separate task
**The columns-view sequence-preservation fix** (Q9): undated court-set events currently collapse into one row in the t-paliad-129 columns view. They have an inherent sequence (Counterclaim Defence Reply Decision per `sequence_order`). This is a t-paliad-129 follow-up the rule data carries `sequence_order` already, and the columns-view renderer (`frontend/src/client/fristenrechner.ts`) just needs to use it for vertical positioning even when a date is missing. **Recommend: separate task t-paliad-132 (or similar) to file after this design lands.** Out of scope for the unified-Fristenrechner core work.
### Full Appeal Chain — implementation note (Q5 locked)
The toggle is a render option; no new "DE_INF_FULL_CHAIN" proceeding type in the data. The frontend has the proceeding-chain mapping baked in:
```ts
const APPEAL_CHAINS: Record<string, string[]> = {
DE_INF: ["DE_INF", "DE_INF_OLG", "DE_INF_BGH"],
DE_NULL: ["DE_NULL", "DE_NULL_BGH"],
DPMA_OPP: ["DPMA_OPP", "DPMA_BPATG_BESCHWERDE", "DPMA_BGH_RB"],
EPA_OPP: ["EPA_OPP", "EPA_APP"],
UPC_INF: ["UPC_INF", "UPC_APP"],
UPC_REV: ["UPC_REV", "UPC_APP"],
};
```
**Anchor handoff (Q5):** the calculator does NOT guess inter-stage gaps. Instead, when the toggle is on the UI renders **one date input per stage anchor + one date input per terminal decision in the chain**:
```
Vollständige Instanzenkette: Verletzungsklage (LG → OLG → BGH)
Stage 1 — LG:
Klageerhebung am: [ 2026-05-01 ] (required)
Urteil LG am: [ ___________ ] (optional — required for stage 2)
Stage 2 — OLG (Berufung):
Urteil OLG am: [ ___________ ] (optional — required for stage 3)
Stage 3 — BGH (Revision/NZB):
Urteil BGH am: [ ___________ ] (n/a — terminal)
[ Berechnen ]
```
If Stage 1's Urteil date is missing, all Stage 2 / Stage 3 deadlines render as IsCourtSet placeholders (same semantic as the existing court-determined-rule path). The user fills in Stage 2's anchor when the LG decision lands. This keeps the calculator honest no fabricated "+18 months" between instances.
Render concatenates the per-stage timelines with section headers ("LG", "OLG", "BGH"). Each stage's Calculate call is independent.
---
## 8. Test plan
- **Phase A:** schema migrations only; round-trip up/down; verify `condition_flag` text[] cast preserves existing semantics (single-element array still triggers correctly).
- **Phase B1 (counterclaim):** unit tests on `FristenrechnerService.Calculate` for every (with_ccr × with_amend × with_cci) combination per UPC_INF and UPC_REV; assert each new rule appears with the exact dates from a hand-computed example.
- **Phase B3B6:** golden-date tables for each new trigger, hard-coded trigger date + expected computed deadline. Pinned against m's spot-check.
- **Phase C:** mat-view query tests search "Klageerwiderung" returns one concept card with N pills; "RoP 23" returns the UPC card with the R.23 pill; 82" returns the BPatG card; "Wiedereinsetzung" returns one concept with cross-context pills (PatG §123, ZPO §233, EPÜ Art.122, DPMA §123).
- **Phase D:** Playwright smoke type "klageerwiderung", click first pill, verify proceeding-tree calculator opens with right tree + focused rule highlighted; refresh URL `?q=klageerwiderung` restores card list.
---
## 9. Risks & mitigations
| Risk | Severity | Mitigation |
|---|---|---|
| Backfilling 90+ legal entries easy to mistype duration / anchor | High | Each B-migration ships one proceeding family; m spot-checks 2-3 rules each; golden-date tests pin every rule |
| Concept slugs drift between migrations | Medium | One canonical slug list maintained at top of `internal/seed/concept_slugs.go` (or similar); every migration references it |
| Mat-view refresh staleness | Low | AFTER triggers refresh CONCURRENTLY (corpus < 1k rows, < 100 ms) |
| Trigram threshold tuning | Medium | Tune via golden-query test set; per-column threshold if needed |
| `condition_flag text → text[]` migration breaks calculator | Low (calculator change is small) | Test with a manual round-trip before merging A3 |
| Two backends staying separate = double-maintenance for new deadlines | Low | Acceptable; the concept layer is the unifier without forcing schema merge |
| `legal_source` format drift between v1 internal `rule_code` and new structured form | Medium | Phase A normalises both: `rule_code` keeps `RoP.029.b` (period-before-letter) shape; `legal_source` uses the new `UPC.RoP.29.b` shape. Map is 1:1 algorithmic. |
| Aliases hand-curation misses search terms | Low | Spot-check during seed; firm-vocabulary edit-window during early access |
| "Full Appeal Chain" anchor handoff between trees gets wrong | Medium | Per-tree user override of stage anchors as the safety hatch (user can enter the Urteil OLG date directly even when chain mode is on) |
---
## 10. Out of scope (deliberate)
- **AI-driven Frist-Extraktion** from court PDFs (Phase H, deferred).
- **Per-user / firm-level aliases.** v1 is curated-only.
- **Time-versioned `legal_source`.** Single-snapshot of current law.
- **Cross-jurisdiction equivalence claims** as data ("§82 PatG R.23 UPC"). Search returns both; data does not assert equivalence beyond shared `concept_id`.
- **Linking out to law text** from `legal_source` (mbrian / youpc.org deeplinks). Future iteration.
- **Internal KanzlAI proceeding types** (INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL category='litigation'). Matter-attached; not in this scope.
- **Forum filter** (Q8 m dropped).
- **Tab subsumption** (Q1 m dropped; tabs stay).
- **Columns-view sequence preservation** (Q9 separate task).
---
## 11. Open questions — m's answers (locked)
All v2 open questions resolved by m on 2026-05-04 23:29. Recorded here as the binding spec for the coder shift:
1. **Concept slug naming convention — mixed.** Use **EN slugs** for concepts that exist primarily in UPC / EPC contexts (`application-to-amend`, `request-for-discretionary-review`, `notice-of-appeal-upc`). Use **DE slugs** for concepts that only exist in German law (`nichtzulassungsbeschwerde`, `versaeumnisurteil-einspruch`, `hinweisbeschluss-stellungnahme`). For **shared concepts that exist in both DE and UPC/EPC** (e.g. Klageerwiderung exists in ZPO §276, PatG §82, UPC R.23, EPA R.79): use the **DE slug** because (a) m works primarily in German, (b) the slug is internal/maintenance-facing only, (c) `name_de` and `name_en` columns carry both labels for the user-facing surface, and (d) it sidesteps EN-translation arguments ("Defence" vs "Statement of Defence" vs "Reply to Application"). Resulting slug examples: `klageerwiderung` (shared, DE wins), `replik` (shared, DE wins), `berufungsfrist` (shared, DE wins), `application-to-amend` (UPC/EPC native, EN), `wiedereinsetzung` (shared cross-cutting, DE wins because the German name dominates HLC vocabulary).
2. **`legal_source` namespace `EU.` for EPÜ.** Confirmed. Format stays `EU.EPÜ.108`, `EU.EPC-R.79.1`, `EU.RPBA.12.1.c`.
3. **DE_NULL Berufungsbegründung 1 → 3 months — confirmed.** Ship the fix as part of Phase B3. Test pin: `de_null.beruf_begr` 1mo 3mo, `legal_source = 'DE.PatG.111.1'`.
4. **PatG §82(1) — keep simple seed; user overrides the date inline.** m's revised direction (23:36): drop the customizable-extension flag mechanism *"sounds complicated, I just want to be able to set a custom date and following deadlines calculate from there."*
Generalised capability instead: **any computed deadline date in the result is user-overridable**, and downstream rules that chain off it re-compute from the override. So PatG §82's "1 month + court extension to 5 weeks" case is handled by the user typing the actual extended date into the result row, and Replik / Duplik re-flow off it.
PatG §82(1) seed stays at 2 months (the practical typical) with `deadline_notes` "1 Monat Grundfrist + bis +1 Monat richterliche Verlängerung typisch". No `with_extension` flag, no `flag_param` mechanism. Cleaner for everyone.
**Calculator change for the override capability:** `CalcOptions` gains an `AnchorOverrides map[string]string` field (rule_code YYYY-MM-DD). The tree-walk loop in `Calculate` checks `AnchorOverrides[parent.code]` before reading `computed[parent.code]` if present, that override anchors the child rule.
**UI change for the override capability:** each result row's date display becomes click-to-edit (or has a small icon). Editing fires a re-fetch with the override added to the request. The court-set placeholder rows (existing `IsCourtSet=true` rendering) get the same treatment the user can type the actual decision date once it's known, and downstream deadlines reflow.
Implementation cost: small. CalcOptions field + 5-line lookup in the tree walk + per-row edit affordance in the timeline / columns view.
5. **Full Appeal Chain — multiple date inputs, decisions need a date.** Confirmed shape (b). When toggle is on, the UI renders **one date input per stage** plus required date inputs for each terminal decision in the chain (LG Urteil, OLG Urteil, BGH Urteil). The intra-stage deadlines compute off the relevant stage anchor; inter-stage handoff is user-entered, never guessed. If the user hasn't yet got a stage's terminal decision date, that stage's downstream deadlines render as IsCourtSet placeholders same semantic as the existing isCourtDeterminedRule path. Worth noting: this means the Full Appeal Chain isn't a single "calculate-from-one-date" tool; it's a multi-stage timeline view that the user fills in as the case progresses.
6. **Concept description copy — drafted in seed migration, reviewed by m or colleague.** Confirmed. ~30 concepts × 1-2 sentences each; PR-1 will include them; m or a colleague reviews on the PR.
7. **Concept-level `party` = dominant case, per-rule overrides.** Confirmed. Pill displays the per-rule value (e.g. "Patentinhaber" on the EPA_OPP pill of the Klageerwiderung concept, even though concept-level party is "Beklagte" because that's the dominant case).
8. **Quick-pick chip seed — 8 chips approved.** Klageerwiderung · Berufung · Einspruch · Replik · Beschwerde · Statement of Defence · Schadensbemessung · Wiedereinsetzung. Hot-tunable in a follow-up later if telemetry shows different chip preferences.
**Net effect on coder shift scope:** Phase A4 picks up the `flag_param` calculator extension (small code change) so PatG §82's customizable-extension shape works when Phase B3 lands. Otherwise the coder spec is unchanged from §7 above.
---
## 12. Proposed cycle
- **Inventor (this shift, cronus, branch `mai/cronus/unified-fristenrechner-design`):** v2 design doc + go/no-go gate (this commit).
- **Coder (next shift, after m's go-ahead):**
- PR-1 = Phase A1+A2+A3+A4 (concept layer + structural additions + backfill). Smallest first; verifies the concept slug list and the `condition_flag` array migration in isolation.
- PR-2 = Phase B1 (UPC counterclaim cross-flows). Closes m's primary complaint.
- **Coder (third shift):** B3 + B4 + B5 + B6 (DE + DPMA + EPA + cross-cutting). Each as a separate PR for spot-checkability, parallelisable.
- **Coder (fourth shift):** Phase C + D (search infra + UI).
- **Separately filed:** t-paliad-132 (or similar) columns-view sequence preservation per Q9.
I'd recommend **curie** as implementer (port-heavy, careful seed work same shape as t-paliad-084 / t-paliad-086 audit + import sequence). Alternative for B1 only: **fritz** (bug-fix shape). Head decides.
---
## Appendix A — Files & references
**Code paths read:**
- `internal/services/fristenrechner.go` (proceeding-tree calculator, CalcOptions extension, `isCourtDeterminedRule` helper)
- `internal/services/event_deadline_service.go` (trigger-event calculator, composite-rule resolver, working_days unit)
- `internal/services/deadline_rule_service.go` (CRUD over `paliad.deadline_rules`)
- `internal/services/holidays.go` (DB-driven holidays + UPC vacation gating from t-paliad-121)
- `frontend/src/fristenrechner.tsx` (current 252-line UI shell proceeding tile grid + 2-tab shell)
- `frontend/src/client/fristenrechner.ts` (1031-line client, both modes)
- migrations 012 / 028 / 029 / 030 / 031 / 033 / 034 / 035 / 036
**DB read:**
- `paliad.proceeding_types` (19 rows, 12 fristenrechner-category)
- `paliad.deadline_rules` (74 rules in fristenrechner trees, 96 total including KanzlAI)
- `paliad.trigger_events` (102) + `paliad.event_deadlines` (70) + `paliad.event_deadline_rule_codes` (72, 64 distinct UPC RoP codes)
- `data.laws_contents` (UPCRoP/UPCA/UPCS texts in EN used for §5.1 verbatim quotations)
**Prior work:**
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie's t-paliad-084 audit §5 builds on it)
- t-paliad-086 (3-PR shipped: trigger-event Fristenrechner + composite rules + Tier 1 fixes)
- t-paliad-101 / t-paliad-111 / t-paliad-112 (QA bug bundles)
- t-paliad-121 (UPC vacation no-shift)
- t-paliad-127 / t-paliad-129 (columns view + polish)
- t-paliad-088 (Event Types design relationship: Event Types is matter-attached deadline tagging; this design's concept layer is the calculator-side equivalent. They could share concept slugs eventually but ship independently.)
**Memory references:**
- `paliad t-paliad-084 Fristenrechner audit`
- `paliad t-paliad-086 Fristenrechner youpc-parity`
- `paliad t-paliad-111 SHIPPED — bug-bundle correctness`
- `paliad t-paliad-110 SHIPPED — Fristen+Termine unification`
- `Design: Event Types for deadlines + submissions (t-paliad-088, cronus)`

25
docs/project-status.md Normal file
View File

@@ -0,0 +1,25 @@
# Paliad — project status
Living document tracking what's shipped, what's deferred, and historical context. Update when phases land or open follow-ups change. AI instructions live in `.claude/CLAUDE.md`; this file is project state for humans.
## Phase status
Phases AG shipped (April 2026): schema + RLS, services, Fristenrechner→DB, Akten CRUD, Fristen UI, Termine + CalDAV, Dashboard. See `docs/feature-roadmap.md` for the per-phase scope.
**Phase H (AI Frist-Extraktion) is deferred** — decision by m on 2026-04-16 ("we don't want Anthropic API"). The Dokumente tab on Akten detail stays as a "Kommt bald" placeholder. No `ANTHROPIC_API_KEY` on Dokploy.
**Phase I (Notizen polymorphic notes) shipped**`paliad.notizen` table + RLS (migrations 005, 007), `NoteService` (`internal/services/note_service.go`), REST handlers (`internal/handlers/notes.go``GET/POST /api/{projects|deadlines|appointments}/{id}/notes`, `PATCH/DELETE /api/notes/{id}`), shared client module `frontend/src/client/notes.ts` (`initNotes`), wired into project / deadline / appointment detail pages. i18n keys under `notizen.*`.
**Phase J (this doc + roadmap rewrite + KanzlAI doc retirement notes)** completed 2026-04-17 on `mai/ritchie/phase-j-roadmap-rewrite`. Infra retirement (KanzlAI Dokploy shutdown, `kanzlai` schema drop, Gitea archive) still pending m + head coordination.
**Reminder system redesign (t-paliad-064)** — landed 2026-04-28 across PR-1..PR-4 on `mai/cronus/reminder-system-redesign`. Zero-overdue SLO model: per-user bundled morning/evening digests with category sections (überfällig / heute / diese Woche), DRINGEND escalation in the evening slot, and global-admin escalation framing on overdues. See `docs/design-reminder-redesign-2026-04-28.md`.
## Open follow-ups
- **Settings → Notifications: escalation contact dropdown** — migration 025 ships `paliad.users.escalation_contact_id` (FK to `paliad.users`, nullable, ON DELETE SET NULL). NULL means "fall back to global_admins for the escalation channel"; setting it lets a user designate a specific colleague as their escalation contact. UI shipped t-paliad-066 on 2026-04-29.
- **Audit polish-2** — shipped 2026-04-30 across t-paliad-067 / t-paliad-068 / t-paliad-073 (BATCH-level findings + DEFER list). Follow-ups from the 2026-04-30 re-audit (`docs/improvement-audit-2026-04-30.md`) are tracked under t-paliad-074 and downstream task IDs.
- **KanzlAI infra retirement** — Dokploy shutdown, `kanzlai` schema drop, Gitea archive. Pending m + head coordination.
## Historical naming
Previously called *patHoLo* (Patent + Hogan Lovells). Rebranded to Paliad on 2026-04-16 when HL announced the merger into HLC, making "HoLo" obsolete. Paliad — "Patent Litigation Administration" but in UI used as a standalone word evoking *paladin*, the champion. Firm-agnostic so the brand survives any future firm renames (see t-paliad-065 — single `FIRM_NAME` constant, default "HLC"). Lime branding kept throughout.

View File

@@ -1,58 +1,281 @@
import { mkdir, cp, rm } from "fs/promises";
import { join } from "path";
import { mkdir, cp, rm, readdir } from "fs/promises";
import { join, relative } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossar } from "./src/glossar";
import { renderGlossary } from "./src/glossary";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklisten } from "./src/checklisten";
import { renderChecklistenDetail } from "./src/checklisten-detail";
import { renderGerichte } from "./src/gerichte";
import { renderAkten } from "./src/akten";
import { renderAktenNeu } from "./src/akten-neu";
import { renderAktenDetail } from "./src/akten-detail";
import { renderFristen } from "./src/fristen";
import { renderFristenNeu } from "./src/fristen-neu";
import { renderFristenDetail } from "./src/fristen-detail";
import { renderFristenKalender } from "./src/fristen-kalender";
import { renderChecklists } from "./src/checklists";
import { renderChecklistsDetail } from "./src/checklists-detail";
import { renderChecklistsInstance } from "./src/checklists-instance";
import { renderCourts } from "./src/courts";
import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderInbox } from "./src/inbox";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
// Bundle-scope isolation guard (t-paliad-043).
//
// All client bundles MUST be built with format: "iife" so each bundle's
// top-level `var`/`function` declarations are wrapped in their own scope.
// Without IIFE wrapping, minified identifiers leak to `window` and clobber
// each other across bundles. On Apr 26, app.js's `var d = "patholo-sidebar-pinned"`
// overwrote projects.js's `function d()` (applyTranslations), and the entire
// authenticated surface crashed in initI18n with "TypeError: d is not a function".
//
// The constant below is the single source of truth for the bundle format;
// the post-build inspection further down verifies that every emitted asset
// actually starts with an IIFE prologue, so this guard survives future Bun
// versions, refactors that drop the constant, or anyone trying to silence
// the type system with `as "esm"`.
const BUILD_FORMAT = "iife" as const;
// Bun emits IIFE bundles as either `(()=>{...})()` (arrow form, what we get
// today with minify: true) or `(function(){...})()`. Match either prologue.
const IIFE_PROLOGUE = /^(\(\(\)\s*=>\s*\{|\(function\s*\(\s*\)\s*\{)/;
// Resolve FIRM_NAME once so both the client bundle's `define` substitution
// and the server-side TSX render see the same value. Mirrors the server's
// internal/branding/firm.go default — the two MUST stay in sync because
// users compare a rendered email body against a rendered HTML page and a
// drifted default would produce two different firm names per deploy.
const FIRM_NAME = (process.env.FIRM_NAME ?? "").trim() || "HLC";
// i18n-key codegen + data-i18n scan (t-paliad-078).
//
// `frontend/src/client/i18n.ts` is the single source of truth for translation
// keys. The codegen below extracts every key into a TS literal-union type at
// `frontend/src/i18n-keys.ts`, which `t()` and `tOrEmpty()` use to flag
// literal-string typos at compile time. The scan downstream cross-checks every
// `data-i18n*` attribute literal in `src/**/*.{ts,tsx}` against the same set
// — that's the path the runtime `applyTranslations` walks, so a typo there is
// just as silent as a `t("typo")` (and how F-04 shipped a raw key in prod).
//
// The regex over `i18n.ts` source matches only `^[ \t]*"key": value` lines —
// both the `de` and `en` blocks. The file is a static literal so this is
// robust; if the file shape changes (e.g. someone introduces a function-built
// translations object), the explicit zero-key guard below catches it.
const I18N_SOURCE = join(import.meta.dir, "src/client/i18n.ts");
const I18N_KEYS_OUT = join(import.meta.dir, "src/i18n-keys.ts");
async function generateI18nKeys(): Promise<ReadonlySet<string>> {
const src = await Bun.file(I18N_SOURCE).text();
const re = /^[ \t]*"([A-Za-z][\w.\-]*)"\s*:/gm;
const keys = new Set<string>();
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) keys.add(m[1]);
if (keys.size === 0) {
console.error(
"i18n codegen: extracted 0 keys from src/client/i18n.ts. " +
"Either the file is empty or the regex no longer matches its shape — " +
"fix the codegen before continuing.",
);
process.exit(1);
}
const sorted = [...keys].sort();
const lines: string[] = [
"// GENERATED FILE — do not edit by hand.",
"// Regenerated on every build by frontend/build.ts (generateI18nKeys).",
"// Source of truth: frontend/src/client/i18n.ts.",
"//",
"// `t(key: I18nKey)` accepts this union, so a literal-string typo at a",
"// call site fails `tsc --noEmit`. Runtime-composed keys go through",
"// `tDyn(key: string)` which deliberately bypasses the type check. The",
"// build's `data-i18n` scan uses the same set to verify literal",
"// `data-i18n*` attributes in TSX/TS sources.",
"",
"export type I18nKey =",
...sorted.map(
(k, i) => ` | ${JSON.stringify(k)}${i === sorted.length - 1 ? ";" : ""}`,
),
"",
];
const next = lines.join("\n");
// Skip writing if unchanged — keeps tsc/editor watchers quiet on no-op
// builds and avoids spurious git diffs when the type is already current.
const existing = await Bun.file(I18N_KEYS_OUT)
.text()
.catch(() => "");
if (existing !== next) {
await Bun.write(I18N_KEYS_OUT, next);
console.log(`i18n codegen: ${sorted.length} keys → src/i18n-keys.ts (updated)`);
} else {
console.log(`i18n codegen: ${sorted.length} keys (unchanged)`);
}
return keys;
}
// Scan every TSX/TS source file for literal `data-i18n*` attribute values and
// verify each one is a known I18nKey. Skips dynamic forms (`={...}`,
// `="${...}"`) since those can't be resolved statically. Mirrors the runtime
// behaviour in `applyTranslations` — three attributes, all read literally.
async function checkDataI18nUsage(keys: ReadonlySet<string>): Promise<void> {
const SRC = join(import.meta.dir, "src");
const ATTR_RE =
/\bdata-i18n(?:-placeholder|-title)?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
type Hit = { file: string; line: number; attr: string; key: string };
const unknown: Hit[] = [];
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const ent of entries) {
const full = join(dir, ent.name);
if (ent.isDirectory()) {
await walk(full);
continue;
}
if (!ent.name.endsWith(".ts") && !ent.name.endsWith(".tsx")) continue;
// Skip the generated file itself + the i18n source-of-truth (its
// string keys are translation values, not data-i18n attrs).
if (full === I18N_KEYS_OUT) continue;
if (full === I18N_SOURCE) continue;
const text = await Bun.file(full).text();
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let m: RegExpExecArray | null;
ATTR_RE.lastIndex = 0;
while ((m = ATTR_RE.exec(line)) !== null) {
const value = m[1] ?? m[2];
if (value === undefined) continue;
// Skip dynamic interpolations — can't statically resolve.
if (value.includes("${")) continue;
// The full attribute name is up to the `=` for the report.
const attr = m[0].slice(0, m[0].indexOf("="));
if (!keys.has(value)) {
unknown.push({
file: relative(import.meta.dir, full),
line: i + 1,
attr: attr.trim(),
key: value,
});
}
}
}
}
}
await walk(SRC);
if (unknown.length > 0) {
console.error(
`i18n scan: ${unknown.length} unknown ${unknown.length === 1 ? "key" : "keys"} ` +
`referenced via data-i18n* attributes — every literal must exist in i18n.ts:`,
);
for (const h of unknown) {
console.error(` ${h.file}:${h.line} ${h.attr}="${h.key}"`);
}
process.exit(1);
}
console.log("i18n scan: data-i18n attributes clean");
}
async function build() {
// Clean dist/
await rm(DIST, { recursive: true, force: true });
await mkdir(join(DIST, "assets"), { recursive: true });
// Regenerate the I18nKey union BEFORE bundling. Bun.build runs the TSX
// renderers, which import t() — if a recent commit added a key without
// regenerating, the renderer would still pass tsc only because the union
// is stale, so we always rewrite first. The data-i18n scan runs next so
// any unknown literal aborts the build before any artefact is emitted.
const i18nKeys = await generateI18nKeys();
await checkDataI18nUsage(i18nKeys);
console.log(`branding: firm="${FIRM_NAME}" (override with FIRM_NAME env)`);
// Bundle client-side JS
const result = await Bun.build({
entrypoints: [
// app.ts is loaded on every page (SW registration + bottom-nav init +
// install prompt). Keep it ahead of per-page bundles so name collisions
// surface fast.
join(import.meta.dir, "src/client/app.ts"),
join(import.meta.dir, "src/client/index.ts"),
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossar.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklisten.ts"),
join(import.meta.dir, "src/client/checklisten-detail.ts"),
join(import.meta.dir, "src/client/gerichte.ts"),
join(import.meta.dir, "src/client/akten.ts"),
join(import.meta.dir, "src/client/akten-neu.ts"),
join(import.meta.dir, "src/client/akten-detail.ts"),
join(import.meta.dir, "src/client/fristen.ts"),
join(import.meta.dir, "src/client/fristen-neu.ts"),
join(import.meta.dir, "src/client/fristen-detail.ts"),
join(import.meta.dir, "src/client/fristen-kalender.ts"),
join(import.meta.dir, "src/client/checklists.ts"),
join(import.meta.dir, "src/client/checklists-detail.ts"),
join(import.meta.dir, "src/client/checklists-instance.ts"),
join(import.meta.dir, "src/client/courts.ts"),
join(import.meta.dir, "src/client/projects.ts"),
join(import.meta.dir, "src/client/projects-new.ts"),
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/inbox.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
join(import.meta.dir, "src/client/admin.ts"),
join(import.meta.dir, "src/client/admin-team.ts"),
join(import.meta.dir, "src/client/admin-audit-log.ts"),
join(import.meta.dir, "src/client/admin-partner-units.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
minify: true,
// See BUILD_FORMAT comment at top of file — bundle-scope isolation
// depends on IIFE wrapping. Reuses the single-source-of-truth constant
// so the post-build guard below can detect a format swap.
format: BUILD_FORMAT,
// Inline the resolved firm name into every browser bundle. branding.ts
// reads `process.env.FIRM_NAME`, which Bun's bundler does NOT replace by
// default for browser targets — so without `define`, client code would
// see undefined and fall back to "HLC" regardless of FIRM_NAME.
define: {
"process.env.FIRM_NAME": JSON.stringify(FIRM_NAME),
},
});
if (!result.success) {
@@ -63,12 +286,52 @@ async function build() {
process.exit(1);
}
// Bundle-scope isolation guard (t-paliad-043) — verify every emitted JS
// bundle starts with an IIFE prologue. This catches the case where
// BUILD_FORMAT is changed to "esm", `format` is dropped from the Bun.build
// call, or a future Bun version emits a non-IIFE wrapper despite the
// option. Without this, top-level identifier collisions between bundles
// can take down the whole authenticated surface (see comment at top).
const emittedAssets = await readdir(join(DIST, "assets"));
for (const f of emittedAssets) {
if (!f.endsWith(".js")) continue;
const head = (await Bun.file(join(DIST, "assets", f)).text()).slice(0, 64);
if (!IIFE_PROLOGUE.test(head)) {
console.error(
`Build aborted: dist/assets/${f} is not IIFE-wrapped ` +
`(starts with ${JSON.stringify(head.slice(0, 32))}). ` +
`All client bundles must be built with Bun.build({ format: "iife" }) — ` +
`per-page bundles' top-level identifiers leak to window and clobber ` +
`each other after minification (see t-paliad-043).`,
);
process.exit(1);
}
}
// Copy CSS
await cp(
join(import.meta.dir, "src/styles/global.css"),
join(DIST, "assets/global.css"),
);
// Copy public/ → dist/ (manifest.json, sw.js, icons/) — served at the
// application root so the service worker can claim scope=/ and so the
// manifest is reachable at /manifest.json without a sub-path rewrite.
await cp(
join(import.meta.dir, "public"),
DIST,
{ recursive: true },
);
// Stamp a unique version into sw.js so each deploy opens a fresh cache.
// Activate-time eviction (in sw.js) deletes any prior cache, including
// pre-versioning names like paliad-v1-static — that's what stops a stale
// /assets/projects.js from a previous deploy lingering on a user's device.
const swPath = join(DIST, "sw.js");
const swSrc = await Bun.file(swPath).text();
const buildVersion = `v${Date.now()}`;
await Bun.write(swPath, swSrc.replace("__PALIAD_BUILD_VERSION__", buildVersion));
// Render HTML pages
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
@@ -76,19 +339,59 @@ async function build() {
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossar.html"), renderGlossar());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklisten.html"), renderChecklisten());
await Bun.write(join(DIST, "checklisten-detail.html"), renderChecklistenDetail());
await Bun.write(join(DIST, "gerichte.html"), renderGerichte());
await Bun.write(join(DIST, "akten.html"), renderAkten());
await Bun.write(join(DIST, "akten-neu.html"), renderAktenNeu());
await Bun.write(join(DIST, "akten-detail.html"), renderAktenDetail());
await Bun.write(join(DIST, "fristen.html"), renderFristen());
await Bun.write(join(DIST, "fristen-neu.html"), renderFristenNeu());
await Bun.write(join(DIST, "fristen-detail.html"), renderFristenDetail());
await Bun.write(join(DIST, "fristen-kalender.html"), renderFristenKalender());
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
await Bun.write(join(DIST, "courts.html"), renderCourts());
await Bun.write(join(DIST, "projects.html"), renderProjects());
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights
// the matching one at hydration time based on the active type.
await Bun.write(join(DIST, "events.html"), renderEvents());
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "inbox.html"), renderInbox());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());
await Bun.write(join(DIST, "admin.html"), renderAdmin());
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
await Bun.write(join(DIST, "admin-audit-log.html"), renderAdminAuditLog());
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
// every emitted HTML file. Cache-Control alone isn't enough: a browser that
// cached a script in a previous deploy keeps serving it from disk because
// the cache entry was stored without the no-cache directive. Versioning the
// URL changes the cache key, so the next page load fetches a fresh bundle
// unconditionally \u2014 this is what guarantees t-paliad-043's IIFE wrap fix
// actually reaches users on their next visit even without a SW.
const htmlFiles = (await readdir(DIST)).filter((f) => f.endsWith(".html"));
for (const f of htmlFiles) {
const path = join(DIST, f);
const html = await Bun.file(path).text();
const stamped = html.replace(
/(\/assets\/[\w-]+\.(?:js|css))/g,
`$1?v=${buildVersion}`,
);
await Bun.write(path, stamped);
}
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#BFF355"/>
<text x="256" y="340"
font-family="DejaVu Sans Mono, Liberation Mono, Menlo, Consolas, monospace"
font-size="288" font-weight="700"
fill="#002236"
text-anchor="middle"
textLength="170" lengthAdjust="spacingAndGlyphs">p</text>
</svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#BFF355"/>
<text x="256" y="376"
font-family="DejaVu Sans Mono, Liberation Mono, Menlo, Consolas, monospace"
font-size="384" font-weight="700"
fill="#002236"
text-anchor="middle"
textLength="220" lengthAdjust="spacingAndGlyphs">p</text>
</svg>

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,41 @@
{
"name": "Paliad",
"short_name": "Paliad",
"description": "Patentwissen und Aktenverwaltung für das HLC-Patent-Team.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#BFF355",
"background_color": "#EEE5E1",
"lang": "de",
"dir": "ltr",
"id": "paliad",
"categories": ["productivity", "business"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

82
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,82 @@
// Paliad service worker. Cache strategy:
// /assets/* + /icons/* → cache-first (immutable per deploy)
// /api/* → network-first (fall back to cached snapshot)
// everything else → network passthrough
//
// CACHE_VERSION is rewritten to "v<build-epoch-ms>" by frontend/build.ts on
// every deploy. The activate handler deletes ANY cache whose name doesn't
// match — covers both prior versioned caches (v17143…) and any pre-versioning
// cache name (paliad-v1-static, t-paliad-043 kill-switch survivors). This is
// what guarantees a stale `/assets/projects.js` from a previous deploy gets
// purged the moment the new SW activates, instead of lingering until the user
// manually clears site data.
const CACHE_VERSION = "__PALIAD_BUILD_VERSION__";
const STATIC_CACHE = `${CACHE_VERSION}-static`;
self.addEventListener("install", () => {
// skipWaiting so the new SW takes over the moment install completes,
// rather than waiting for every tab to close. Combined with clients.claim
// in activate, this means a deploy reaches users on their next navigation.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k !== STATIC_CACHE).map((k) => caches.delete(k)),
);
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
event.respondWith(cacheFirst(req));
return;
}
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirst(req));
return;
}
});
async function cacheFirst(req) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
// cache: "reload" forces the network leg to BYPASS the browser HTTP
// cache. Without this, a stale /assets/projects.js sitting in the
// browser's disk cache from a previous deploy would be returned to us,
// we'd cache it again, and the user would be stuck on the old bundle
// forever — exactly the failure mode that caused t-paliad-043.
const res = await fetch(req, { cache: "reload" });
if (res && res.ok) cache.put(req, res.clone());
return res;
} catch (err) {
if (cached) return cached;
throw err;
}
}
async function networkFirst(req) {
try {
return await fetch(req);
} catch (err) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
throw err;
}
}

View File

@@ -0,0 +1,123 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminAuditLog(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.audit.title">Audit-Log &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/audit-log" />
<BottomNav currentPath="/admin/audit-log" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.audit.heading">Audit-Log</h1>
<p className="tool-subtitle" data-i18n="admin.audit.subtitle">
Globale Zeitleiste &uuml;ber Projekt-, CalDAV- und Reminder-Ereignisse.
</p>
</div>
</div>
<div className="admin-audit-controls">
<div className="admin-audit-control-row">
<div className="admin-audit-field">
<label htmlFor="audit-source" data-i18n="admin.audit.filter.source">Quelle</label>
<select id="audit-source" className="admin-audit-input">
<option value="" data-i18n="admin.audit.source.all">Alle</option>
<option value="project_events" data-i18n="admin.audit.source.project_events">Projekt-Ereignisse</option>
<option value="caldav_sync_log" data-i18n="admin.audit.source.caldav_sync_log">CalDAV-Sync</option>
<option value="reminder_log" data-i18n="admin.audit.source.reminder_log">Reminder</option>
</select>
</div>
<div className="admin-audit-field">
<label htmlFor="audit-range" data-i18n="admin.audit.filter.range">Zeitraum</label>
<select id="audit-range" className="admin-audit-input">
<option value="24h" data-i18n="admin.audit.range.24h">Letzte 24h</option>
<option value="7d" selected data-i18n="admin.audit.range.7d">Letzte 7 Tage</option>
<option value="30d" data-i18n="admin.audit.range.30d">Letzte 30 Tage</option>
<option value="custom" data-i18n="admin.audit.range.custom">Benutzerdefiniert</option>
<option value="all" data-i18n="admin.audit.range.all">Alles</option>
</select>
</div>
<div className="admin-audit-field admin-audit-custom-range" id="audit-custom-range" style="display:none">
<label htmlFor="audit-from" data-i18n="admin.audit.filter.from">Von</label>
<input type="date" lang="de" id="audit-from" className="admin-audit-input" />
<label htmlFor="audit-to" data-i18n="admin.audit.filter.to">Bis</label>
<input type="date" lang="de" id="audit-to" className="admin-audit-input" />
</div>
</div>
<div className="admin-audit-control-row">
<div className="admin-audit-field admin-audit-search-field">
<label htmlFor="audit-search" data-i18n="admin.audit.filter.search">Suche</label>
<input
type="text"
id="audit-search"
className="admin-audit-input"
placeholder="Subjekt, Beschreibung, Ereignistyp ..."
data-i18n-placeholder="admin.audit.search.placeholder"
autocomplete="off"
/>
</div>
<div className="admin-audit-field admin-audit-counter-field">
<span className="admin-audit-counter" id="audit-count">&nbsp;</span>
</div>
</div>
</div>
<div id="audit-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap admin-audit-table-wrap">
<table className="entity-table entity-table--readonly admin-audit-table">
<thead>
<tr>
<th data-i18n="admin.audit.col.time">Zeit</th>
<th data-i18n="admin.audit.col.source">Quelle</th>
<th data-i18n="admin.audit.col.event">Ereignis</th>
<th data-i18n="admin.audit.col.actor">Akteur</th>
<th data-i18n="admin.audit.col.subject">Subjekt</th>
<th data-i18n="admin.audit.col.description">Beschreibung</th>
</tr>
</thead>
<tbody id="audit-tbody">
<tr><td colspan={6} className="admin-audit-loading" data-i18n="admin.audit.loading">Lade ...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="audit-empty" style="display:none">
<p data-i18n="admin.audit.empty">Keine Ereignisse f&uuml;r die gew&auml;hlten Filter.</p>
</div>
<div className="admin-audit-pagination">
<button type="button" id="audit-loadmore" className="btn-secondary" style="display:none" data-i18n="admin.audit.loadmore">
Weitere laden
</button>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-audit-log.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,117 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/email-templates/{key}?lang=de — full editor. The shell holds the
// chrome and the empty form/preview/variable wells. The client bundle reads
// the key from location.pathname and the lang from location.search, fetches
// the active row + variables + version log in parallel, and populates.
export function renderAdminEmailTemplatesEdit(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.email_templates.editor.title">Email-Template bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/email-templates" />
<BottomNav currentPath="/admin/email-templates" />
<main>
<section className="tool-page">
<div className="container admin-et-edit-container">
<div className="tool-header">
<div>
<a href="/admin/email-templates" className="admin-et-back" data-i18n="admin.email_templates.back">
&larr; Zurück zur Liste
</a>
<h1 id="admin-et-title" data-i18n="admin.email_templates.editor.heading">Email-Template bearbeiten</h1>
<p id="admin-et-subtitle" className="tool-subtitle" />
</div>
<div className="admin-et-lang-toggle" id="admin-et-lang-toggle" role="tablist" aria-label="Language">
<button type="button" className="admin-et-lang-btn" data-lang="de" aria-pressed="true">DE</button>
<button type="button" className="admin-et-lang-btn" data-lang="en" aria-pressed="false">EN</button>
</div>
</div>
<div id="admin-et-feedback" className="form-msg" style="display:none" />
<div className="admin-et-editor">
<div className="admin-et-editor-form">
<div className="form-field" id="admin-et-subject-wrap">
<label htmlFor="admin-et-subject" data-i18n="admin.email_templates.editor.subject">Betreff</label>
<input type="text" id="admin-et-subject" className="admin-et-subject-input" autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-et-body" data-i18n="admin.email_templates.editor.body">HTML-Body</label>
<textarea id="admin-et-body" className="admin-et-body-input" rows={24} spellcheck={false} />
</div>
<div className="form-field">
<label htmlFor="admin-et-note" data-i18n="admin.email_templates.editor.note_optional">Notiz (optional)</label>
<input type="text" id="admin-et-note" className="admin-et-note-input" autocomplete="off"
placeholder="z.B. Korrektur nach Anwalts-Feedback"
data-i18n-placeholder="admin.email_templates.editor.note_placeholder" />
</div>
<details className="admin-et-variables">
<summary data-i18n="admin.email_templates.editor.variables">Verfügbare Variablen</summary>
<div id="admin-et-variables-list" className="admin-et-variables-list" />
</details>
<div className="form-actions admin-et-actions">
<button type="button" id="admin-et-save" className="btn-primary" disabled
data-i18n="admin.email_templates.editor.save">Speichern</button>
<button type="button" id="admin-et-reset" className="btn-secondary"
data-i18n="admin.email_templates.editor.reset">Auf Standard zur&uuml;cksetzen</button>
</div>
</div>
<div className="admin-et-editor-preview">
<div className="admin-et-preview-header">
<h2 data-i18n="admin.email_templates.editor.preview">Vorschau</h2>
<div className="admin-et-preview-actions">
<select id="admin-et-slot" className="admin-et-slot-select" style="display:none">
<option value="morning" data-i18n="admin.email_templates.editor.slot.morning">Morgen-Slot</option>
<option value="evening" data-i18n="admin.email_templates.editor.slot.evening">Abend-Slot</option>
</select>
<button type="button" id="admin-et-preview-refresh" className="btn-tertiary"
data-i18n="admin.email_templates.editor.preview_refresh">Vorschau aktualisieren</button>
</div>
</div>
<div className="admin-et-preview-subject" id="admin-et-preview-subject" />
<iframe
id="admin-et-preview-frame"
className="admin-et-preview-frame"
sandbox="allow-same-origin"
title="Email preview"
/>
<details className="admin-et-versions">
<summary data-i18n="admin.email_templates.editor.versions">Versionen</summary>
<ul id="admin-et-versions-list" className="admin-et-versions-list" />
</details>
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-email-templates-edit.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,56 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/email-templates — list of canonical templates with per-language
// edit links. Cards are populated client-side from
// GET /api/admin/email-templates so the static SPA shell stays language-
// neutral and the "Standard" / "Zuletzt geändert" status reflects current
// DB state, not build time.
export function renderAdminEmailTemplates(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.email_templates.title">Email-Templates &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/email-templates" />
<BottomNav currentPath="/admin/email-templates" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.email_templates.heading">Email-Templates</h1>
<p className="tool-subtitle" data-i18n="admin.email_templates.subtitle">
Vorlagen f&uuml;r Einladungen, Erinnerungen und das Layout-Wrapper anpassen.
</p>
</div>
</div>
<div id="admin-et-feedback" className="form-msg" style="display:none" />
<div className="grid grid-2" id="admin-et-list">
<div className="card admin-et-loading" data-i18n="admin.email_templates.loading">Lade&hellip;</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-email-templates.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,156 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminEventTypes(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.event_types.title">Event-Typen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/event-types" />
<BottomNav currentPath="/admin/event-types" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.event_types.heading">Event-Typen</h1>
<p className="tool-subtitle" data-i18n="admin.event_types.subtitle">
Firmenweite Event-Typen moderieren: archivieren, zusammenf&uuml;hren, private Typen befördern.
</p>
</div>
</div>
<div className="admin-team-controls">
<div className="glossar-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="aet-search"
className="glossar-search"
placeholder="Bezeichnung, Slug oder Author suchen..."
data-i18n-placeholder="admin.event_types.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="aet-count" />
</div>
<label className="admin-team-multi-opt">
<input type="checkbox" id="aet-show-archived" />
<span data-i18n="admin.event_types.show_archived">Archivierte anzeigen</span>
</label>
</div>
<div className="admin-team-actions" id="aet-bulk-actions" style="display:none">
<span id="aet-bulk-count" className="admin-team-muted" />
<button className="btn-primary" id="aet-bulk-archive" type="button" data-i18n="admin.event_types.action.archive_selected">
Ausgew&auml;hlte archivieren
</button>
<button className="btn-primary" id="aet-bulk-merge" type="button" data-i18n="admin.event_types.action.merge_selected">
Zusammenf&uuml;hren&hellip;
</button>
</div>
<div id="aet-feedback" className="form-msg" style="display:none" />
<h3 className="section-heading" data-i18n="admin.event_types.section.firm_wide">Firmenweite Typen</h3>
<div className="entity-table-wrap admin-team-table-wrap">
<table className="entity-table entity-table--readonly admin-team-table">
<thead>
<tr>
<th className="aet-col-check" />
<th data-i18n="admin.event_types.col.label">Bezeichnung</th>
<th data-i18n="admin.event_types.col.category">Kategorie</th>
<th data-i18n="admin.event_types.col.jurisdiction">Jurisdiktion</th>
<th data-i18n="admin.event_types.col.author">Author</th>
<th data-i18n="admin.event_types.col.created">Erstellt</th>
<th data-i18n="admin.event_types.col.usage">Verwendung</th>
<th data-i18n="admin.event_types.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="aet-tbody">
<tr><td colspan={8} className="admin-team-loading" data-i18n="admin.event_types.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="aet-empty" style="display:none">
<p data-i18n="admin.event_types.empty">Keine Treffer.</p>
</div>
<h3 className="section-heading" data-i18n="admin.event_types.section.private_pending">
Private Typen (zur Bef&ouml;rderung)
</h3>
<p className="tool-subtitle" data-i18n="admin.event_types.section.private_pending.hint">
Private Typen anderer Kolleg:innen, sortiert nach H&auml;ufigkeit. Bef&ouml;rdern macht den Typ firmenweit sichtbar.
</p>
<div className="entity-table-wrap admin-team-table-wrap">
<table className="entity-table entity-table--readonly admin-team-table">
<thead>
<tr>
<th data-i18n="admin.event_types.col.label">Bezeichnung</th>
<th data-i18n="admin.event_types.col.category">Kategorie</th>
<th data-i18n="admin.event_types.col.jurisdiction">Jurisdiktion</th>
<th data-i18n="admin.event_types.col.author">Author</th>
<th data-i18n="admin.event_types.col.usage">Verwendung</th>
<th data-i18n="admin.event_types.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="aet-private-tbody">
<tr><td colspan={6} className="admin-team-loading" data-i18n="admin.event_types.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="aet-private-empty" style="display:none">
<p data-i18n="admin.event_types.private.empty">Keine privaten Typen.</p>
</div>
</div>
</section>
</main>
{/* Merge modal — list of selected types as candidates, admin picks one
as winner. Confirms with usage count, then POST /merge atomically
redirects junction rows + archives losers. */}
<div className="modal-overlay" id="aet-merge-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.event_types.merge.title">Typen zusammenf&uuml;hren</h2>
<button className="modal-close" id="aet-merge-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.event_types.merge.body" className="invite-modal-body">
W&auml;hlen Sie den Gewinner-Typ. Die Junction-Eintr&auml;ge der Verlierer werden auf den Gewinner umgeleitet, anschlie&szlig;end werden die Verlierer archiviert.
</p>
<form id="aet-merge-form" className="entity-form" autocomplete="off">
<div id="aet-merge-options" className="aet-merge-options" />
<p className="form-msg" id="aet-merge-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="aet-merge-cancel" data-i18n="common.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="aet-merge-submit" data-i18n="admin.event_types.merge.submit">Zusammenf&uuml;hren</button>
</div>
</form>
</div>
</div>
<Footer />
<script src="/assets/admin-event-types.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,131 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminPartnerUnits(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.partner_units.title">Partner Units &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/partner-units" />
<BottomNav currentPath="/admin/partner-units" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.partner_units.heading">Partner Units</h1>
<p className="tool-subtitle" data-i18n="admin.partner_units.subtitle">
Strukturelle Partnereinheiten verwalten und Mitglieder zuordnen.
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="pu-new-btn" type="button" data-i18n="admin.partner_units.new">
Neue Partner Unit
</button>
</div>
</div>
<div id="pu-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap admin-team-table-wrap">
<table className="entity-table entity-table--readonly admin-team-table">
<thead>
<tr>
<th data-i18n="admin.partner_units.col.name">Name</th>
<th data-i18n="admin.partner_units.col.office">B&uuml;ro</th>
<th data-i18n="admin.partner_units.col.lead">Lead</th>
<th data-i18n="admin.partner_units.col.members">Mitglieder</th>
<th data-i18n="admin.partner_units.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="pu-tbody">
<tr><td colspan={5} className="admin-team-loading" data-i18n="admin.partner_units.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="pu-empty" style="display:none">
<p data-i18n="admin.partner_units.empty">Noch keine Partner Units angelegt.</p>
</div>
</div>
</section>
</main>
{/* Create / edit modal — same shape for both, "id" is empty when
creating. Office select is populated from /api/offices at init,
lead picker from /api/users (filtered to display_name+email). */}
<div className="modal-overlay" id="pu-edit-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="pu-edit-title" data-i18n="admin.partner_units.new.heading">Partner Unit anlegen</h2>
<button className="modal-close" id="pu-edit-close" type="button" aria-label="Close">&times;</button>
</div>
<form id="pu-edit-form" className="entity-form" autocomplete="off">
<input type="hidden" id="pu-edit-id" />
<div className="form-field">
<label htmlFor="pu-edit-name" data-i18n="admin.partner_units.col.name">Name</label>
<input type="text" id="pu-edit-name" required />
</div>
<div className="form-field">
<label htmlFor="pu-edit-office" data-i18n="admin.partner_units.col.office">B&uuml;ro</label>
<select id="pu-edit-office" required />
</div>
<div className="form-field">
<label htmlFor="pu-edit-lead" data-i18n="admin.partner_units.col.lead">Lead</label>
<select id="pu-edit-lead">
<option value="">&mdash;</option>
</select>
</div>
<p className="form-msg" id="pu-edit-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="pu-edit-cancel" data-i18n="admin.partner_units.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="admin.partner_units.create">Speichern</button>
</div>
</form>
</div>
</div>
{/* Member-management modal — opens from the row's "Verwalten" button. */}
<div className="modal-overlay" id="pu-members-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="pu-members-title">Mitglieder verwalten</h2>
<button className="modal-close" id="pu-members-close" type="button" aria-label="Close">&times;</button>
</div>
<div id="pu-members-body">
<ul className="partner-unit-member-list" id="pu-members-list" />
<form id="pu-add-form" autocomplete="off" className="entity-form">
<div className="form-field">
<label htmlFor="pu-add-input" data-i18n="admin.partner_units.member.add">Mitglied hinzuf&uuml;gen</label>
<input type="text" id="pu-add-input" data-i18n-placeholder="admin.partner_units.member.placeholder" placeholder="Name oder E-Mail" />
<input type="hidden" id="pu-add-user-id" />
<div className="collab-suggestions" id="pu-add-suggestions" />
</div>
<p className="form-msg" id="pu-add-msg" />
<div className="form-actions">
<button type="submit" className="btn-primary btn-cta-lime btn-small" data-i18n="admin.partner_units.member.add_btn">Hinzuf&uuml;gen</button>
</div>
</form>
</div>
</div>
</div>
<Footer />
<script src="/assets/admin-partner-units.js"></script>
</body>
</html>
);
}

138
frontend/src/admin-team.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminTeam(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.team.title">Team-Verwaltung &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/team" />
<BottomNav currentPath="/admin/team" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.team.heading">Team-Verwaltung</h1>
<p className="tool-subtitle" data-i18n="admin.team.subtitle">
Alle Paliad-Konten anzeigen, bearbeiten oder hinzuf&uuml;gen.
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
Bestehendes Konto onboarden
</button>
<button className="btn-primary" id="admin-team-invite" type="button" data-i18n="admin.team.add.invite">
Neue:n Kolleg:in einladen
</button>
</div>
</div>
<div className="admin-team-controls">
<div className="glossar-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="admin-team-search"
className="glossar-search"
placeholder="Nach Name oder E-Mail suchen..."
data-i18n-placeholder="admin.team.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="admin-team-count" />
</div>
<div className="admin-team-filter-row" id="admin-team-office-filters">
<button className="filter-pill active" data-office="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
</div>
<div id="admin-team-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap admin-team-table-wrap">
<table className="entity-table entity-table--readonly admin-team-table">
<thead>
<tr>
<th data-i18n="admin.team.col.name">Name</th>
<th data-i18n="admin.team.col.email">E-Mail</th>
<th data-i18n="admin.team.col.office">Standort</th>
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
<th data-i18n="admin.team.col.permission">Berechtigung</th>
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
<th data-i18n="admin.team.col.lang">Sprache</th>
<th data-i18n="admin.team.col.created">Angelegt</th>
<th data-i18n="admin.team.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="admin-team-tbody">
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="admin-team-empty" style="display:none">
<p data-i18n="admin.team.empty">Keine Treffer.</p>
</div>
</div>
</section>
</main>
{/* Direct-add modal: pick from unonboarded auth.users dropdown. */}
<div className="modal-overlay" id="admin-direct-add-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.direct_add.title">Bestehendes Konto onboarden</h2>
<button className="modal-close" id="admin-direct-add-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.direct_add.body" className="invite-modal-body">
Diese Auswahl zeigt Konten, die sich angemeldet haben, aber noch kein Profil ausgef&uuml;llt haben.
</p>
<form id="admin-direct-add-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-da-email" data-i18n="admin.team.direct_add.email">E-Mail</label>
<select id="admin-da-email" name="email" required>
<option value="" data-i18n="admin.team.direct_add.email.placeholder">Bitte ausw&auml;hlen...</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-da-name" data-i18n="admin.team.direct_add.name">Anzeigename</label>
<input type="text" id="admin-da-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-da-office" data-i18n="admin.team.direct_add.office">Standort</label>
<select id="admin-da-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
</div>
<div id="admin-da-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-da-cancel" data-i18n="admin.team.direct_add.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-da-submit" data-i18n="admin.team.direct_add.submit">Anlegen</button>
</div>
</form>
</div>
</div>
<Footer />
<script src="/assets/admin-team.js"></script>
</body>
</html>
);
}

108
frontend/src/admin.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
interface PlannedCard {
icon: string;
i18nTitle: string;
i18nDesc: string;
fallbackTitle: string;
fallbackDesc: string;
}
const PLANNED: PlannedCard[] = [
{
icon: ICON_FLAG,
i18nTitle: "admin.card.feature_flags.title",
i18nDesc: "admin.card.feature_flags.desc",
fallbackTitle: "Feature-Flags",
fallbackDesc: "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
},
];
export function renderAdmin(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.title">Admin-Bereich &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin" />
<BottomNav currentPath="/admin" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="admin.heading">Admin-Bereich</h1>
<p className="tool-subtitle" data-i18n="admin.subtitle">
Werkzeuge zur Verwaltung von Paliad. Nur f&uuml;r Administrator:innen sichtbar.
</p>
</div>
<h3 className="section-heading" data-i18n="admin.section.available">Verf&uuml;gbar</h3>
<div className="grid grid-2">
<a href="/admin/team" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_USERS }} />
<h2 data-i18n="admin.card.team.title">Team-Verwaltung</h2>
<p data-i18n="admin.card.team.desc">Benutzer:innen anlegen, bearbeiten, l&ouml;schen.</p>
</a>
<a href="/admin/partner-units" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BUILDING }} />
<h2 data-i18n="admin.card.partner_units.title">Partner Units</h2>
<p data-i18n="admin.card.partner_units.desc">Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.</p>
</a>
<a href="/admin/audit-log" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_LOG }} />
<h2 data-i18n="admin.card.audit.title">Audit-Log</h2>
<p data-i18n="admin.card.audit.desc">Wer hat wann was ge&auml;ndert? Nachvollziehbarkeit f&uuml;r sicherheitsrelevante Aktionen.</p>
</a>
<a href="/admin/email-templates" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<h2 data-i18n="admin.card.email_templates.title">Email-Templates</h2>
<p data-i18n="admin.card.email_templates.desc">Vorlagen f&uuml;r Einladungen, Erinnerungen und Layout anpassen.</p>
</a>
<a href="/admin/event-types" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenf&uuml;hren, bef&ouml;rdern.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
<div className="grid grid-2">
{PLANNED.map((c) => (
<div className="card admin-card-soon" title="Kommt bald" data-i18n-title="admin.coming_soon">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: c.icon }} />
<h2 data-i18n={c.i18nTitle}>{c.fallbackTitle}</h2>
<p data-i18n={c.i18nDesc}>{c.fallbackDesc}</p>
<span className="admin-soon-badge" data-i18n="admin.coming_soon">Kommt bald</span>
</div>
))}
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin.js"></script>
</body>
</html>
);
}

98
frontend/src/agenda.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// The /*__PALIAD_AGENDA_DATA__*/ token is replaced at request time by the Go
// handler (internal/handlers/agenda_shell.go) with a JSON payload assigned
// to window.__PALIAD_AGENDA__. Keep the token intact and exactly once.
const HYDRATION_SCRIPT = "/*__PALIAD_AGENDA_DATA__*/";
export function renderAgenda(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="agenda.title">Agenda &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/agenda" />
<BottomNav currentPath="/agenda" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="agenda.heading">Agenda</h1>
<p className="tool-subtitle" data-i18n="agenda.subtitle">
Kommende Fristen und Termine &uuml;ber alle sichtbaren Akten, nach Tag gruppiert.
</p>
</div>
</div>
</div>
<div id="agenda-unavailable" className="entity-unavailable" style="display:none">
<p data-i18n="agenda.unavailable">
Agenda zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="agenda-controls">
<div className="agenda-filter-group" role="group" aria-labelledby="agenda-type-heading">
<span id="agenda-type-heading" className="agenda-filter-label" data-i18n="agenda.filter.type">Ansicht</span>
<div className="agenda-chip-row">
<button type="button" className="agenda-chip" data-type="both" data-i18n="agenda.filter.both">Beides</button>
<button type="button" className="agenda-chip" data-type="deadlines" data-i18n="agenda.filter.deadlines">Nur Fristen</button>
<button type="button" className="agenda-chip" data-type="appointments" data-i18n="agenda.filter.appointments">Nur Termine</button>
</div>
</div>
<div className="agenda-filter-group" role="group" aria-labelledby="agenda-range-heading">
<span id="agenda-range-heading" className="agenda-filter-label" data-i18n="agenda.filter.range">Zeitraum</span>
<div className="agenda-chip-row">
<button type="button" className="agenda-chip" data-range="7" data-i18n="agenda.range.7">7 Tage</button>
<button type="button" className="agenda-chip" data-range="14" data-i18n="agenda.range.14">14 Tage</button>
<button type="button" className="agenda-chip" data-range="30" data-i18n="agenda.range.30">30 Tage</button>
<button type="button" className="agenda-chip" data-range="90" data-i18n="agenda.range.90">90 Tage</button>
</div>
</div>
<div className="agenda-filter-group">
<label className="agenda-filter-label" htmlFor="agenda-filter-event-type" data-i18n="agenda.filter.event_type">Typ</label>
<button type="button" id="agenda-filter-event-type" className="entity-select multi-trigger" aria-haspopup="listbox" />
<div id="agenda-filter-event-type-panel" className="multi-panel" hidden />
</div>
</div>
<div className="agenda-loading" id="agenda-loading" style="display:none" data-i18n="agenda.loading">
L&auml;dt &hellip;
</div>
<div className="agenda-timeline" id="agenda-timeline" />
<div className="entity-empty" id="agenda-empty" style="display:none">
<h2 data-i18n="agenda.empty.title">Keine Eintr&auml;ge im Zeitraum</h2>
<p data-i18n="agenda.empty.hint">
Nichts F&auml;lliges &mdash; erweitern Sie den Zeitraum oder legen Sie neue Fristen oder Termine an.
</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/agenda.js"></script>
</body>
</html>
);
}

View File

@@ -1,290 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderAktenDetail(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="akten.detail.title">Akte &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/akten" />
<main>
<section className="tool-page">
<div className="container">
<a href="/akten" className="akten-back-link" data-i18n="akten.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<div id="akten-detail-loading" className="akten-loading">
<p data-i18n="akten.detail.loading">L&auml;dt&hellip;</p>
</div>
<div id="akten-detail-notfound" className="akten-empty" style="display:none">
<p data-i18n="akten.detail.notfound">Akte nicht gefunden oder keine Berechtigung.</p>
</div>
<div id="akten-detail-body" style="display:none">
<header className="akten-detail-header">
<div className="akten-detail-title-row">
<div className="akten-detail-title-col">
<h1 id="akte-title-display" />
<input type="text" id="akte-title-edit" className="akten-title-input" style="display:none" />
<div className="akten-detail-meta">
<span className="akten-ref" id="akte-ref-display" />
<span id="akte-office-chip" className="akten-office-chip" />
<span id="akte-status-chip" className="akten-status-chip" />
<span id="akte-firmwide-chip" className="akten-firmwide-chip" style="display:none" />
</div>
</div>
<div className="akten-detail-actions">
<button id="akte-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="akten.detail.edit" title="Bearbeiten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button id="akte-save-btn" className="btn-primary btn-cta-lime" type="button" style="display:none" data-i18n="akten.detail.save">Speichern</button>
</div>
</div>
</header>
<nav className="akten-tabs" id="akte-tabs">
<a className="akten-tab" data-tab="verlauf" href="#" data-i18n="akten.detail.tab.verlauf">Verlauf</a>
<a className="akten-tab" data-tab="parteien" href="#" data-i18n="akten.detail.tab.parteien">Parteien</a>
<a className="akten-tab" data-tab="fristen" href="#" data-i18n="akten.detail.tab.fristen">Fristen</a>
<a className="akten-tab" data-tab="termine" href="#" data-i18n="akten.detail.tab.termine">Termine</a>
<a className="akten-tab" data-tab="dokumente" href="#" data-i18n="akten.detail.tab.dokumente">Dokumente</a>
<a className="akten-tab" data-tab="notizen" href="#" data-i18n="akten.detail.tab.notizen">Notizen</a>
</nav>
{/* Verlauf (Activity) */}
<section className="akten-tab-panel" id="tab-verlauf">
<ul className="akten-events" id="akten-events-list" />
<p className="akten-events-empty" id="akten-events-empty" style="display:none" data-i18n="akten.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
</p>
</section>
{/* Parteien */}
<section className="akten-tab-panel" id="tab-parteien" style="display:none">
<div className="akten-parteien-controls">
<button id="partei-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="akten.detail.parteien.add">
Partei hinzuf&uuml;gen
</button>
</div>
<form id="partei-form" className="akten-form akten-partei-form" style="display:none" autocomplete="off">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="partei-name" data-i18n="akten.detail.parteien.form.name">Name</label>
<input type="text" id="partei-name" required />
</div>
<div className="form-field">
<label htmlFor="partei-role" data-i18n="akten.detail.parteien.form.role">Rolle</label>
<select id="partei-role">
<option value="claimant" data-i18n="akten.detail.parteien.role.claimant">Kl&auml;ger</option>
<option value="defendant" data-i18n="akten.detail.parteien.role.defendant">Beklagter</option>
<option value="thirdparty" data-i18n="akten.detail.parteien.role.thirdparty">Streitverk&uuml;ndeter / Drittpartei</option>
</select>
</div>
</div>
<div className="form-field">
<label htmlFor="partei-rep" data-i18n="akten.detail.parteien.form.rep">Vertreter (optional)</label>
<input type="text" id="partei-rep" />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="partei-cancel" data-i18n="akten.detail.parteien.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.detail.parteien.form.submit">Hinzuf&uuml;gen</button>
</div>
<p className="form-msg" id="partei-msg" />
</form>
<table className="akten-parteien-table">
<thead>
<tr>
<th data-i18n="akten.detail.parteien.col.name">Name</th>
<th data-i18n="akten.detail.parteien.col.role">Rolle</th>
<th data-i18n="akten.detail.parteien.col.rep">Vertreter</th>
<th />
</tr>
</thead>
<tbody id="parteien-body" />
</table>
<p className="akten-events-empty" id="parteien-empty" style="display:none" data-i18n="akten.detail.parteien.empty">
Noch keine Parteien eingetragen.
</p>
</section>
{/* Fristen — Phase E */}
<section className="akten-tab-panel" id="tab-fristen" style="display:none">
<div className="akten-parteien-controls">
<a id="frist-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="akten.detail.fristen.add" href="#">
Frist hinzuf&uuml;gen
</a>
</div>
<div className="akten-table-wrap" id="akte-fristen-tablewrap">
<table className="akten-table fristen-table">
<thead>
<tr>
<th />
<th data-i18n="fristen.col.due">F&auml;llig</th>
<th data-i18n="fristen.col.title">Titel</th>
<th data-i18n="fristen.col.rule">Regel</th>
<th data-i18n="fristen.col.status">Status</th>
</tr>
</thead>
<tbody id="akte-fristen-body" />
</table>
</div>
<p className="akten-events-empty" id="akte-fristen-empty" style="display:none" data-i18n="akten.detail.fristen.empty">
F&uuml;r diese Akte sind noch keine Fristen erfasst.
</p>
</section>
{/* Termine — Phase F placeholder */}
<section className="akten-tab-panel" id="tab-termine" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.termine">
Termine &amp; CalDAV-Sync folgen in Phase F.
</p>
</div>
</section>
{/* Dokumente — Phase H (upload + AI extraction) */}
<section className="akten-tab-panel" id="tab-dokumente" style="display:none">
<div className="dokumente-intro">
<h2 className="dokumente-heading" data-i18n="akten.detail.dokumente.heading">Dokumente</h2>
<p className="dokumente-subtitle" data-i18n="akten.detail.dokumente.subtitle">
Gerichtsdokumente hochladen und per KI automatisch Fristen extrahieren.
</p>
</div>
<div id="dokument-upload-wrap">
<div id="dokument-upload-disabled" className="dokumente-disabled-notice" style="display:none" data-i18n="akten.detail.dokumente.upload.disabled">
Dokumenten-Upload ist auf diesem Server nicht konfiguriert.
</div>
<label id="dokument-upload-zone" className="dokumente-upload-zone" htmlFor="dokument-file-input">
<svg className="dokumente-upload-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<polyline points="9 15 12 12 15 15" />
</svg>
<div className="dokumente-upload-text" data-i18n="akten.detail.dokumente.upload.zone">
PDF hierher ziehen oder klicken zum Ausw&auml;hlen
</div>
<div className="dokumente-upload-hint" data-i18n="akten.detail.dokumente.upload.hint">Nur PDF, max. 20 MB</div>
<input type="file" id="dokument-file-input" accept="application/pdf" style="display:none" />
</label>
<div id="dokument-upload-progress" className="dokumente-upload-progress" style="display:none">
<div className="dokumente-upload-bar"><div className="dokumente-upload-bar-fill" id="dokument-upload-bar-fill" /></div>
<span id="dokument-upload-status" data-i18n="akten.detail.dokumente.upload.progress">Hochladen&hellip;</span>
</div>
<div id="dokument-upload-msg" className="form-msg" />
</div>
<div className="akten-table-wrap" id="dokumente-tablewrap" style="margin-top:1.5rem">
<table className="akten-table dokumente-table">
<thead>
<tr>
<th data-i18n="akten.detail.dokumente.col.name">Dateiname</th>
<th data-i18n="akten.detail.dokumente.col.uploaded">Hochgeladen</th>
<th data-i18n="akten.detail.dokumente.col.size">Gr&ouml;&szlig;e</th>
<th data-i18n="akten.detail.dokumente.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="dokumente-body" />
</table>
</div>
<p className="akten-events-empty" id="dokumente-empty" style="display:none" data-i18n="akten.detail.dokumente.list.empty">
Noch keine Dokumente hochgeladen.
</p>
</section>
{/* Phase H — Extraction review modal */}
<div className="modal-overlay" id="extraction-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<div>
<h2 data-i18n="akten.detail.dokumente.extraction.title">Extrahierte Fristen</h2>
<p className="modal-subtitle" data-i18n="akten.detail.dokumente.extraction.subtitle">
W&auml;hlen Sie aus, welche Vorschl&auml;ge als Fristen an die Akte &uuml;bernommen werden sollen.
</p>
</div>
<button className="modal-close" id="extraction-modal-close" type="button">&times;</button>
</div>
<div className="extraction-body">
<p className="extraction-none" id="extraction-none" style="display:none" data-i18n="akten.detail.dokumente.extraction.none">
Die KI hat keine Fristen im Dokument gefunden.
</p>
<table className="akten-table extraction-table" id="extraction-table">
<thead>
<tr>
<th data-i18n="akten.detail.dokumente.extraction.col.keep">&Uuml;bernehmen</th>
<th data-i18n="akten.detail.dokumente.extraction.col.title">Titel</th>
<th data-i18n="akten.detail.dokumente.extraction.col.due">F&auml;llig</th>
<th data-i18n="akten.detail.dokumente.extraction.col.rule">Regel</th>
<th data-i18n="akten.detail.dokumente.extraction.col.confidence">Konfidenz</th>
<th data-i18n="akten.detail.dokumente.extraction.col.source">Quelle</th>
</tr>
</thead>
<tbody id="extraction-body" />
</table>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="extraction-cancel" data-i18n="akten.detail.dokumente.extraction.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="extraction-save" data-i18n="akten.detail.dokumente.extraction.save">Als Fristen speichern</button>
</div>
<p className="form-msg" id="extraction-msg" />
</div>
</div>
{/* Notizen — Phase I placeholder */}
<section className="akten-tab-panel" id="tab-notizen" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.notizen">
Notizfunktion folgt in Phase I.
</p>
</div>
</section>
<div className="akten-detail-footer" id="akte-delete-wrap" style="display:none">
<button id="akte-delete-btn" className="btn-danger" type="button" data-i18n="akten.detail.delete">
Akte l&ouml;schen
</button>
</div>
</div>
{/* Delete confirmation modal */}
<div className="modal-overlay" id="delete-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="akten.detail.delete.confirm.title">Akte wirklich l&ouml;schen?</h2>
<button className="modal-close" id="delete-modal-close" type="button">&times;</button>
</div>
<p data-i18n="akten.detail.delete.confirm.body">
Die Akte wird archiviert. Sie kann nicht direkt wiederhergestellt werden.
</p>
<div className="form-actions">
<button type="button" className="btn-cancel" id="delete-modal-cancel" data-i18n="akten.detail.delete.confirm.cancel">Abbrechen</button>
<button type="button" className="btn-danger" id="delete-modal-confirm" data-i18n="akten.detail.delete.confirm.ok">L&ouml;schen</button>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/akten-detail.js"></script>
</body>
</html>
);
}

View File

@@ -1,141 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderAktenNeu(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="akten.neu.title">Neue Akte &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/akten/neu" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<a href="/akten" className="akten-back-link" data-i18n="akten.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<h1 data-i18n="akten.neu.heading">Neue Akte anlegen</h1>
<p className="tool-subtitle" data-i18n="akten.neu.subtitle">
Anlegen eines neuen Mandats im eigenen B&uuml;ro. Sichtbarkeit folgt der B&uuml;ro-Regel;
Partner k&ouml;nnen firmenweite Sichtbarkeit aktivieren.
</p>
</div>
<form id="akten-neu-form" className="akten-form" autocomplete="off">
<div className="form-field">
<label htmlFor="akte-title" data-i18n="akten.field.title">Titel</label>
<input
type="text"
id="akte-title"
required
placeholder="Kurzbezeichnung des Mandats"
data-i18n-placeholder="akten.field.title.placeholder"
/>
</div>
<div className="form-field">
<label htmlFor="akte-ref" data-i18n="akten.field.ref">Aktenzeichen</label>
<input
type="text"
id="akte-ref"
required
placeholder="z.B. HL-2026-0042"
data-i18n-placeholder="akten.field.ref.placeholder"
/>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="akte-office" data-i18n="akten.field.office">Federf&uuml;hrendes B&uuml;ro</label>
<select id="akte-office" required>
<option value="munich" data-i18n="office.munich">M&uuml;nchen</option>
<option value="duesseldorf" data-i18n="office.duesseldorf">D&uuml;sseldorf</option>
<option value="hamburg" data-i18n="office.hamburg">Hamburg</option>
<option value="amsterdam" data-i18n="office.amsterdam">Amsterdam</option>
<option value="london" data-i18n="office.london">London</option>
<option value="paris" data-i18n="office.paris">Paris</option>
<option value="milan" data-i18n="office.milan">Mailand</option>
</select>
</div>
<div className="form-field">
<label htmlFor="akte-status" data-i18n="akten.field.status">Status</label>
<select id="akte-status">
<option value="active" data-i18n="akten.status.active">Aktiv</option>
<option value="completed" data-i18n="akten.status.completed">Abgeschlossen</option>
</select>
</div>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="akte-court" data-i18n="akten.field.court">Gericht (optional)</label>
<input type="text" id="akte-court" />
</div>
<div className="form-field">
<label htmlFor="akte-courtref" data-i18n="akten.field.courtRef">Gerichtsaktenzeichen (optional)</label>
<input type="text" id="akte-courtref" />
</div>
</div>
<div className="form-field">
<label htmlFor="akte-type" data-i18n="akten.field.akteType">Verfahrensart (optional)</label>
<input
type="text"
id="akte-type"
placeholder="UPC Infringement, BPatG Nichtigkeit, EPA Opposition..."
/>
</div>
<div className="form-field" id="firm-wide-wrap" style="display:none">
<label className="form-checkbox">
<input type="checkbox" id="akte-firmwide" />
<span data-i18n="akten.field.firmWide">Firmenweit sichtbar</span>
</label>
<p className="form-hint" data-i18n="akten.field.firmWide.hint">
Wenn aktiviert, sehen alle Lawyer diese Akte. Nur f&uuml;r Partner/Admin.
</p>
</div>
<div className="form-field">
<label htmlFor="akte-collab-input" data-i18n="akten.field.collaborators">
Weitere Bearbeiter (optional)
</label>
<div className="akten-collab">
<div id="akte-collab-list" className="akten-collab-chips" />
<input
type="text"
id="akte-collab-input"
placeholder="Name oder E-Mail tippen..."
data-i18n-placeholder="akten.field.collaborators.placeholder"
autocomplete="off"
/>
<div id="akte-collab-suggestions" className="akten-collab-suggestions" />
</div>
<p className="form-hint" data-i18n="akten.field.collaborators.hint">
Personen, die auch Zugriff erhalten sollen (auch b&uuml;ro&uuml;bergreifend).
</p>
</div>
<p className="form-msg" id="akten-neu-msg" />
<div className="form-actions">
<a href="/akten" className="btn-cancel" data-i18n="akten.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.submit">Akte anlegen</button>
</div>
</form>
</div>
</section>
</main>
<Footer />
<script src="/assets/akten-neu.js"></script>
</body>
</html>
);
}

View File

@@ -1,115 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderAkten(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="akten.title">Akten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/akten" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="akten-header-row">
<div>
<h1 data-i18n="akten.heading">Akten</h1>
<p className="tool-subtitle" data-i18n="akten.subtitle">
B&uuml;ro-bezogene Mandate. Verlauf, Parteien und (bald) Fristen &amp; Termine an einem Ort.
</p>
</div>
<a href="/akten/neu" className="btn-primary btn-cta-lime" data-i18n="akten.new">
Neue Akte
</a>
</div>
</div>
<div className="akten-controls">
<div className="glossar-search-wrap akten-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="akten-search"
className="glossar-search"
placeholder="Titel oder Aktenzeichen suchen..."
data-i18n-placeholder="akten.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="akten-count" />
</div>
<div className="akten-filter-row">
<label className="akten-filter-label" htmlFor="akten-office" data-i18n="akten.filter.office">B&uuml;ro</label>
<select id="akten-office" className="akten-select">
<option value="" data-i18n="akten.filter.office.all">Alle B&uuml;ros</option>
<option value="munich" data-i18n="office.munich">M&uuml;nchen</option>
<option value="duesseldorf" data-i18n="office.duesseldorf">D&uuml;sseldorf</option>
<option value="hamburg" data-i18n="office.hamburg">Hamburg</option>
<option value="amsterdam" data-i18n="office.amsterdam">Amsterdam</option>
<option value="london" data-i18n="office.london">London</option>
<option value="paris" data-i18n="office.paris">Paris</option>
<option value="milan" data-i18n="office.milan">Mailand</option>
</select>
<label className="akten-filter-label" htmlFor="akten-status" data-i18n="akten.filter.status">Status</label>
<select id="akten-status" className="akten-select">
<option value="" data-i18n="akten.filter.status.all">Alle Status</option>
<option value="active" data-i18n="akten.filter.status.active">Aktiv</option>
<option value="completed" data-i18n="akten.filter.status.completed">Abgeschlossen</option>
<option value="archived" data-i18n="akten.filter.status.archived">Archiviert</option>
</select>
</div>
</div>
<div id="akten-unavailable" className="akten-unavailable" style="display:none">
<p data-i18n="akten.unavailable">
Aktenverwaltung zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="akten-table-wrap">
<table className="akten-table" id="akten-table">
<thead>
<tr>
<th data-i18n="akten.col.title">Titel</th>
<th data-i18n="akten.col.ref">Aktenzeichen</th>
<th data-i18n="akten.col.office">B&uuml;ro</th>
<th data-i18n="akten.col.status">Status</th>
<th data-i18n="akten.col.updated">Zuletzt ge&auml;ndert</th>
</tr>
</thead>
<tbody id="akten-body" />
</table>
</div>
<div className="akten-empty" id="akten-empty" style="display:none">
<h2 data-i18n="akten.empty.title">Noch keine Akte angelegt</h2>
<p data-i18n="akten.empty.hint">
Starten Sie &uuml;ber &bdquo;Neue Akte&ldquo; &mdash; Sie sehen hier sp&auml;ter Ihre Mandate, nach B&uuml;ro gefiltert.
</p>
<a href="/akten/neu" className="btn-primary btn-cta-lime" data-i18n="akten.new">Neue Akte</a>
</div>
<div className="akten-empty akten-empty-filtered" id="akten-empty-filtered" style="display:none">
<p data-i18n="akten.empty.filtered">Keine Treffer f&uuml;r diese Filter.</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/akten.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,101 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsCalendar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.kalender.title">Terminkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
Monats&uuml;bersicht aller Termine.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="termin-cal-legend">
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-hearing" />
<span data-i18n="appointments.type.hearing">Verhandlung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-meeting" />
<span data-i18n="appointments.type.meeting">Besprechung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-consultation" />
<span data-i18n="appointments.type.consultation">Beratung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-deadline_hearing" />
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
</span>
</div>
<div className="frist-calendar" id="appointment-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="appointment-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
Keine Termine im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/appointments-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,111 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsDetail(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.detail.title">Termin &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<a href="/events?type=appointment" className="back-link" data-i18n="appointments.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<div id="appointment-loading" className="entity-loading" data-i18n="appointments.detail.loading">L&auml;dt&hellip;</div>
<div id="appointment-not-found" style="display:none" className="entity-empty">
<h2 data-i18n="appointments.detail.notfound">Termin nicht gefunden</h2>
<p data-i18n="appointments.detail.notfound.hint">Der Termin existiert nicht oder Sie haben keine Berechtigung.</p>
</div>
<div id="appointment-body" style="display:none">
<div className="tool-header">
<span className="termin-type-badge" id="appointment-type-badge" />
<h1 id="appointment-title-display" />
<p className="tool-subtitle" id="appointment-time-display" />
</div>
<div id="appointment-project-row" className="entity-detail-meta-row" style="display:none">
<span className="entity-detail-meta-label" data-i18n="appointments.detail.akte">Akte:</span>
<a id="appointment-project-link" className="entity-ref-link" />
</div>
<section className="termin-notes-section">
<h2 className="frist-section-heading" data-i18n="notes.section.title">Notizen</h2>
<div id="notes-container" className="notiz-container" data-parent-type="appointment" />
</section>
<form id="appointment-edit-form" className="entity-form">
<div className="form-field">
<label htmlFor="appointment-title-edit" data-i18n="appointments.field.title">Titel</label>
<input type="text" id="appointment-title-edit" required />
</div>
<div className="form-field">
<label htmlFor="appointment-project-edit" data-i18n="appointments.field.akte">Akte (optional)</label>
<select id="appointment-project-edit">
<option value="" data-i18n="appointments.field.akte.none">Pers&ouml;nlicher Termin</option>
</select>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="appointment-start-edit" data-i18n="appointments.field.start">Beginn</label>
<input type="datetime-local" id="appointment-start-edit" required />
</div>
<div className="form-field">
<label htmlFor="appointment-end-edit" data-i18n="appointments.field.end">Ende (optional)</label>
<input type="datetime-local" id="appointment-end-edit" />
</div>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="appointment-type-edit" data-i18n="appointments.field.type">Typ</label>
<select id="appointment-type-edit">
<option value="" data-i18n="appointments.field.type.none">Kein Typ</option>
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
</select>
</div>
<div className="form-field">
<label htmlFor="appointment-location-edit" data-i18n="appointments.field.location">Ort</label>
<input type="text" id="appointment-location-edit" />
</div>
</div>
<div className="form-field">
<label htmlFor="appointment-description-edit" data-i18n="appointments.field.description">Beschreibung</label>
<textarea id="appointment-description-edit" rows={3} />
</div>
<p className="form-msg" id="appointment-edit-msg" />
<div className="form-actions">
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin l&ouml;schen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">&Auml;nderungen speichern</button>
</div>
</form>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/appointments-detail.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,103 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsNew(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.neu.title">Neuer Termin &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<a href="/events?type=appointment" className="back-link" id="appointment-new-back" data-i18n="appointments.neu.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<h1 data-i18n="appointments.neu.heading">Neuer Termin</h1>
<p className="tool-subtitle" data-i18n="appointments.neu.subtitle">
Pers&ouml;nlich oder einer Akte zugeordnet. Bei aktiver CalDAV-Synchronisation erscheint der Termin auch im externen Kalender.
</p>
</div>
<form id="appointment-new-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="appointment-title" data-i18n="appointments.field.title">Titel</label>
<input
type="text"
id="appointment-title"
required
placeholder="z.B. M&uuml;ndliche Verhandlung"
data-i18n-placeholder="appointments.field.title.placeholder"
/>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="appointment-start" data-i18n="appointments.field.start">Beginn</label>
<input type="datetime-local" id="appointment-start" required />
</div>
<div className="form-field">
<label htmlFor="appointment-end" data-i18n="appointments.field.end">Ende (optional)</label>
<input type="datetime-local" id="appointment-end" />
</div>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="appointment-type" data-i18n="appointments.field.type">Typ</label>
<select id="appointment-type">
<option value="" data-i18n="appointments.field.type.none">Kein Typ</option>
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
</select>
</div>
<div className="form-field">
<label htmlFor="appointment-project" data-i18n="appointments.field.akte">Akte (optional)</label>
<select id="appointment-project">
<option value="" data-i18n="appointments.field.akte.none">Pers&ouml;nlicher Termin</option>
</select>
</div>
</div>
<div className="form-field">
<label htmlFor="appointment-location" data-i18n="appointments.field.location">Ort (optional)</label>
<input type="text" id="appointment-location" placeholder="z.B. UPC LD M&uuml;nchen" data-i18n-placeholder="appointments.field.location.placeholder" />
</div>
<div className="form-field">
<label htmlFor="appointment-description" data-i18n="appointments.field.description">Beschreibung (optional)</label>
<textarea id="appointment-description" rows={3} placeholder="Hinweise, Tagesordnung, n&auml;chste Schritte&hellip;" data-i18n-placeholder="appointments.field.description.placeholder" />
</div>
<p className="form-msg" id="appointment-new-msg" />
<div className="form-actions">
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>
</div>
</form>
</div>
</section>
</main>
<Footer />
<script src="/assets/appointments-new.js"></script>
</body>
</html>
);
}

31
frontend/src/branding.ts Normal file
View File

@@ -0,0 +1,31 @@
// frontend/src/branding.ts — single source of truth for the firm name
// Paliad's UI renders. Mirrors internal/branding/firm.go on the server.
//
// At build time this resolves twice:
// 1. In the server-side render path (build.ts → renderXxx() returning HTML)
// Bun is running under Node, so process.env.FIRM_NAME is the real env
// var the deployer set; this file is loaded as a regular ESM module.
// 2. In the bundled client modules (e.g. client/i18n.ts) Bun.build replaces
// `process.env.FIRM_NAME` with a string literal via the `define` option
// configured in build.ts. Browsers never see process.env — every
// reference is statically substituted before the bundle is emitted.
//
// Both paths default to "HLC" when FIRM_NAME is unset.
//
// IMPORTANT: do NOT guard the read with `typeof process !== "undefined"` or
// any check on `process` itself. The minifier rewrites that guard into a
// short-string lexical comparison (`typeof process < "u"`) which evaluates
// false in the browser and would short-circuit the value back to "HLC" even
// when define has substituted the env var. The bare `process.env.FIRM_NAME`
// reference is only safe because build.ts's `define` rewrites it away
// completely for browser bundles.
//
// Why a runtime constant rather than i18n placeholder substitution: every
// Paliad surface (HTML title, hero headline, email body, PDF footer) has the
// firm name baked in literally; threading {{firm}} placeholders + a
// formatter through every t() call would be a far larger churn for the same
// firm-agnostic outcome. Re-deploying with FIRM_NAME=Acme rebuilds every
// asset with the new name in one step.
const RAW: string = (process.env.FIRM_NAME ?? "").trim();
export const FIRM: string = RAW !== "" ? RAW : "HLC";

View File

@@ -0,0 +1,48 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderChangelog(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="changelog.title">Neuigkeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/changelog" />
<BottomNav currentPath="/changelog" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="changelog.heading">Neuigkeiten</h1>
<p className="tool-subtitle" data-i18n="changelog.subtitle">
Was sich in Paliad in letzter Zeit getan hat.
</p>
</div>
<ol className="changelog-list" id="changelog-list" />
<p className="changelog-empty" id="changelog-empty" style="display:none" data-i18n="changelog.empty">
Noch keine Einträge.
</p>
</div>
</section>
</main>
<Footer />
<script src="/assets/changelog.js"></script>
</body>
</html>
);
}

View File

@@ -1,95 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderChecklistenDetail(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="checklisten.title">Checkliste &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklisten" />
<main>
<section className="tool-page">
<div className="container">
<a href="/checklisten" className="checklist-back">
<span className="checklist-back-arrow">&larr;</span>
<span data-i18n="checklisten.back">Zur&uuml;ck zur &Uuml;bersicht</span>
</a>
<div className="tool-header checklist-detail-header">
<div className="checklist-detail-head-row">
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
<button type="button" id="btn-reset" className="btn-ghost" data-i18n="checklisten.reset">Zur&uuml;cksetzen</button>
<button type="button" id="btn-feedback" className="btn-suggest">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
</div>
</div>
<div className="checklist-progress">
<div className="checklist-progress-bar">
<div className="checklist-progress-fill" id="progress-fill" />
</div>
<span className="checklist-progress-label" id="progress-label">0 / 0</span>
</div>
</div>
<div id="checklist-groups" className="checklist-groups" />
<div className="checklist-print-footer">
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
Hinweis: Diese Checklisten dienen als Ged&auml;chtnisst&uuml;tze und ersetzen keine Pr&uuml;fung im Einzelfall. Ma&szlig;geblich sind die jeweils geltenden Verfahrensregeln.
</p>
</div>
</div>
</section>
</main>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
<button className="modal-close" id="modal-close" type="button">&times;</button>
</div>
<form id="feedback-form">
<div className="form-field">
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
<select id="feedback-type" required>
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
</select>
</div>
<div className="form-field">
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
<textarea id="feedback-message" rows={4} required />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
</div>
<p className="form-msg" id="feedback-msg" />
</form>
</div>
</div>
<Footer />
<script src="/assets/checklisten-detail.js"></script>
</body>
</html>
);
}

View File

@@ -1,44 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderChecklisten(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="checklisten.title">Checklisten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklisten" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="checklisten.heading">Checklisten</h1>
<p className="tool-subtitle" data-i18n="checklisten.subtitle">
Interaktive Checklisten f&uuml;r typische Verfahrensschritte vor UPC, BPatG und EPA.
</p>
</div>
<div className="checklist-filters" id="checklist-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
</div>
<div className="checklist-grid" id="checklist-grid" />
</div>
</section>
</main>
<Footer />
<script src="/assets/checklisten.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,159 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Template detail page. Shows template metadata + list of existing
// instances + CTA to create a new instance. Clicking an instance takes
// the user to /checklisten/instances/{id} where the interactive
// checkboxes live.
export function renderChecklistsDetail(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="checklisten.title">Checkliste &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<a href="/checklists" className="checklist-back">
<span className="checklist-back-arrow">&larr;</span>
<span data-i18n="checklisten.back">Zur&uuml;ck zur &Uuml;bersicht</span>
</a>
<div className="tool-header checklist-detail-header">
<div className="checklist-detail-head-row">
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
Neue Instanz
</button>
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
</div>
</div>
</div>
<section className="checklist-instances-section">
<h2 data-i18n="checklisten.instances.heading">Instanzen</h2>
<p className="tool-subtitle" data-i18n="checklisten.instances.sub">
Jede Instanz hat ihren eigenen Fortschritt und kann optional an eine Akte geh&auml;ngt werden.
</p>
<div id="instances-loading" className="entity-loading">
<p data-i18n="checklisten.instances.loading">L&auml;dt&hellip;</p>
</div>
<div id="instances-empty" className="entity-events-empty" style="display:none">
<p data-i18n="checklisten.instances.empty">
Noch keine Instanzen. Klicken Sie auf &bdquo;Neue Instanz&ldquo;, um zu beginnen.
</p>
</div>
<div className="entity-table-wrap" id="instances-tablewrap" style="display:none">
<table className="entity-table">
<thead>
<tr>
<th data-i18n="checklisten.instances.col.name">Name</th>
<th data-i18n="checklisten.instances.col.progress">Fortschritt</th>
<th data-i18n="checklisten.instances.col.akte">Akte</th>
<th data-i18n="checklisten.instances.col.created">Angelegt</th>
<th />
</tr>
</thead>
<tbody id="instances-body" />
</table>
</div>
</section>
<div className="checklist-print-footer">
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
Hinweis: Diese Checklisten dienen als Ged&auml;chtnisst&uuml;tze und ersetzen keine Pr&uuml;fung im Einzelfall. Ma&szlig;geblich sind die jeweils geltenden Verfahrensregeln.
</p>
</div>
</div>
</section>
</main>
{/* Neue Instanz modal */}
<div className="modal-overlay" id="new-instance-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="checklisten.newInstance.title">Neue Checklisten-Instanz</h2>
<button className="modal-close" id="new-instance-close" type="button">&times;</button>
</div>
<form id="new-instance-form" autocomplete="off">
<div className="form-field">
<label htmlFor="new-instance-name" data-i18n="checklisten.newInstance.name">Name</label>
<input type="text" id="new-instance-name" required maxLength={200} />
<p className="form-hint" data-i18n="checklisten.newInstance.name.hint">z.B. &bdquo;M&uuml;ller v. Schmidt &mdash; SoC&ldquo;.</p>
</div>
<div className="form-field">
<label htmlFor="new-instance-project" data-i18n="checklisten.newInstance.akte">Akte (optional)</label>
<select id="new-instance-project">
<option value="" data-i18n="checklisten.newInstance.akte.none">&mdash; keine Akte &mdash;</option>
</select>
<p className="form-hint" data-i18n="checklisten.newInstance.akte.hint">Wenn verkn&uuml;pft, sehen B&uuml;rokollegen die Instanz.</p>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="new-instance-cancel" data-i18n="checklisten.newInstance.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance.submit">Anlegen</button>
</div>
<p className="form-msg" id="new-instance-msg" />
</form>
</div>
</div>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
<button className="modal-close" id="modal-close" type="button">&times;</button>
</div>
<form id="feedback-form">
<div className="form-field">
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
<select id="feedback-type" required>
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
</select>
</div>
<div className="form-field">
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
<textarea id="feedback-message" rows={4} required />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
</div>
<p className="form-msg" id="feedback-msg" />
</form>
</div>
</div>
<Footer />
<script src="/assets/checklists-detail.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,125 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Interactive instance page. Loads template + instance JSON, renders
// checkboxes, PATCHes /api/checklist-instances/{id} on every toggle.
// Reset button POSTs to .../reset.
export function renderChecklistsInstance(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="checklisten.instance.title">Checklisten-Instanz &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<a href="#" id="instance-back" className="checklist-back">
<span className="checklist-back-arrow">&larr;</span>
<span data-i18n="checklisten.instance.back">Zur&uuml;ck zur Vorlage</span>
</a>
<div id="instance-loading" className="entity-loading">
<p data-i18n="checklisten.instance.loading">L&auml;dt&hellip;</p>
</div>
<div id="instance-notfound" className="entity-empty" style="display:none">
<p data-i18n="checklisten.instance.notfound">Instanz nicht gefunden oder keine Berechtigung.</p>
</div>
<div id="instance-body" style="display:none">
<div className="tool-header checklist-detail-header">
<div className="checklist-detail-head-row">
<div className="checklist-instance-titles">
<div className="checklist-instance-name-row">
<h1 id="instance-name-display" />
<input type="text" id="instance-name-edit" className="entity-title-input" maxLength={200} style="display:none" />
<button id="instance-rename-btn" className="btn-icon" type="button" aria-label="Umbenennen" data-i18n-title="checklisten.instance.rename" title="Umbenennen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button id="instance-name-save" className="btn-primary btn-cta-lime btn-small" type="button" style="display:none" data-i18n="checklisten.instance.rename.save">Speichern</button>
</div>
<p className="tool-subtitle" id="instance-template-title">&nbsp;</p>
<dl className="checklist-meta" id="instance-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
<button type="button" id="btn-reset" className="btn-ghost" data-i18n="checklisten.reset">Zur&uuml;cksetzen</button>
<button type="button" id="btn-feedback" className="btn-suggest">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
</div>
</div>
<div className="checklist-progress">
<div className="checklist-progress-bar">
<div className="checklist-progress-fill" id="progress-fill" />
</div>
<span className="checklist-progress-label" id="progress-label">0 / 0</span>
</div>
</div>
<div id="checklist-groups" className="checklist-groups" />
<div className="checklist-print-footer">
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
Hinweis: Diese Checklisten dienen als Ged&auml;chtnisst&uuml;tze und ersetzen keine Pr&uuml;fung im Einzelfall. Ma&szlig;geblich sind die jeweils geltenden Verfahrensregeln.
</p>
</div>
</div>
</div>
</section>
</main>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
<button className="modal-close" id="modal-close" type="button">&times;</button>
</div>
<form id="feedback-form">
<div className="form-field">
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
<select id="feedback-type" required>
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
</select>
</div>
<div className="form-field">
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
<textarea id="feedback-message" rows={4} required />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
</div>
<p className="form-msg" id="feedback-msg" />
</form>
</div>
</div>
<Footer />
<script src="/assets/checklists-instance.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,81 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderChecklists(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="checklisten.title">Checklisten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="checklisten.heading">Checklisten</h1>
<p className="tool-subtitle" data-i18n="checklisten.subtitle">
Interaktive Checklisten f&uuml;r typische Verfahrensschritte vor UPC, BPatG und EPA.
</p>
</div>
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
</nav>
{/* Templates tab — pick a template to inspect / instantiate */}
<section className="entity-tab-panel" id="tab-templates">
<div className="checklist-filters" id="checklist-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
</div>
<div className="checklist-grid" id="checklist-grid" />
</section>
{/* Instances tab — every visible instance across templates */}
<section className="entity-tab-panel" id="tab-instances" style="display:none">
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-instances-empty" style="display:none" data-i18n="checklisten.instances.all.empty">
Noch keine Checklisten-Instanzen erfasst. Legen Sie eine &uuml;ber den Vorlagen-Tab an.
</p>
<div className="entity-table-wrap" id="checklists-instances-tablewrap" style="display:none">
<table className="entity-table">
<thead>
<tr>
<th data-i18n="checklisten.instances.all.col.template">Vorlage</th>
<th data-i18n="checklisten.instances.all.col.name">Name</th>
<th data-i18n="checklisten.instances.all.col.project">Projekt</th>
<th data-i18n="checklisten.instances.all.col.progress">Fortschritt</th>
<th data-i18n="checklisten.instances.all.col.created">Angelegt</th>
</tr>
</thead>
<tbody id="checklists-instances-body" />
</table>
</div>
</section>
</div>
</section>
</main>
<Footer />
<script src="/assets/checklists.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,253 @@
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
interface AuditEntry {
timestamp: string;
id: string;
source: string;
event_type: string;
actor: string;
subject: string;
project_id?: string;
title?: string;
description?: string;
}
interface Cursor {
ts: string;
id: string;
}
interface AuditResponse {
entries: AuditEntry[];
next_cursor: Cursor | null;
}
const PAGE_SIZE = 50;
let entries: AuditEntry[] = [];
let nextCursor: Cursor | null = null;
let searchDebounce: number | undefined;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function fmtDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleString(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function sourceLabel(source: string): string {
return tDyn(`admin.audit.source.${source}`) || source;
}
function sourceClass(source: string): string {
switch (source) {
case "project_events":
return "admin-audit-source admin-audit-source-project";
case "caldav_sync_log":
return "admin-audit-source admin-audit-source-caldav";
case "reminder_log":
return "admin-audit-source admin-audit-source-reminder";
default:
return "admin-audit-source";
}
}
// eventNarrative produces the localised "what happened" pair (event label,
// description body) for a row. project_events delegates to the shared
// translateEvent — that's the t-paliad-067 PR-1 logic the audit table is
// supposed to reuse so DE/EN narratives stay identical to the dashboard
// activity feed. caldav_sync_log and reminder_log have their own per-event
// label keys; their stored description is already a flat key=value summary
// so we surface it verbatim.
function eventNarrative(e: AuditEntry): { label: string; body: string } {
if (e.source === "project_events") {
const { title, description } = translateEvent(
e.event_type,
e.title ?? "",
e.description ?? "",
);
return { label: title || e.event_type, body: description };
}
const labelKey = `admin.audit.event.${e.event_type}`;
const translated = tDyn(labelKey);
const label = translated && translated !== labelKey ? translated : e.event_type;
return { label, body: e.description ?? "" };
}
function rowHTML(e: AuditEntry): string {
const { label, body } = eventNarrative(e);
const subjectCell = e.project_id
? `<a href="/projects/${esc(e.project_id)}">${esc(e.subject)}</a>`
: esc(e.subject);
return `
<tr data-id="${esc(e.id)}">
<td class="admin-audit-time">${esc(fmtDateTime(e.timestamp))}</td>
<td><span class="${sourceClass(e.source)}">${esc(sourceLabel(e.source))}</span></td>
<td><code class="admin-audit-event">${esc(label)}</code></td>
<td>${esc(e.actor)}</td>
<td>${subjectCell}</td>
<td class="admin-audit-desc">${esc(body)}</td>
</tr>`;
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("audit-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) {
setTimeout(() => { el.style.display = "none"; }, 2500);
}
}
function render() {
const tbody = document.getElementById("audit-tbody")!;
const empty = document.getElementById("audit-empty")!;
const counter = document.getElementById("audit-count")!;
const loadmore = document.getElementById("audit-loadmore") as HTMLButtonElement;
if (entries.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
counter.textContent = "0";
} else {
empty.style.display = "none";
tbody.innerHTML = entries.map(rowHTML).join("");
counter.textContent = String(entries.length);
}
loadmore.style.display = nextCursor ? "" : "none";
}
function rangePresetToFrom(preset: string): Date | null {
const now = new Date();
switch (preset) {
case "24h":
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
case "7d":
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case "30d":
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
default:
return null;
}
}
function buildQuery(cursor: Cursor | null): string {
const params = new URLSearchParams();
const source = (document.getElementById("audit-source") as HTMLSelectElement).value;
if (source) params.set("source", source);
const range = (document.getElementById("audit-range") as HTMLSelectElement).value;
if (range === "custom") {
const from = (document.getElementById("audit-from") as HTMLInputElement).value;
const to = (document.getElementById("audit-to") as HTMLInputElement).value;
if (from) params.set("from", from);
if (to) params.set("to", to);
} else if (range !== "all") {
const from = rangePresetToFrom(range);
if (from) params.set("from", from.toISOString());
}
const q = (document.getElementById("audit-search") as HTMLInputElement).value.trim();
if (q) params.set("q", q);
params.set("limit", String(PAGE_SIZE));
if (cursor) {
params.set("before_ts", cursor.ts);
params.set("before_id", cursor.id);
}
return params.toString();
}
async function fetchPage(cursor: Cursor | null): Promise<AuditResponse | null> {
const url = "/api/audit-log?" + buildQuery(cursor);
const resp = await fetch(url);
if (resp.status === 403) {
showFeedback(t("admin.audit.error.forbidden") || "Nur Admins.", true);
return null;
}
if (resp.status === 503) {
showFeedback(t("admin.audit.error.unavailable") || "Audit-Service nicht verfügbar.", true);
return null;
}
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || resp.statusText, true);
return null;
}
return (await resp.json()) as AuditResponse;
}
async function reload() {
const tbody = document.getElementById("audit-tbody")!;
tbody.innerHTML = `<tr><td colspan="6" class="admin-audit-loading">${esc(t("admin.audit.loading") || "Lade ...")}</td></tr>`;
const resp = await fetchPage(null);
if (!resp) return;
entries = resp.entries;
nextCursor = resp.next_cursor;
render();
}
async function loadMore() {
if (!nextCursor) return;
const btn = document.getElementById("audit-loadmore") as HTMLButtonElement;
btn.disabled = true;
btn.textContent = t("admin.audit.loading") || "Lade ...";
const resp = await fetchPage(nextCursor);
btn.disabled = false;
btn.textContent = t("admin.audit.loadmore") || "Weitere laden";
if (!resp) return;
entries = entries.concat(resp.entries);
nextCursor = resp.next_cursor;
render();
}
function bindFilters() {
const sourceSel = document.getElementById("audit-source") as HTMLSelectElement;
const rangeSel = document.getElementById("audit-range") as HTMLSelectElement;
const fromInput = document.getElementById("audit-from") as HTMLInputElement;
const toInput = document.getElementById("audit-to") as HTMLInputElement;
const searchInput = document.getElementById("audit-search") as HTMLInputElement;
const customWrap = document.getElementById("audit-custom-range")!;
const onChange = () => { void reload(); };
sourceSel.addEventListener("change", onChange);
rangeSel.addEventListener("change", () => {
customWrap.style.display = rangeSel.value === "custom" ? "" : "none";
onChange();
});
fromInput.addEventListener("change", onChange);
toInput.addEventListener("change", onChange);
searchInput.addEventListener("input", () => {
if (searchDebounce) window.clearTimeout(searchDebounce);
searchDebounce = window.setTimeout(reload, 250);
});
document.getElementById("audit-loadmore")!.addEventListener("click", () => {
void loadMore();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
bindFilters();
onLangChange(render);
void reload();
});

View File

@@ -0,0 +1,420 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
// /admin/email-templates/{key}?lang=de — editor client.
//
// Wires the static SPA shell to the API: fetches active row + variables +
// version log on load, debounces a preview request on every input change,
// posts saves and resets, and offers per-version restore.
//
// Render-only HTML is built with createElement (not innerHTML on user data)
// so a malicious template body can't escape the editor chrome — the
// preview iframe sandboxes the rendered HTML separately.
interface ActiveRow {
key: string;
lang: string;
subject: string;
body: string;
updated_at?: string | null;
is_default: boolean;
}
interface VariableContract {
name: string;
type: string;
description: string;
sample_de: string;
sample_en: string;
}
interface VersionRow {
id: string;
saved_at: string;
saved_by?: string | null;
note: string;
subject: string;
body: string;
}
const PREVIEW_DEBOUNCE_MS = 500;
let currentKey = "";
let currentLang: "de" | "en" = "de";
let activeRow: ActiveRow | null = null;
let variables: VariableContract[] = [];
let dirty = false;
let previewTimer: number | null = null;
function readKeyFromPath(): string {
const m = location.pathname.match(/^\/admin\/email-templates\/([^/]+)/);
return m ? decodeURIComponent(m[1]) : "";
}
function readLangFromQuery(): "de" | "en" {
const params = new URLSearchParams(location.search);
const v = params.get("lang");
return v === "en" ? "en" : "de";
}
function setLangInURL(lang: "de" | "en") {
const url = new URL(location.href);
url.searchParams.set("lang", lang);
history.replaceState(null, "", url.toString());
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-et-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
if (!isError) {
setTimeout(() => {
if (el.textContent === msg) el.style.display = "none";
}, 3000);
}
}
function clearFeedback() {
const el = document.getElementById("admin-et-feedback");
if (el) el.style.display = "none";
}
function setDirty(d: boolean) {
dirty = d;
const saveBtn = document.getElementById("admin-et-save") as HTMLButtonElement | null;
if (saveBtn) saveBtn.disabled = !d;
}
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
function applyToInputs(row: ActiveRow) {
const subj = document.getElementById("admin-et-subject") as HTMLInputElement | null;
const body = document.getElementById("admin-et-body") as HTMLTextAreaElement | null;
const subjWrap = document.getElementById("admin-et-subject-wrap");
if (subj) subj.value = row.subject;
if (body) body.value = row.body;
if (subjWrap) {
// base templates have no subject of their own — hide the field.
subjWrap.style.display = row.key === "base" ? "none" : "";
}
const slot = document.getElementById("admin-et-slot") as HTMLSelectElement | null;
if (slot) {
slot.style.display = row.key === "deadline_digest" ? "" : "none";
}
setDirty(false);
const sub = document.getElementById("admin-et-subtitle");
if (sub) {
if (row.is_default) {
sub.textContent = t("admin.email_templates.editor.is_default") || "Aktuell wird der Standard verwendet.";
} else {
const tpl = t("admin.email_templates.editor.last_modified") || "Zuletzt geändert: {date}";
sub.textContent = tpl.replace("{date}", fmtDate(row.updated_at));
}
}
}
function applyTitle(key: string, lang: string) {
const title = document.getElementById("admin-et-title");
if (!title) return;
const tpl = t("admin.email_templates.editor.heading_for") ||
"{title} — {lang}";
const langName = lang === "en"
? (t("admin.email_templates.lang.en") || "Englisch")
: (t("admin.email_templates.lang.de") || "Deutsch");
title.textContent = tpl
.replace("{title}", tDyn(`admin.email_templates.card.${key}.title`) || key)
.replace("{lang}", langName);
}
function applyLangToggle(lang: "de" | "en") {
document.querySelectorAll(".admin-et-lang-btn").forEach((el) => {
const btn = el as HTMLButtonElement;
const isActive = btn.dataset.lang === lang;
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
btn.classList.toggle("active", isActive);
});
}
function renderVariables() {
const list = document.getElementById("admin-et-variables-list");
if (!list) return;
list.innerHTML = "";
for (const v of variables) {
const row = document.createElement("div");
row.className = "admin-et-variable-row";
const name = document.createElement("code");
name.className = "admin-et-variable-name";
name.textContent = v.name;
const type = document.createElement("span");
type.className = "admin-et-variable-type";
type.textContent = v.type;
const desc = document.createElement("span");
desc.className = "admin-et-variable-desc";
desc.textContent = v.description;
const sample = document.createElement("span");
sample.className = "admin-et-variable-sample";
sample.textContent = "→ " + (currentLang === "en" ? v.sample_en : v.sample_de);
row.appendChild(name);
row.appendChild(type);
row.appendChild(desc);
row.appendChild(sample);
list.appendChild(row);
}
}
function renderVersions(rows: VersionRow[]) {
const list = document.getElementById("admin-et-versions-list");
if (!list) return;
list.innerHTML = "";
if (rows.length === 0) {
const empty = document.createElement("li");
empty.className = "admin-et-version-empty";
empty.textContent = t("admin.email_templates.editor.versions_empty") || "Keine Versionen.";
list.appendChild(empty);
return;
}
for (const v of rows) {
const li = document.createElement("li");
li.className = "admin-et-version-row";
const date = document.createElement("span");
date.className = "admin-et-version-date";
date.textContent = fmtDate(v.saved_at);
const note = document.createElement("span");
note.className = "admin-et-version-note";
note.textContent = v.note || "";
const restore = document.createElement("button");
restore.type = "button";
restore.className = "btn-tertiary admin-et-version-restore";
restore.textContent = t("admin.email_templates.editor.restore") || "Wiederherstellen";
restore.dataset.versionId = v.id;
restore.addEventListener("click", () => onRestoreClick(v.id));
li.appendChild(date);
li.appendChild(note);
li.appendChild(restore);
list.appendChild(li);
}
}
async function loadActive() {
applyTitle(currentKey, currentLang);
applyLangToggle(currentLang);
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}`);
if (resp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
return;
}
activeRow = (await resp.json()) as ActiveRow;
applyToInputs(activeRow);
void schedulePreview();
}
async function loadVariables() {
const resp = await fetch(`/api/admin/email-templates/${currentKey}/variables`);
if (!resp.ok) {
variables = [];
renderVariables();
return;
}
variables = (await resp.json()) as VariableContract[];
renderVariables();
}
async function loadVersions() {
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}/versions`);
if (!resp.ok) {
renderVersions([]);
return;
}
const rows = (await resp.json()) as VersionRow[];
renderVersions(rows);
}
async function refreshPreview() {
const subj = (document.getElementById("admin-et-subject") as HTMLInputElement | null)?.value || "";
const body = (document.getElementById("admin-et-body") as HTMLTextAreaElement | null)?.value || "";
const slotEl = document.getElementById("admin-et-slot") as HTMLSelectElement | null;
const slot = currentKey === "deadline_digest" && slotEl ? slotEl.value : "";
const resp = await fetch(
`/api/admin/email-templates/${currentKey}/${currentLang}/preview`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject: subj, body, slot }),
},
);
const subjEl = document.getElementById("admin-et-preview-subject");
const frame = document.getElementById("admin-et-preview-frame") as HTMLIFrameElement | null;
if (resp.status === 422) {
const err = (await resp.json().catch(() => ({ error: "" }))) as { error?: string };
if (subjEl) subjEl.textContent = "";
if (frame) frame.srcdoc = "";
showFeedback(
(t("admin.email_templates.editor.parse_error") || "Template-Fehler:") + " " + (err.error || ""),
true,
);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.editor.preview_error") || "Vorschau fehlgeschlagen.", true);
return;
}
clearFeedback();
const data = (await resp.json()) as { subject_rendered: string; html_rendered: string };
if (subjEl) subjEl.textContent = data.subject_rendered;
if (frame) frame.srcdoc = data.html_rendered;
}
function schedulePreview() {
if (previewTimer !== null) {
clearTimeout(previewTimer);
}
previewTimer = window.setTimeout(() => {
previewTimer = null;
void refreshPreview();
}, PREVIEW_DEBOUNCE_MS);
}
async function onSaveClick() {
if (!activeRow) return;
const subj = (document.getElementById("admin-et-subject") as HTMLInputElement | null)?.value || "";
const body = (document.getElementById("admin-et-body") as HTMLTextAreaElement | null)?.value || "";
const note = (document.getElementById("admin-et-note") as HTMLInputElement | null)?.value || "";
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject: subj, body, note }),
});
if (resp.status === 422) {
const err = (await resp.json().catch(() => ({ error: "" }))) as { error?: string };
showFeedback(
(t("admin.email_templates.editor.parse_error") || "Template-Fehler:") + " " + (err.error || ""),
true,
);
return;
}
if (!resp.ok) {
const err = (await resp.json().catch(() => ({ error: resp.statusText }))) as { error?: string };
showFeedback((t("admin.email_templates.editor.save_error") || "Speichern fehlgeschlagen.") + " " + (err.error || ""), true);
return;
}
showFeedback(t("admin.email_templates.editor.save_ok") || "Gespeichert.", false);
const noteEl = document.getElementById("admin-et-note") as HTMLInputElement | null;
if (noteEl) noteEl.value = "";
void loadActive();
void loadVersions();
}
async function onResetClick() {
if (!confirm(t("admin.email_templates.editor.reset_confirm") || "Wirklich auf den Standard zurücksetzen?")) {
return;
}
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}/reset`, {
method: "POST",
});
if (!resp.ok) {
showFeedback(t("admin.email_templates.editor.reset_error") || "Zurücksetzen fehlgeschlagen.", true);
return;
}
showFeedback(t("admin.email_templates.editor.reset_ok") || "Auf Standard zurückgesetzt.", false);
void loadActive();
void loadVersions();
}
async function onRestoreClick(versionID: string) {
if (!confirm(t("admin.email_templates.editor.restore_confirm") || "Diese Version wiederherstellen?")) {
return;
}
const resp = await fetch(
`/api/admin/email-templates/${currentKey}/${currentLang}/restore/${versionID}`,
{ method: "POST" },
);
if (!resp.ok) {
showFeedback(t("admin.email_templates.editor.restore_error") || "Wiederherstellen fehlgeschlagen.", true);
return;
}
showFeedback(t("admin.email_templates.editor.restore_ok") || "Version wiederhergestellt.", false);
void loadActive();
void loadVersions();
}
function onLangButton(lang: "de" | "en") {
if (lang === currentLang) return;
if (dirty && !confirm(t("admin.email_templates.editor.dirty_warn") || "Ungespeicherte Änderungen verwerfen?")) {
return;
}
currentLang = lang;
setLangInURL(lang);
applyTitle(currentKey, lang);
applyLangToggle(lang);
renderVariables();
void loadActive();
void loadVersions();
}
function wireInputs() {
const onAnyChange = () => {
setDirty(true);
schedulePreview();
};
document.getElementById("admin-et-subject")?.addEventListener("input", onAnyChange);
document.getElementById("admin-et-body")?.addEventListener("input", onAnyChange);
document.getElementById("admin-et-slot")?.addEventListener("change", () => {
void refreshPreview();
});
document.getElementById("admin-et-save")?.addEventListener("click", () => {
void onSaveClick();
});
document.getElementById("admin-et-reset")?.addEventListener("click", () => {
void onResetClick();
});
document.getElementById("admin-et-preview-refresh")?.addEventListener("click", () => {
void refreshPreview();
});
document.querySelectorAll(".admin-et-lang-btn").forEach((el) => {
const btn = el as HTMLButtonElement;
btn.addEventListener("click", () => {
const lang = btn.dataset.lang as "de" | "en";
onLangButton(lang);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
currentKey = readKeyFromPath();
currentLang = readLangFromQuery();
if (!currentKey) {
showFeedback(t("admin.email_templates.editor.unknown_key") || "Unbekannter Template-Schlüssel.", true);
return;
}
wireInputs();
applyTitle(currentKey, currentLang);
applyLangToggle(currentLang);
void loadActive();
void loadVariables();
void loadVersions();
onLangChange(() => {
applyTitle(currentKey, currentLang);
renderVariables();
});
});

View File

@@ -0,0 +1,150 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
// /admin/email-templates list page. Fetches per-(key, lang) summaries and
// renders them as cards grouped by key. Each card shows the human title plus
// two language buttons that link into the editor.
interface Summary {
key: string;
lang: string;
is_default: boolean;
updated_at?: string | null;
}
interface CardCopy {
title_key: string;
desc_key: string;
fallback_title: string;
fallback_desc: string;
}
const CARDS: Record<string, CardCopy> = {
invitation: {
title_key: "admin.email_templates.card.invitation.title",
desc_key: "admin.email_templates.card.invitation.desc",
fallback_title: "Einladung",
fallback_desc:
"E-Mail an neue Kolleg:innen, ausgelöst über die Sidebar.",
},
deadline_digest: {
title_key: "admin.email_templates.card.deadline_digest.title",
desc_key: "admin.email_templates.card.deadline_digest.desc",
fallback_title: "Fristen-Sammelmail",
fallback_desc:
"Tägliche Morgen- und Abend-Mail mit überfälligen, heute fälligen und kommenden Fristen.",
},
base: {
title_key: "admin.email_templates.card.base.title",
desc_key: "admin.email_templates.card.base.desc",
fallback_title: "Layout-Wrapper",
fallback_desc:
"Geteilter HTML-Rahmen mit Header und Footer, der alle E-Mails umschliesst.",
},
};
const KEY_ORDER = ["invitation", "deadline_digest", "base"];
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
function statusLabel(s: Summary): string {
if (s.is_default) {
return t("admin.email_templates.status.default") || "Standard";
}
const date = fmtDate(s.updated_at);
const tpl = t("admin.email_templates.status.last_modified") || "Zuletzt geändert: {date}";
return tpl.replace("{date}", date);
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-et-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
}
function render(summaries: Summary[]) {
const container = document.getElementById("admin-et-list");
if (!container) return;
// Group by key in canonical order.
const byKey: Record<string, Summary[]> = {};
for (const s of summaries) {
(byKey[s.key] ||= []).push(s);
}
container.innerHTML = "";
for (const key of KEY_ORDER) {
const meta = CARDS[key];
const rows = byKey[key] || [];
const card = document.createElement("div");
card.className = "card admin-et-card";
const title = tDyn(meta.title_key) || meta.fallback_title;
const desc = tDyn(meta.desc_key) || meta.fallback_desc;
const header = document.createElement("div");
header.className = "admin-et-card-header";
const h2 = document.createElement("h2");
h2.textContent = title;
const code = document.createElement("code");
code.className = "admin-et-card-key";
code.textContent = key;
header.appendChild(h2);
header.appendChild(code);
card.appendChild(header);
const p = document.createElement("p");
p.textContent = desc;
card.appendChild(p);
const langs = document.createElement("div");
langs.className = "admin-et-card-langs";
for (const lang of ["de", "en"]) {
const s = rows.find((r) => r.lang === lang);
const a = document.createElement("a");
a.className = "admin-et-card-lang-btn";
a.href = `/admin/email-templates/${key}?lang=${lang}`;
const flag = document.createElement("span");
flag.className = "admin-et-card-lang-flag";
flag.textContent = lang.toUpperCase();
const status = document.createElement("span");
status.className = "admin-et-card-lang-status";
status.textContent = s ? statusLabel(s) : t("admin.email_templates.status.default") || "Standard";
a.appendChild(flag);
a.appendChild(status);
langs.appendChild(a);
}
card.appendChild(langs);
container.appendChild(card);
}
}
async function loadAndRender() {
const resp = await fetch("/api/admin/email-templates");
if (resp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
return;
}
const summaries = (await resp.json()) as Summary[];
render(summaries);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
void loadAndRender();
onLangChange(() => {
void loadAndRender();
});
});

View File

@@ -0,0 +1,418 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-event-types.ts — moderation panel for paliad.event_types
// (t-paliad-089). Loads two tables: firm-wide (with archived toggle, bulk
// archive, merge) and private-pending-promotion (per-row promote button).
interface EventTypeRow {
id: string;
slug: string;
label_de: string;
label_en: string;
category: string;
jurisdiction?: string | null;
description?: string;
is_firm_wide: boolean;
archived_at?: string | null;
created_by?: string | null;
created_at: string;
updated_at: string;
usage_count: number;
author_display_name?: string | null;
}
let firmwide: EventTypeRow[] = [];
let priv: EventTypeRow[] = [];
let selected = new Set<string>();
let showArchived = false;
let searchQuery = "";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
function categoryLabel(cat: string): string {
return tDyn(`event_types.cat.${cat}`) || cat;
}
function labelFor(row: EventTypeRow): string {
// Show DE primary, EN as a small secondary line if it differs.
return row.label_de;
}
function rowMatchesSearch(row: EventTypeRow): boolean {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
row.label_de.toLowerCase().includes(q) ||
row.label_en.toLowerCase().includes(q) ||
row.slug.toLowerCase().includes(q) ||
(row.author_display_name ?? "").toLowerCase().includes(q)
);
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("aet-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) {
setTimeout(() => { el.style.display = "none"; }, 3500);
}
}
async function loadFirmwide(): Promise<void> {
const url = "/api/admin/event-types" + (showArchived ? "?include_archived=1" : "");
const resp = await fetch(url);
if (resp.status === 403) {
showFeedback(t("admin.event_types.error.forbidden") || "Nur Admins.", true);
firmwide = [];
return;
}
if (!resp.ok) {
firmwide = [];
return;
}
firmwide = (await resp.json()) as EventTypeRow[];
}
async function loadPrivate(): Promise<void> {
const resp = await fetch("/api/admin/event-types/private");
if (!resp.ok) {
priv = [];
return;
}
priv = (await resp.json()) as EventTypeRow[];
}
function jurisdictionLabel(j: string | null | undefined): string {
if (!j) return "—";
if (j === "any") return tDyn("event_types.add.jurisdiction.any") || j;
return j;
}
function renderFirmwideRow(row: EventTypeRow): string {
const archived = !!row.archived_at;
const checked = selected.has(row.id) ? " checked" : "";
const labelEn = row.label_en && row.label_en !== row.label_de
? `<div class="admin-team-muted aet-label-en">${esc(row.label_en)}</div>`
: "";
const archivedBadge = archived
? `<span class="aet-archived-badge">${esc(t("admin.event_types.row.archived") || "Archiviert")}</span>`
: "";
const restoreBtn = archived
? `<button type="button" class="btn-link aet-restore" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.restore">Wiederherstellen</button>`
: `<button type="button" class="btn-link aet-archive" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.archive">Archivieren</button>`;
return `
<tr data-row-id="${esc(row.id)}"${archived ? " class=\"aet-row-archived\"" : ""}>
<td class="aet-col-check">
${archived ? "" : `<input type="checkbox" class="aet-row-check" data-id="${esc(row.id)}"${checked} />`}
</td>
<td class="entity-col-title">
${archivedBadge}
<div>${esc(labelFor(row))}</div>
${labelEn}
<div class="admin-team-muted aet-slug">${esc(row.slug)}</div>
</td>
<td>${esc(categoryLabel(row.category))}</td>
<td>${esc(jurisdictionLabel(row.jurisdiction))}</td>
<td>${row.author_display_name ? esc(row.author_display_name) : `<span class="admin-team-muted">${esc(t("admin.event_types.author.system") || "System")}</span>`}</td>
<td class="entity-col-updated">${esc(fmtDate(row.created_at))}</td>
<td>${row.usage_count}</td>
<td class="admin-team-actions-cell">${restoreBtn}</td>
</tr>`;
}
function renderPrivateRow(row: EventTypeRow): string {
return `
<tr data-row-id="${esc(row.id)}">
<td class="entity-col-title">
<div>${esc(row.label_de)}</div>
${row.label_en && row.label_en !== row.label_de ? `<div class="admin-team-muted aet-label-en">${esc(row.label_en)}</div>` : ""}
<div class="admin-team-muted aet-slug">${esc(row.slug)}</div>
</td>
<td>${esc(categoryLabel(row.category))}</td>
<td>${esc(jurisdictionLabel(row.jurisdiction))}</td>
<td>${row.author_display_name ? esc(row.author_display_name) : `<span class="admin-team-muted">${esc(t("admin.event_types.author.unknown") || "Unbekannt")}</span>`}</td>
<td>${row.usage_count}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-primary btn-small aet-promote" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.promote">Bef&ouml;rdern</button>
</td>
</tr>`;
}
function renderFirmwide() {
const tbody = document.getElementById("aet-tbody")!;
const empty = document.getElementById("aet-empty")!;
const count = document.getElementById("aet-count")!;
const filtered = firmwide.filter(rowMatchesSearch);
count.textContent = `${filtered.length} / ${firmwide.length}`;
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
updateBulkBar();
return;
}
empty.style.display = "none";
tbody.innerHTML = filtered.map(renderFirmwideRow).join("");
attachFirmwideRowListeners();
updateBulkBar();
}
function renderPrivate() {
const tbody = document.getElementById("aet-private-tbody")!;
const empty = document.getElementById("aet-private-empty")!;
const filtered = priv.filter(rowMatchesSearch);
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = filtered.map(renderPrivateRow).join("");
attachPrivateRowListeners();
}
function attachFirmwideRowListeners() {
document.querySelectorAll<HTMLInputElement>(".aet-row-check").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.id!;
if (cb.checked) selected.add(id);
else selected.delete(id);
updateBulkBar();
});
});
document.querySelectorAll<HTMLButtonElement>(".aet-archive").forEach((b) => {
b.addEventListener("click", () => archiveOne(b.dataset.id!));
});
document.querySelectorAll<HTMLButtonElement>(".aet-restore").forEach((b) => {
b.addEventListener("click", () => restoreOne(b.dataset.id!));
});
}
function attachPrivateRowListeners() {
document.querySelectorAll<HTMLButtonElement>(".aet-promote").forEach((b) => {
b.addEventListener("click", () => promoteOne(b.dataset.id!));
});
}
function updateBulkBar() {
const bar = document.getElementById("aet-bulk-actions")!;
const count = document.getElementById("aet-bulk-count")!;
const mergeBtn = document.getElementById("aet-bulk-merge") as HTMLButtonElement;
// Drop selections that no longer correspond to a visible live row.
const liveIDs = new Set(firmwide.filter((r) => !r.archived_at).map((r) => r.id));
for (const id of Array.from(selected)) {
if (!liveIDs.has(id)) selected.delete(id);
}
if (selected.size === 0) {
bar.style.display = "none";
return;
}
bar.style.display = "flex";
const tmpl = t("admin.event_types.bulk.count") || "{n} ausgewählt";
count.textContent = tmpl.replace("{n}", String(selected.size));
mergeBtn.disabled = selected.size < 2;
}
async function archiveOne(id: string) {
const row = firmwide.find((r) => r.id === id);
if (!row) return;
const confirmMsg = (t("admin.event_types.confirm.archive") || "„{label}\" wirklich archivieren?").replace("{label}", row.label_de);
if (!window.confirm(confirmMsg)) return;
await bulkArchive([id]);
}
async function bulkArchiveSelected() {
if (selected.size === 0) return;
const tmpl = t("admin.event_types.confirm.bulk_archive") || "{n} Typen wirklich archivieren?";
if (!window.confirm(tmpl.replace("{n}", String(selected.size)))) return;
await bulkArchive(Array.from(selected));
}
async function bulkArchive(ids: string[]) {
const resp = await fetch("/api/admin/event-types/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || t("admin.event_types.feedback.archive_error") || "Archivierung fehlgeschlagen.", true);
return;
}
const body = (await resp.json()) as { archived: number };
selected.clear();
showFeedback((t("admin.event_types.feedback.archived") || "{n} archiviert.").replace("{n}", String(body.archived)), false);
await Promise.all([loadFirmwide(), loadPrivate()]);
renderFirmwide();
renderPrivate();
}
async function restoreOne(id: string) {
const row = firmwide.find((r) => r.id === id);
if (!row) return;
const resp = await fetch(`/api/admin/event-types/${id}/restore`, { method: "POST" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || t("admin.event_types.feedback.restore_error") || "Wiederherstellung fehlgeschlagen.", true);
return;
}
showFeedback(t("admin.event_types.feedback.restored") || "Wiederhergestellt.", false);
await loadFirmwide();
renderFirmwide();
}
async function promoteOne(id: string) {
const row = priv.find((r) => r.id === id);
if (!row) return;
const confirmMsg = (t("admin.event_types.confirm.promote") || "„{label}\" firmenweit verfügbar machen?").replace("{label}", row.label_de);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/admin/event-types/${id}/promote`, { method: "POST" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || t("admin.event_types.feedback.promote_error") || "Beförderung fehlgeschlagen.", true);
return;
}
showFeedback(t("admin.event_types.feedback.promoted") || "Befördert.", false);
await Promise.all([loadFirmwide(), loadPrivate()]);
renderFirmwide();
renderPrivate();
}
function openMergeModal() {
if (selected.size < 2) return;
const candidates = firmwide.filter((r) => selected.has(r.id) && !r.archived_at);
if (candidates.length < 2) return;
// Suggest the highest-usage row as the default winner — preserves the most
// junction rows untouched (they don't even need an INSERT, just the others
// get redirected onto it).
const initialWinner = candidates.slice().sort((a, b) => b.usage_count - a.usage_count)[0]!.id;
const opts = document.getElementById("aet-merge-options")!;
opts.innerHTML = candidates.map((r) => {
const checked = r.id === initialWinner ? " checked" : "";
return `
<label class="aet-merge-option">
<input type="radio" name="aet-merge-winner" value="${esc(r.id)}"${checked} />
<div class="aet-merge-option-body">
<div class="aet-merge-option-label">${esc(r.label_de)}</div>
<div class="admin-team-muted aet-merge-option-meta">
${esc(categoryLabel(r.category))} · ${esc(jurisdictionLabel(r.jurisdiction))} · ${r.usage_count}&times;
</div>
</div>
</label>`;
}).join("");
const msg = document.getElementById("aet-merge-msg")!;
msg.textContent = "";
msg.className = "form-msg";
document.getElementById("aet-merge-modal")!.style.display = "flex";
}
function closeMergeModal() {
document.getElementById("aet-merge-modal")!.style.display = "none";
}
async function submitMerge(e: Event) {
e.preventDefault();
const winnerInput = document.querySelector<HTMLInputElement>('input[name="aet-merge-winner"]:checked');
if (!winnerInput) return;
const winnerID = winnerInput.value;
const losers = Array.from(selected).filter((id) => id !== winnerID);
if (losers.length === 0) return;
const winner = firmwide.find((r) => r.id === winnerID);
const totalUsage = firmwide
.filter((r) => losers.includes(r.id))
.reduce((acc, r) => acc + r.usage_count, 0);
const tmpl = t("admin.event_types.confirm.merge")
|| "„{winner}\" als Gewinner: {n} Verlierer-Typ(en) werden archiviert, {usage} Junction-Eintrag/-träge umgeleitet. Fortfahren?";
const confirmMsg = tmpl
.replace("{winner}", winner?.label_de ?? winnerID)
.replace("{n}", String(losers.length))
.replace("{usage}", String(totalUsage));
if (!window.confirm(confirmMsg)) return;
const resp = await fetch("/api/admin/event-types/merge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ winner_id: winnerID, loser_ids: losers }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
const msgEl = document.getElementById("aet-merge-msg")!;
msgEl.textContent = body.error || t("admin.event_types.feedback.merge_error") || "Zusammenführung fehlgeschlagen.";
msgEl.className = "form-msg form-msg-error";
return;
}
closeMergeModal();
selected.clear();
showFeedback(t("admin.event_types.feedback.merged") || "Zusammengeführt.", false);
await Promise.all([loadFirmwide(), loadPrivate()]);
renderFirmwide();
renderPrivate();
}
function initSearch() {
const input = document.getElementById("aet-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value;
renderFirmwide();
renderPrivate();
});
}
function initShowArchivedToggle() {
const cb = document.getElementById("aet-show-archived") as HTMLInputElement;
cb.addEventListener("change", async () => {
showArchived = cb.checked;
await loadFirmwide();
renderFirmwide();
});
}
function initBulkActions() {
document.getElementById("aet-bulk-archive")!.addEventListener("click", bulkArchiveSelected);
document.getElementById("aet-bulk-merge")!.addEventListener("click", openMergeModal);
}
function initMergeModal() {
document.getElementById("aet-merge-close")!.addEventListener("click", closeMergeModal);
document.getElementById("aet-merge-cancel")!.addEventListener("click", closeMergeModal);
document.getElementById("aet-merge-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeMergeModal();
});
(document.getElementById("aet-merge-form") as HTMLFormElement).addEventListener("submit", submitMerge);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initShowArchivedToggle();
initBulkActions();
initMergeModal();
onLangChange(() => {
renderFirmwide();
renderPrivate();
});
Promise.all([loadFirmwide(), loadPrivate()]).then(() => {
renderFirmwide();
renderPrivate();
});
});

View File

@@ -0,0 +1,403 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface PartnerUnit {
id: string;
name: string;
lead_user_id?: string | null;
office: string;
created_at: string;
updated_at: string;
}
interface Member {
user_id: string;
email: string;
display_name: string;
office: string;
job_title: string | null;
}
interface PartnerUnitWithMembers extends PartnerUnit {
lead_display_name?: string;
lead_email?: string;
members: Member[];
}
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface UserOption {
id: string;
display_name: string;
email: string;
}
let units: PartnerUnitWithMembers[] = [];
let offices: Office[] = [];
let userOptions: UserOption[] = [];
let activeUnitID: string | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function officeLabel(key: string): string {
const o = offices.find((x) => x.key === key);
if (!o) return key;
return getLang() === "de" ? o.label_de : o.label_en;
}
async function loadAll(): Promise<void> {
await Promise.all([loadOffices(), loadUnits(), loadUsers()]);
render();
}
async function loadOffices(): Promise<void> {
const resp = await fetch("/api/offices");
if (resp.ok) offices = (await resp.json()) as Office[];
}
async function loadUnits(): Promise<void> {
const resp = await fetch("/api/partner-units?include=members");
if (resp.ok) units = (await resp.json()) as PartnerUnitWithMembers[];
}
async function loadUsers(): Promise<void> {
const resp = await fetch("/api/users");
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
}
function showFeedback(msg: string, isError: boolean): void {
const el = document.getElementById("pu-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
}
function render(): void {
const tbody = document.getElementById("pu-tbody")!;
const empty = document.getElementById("pu-empty")!;
if (!units.length) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = units
.map((u) => {
const lead = u.lead_display_name ?? "&mdash;";
const memberCount = u.members.length;
return `<tr data-id="${esc(u.id)}">
<td class="entity-col-title">${esc(u.name)}</td>
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${esc(lead)}</td>
<td>${memberCount}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-link pu-members-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.members">Mitglieder</button>
<button type="button" class="btn-link pu-edit-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.edit">Bearbeiten</button>
<button type="button" class="btn-link pu-delete-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.delete">L&ouml;schen</button>
</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>(".pu-members-btn").forEach((b) =>
b.addEventListener("click", () => openMembersModal(b.dataset.id!)),
);
tbody.querySelectorAll<HTMLButtonElement>(".pu-edit-btn").forEach((b) =>
b.addEventListener("click", () => openEditModal(b.dataset.id!)),
);
tbody.querySelectorAll<HTMLButtonElement>(".pu-delete-btn").forEach((b) =>
b.addEventListener("click", () => deleteUnit(b.dataset.id!)),
);
}
// ---- Edit modal -----------------------------------------------------------
function openEditModal(id: string | null): void {
const modal = document.getElementById("pu-edit-modal")!;
const titleEl = document.getElementById("pu-edit-title")!;
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
const msg = document.getElementById("pu-edit-msg")!;
msg.textContent = "";
msg.className = "form-msg";
// Populate office options.
officeSel.innerHTML = offices
.map((o) => `<option value="${esc(o.key)}">${esc(officeLabel(o.key))}</option>`)
.join("");
// Populate lead options (sorted).
const leadEntries = userOptions
.slice()
.sort((a, b) => (a.display_name || a.email).localeCompare(b.display_name || b.email));
leadSel.innerHTML =
`<option value="">&mdash;</option>` +
leadEntries
.map((u) => {
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
return `<option value="${esc(u.id)}">${esc(label)}</option>`;
})
.join("");
if (id) {
const u = units.find((x) => x.id === id);
if (!u) return;
titleEl.setAttribute("data-i18n", "admin.partner_units.edit.heading");
titleEl.textContent = t("admin.partner_units.edit.heading") || "Partner Unit bearbeiten";
idField.value = u.id;
nameField.value = u.name;
officeSel.value = u.office;
leadSel.value = u.lead_user_id ?? "";
} else {
titleEl.setAttribute("data-i18n", "admin.partner_units.new.heading");
titleEl.textContent = t("admin.partner_units.new.heading") || "Partner Unit anlegen";
idField.value = "";
nameField.value = "";
officeSel.value = offices[0]?.key ?? "munich";
leadSel.value = "";
}
modal.style.display = "flex";
nameField.focus();
}
function closeEditModal(): void {
document.getElementById("pu-edit-modal")!.style.display = "none";
}
async function submitEdit(e: Event): Promise<void> {
e.preventDefault();
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
const msg = document.getElementById("pu-edit-msg")!;
const name = nameField.value.trim();
if (!name) {
msg.textContent = t("admin.partner_units.error.name_required") || "Name erforderlich";
msg.className = "form-msg form-msg-error";
return;
}
const isEdit = !!idField.value;
// Server treats missing keys as "no change". For lead clearing we send the
// nil UUID — service code interprets that as "explicit clear".
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
const payload: Record<string, unknown> = {
name,
office: officeSel.value,
lead_user_id: leadSel.value || NIL_UUID,
};
const url = isEdit ? `/api/partner-units/${idField.value}` : "/api/partner-units";
const method = isEdit ? "PATCH" : "POST";
const resp = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || "Fehler.";
msg.className = "form-msg form-msg-error";
return;
}
closeEditModal();
await loadUnits();
render();
showFeedback(
isEdit
? t("admin.partner_units.feedback.updated") || "Aktualisiert."
: t("admin.partner_units.feedback.created") || "Angelegt.",
false,
);
}
async function deleteUnit(id: string): Promise<void> {
const u = units.find((x) => x.id === id);
if (!u) return;
const confirmMsg = (t("admin.partner_units.confirm_delete") || "Partner Unit \"{name}\" wirklich löschen?")
.replace("{name}", u.name);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/partner-units/${id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
return;
}
await loadUnits();
render();
showFeedback(t("admin.partner_units.feedback.deleted") || "Gelöscht.", false);
}
// ---- Members modal --------------------------------------------------------
function openMembersModal(id: string): void {
activeUnitID = id;
const u = units.find((x) => x.id === id);
if (!u) return;
const titleEl = document.getElementById("pu-members-title")!;
titleEl.textContent =
(t("admin.partner_units.member.heading") || "Mitglieder verwalten") + " — " + u.name;
renderMemberList();
// Reset add form
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
(document.getElementById("pu-add-user-id") as HTMLInputElement).value = "";
document.getElementById("pu-add-suggestions")!.innerHTML = "";
const msg = document.getElementById("pu-add-msg")!;
msg.textContent = "";
msg.className = "form-msg";
document.getElementById("pu-members-modal")!.style.display = "flex";
}
function closeMembersModal(): void {
document.getElementById("pu-members-modal")!.style.display = "none";
activeUnitID = null;
}
function renderMemberList(): void {
if (!activeUnitID) return;
const u = units.find((x) => x.id === activeUnitID);
if (!u) return;
const list = document.getElementById("pu-members-list")!;
if (!u.members.length) {
list.innerHTML = `<li class="form-hint">${esc(t("admin.partner_units.member.empty") || "Noch keine Mitglieder.")}</li>`;
return;
}
list.innerHTML = u.members
.map(
(m) => `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</li>`,
)
.join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)),
);
}
function wireSuggestions(): void {
const input = document.getElementById("pu-add-input") as HTMLInputElement;
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
const sugs = document.getElementById("pu-add-suggestions")!;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.slice(0, 8);
sugs.innerHTML = matches
.map(
(u) => `<div class="collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
<strong>${esc(u.display_name || u.email)}</strong>
<span class="form-hint">${esc(u.email)}</span>
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
});
});
});
}
async function submitAddMember(e: Event): Promise<void> {
e.preventDefault();
if (!activeUnitID) return;
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
const msg = document.getElementById("pu-add-msg")!;
if (!hidden.value) {
msg.textContent = t("admin.partner_units.error.user_required") || "Benutzer auswählen";
msg.className = "form-msg form-msg-error";
return;
}
const resp = await fetch(`/api/partner-units/${activeUnitID}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: hidden.value }),
});
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || "Fehler.";
msg.className = "form-msg form-msg-error";
return;
}
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
hidden.value = "";
document.getElementById("pu-add-suggestions")!.innerHTML = "";
msg.textContent = "";
await loadUnits();
renderMemberList();
render();
}
async function removeMember(userID: string): Promise<void> {
if (!activeUnitID) return;
const confirmMsg = t("admin.partner_units.member.confirm_remove") || "Mitglied entfernen?";
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}`,
{ method: "DELETE" },
);
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Entfernen fehlgeschlagen.", true);
return;
}
await loadUnits();
renderMemberList();
render();
}
// ---- Init -----------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
document.getElementById("pu-new-btn")!.addEventListener("click", () => openEditModal(null));
document.getElementById("pu-edit-close")!.addEventListener("click", closeEditModal);
document.getElementById("pu-edit-cancel")!.addEventListener("click", closeEditModal);
document.getElementById("pu-edit-form")!.addEventListener("submit", submitEdit);
document.getElementById("pu-edit-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
document.getElementById("pu-members-close")!.addEventListener("click", closeMembersModal);
document.getElementById("pu-add-form")!.addEventListener("submit", submitAddMember);
document.getElementById("pu-members-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeMembersModal();
});
wireSuggestions();
onLangChange(() => render());
void loadAll();
});

View File

@@ -0,0 +1,447 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
interface User {
id: string;
email: string;
display_name: string;
office: string;
additional_offices?: string[];
job_title: string | null;
global_role: string;
lang: string;
reminder_morning_time?: string;
reminder_evening_time?: string;
reminder_timezone?: string;
created_at: string;
}
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface Unonboarded {
id: string;
email: string;
created_at: string;
}
const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];
const JOB_TITLE_SUGGESTIONS = [
"Partner",
"Associate",
"PA",
"Of Counsel",
"Counsel",
"Counsel Knowledge Lawyer",
"Knowledge Lawyer",
"Referendar/in",
"Trainee",
"wiss. Mitarbeiter/in",
"Sekretariat",
];
let users: User[] = [];
let offices: Office[] = [];
let unonboarded: Unonboarded[] = [];
let activeOffice = "all";
let searchQuery = "";
let editingId: string | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function officeLabel(key: string): string {
const o = offices.find((x) => x.key === key);
if (!o) return key;
return tDyn("office." + key) || (document.documentElement.lang === "en" ? o.label_en : o.label_de);
}
function fmtDate(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
function permissionLabel(globalRole: string): string {
if (globalRole === "global_admin") return t("admin.team.permission.global_admin") || "Global Admin";
return t("admin.team.permission.standard") || "Standard";
}
async function loadAll() {
const [usersResp, officesResp] = await Promise.all([
fetch("/api/admin/users"),
fetch("/api/offices"),
]);
if (usersResp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (officesResp.ok) offices = (await officesResp.json()) as Office[];
buildOfficeFilters();
render();
}
async function loadUnonboarded() {
const resp = await fetch("/api/admin/users/unonboarded");
if (!resp.ok) {
unonboarded = [];
return;
}
unonboarded = (await resp.json()) as Unonboarded[];
}
function presentOffices(): string[] {
const seen = new Set<string>();
for (const u of users) seen.add(u.office);
return OFFICE_ORDER.filter((k) => seen.has(k)).concat(
Array.from(seen).filter((k) => !OFFICE_ORDER.includes(k)).sort(),
);
}
function buildOfficeFilters() {
const container = document.getElementById("admin-team-office-filters")!;
const present = presentOffices();
const allBtn = `<button class="filter-pill${activeOffice === "all" ? " active" : ""}" data-office="all" type="button">${esc(t("team.filter.all") || "Alle")}</button>`;
const pills = present
.map((k) => `<button class="filter-pill${activeOffice === k ? " active" : ""}" data-office="${esc(k)}" type="button">${esc(officeLabel(k))}</button>`)
.join("");
container.innerHTML = allBtn + pills;
container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
btn.addEventListener("click", () => {
activeOffice = btn.dataset.office ?? "all";
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
render();
});
});
}
function userMatchesSearch(u: User): boolean {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
u.display_name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.job_title ?? "").toLowerCase().includes(q)
);
}
function userMatchesOffice(u: User): boolean {
if (activeOffice === "all") return true;
if (u.office === activeOffice) return true;
return (u.additional_offices ?? []).includes(activeOffice);
}
function officeOptions(selected: string): string {
return offices
.map((o) => `<option value="${esc(o.key)}"${o.key === selected ? " selected" : ""}>${esc(officeLabel(o.key))}</option>`)
.join("");
}
function additionalOfficesEditor(selected: string[]): string {
return offices
.map((o) => {
const checked = selected.includes(o.key) ? " checked" : "";
return `<label class="admin-team-multi-opt"><input type="checkbox" data-additional="${esc(o.key)}"${checked} /> ${esc(officeLabel(o.key))}</label>`;
})
.join("");
}
function langOptions(selected: string): string {
return ["de", "en"]
.map((l) => `<option value="${l}"${l === selected ? " selected" : ""}>${l.toUpperCase()}</option>`)
.join("");
}
function globalAdminCount(): number {
return users.reduce((acc, u) => acc + (u.global_role === "global_admin" ? 1 : 0), 0);
}
function permissionCell(u: User): string {
const cls = u.global_role === "global_admin" ? "admin-team-perm admin-team-perm-admin" : "admin-team-perm";
return `<span class="${cls}">${esc(permissionLabel(u.global_role))}</span>`;
}
function permissionEditor(u: User): string {
// Disable demoting the only remaining global_admin.
const isLastAdmin = u.global_role === "global_admin" && globalAdminCount() <= 1;
const standardOpt = `<option value="standard"${u.global_role === "standard" ? " selected" : ""}>${esc(permissionLabel("standard"))}</option>`;
const adminOpt = `<option value="global_admin"${u.global_role === "global_admin" ? " selected" : ""}>${esc(permissionLabel("global_admin"))}</option>`;
const disabled = isLastAdmin ? " disabled" : "";
const title = isLastAdmin ? ` title="${esc(t("admin.team.permission.last_admin") || "Letzter Admin kann nicht degradiert werden.")}"` : "";
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
}
function renderRow(u: User): string {
if (editingId === u.id) return renderEditRow(u);
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
const jobTitle = u.job_title ?? "";
return `
<tr data-user-id="${esc(u.id)}">
<td class="entity-col-title">${esc(u.display_name)}</td>
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${permissionCell(u)}</td>
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${esc(u.lang.toUpperCase())}</td>
<td class="entity-col-updated">${esc(fmtDate(u.created_at))}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-link admin-team-edit" data-id="${esc(u.id)}" data-i18n="admin.team.row.edit">Bearbeiten</button>
<button type="button" class="btn-link admin-team-delete" data-id="${esc(u.id)}" data-i18n="admin.team.row.delete">L&ouml;schen</button>
</td>
</tr>`;
}
function renderEditRow(u: User): string {
const additional = u.additional_offices ?? [];
const jobTitleList = JOB_TITLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
const jobTitle = u.job_title ?? "";
return `
<tr data-user-id="${esc(u.id)}" class="admin-team-edit-row">
<td><input type="text" class="admin-team-input" data-field="display_name" value="${esc(u.display_name)}" /></td>
<td><span class="admin-team-muted" title="E-Mail kann nicht ge&auml;ndert werden">${esc(u.email)}</span></td>
<td><select class="admin-team-input" data-field="office">${officeOptions(u.office)}</select></td>
<td>
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
</td>
<td>${permissionEditor(u)}</td>
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
<td class="entity-col-updated">${esc(fmtDate(u.created_at))}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-primary admin-team-save" data-id="${esc(u.id)}" data-i18n="admin.team.row.save">Speichern</button>
<button type="button" class="btn-cancel admin-team-cancel" data-id="${esc(u.id)}" data-i18n="admin.team.row.cancel">Abbrechen</button>
</td>
</tr>`;
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-team-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) {
setTimeout(() => { el.style.display = "none"; }, 3500);
}
}
function render() {
const tbody = document.getElementById("admin-team-tbody")!;
const empty = document.getElementById("admin-team-empty")!;
const count = document.getElementById("admin-team-count")!;
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesSearch(u));
count.textContent = `${filtered.length} / ${users.length}`;
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
// Stable sort: global admins first, then by display_name.
const sorted = filtered.slice().sort((a, b) => {
const aAdmin = a.global_role === "global_admin";
const bAdmin = b.global_role === "global_admin";
if (aAdmin && !bAdmin) return -1;
if (bAdmin && !aAdmin) return 1;
return a.display_name.localeCompare(b.display_name);
});
tbody.innerHTML = sorted.map(renderRow).join("");
attachRowListeners();
}
function attachRowListeners() {
document.querySelectorAll<HTMLButtonElement>(".admin-team-edit").forEach((b) => {
b.addEventListener("click", () => {
editingId = b.dataset.id ?? null;
render();
});
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-cancel").forEach((b) => {
b.addEventListener("click", () => {
editingId = null;
render();
});
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-save").forEach((b) => {
b.addEventListener("click", () => saveRow(b.dataset.id!));
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-delete").forEach((b) => {
b.addEventListener("click", () => deleteRow(b.dataset.id!));
});
}
async function saveRow(id: string) {
const tr = document.querySelector<HTMLTableRowElement>(`tr[data-user-id="${id}"]`);
if (!tr) return;
const payload: Record<string, unknown> = {};
tr.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-field]").forEach((el) => {
payload[el.dataset.field!] = el.value;
});
const additional: string[] = [];
tr.querySelectorAll<HTMLInputElement>("[data-additional]").forEach((cb) => {
if (cb.checked) additional.push(cb.dataset.additional!);
});
payload.additional_offices = additional;
const resp = await fetch(`/api/admin/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Fehler beim Speichern.", true);
return;
}
const updated = (await resp.json()) as User;
users = users.map((u) => (u.id === id ? updated : u));
editingId = null;
showFeedback(t("admin.team.feedback.saved") || "Gespeichert.", false);
render();
}
async function deleteRow(id: string) {
const u = users.find((x) => x.id === id);
if (!u) return;
const confirmMsg = (t("admin.team.confirm.delete") || "{name} wirklich löschen?").replace("{name}", u.display_name);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/admin/users/${id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
return;
}
users = users.filter((x) => x.id !== id);
showFeedback(t("admin.team.feedback.deleted") || "Gelöscht.", false);
render();
}
function initSearch() {
const input = document.getElementById("admin-team-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value;
render();
});
}
function openDirectAddModal() {
const modal = document.getElementById("admin-direct-add-modal")!;
const select = document.getElementById("admin-da-email") as HTMLSelectElement;
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const fb = document.getElementById("admin-da-feedback")!;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
fb.style.display = "none";
nameField.value = "";
jobTitleField.value = "";
officeSel.innerHTML = officeOptions("munich");
loadUnonboarded().then(() => {
select.innerHTML = `<option value="">${esc(t("admin.team.direct_add.email.placeholder") || "Bitte auswählen...")}</option>` +
unonboarded.map((u) => `<option value="${esc(u.email)}">${esc(u.email)}</option>`).join("");
if (unonboarded.length === 0) {
const noneMsg = t("admin.team.direct_add.empty") || "Keine offenen Konten.";
select.innerHTML = `<option value="">${esc(noneMsg)}</option>`;
}
});
modal.style.display = "flex";
}
function closeDirectAddModal() {
document.getElementById("admin-direct-add-modal")!.style.display = "none";
}
function initDirectAddModal() {
document.getElementById("admin-team-direct-add")!.addEventListener("click", openDirectAddModal);
document.getElementById("admin-direct-add-close")!.addEventListener("click", closeDirectAddModal);
document.getElementById("admin-da-cancel")!.addEventListener("click", closeDirectAddModal);
const emailSel = document.getElementById("admin-da-email") as HTMLSelectElement;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
emailSel.addEventListener("change", () => {
if (!nameField.value && emailSel.value) {
// Pre-fill from email local-part.
const local = emailSel.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
}
});
document.getElementById("admin-direct-add-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeDirectAddModal();
});
const form = document.getElementById("admin-direct-add-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-da-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
const payload: Record<string, unknown> = {
email: emailSel.value,
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
lang: "de",
};
const resp = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
fb.textContent = body.error || "Fehler.";
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeDirectAddModal();
showFeedback(t("admin.team.feedback.added") || "Konto onboardet.", false);
render();
});
}
function initInviteButton() {
document.getElementById("admin-team-invite")!.addEventListener("click", () => {
document.getElementById("sidebar-invite-btn")?.click();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();
render();
});
loadAll();
});

View File

@@ -0,0 +1,7 @@
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
});

View File

@@ -0,0 +1,414 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
let eventTypeFilter: FilterHandle | null = null;
type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
type AgendaType = "deadline" | "appointment";
type TypeFilter = "both" | "deadlines" | "appointments";
interface AgendaItem {
id: string;
type: AgendaType;
title: string;
date: string; // ISO 8601
end_at?: string | null;
due_date?: string | null; // YYYY-MM-DD (deadlines only)
status?: string | null; // deadlines: pending/completed/...
location?: string | null;
appointment_type?: string | null;
urgency: Urgency;
project_id?: string | null;
project_title?: string | null;
project_type?: string | null; // client | litigation | patent | case | project
project_reference?: string | null;
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
approval_status?: "approved" | "pending" | "legacy" | null;
}
interface AgendaPayload {
items: AgendaItem[];
from: string;
to: string;
types: string[];
}
declare global {
interface Window {
__PALIAD_AGENDA__?: AgendaPayload | null;
}
}
// Range presets match the TSX chips; 30d stays the default (server agrees).
const RANGE_DAYS_DEFAULT = 30;
const VALID_RANGES = new Set([7, 14, 30, 90]);
const state = {
items: [] as AgendaItem[],
type: "both" as TypeFilter,
rangeDays: RANGE_DAYS_DEFAULT,
};
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
readInitialStateFromURL();
const inlined = window.__PALIAD_AGENDA__;
if (inlined !== undefined) {
if (inlined === null) {
showUnavailable();
} else {
hydrate(inlined);
}
} else {
void refetch();
}
wireControls();
onLangChange(() => render());
});
// Pull initial state from ?types=...&range=... so reloads and bookmarks work.
// Any deviation triggers a refetch via wireControls once the UI is ready.
function readInitialStateFromURL(): void {
const q = new URLSearchParams(window.location.search);
const typesRaw = q.get("types");
if (typesRaw) {
const set = new Set(typesRaw.split(",").map((s) => s.trim()));
const hasD = set.has("deadlines");
const hasA = set.has("appointments");
if (hasD && !hasA) state.type = "deadlines";
else if (hasA && !hasD) state.type = "appointments";
else state.type = "both";
}
const rangeRaw = q.get("range");
if (rangeRaw) {
const n = parseInt(rangeRaw, 10);
if (!isNaN(n) && VALID_RANGES.has(n)) state.rangeDays = n;
}
}
function hydrate(payload: AgendaPayload): void {
state.items = payload.items;
// Infer type filter from server payload when the URL didn't pin it.
if (!window.location.search.includes("types=")) {
const set = new Set(payload.types);
if (set.has("deadlines") && !set.has("appointments")) state.type = "deadlines";
else if (set.has("appointments") && !set.has("deadlines")) state.type = "appointments";
else state.type = "both";
}
render();
}
function wireControls(): void {
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = (btn.dataset.type || "both") as TypeFilter;
if (state.type === next) return;
state.type = next;
pushURL();
void refetch();
});
});
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-range]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = parseInt(btn.dataset.range || "30", 10);
if (!VALID_RANGES.has(next) || state.rangeDays === next) return;
state.rangeDays = next;
pushURL();
void refetch();
});
});
syncChips();
const eventTrigger = document.getElementById("agenda-filter-event-type") as HTMLButtonElement | null;
const eventPanel = document.getElementById("agenda-filter-event-type-panel") as HTMLElement | null;
if (eventTrigger && eventPanel) {
const q = new URLSearchParams(window.location.search);
const initialEventIDs: string[] = [];
let initialIncludeUntyped = false;
const raw = q.get("event_type") ?? "";
if (raw) {
for (const tok of raw.split(",")) {
const t = tok.trim();
if (!t) continue;
if (t === "none") initialIncludeUntyped = true;
else initialEventIDs.push(t);
}
}
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
initialIDs: initialEventIDs,
initialIncludeUntyped,
onChange: () => {
pushURL();
void refetch();
},
});
}
}
function pushURL(): void {
const q = new URLSearchParams(window.location.search);
q.set("range", String(state.rangeDays));
q.set("types", typesParam(state.type));
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
if (eventQuery) q.set("event_type", eventQuery);
else q.delete("event_type");
history.replaceState(null, "", `${window.location.pathname}?${q.toString()}`);
}
function typesParam(tf: TypeFilter): string {
if (tf === "deadlines") return "deadlines";
if (tf === "appointments") return "appointments";
return "deadlines,appointments";
}
async function refetch(): Promise<void> {
const loading = document.getElementById("agenda-loading")!;
const timeline = document.getElementById("agenda-timeline")!;
const empty = document.getElementById("agenda-empty")!;
loading.style.display = "block";
timeline.style.display = "none";
empty.style.display = "none";
syncChips();
const from = toISODate(startOfToday());
const to = toISODate(addDays(startOfToday(), state.rangeDays - 1));
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
const eventParam = eventQuery ? `&event_type=${encodeURIComponent(eventQuery)}` : "";
const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}${eventParam}`;
try {
const resp = await fetch(url);
if (resp.status === 503) {
showUnavailable();
return;
}
if (!resp.ok) throw new Error(`status ${resp.status}`);
state.items = (await resp.json()) as AgendaItem[];
render();
} catch {
showUnavailable();
} finally {
loading.style.display = "none";
}
}
function showUnavailable(): void {
document.getElementById("agenda-unavailable")!.style.display = "block";
document.getElementById("agenda-timeline")!.style.display = "none";
document.getElementById("agenda-empty")!.style.display = "none";
}
function render(): void {
syncChips();
const timeline = document.getElementById("agenda-timeline")!;
const empty = document.getElementById("agenda-empty")!;
if (!state.items.length) {
timeline.innerHTML = "";
timeline.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
timeline.style.display = "";
const buckets = groupByDay(state.items);
timeline.innerHTML = buckets.map((b) => renderDay(b)).join("");
}
interface DayBucket {
dayKey: string; // YYYY-MM-DD local
day: Date;
items: AgendaItem[];
}
function groupByDay(items: AgendaItem[]): DayBucket[] {
const map = new Map<string, DayBucket>();
for (const it of items) {
const d = new Date(it.date);
if (isNaN(d.getTime())) continue;
const key = toLocalDayKey(d);
let b = map.get(key);
if (!b) {
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
map.set(key, b);
}
b.items.push(it);
}
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
}
function renderDay(bucket: DayBucket): string {
const expected = expectedUrgency(bucket.day);
return `<section class="agenda-day">
<h2 class="agenda-day-heading">
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
</h2>
<ul class="agenda-items">
${bucket.items.map((it) => renderItem(it, expected)).join("")}
</ul>
</section>`;
}
// F-32: an item's urgency tag duplicates the day-bucket heading in the
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
// "Überfällig" deadline that lands in today's bucket because of a filter
// quirk. expectedUrgency mirrors the server's bucketing rule against the
// bucket's day.
function expectedUrgency(day: Date): Urgency {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) return "overdue";
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff <= 6) return "this_week";
return "later";
}
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
const urgencyClass = `agenda-item-${it.urgency}`;
const typeClass = `agenda-item-type-${it.type}`;
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
const detailHref = itemDetailHref(it);
const project = it.project_id
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
: "";
const pendingPill = it.approval_status === "pending"
? `<span class="approval-pill" title="${esc(tDyn("approvals.pending_update.label"))}">${esc(tDyn("approvals.pending_update.label"))}</span>`
: "";
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
: "";
const urgencyTag = it.urgency !== bucketUrgency
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
: "";
const locationPart = it.type === "appointment" && it.location
? `<span class="agenda-item-location">${esc(it.location)}</span>`
: "";
const typeLabelKey = it.type === "deadline"
? "agenda.label.deadline"
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
const typeLabel = tDyn(typeLabelKey);
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
<a class="agenda-item-link" href="${esc(detailHref)}">
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
<span class="agenda-item-main">
<span class="agenda-item-headline">
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
<span class="agenda-item-title">${esc(it.title)}</span>
${pendingPill}
</span>
<span class="agenda-item-sub">
${project}
${timePart}
${locationPart}
</span>
</span>
<span class="agenda-item-meta">
${urgencyTag}
</span>
</a>
</li>`;
}
function itemDetailHref(it: AgendaItem): string {
return it.type === "deadline"
? `/deadlines/${encodeURIComponent(it.id)}`
: `/appointments/${encodeURIComponent(it.id)}`;
}
function formatProjectLabel(it: AgendaItem): string {
const ref = it.project_reference ? `${it.project_reference} · ` : "";
const title = it.project_title || "";
return `${ref}${title}`.trim();
}
function formatAppointmentTime(it: AgendaItem): string {
const start = new Date(it.date);
if (isNaN(start.getTime())) return "";
const locale = getLang() === "de" ? "de-DE" : "en-GB";
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
if (!it.end_at) return startStr;
const end = new Date(it.end_at);
if (isNaN(end.getTime())) return startStr;
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
return `${startStr}${endStr}`;
}
function relativeDayLabel(day: Date): string {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) {
const n = Math.abs(diff);
return getLang() === "de"
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
: (n === 1 ? "Yesterday" : `${n} days ago`);
}
if (diff === 0) return t("agenda.day.today");
if (diff === 1) return t("agenda.day.tomorrow");
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
}
function fullDateLabel(day: Date): string {
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return day.toLocaleDateString(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
function syncChips(): void {
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
btn.classList.toggle("agenda-chip-active", btn.dataset.type === state.type);
});
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-range]").forEach((btn) => {
btn.classList.toggle("agenda-chip-active", btn.dataset.range === String(state.rangeDays));
});
}
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
function toISODate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function toLocalDayKey(d: Date): string {
return toISODate(d);
}
function esc(s: string): string {
const div = document.createElement("div");
div.textContent = s ?? "";
return div.innerHTML;
}
function deadlineIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
}
function appointmentIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
}

View File

@@ -1,971 +0,0 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Akte {
id: string;
aktenzeichen: string;
title: string;
status: string;
owning_office: string;
firm_wide_visible: boolean;
collaborators: string[];
updated_at: string;
created_at: string;
}
interface Partei {
id: string;
akte_id: string;
name: string;
role?: string;
representative?: string;
}
interface AkteEvent {
id: string;
akte_id: string;
event_type?: string;
title: string;
description?: string;
created_at: string;
created_by?: string;
}
interface Frist {
id: string;
akte_id: string;
title: string;
due_date: string;
status: string;
rule_id?: string;
}
interface Me {
id: string;
role: string;
office: string;
}
interface Dokument {
id: string;
akte_id: string;
title: string;
file_size?: number;
mime_type?: string;
ai_extraction_count: number;
ai_extracted_at?: string;
uploaded_by?: string;
created_at: string;
}
interface ExtractedDeadline {
title: string;
due_date: string;
rule_code: string;
confidence: number;
source_quote: string;
}
interface FeatureFlags {
document_upload: boolean;
ai_extraction: boolean;
}
type TabId = "verlauf" | "parteien" | "fristen" | "termine" | "dokumente" | "notizen";
const VALID_TABS: TabId[] = ["verlauf", "parteien", "fristen", "termine", "dokumente", "notizen"];
let akte: Akte | null = null;
let me: Me | null = null;
let parteien: Partei[] = [];
let events: AkteEvent[] = [];
let fristen: Frist[] = [];
let dokumente: Dokument[] = [];
let features: FeatureFlags = { document_upload: false, ai_extraction: false };
let extractionDoc: Dokument | null = null;
let extractionItems: ExtractedDeadline[] = [];
function parseAkteID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "akten" || !parts[1]) return null;
return parts[1];
}
function parseTab(): TabId {
const parts = window.location.pathname.split("/").filter(Boolean);
const candidate = parts[2] as TabId | undefined;
if (candidate && VALID_TABS.includes(candidate)) return candidate;
return "verlauf";
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* optional */
}
}
async function loadAkte(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/akten/${id}`);
if (!resp.ok) return false;
akte = await resp.json();
return true;
} catch {
return false;
}
}
async function loadParteien(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/parteien`);
if (resp.ok) parteien = await resp.json();
} catch {
parteien = [];
}
}
async function loadEvents(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/events`);
if (resp.ok) events = await resp.json();
} catch {
events = [];
}
}
async function loadFristen(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/fristen`);
if (resp.ok) fristen = await resp.json();
} catch {
fristen = [];
}
}
async function loadDokumente(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/dokumente`);
if (resp.ok) dokumente = await resp.json();
} catch {
dokumente = [];
}
}
async function loadFeatures() {
try {
const resp = await fetch("/api/config/features");
if (resp.ok) features = await resp.json();
} catch {
/* optional — default flags stay false */
}
}
function fmtFileSize(bytes: number | undefined): string {
if (!bytes || bytes <= 0) return "\u2014";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function confidenceClass(c: number): { cls: string; label: string } {
if (c >= 0.75) {
return { cls: "extraction-confidence-high", label: t("akten.detail.dokumente.extraction.confidence.high") };
}
if (c >= 0.5) {
return { cls: "extraction-confidence-mid", label: t("akten.detail.dokumente.extraction.confidence.mid") };
}
return { cls: "extraction-confidence-low", label: t("akten.detail.dokumente.extraction.confidence.low") };
}
function renderDokumente() {
const body = document.getElementById("dokumente-body") as HTMLTableSectionElement | null;
const wrap = document.getElementById("dokumente-tablewrap") as HTMLElement | null;
const empty = document.getElementById("dokumente-empty") as HTMLElement | null;
const uploadWrap = document.getElementById("dokument-upload-wrap") as HTMLElement | null;
const uploadZone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
const uploadDisabled = document.getElementById("dokument-upload-disabled") as HTMLElement | null;
if (!body || !wrap || !empty || !uploadWrap || !uploadZone || !uploadDisabled) return;
if (features.document_upload) {
uploadZone.style.display = "";
uploadDisabled.style.display = "none";
} else {
uploadZone.style.display = "none";
uploadDisabled.style.display = "";
}
if (dokumente.length === 0) {
body.innerHTML = "";
wrap.style.display = "none";
empty.style.display = "block";
return;
}
wrap.style.display = "";
empty.style.display = "none";
body.innerHTML = dokumente
.map((d) => {
const extractedBadge =
d.ai_extraction_count > 0
? `<span class="dokument-extraction-badge">${d.ai_extraction_count}\u00d7</span>`
: "";
const extractBtn = features.ai_extraction
? `<button type="button" class="dokumente-action-btn dokumente-action-extract" data-action="extract" data-id="${esc(d.id)}">${esc(
t("akten.detail.dokumente.action.extract"),
)}</button>`
: "";
return `<tr data-id="${esc(d.id)}">
<td>
<div class="dokument-name-cell">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span>${esc(d.title)}</span>${extractedBadge}
</div>
</td>
<td>${fmtDateTime(d.created_at)}</td>
<td>${fmtFileSize(d.file_size)}</td>
<td class="dokumente-col-actions">
<a class="dokumente-action-btn" href="/api/dokumente/${esc(d.id)}/download" target="_blank" rel="noopener">${esc(
t("akten.detail.dokumente.action.download"),
)}</a>
${extractBtn}
</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>('button[data-action="extract"]').forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.id!;
const doc = dokumente.find((d) => d.id === id) || null;
if (!doc) return;
await runExtraction(btn, doc);
});
});
}
async function runExtraction(btn: HTMLButtonElement, doc: Dokument) {
const msgEl = document.getElementById("dokument-upload-msg") as HTMLElement | null;
btn.disabled = true;
const originalText = btn.textContent || "";
btn.textContent = t("akten.detail.dokumente.extract.running");
if (msgEl) {
msgEl.textContent = "";
msgEl.className = "form-msg";
}
try {
const resp = await fetch(`/api/dokumente/${doc.id}/extract-deadlines`, { method: "POST" });
if (resp.status === 429) {
if (msgEl) {
msgEl.textContent = t("akten.detail.dokumente.extract.ratelimit");
msgEl.className = "form-msg form-msg-error";
}
return;
}
if (resp.status === 501) {
if (msgEl) {
msgEl.textContent = t("akten.detail.dokumente.extract.disabled");
msgEl.className = "form-msg form-msg-error";
}
return;
}
if (!resp.ok) {
if (msgEl) {
msgEl.textContent = t("akten.detail.dokumente.extract.failed");
msgEl.className = "form-msg form-msg-error";
}
return;
}
const data = (await resp.json()) as { deadlines: ExtractedDeadline[] };
extractionDoc = doc;
extractionItems = data.deadlines || [];
openExtractionModal();
// Refresh doc list so the extraction badge updates.
if (akte) {
await loadDokumente(akte.id);
renderDokumente();
}
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
function openExtractionModal() {
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
const body = document.getElementById("extraction-body") as HTMLTableSectionElement | null;
const none = document.getElementById("extraction-none") as HTMLElement | null;
const table = document.getElementById("extraction-table") as HTMLElement | null;
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
if (!modal || !body || !none || !table) return;
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
if (extractionItems.length === 0) {
table.style.display = "none";
none.style.display = "block";
} else {
table.style.display = "";
none.style.display = "none";
body.innerHTML = extractionItems
.map((it, idx) => {
const confCls = confidenceClass(it.confidence).cls;
const confLabel = confidenceClass(it.confidence).label;
const confPct = Math.round(it.confidence * 100);
const missingDate = !it.due_date
? `<div class="extraction-missing-date">${esc(t("akten.detail.dokumente.extraction.missing.date"))}</div>`
: "";
const quoteId = `extraction-quote-${idx}`;
return `<tr data-idx="${idx}">
<td><input type="checkbox" class="extraction-keep" ${it.due_date ? "checked" : ""} /></td>
<td><input type="text" class="extraction-title-input" value="${esc(it.title)}" /></td>
<td>
<input type="date" class="extraction-due-input" value="${esc(it.due_date)}" />
${missingDate}
</td>
<td><input type="text" class="extraction-rule-input" value="${esc(it.rule_code)}" placeholder="z.B. Rule 23 RoP" /></td>
<td><span class="extraction-confidence ${confCls}">${confPct}% ${esc(confLabel)}</span></td>
<td>
<button type="button" class="extraction-source-toggle" data-target="${quoteId}">${esc(
t("akten.detail.dokumente.extraction.source.show"),
)}</button>
<div class="extraction-source-quote" id="${quoteId}">\u201E${esc(it.source_quote)}\u201C</div>
</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>(".extraction-source-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const tgt = document.getElementById(btn.dataset.target!) as HTMLElement | null;
if (!tgt) return;
const open = tgt.classList.toggle("extraction-source-quote-open");
btn.textContent = open
? t("akten.detail.dokumente.extraction.source.hide")
: t("akten.detail.dokumente.extraction.source.show");
});
});
}
modal.style.display = "flex";
}
function closeExtractionModal() {
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
if (modal) modal.style.display = "none";
extractionDoc = null;
extractionItems = [];
}
async function saveExtractedFristen() {
if (!akte || !extractionDoc) return;
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
const saveBtn = document.getElementById("extraction-save") as HTMLButtonElement | null;
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
const rows = Array.from(document.querySelectorAll<HTMLTableRowElement>("#extraction-body tr"));
const items: Array<{ title: string; due_date: string; rule_code?: string; source_quote?: string }> = [];
for (const row of rows) {
const cb = row.querySelector<HTMLInputElement>(".extraction-keep");
if (!cb?.checked) continue;
const titleInput = row.querySelector<HTMLInputElement>(".extraction-title-input");
const dueInput = row.querySelector<HTMLInputElement>(".extraction-due-input");
const ruleInput = row.querySelector<HTMLInputElement>(".extraction-rule-input");
if (!titleInput || !dueInput) continue;
const title = titleInput.value.trim();
const due = dueInput.value.trim();
const rule = ruleInput?.value.trim() || "";
if (!title || !due) continue;
const idx = parseInt(row.dataset.idx || "-1", 10);
const sourceQuote = idx >= 0 ? extractionItems[idx]?.source_quote : "";
items.push({
title,
due_date: due,
rule_code: rule || undefined,
source_quote: sourceQuote || undefined,
});
}
if (items.length === 0) {
if (msg) {
msg.textContent = t("akten.detail.dokumente.extraction.save.none");
msg.className = "form-msg form-msg-error";
}
return;
}
if (saveBtn) saveBtn.disabled = true;
try {
const resp = await fetch(`/api/akten/${akte.id}/fristen/from-extraction`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document_id: extractionDoc.id, items }),
});
if (!resp.ok) {
if (msg) {
msg.textContent = t("akten.detail.dokumente.extraction.save.failed");
msg.className = "form-msg form-msg-error";
}
return;
}
const created = await resp.json();
const count = Array.isArray(created) ? created.length : items.length;
if (msg) {
const template = t("akten.detail.dokumente.extraction.save.success");
msg.textContent = template.replace("{n}", String(count));
msg.className = "form-msg form-msg-success";
}
if (akte) {
await Promise.all([loadFristen(akte.id), loadEvents(akte.id), loadDokumente(akte.id)]);
renderFristen();
renderEvents();
renderDokumente();
}
// Auto-close after short delay so user can see the success message.
setTimeout(() => closeExtractionModal(), 1200);
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
function initDokumenteUpload() {
const input = document.getElementById("dokument-file-input") as HTMLInputElement | null;
const zone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
const progress = document.getElementById("dokument-upload-progress") as HTMLElement | null;
const bar = document.getElementById("dokument-upload-bar-fill") as HTMLElement | null;
const status = document.getElementById("dokument-upload-status") as HTMLElement | null;
const msg = document.getElementById("dokument-upload-msg") as HTMLElement | null;
if (!input || !zone || !progress || !bar || !status || !msg) return;
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0 || !akte) return;
const file = files[0]!;
msg.textContent = "";
msg.className = "form-msg";
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
msg.textContent = t("akten.detail.dokumente.upload.notpdf");
msg.className = "form-msg form-msg-error";
return;
}
if (file.size > 20 * 1024 * 1024) {
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
msg.className = "form-msg form-msg-error";
return;
}
progress.style.display = "flex";
bar.style.width = "0%";
status.textContent = t("akten.detail.dokumente.upload.progress");
const fd = new FormData();
fd.append("file", file);
// Use XHR for real upload-progress reporting (fetch can't stream upload
// progress in browsers without the experimental ReadableStream.pipeTo).
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/akten/${akte.id}/dokumente`);
xhr.upload.addEventListener("progress", (ev) => {
if (ev.lengthComputable) {
const pct = Math.round((ev.loaded / ev.total) * 100);
bar.style.width = `${pct}%`;
}
});
xhr.onload = async () => {
progress.style.display = "none";
input.value = "";
if (xhr.status >= 200 && xhr.status < 300) {
msg.textContent = t("akten.detail.dokumente.upload.success");
msg.className = "form-msg form-msg-success";
if (akte) {
await loadDokumente(akte.id);
renderDokumente();
await loadEvents(akte.id);
renderEvents();
}
} else if (xhr.status === 413) {
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
msg.className = "form-msg form-msg-error";
} else {
let reason = t("akten.detail.dokumente.upload.failed");
try {
const body = JSON.parse(xhr.responseText) as { error?: string };
if (body.error) reason = body.error;
} catch {
/* ignore parse errors */
}
msg.textContent = reason;
msg.className = "form-msg form-msg-error";
}
};
xhr.onerror = () => {
progress.style.display = "none";
msg.textContent = t("akten.detail.dokumente.upload.failed");
msg.className = "form-msg form-msg-error";
};
xhr.send(fd);
};
input.addEventListener("change", () => handleFiles(input.files));
// Drag-drop on the zone.
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dokumente-upload-zone-drag");
});
zone.addEventListener("dragleave", () => {
zone.classList.remove("dokumente-upload-zone-drag");
});
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dokumente-upload-zone-drag");
handleFiles(e.dataTransfer?.files ?? null);
});
}
function initExtractionModal() {
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
const closeBtn = document.getElementById("extraction-modal-close") as HTMLElement | null;
const cancelBtn = document.getElementById("extraction-cancel") as HTMLElement | null;
const saveBtn = document.getElementById("extraction-save") as HTMLElement | null;
if (!modal || !closeBtn || !cancelBtn || !saveBtn) return;
closeBtn.addEventListener("click", closeExtractionModal);
cancelBtn.addEventListener("click", closeExtractionModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeExtractionModal();
});
saveBtn.addEventListener("click", saveExtractedFristen);
}
function fmtDateOnly(iso: string): string {
try {
const d = new Date(iso.slice(0, 10) + "T00:00:00");
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
function renderFristen() {
const tbody = document.getElementById("akte-fristen-body");
const empty = document.getElementById("akte-fristen-empty");
const wrap = document.getElementById("akte-fristen-tablewrap");
if (!tbody || !empty || !wrap) return;
if (fristen.length === 0) {
tbody.innerHTML = "";
wrap.style.display = "none";
empty.style.display = "block";
return;
}
wrap.style.display = "";
empty.style.display = "none";
tbody.innerHTML = fristen
.map((f) => {
const urgency = urgencyClass(f.due_date, f.status);
const statusLabel = t(`fristen.status.${f.status}`) || f.status;
const checked = f.status === "completed" ? "checked" : "";
const disabled = f.status === "completed" ? "disabled" : "";
const titleClass = f.status === "completed" ? "frist-title-done" : "";
return `<tr class="frist-row" data-id="${esc(f.id)}">
<td class="frist-col-check">
<input type="checkbox" class="frist-complete-cb" ${checked} ${disabled}
aria-label="${esc(t("fristen.complete.action"))}" />
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
<td class="frist-col-rule">\u2014</td>
<td><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".frist-complete-cb")) return;
window.location.href = `/fristen/${id}`;
});
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
if (cb) {
cb.addEventListener("change", async () => {
if (!cb.checked || !akte) return;
cb.disabled = true;
const resp = await fetch(`/api/fristen/${id}/complete`, { method: "PATCH" });
if (resp.ok) {
await loadFristen(akte.id);
renderFristen();
await loadEvents(akte.id);
renderEvents();
} else {
cb.checked = false;
cb.disabled = false;
}
});
}
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function renderHeader() {
if (!akte) return;
(document.getElementById("akte-title-display") as HTMLElement).textContent = akte.title;
(document.getElementById("akte-title-edit") as HTMLInputElement).value = akte.title;
(document.getElementById("akte-ref-display") as HTMLElement).textContent = akte.aktenzeichen;
const officeChip = document.getElementById("akte-office-chip")!;
officeChip.className = `akten-office-chip akten-office-${akte.owning_office}`;
officeChip.textContent = t(`office.${akte.owning_office}`) || akte.owning_office;
const statusChip = document.getElementById("akte-status-chip")!;
statusChip.className = `akten-status-chip akten-status-${akte.status}`;
statusChip.textContent = t(`akten.status.${akte.status}`) || akte.status;
const firmWideChip = document.getElementById("akte-firmwide-chip")!;
if (akte.firm_wide_visible) {
firmWideChip.style.display = "";
firmWideChip.textContent = t("akten.detail.firmwide.on");
} else {
firmWideChip.style.display = "none";
}
// Delete visibility: partner/admin only
const deleteWrap = document.getElementById("akte-delete-wrap")!;
if (me && (me.role === "partner" || me.role === "admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
}
function renderEvents() {
const list = document.getElementById("akten-events-list")!;
const empty = document.getElementById("akten-events-empty")!;
if (events.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.innerHTML = events
.map(
(e) => `<li class="akten-event">
<div class="akten-event-date">${fmtDateTime(e.created_at)}</div>
<div class="akten-event-body">
<div class="akten-event-title">${esc(e.title)}</div>
${e.description ? `<div class="akten-event-desc">${esc(e.description)}</div>` : ""}
</div>
</li>`,
)
.join("");
}
function renderParteien() {
const tbody = document.getElementById("parteien-body")!;
const empty = document.getElementById("parteien-empty")!;
const tableWrap = tbody.closest<HTMLElement>("table")!;
if (parteien.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "block";
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
tbody.innerHTML = parteien
.map((p) => {
const roleKey = p.role ? `akten.detail.parteien.role.${p.role}` : "";
const roleLabel = p.role ? t(roleKey) || p.role : "";
return `<tr data-id="${esc(p.id)}">
<td>${esc(p.name)}</td>
<td>${esc(roleLabel)}</td>
<td>${esc(p.representative || "")}</td>
<td class="akten-col-actions">
<button type="button" class="btn-link-danger partei-remove" data-i18n="akten.detail.parteien.remove">Entfernen</button>
</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>(".partei-remove").forEach((btn) => {
btn.textContent = t("akten.detail.parteien.remove");
btn.addEventListener("click", async () => {
const row = btn.closest<HTMLTableRowElement>("tr")!;
const id = row.dataset.id!;
if (!confirm(t("akten.detail.parteien.remove.confirm"))) return;
const resp = await fetch(`/api/parteien/${id}`, { method: "DELETE" });
if (resp.ok && akte) {
await loadParteien(akte.id);
renderParteien();
}
});
});
}
function showTab(tab: TabId) {
document.querySelectorAll<HTMLElement>(".akten-tab").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tab);
});
document.querySelectorAll<HTMLElement>(".akten-tab-panel").forEach((el) => {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
// Deep-link via pushState so sub-routes stay shareable.
if (akte) {
const newPath = `/akten/${akte.id}/${tab}`;
if (window.location.pathname !== newPath) {
window.history.replaceState({}, "", newPath);
}
}
}
function initTabs() {
document.querySelectorAll<HTMLAnchorElement>(".akten-tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
e.preventDefault();
showTab(tab.dataset.tab as TabId);
});
});
}
function initTitleEdit() {
const display = document.getElementById("akte-title-display")!;
const editInput = document.getElementById("akte-title-edit") as HTMLInputElement;
const editBtn = document.getElementById("akte-edit-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("akte-save-btn") as HTMLButtonElement;
editBtn.addEventListener("click", () => {
display.style.display = "none";
editInput.style.display = "";
saveBtn.style.display = "";
editBtn.style.display = "none";
editInput.focus();
editInput.select();
});
saveBtn.addEventListener("click", async () => {
if (!akte) return;
const newTitle = editInput.value.trim();
if (!newTitle || newTitle === akte.title) {
cancelEdit();
return;
}
saveBtn.disabled = true;
try {
const resp = await fetch(`/api/akten/${akte.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle }),
});
if (resp.ok) {
akte = await resp.json();
renderHeader();
if (akte) await loadEvents(akte.id);
renderEvents();
}
} finally {
saveBtn.disabled = false;
cancelEdit();
}
});
function cancelEdit() {
display.style.display = "";
editInput.style.display = "none";
saveBtn.style.display = "none";
editBtn.style.display = "";
}
}
function initParteienForm() {
const addBtn = document.getElementById("partei-add-btn") as HTMLButtonElement;
const form = document.getElementById("partei-form") as HTMLFormElement;
const cancelBtn = document.getElementById("partei-cancel") as HTMLButtonElement;
const msg = document.getElementById("partei-msg")!;
addBtn.addEventListener("click", () => {
form.style.display = "";
addBtn.style.display = "none";
(document.getElementById("partei-name") as HTMLInputElement).focus();
});
cancelBtn.addEventListener("click", () => {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
msg.textContent = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!akte) return;
const name = (document.getElementById("partei-name") as HTMLInputElement).value.trim();
const role = (document.getElementById("partei-role") as HTMLSelectElement).value;
const rep = (document.getElementById("partei-rep") as HTMLInputElement).value.trim();
if (!name) return;
msg.textContent = "";
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
const payload: Record<string, unknown> = { name, role };
if (rep) payload.representative = rep;
try {
const resp = await fetch(`/api/akten/${akte.id}/parteien`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
await loadParteien(akte.id);
renderParteien();
await loadEvents(akte.id);
renderEvents();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("akten.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("akten.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function initFristAddLink() {
if (!akte) return;
const link = document.getElementById("frist-add-link") as HTMLAnchorElement | null;
if (link) link.href = `/akten/${akte.id}/fristen/neu`;
}
function initDelete() {
const btn = document.getElementById("akte-delete-btn")!;
const modal = document.getElementById("delete-modal")!;
const close = document.getElementById("delete-modal-close")!;
const cancel = document.getElementById("delete-modal-cancel")!;
const confirmBtn = document.getElementById("delete-modal-confirm") as HTMLButtonElement;
btn.addEventListener("click", () => {
modal.style.display = "flex";
});
const closeModal = () => {
modal.style.display = "none";
};
close.addEventListener("click", closeModal);
cancel.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeModal();
});
confirmBtn.addEventListener("click", async () => {
if (!akte) return;
confirmBtn.disabled = true;
const resp = await fetch(`/api/akten/${akte.id}`, { method: "DELETE" });
if (resp.ok) {
window.location.href = "/akten";
} else {
confirmBtn.disabled = false;
closeModal();
}
});
}
async function main() {
const id = parseAkteID();
const loading = document.getElementById("akten-detail-loading")!;
const notfound = document.getElementById("akten-detail-notfound")!;
const body = document.getElementById("akten-detail-body")!;
if (!id) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await Promise.all([loadMe(), loadFeatures()]);
const ok = await loadAkte(id);
if (!ok || !akte) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id), loadDokumente(id)]);
loading.style.display = "none";
body.style.display = "";
renderHeader();
renderParteien();
renderEvents();
renderFristen();
renderDokumente();
initFristAddLink();
initTabs();
initTitleEdit();
initParteienForm();
initDelete();
initDokumenteUpload();
initExtractionModal();
showTab(parseTab());
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => {
renderHeader();
renderEvents();
renderParteien();
renderFristen();
renderDokumente();
});
main();
});

View File

@@ -1,232 +0,0 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface User {
id: string;
email: string;
display_name: string;
office: string;
role: string;
}
interface Me {
id: string;
email: string;
display_name: string;
office: string;
role: string;
}
const selectedCollabs = new Map<string, User>();
let allUsers: User[] = [];
let me: Me | null = null;
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.status === 404) {
showError(t("akten.onboarding.required"));
disableForm();
return;
}
if (!resp.ok) return;
me = await resp.json();
if (!me) return;
const officeSelect = document.getElementById("akte-office") as HTMLSelectElement;
officeSelect.value = me.office;
if (me.role !== "admin") {
officeSelect.disabled = true;
}
if (me.role === "partner" || me.role === "admin") {
document.getElementById("firm-wide-wrap")!.style.display = "";
}
} catch {
/* non-fatal — form still works */
}
}
async function loadUsers() {
try {
const resp = await fetch("/api/users");
if (!resp.ok) return;
allUsers = await resp.json();
} catch {
/* non-fatal — collaborator picker disabled silently */
}
}
function renderCollabChips() {
const wrap = document.getElementById("akte-collab-list")!;
wrap.innerHTML = Array.from(selectedCollabs.values())
.map(
(u) =>
`<span class="akten-chip" data-id="${esc(u.id)}">${esc(u.display_name || u.email)}<button type="button" class="akten-chip-x" aria-label="remove">\u00d7</button></span>`,
)
.join("");
wrap.querySelectorAll<HTMLButtonElement>(".akten-chip-x").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const chip = btn.closest<HTMLElement>(".akten-chip")!;
selectedCollabs.delete(chip.dataset.id!);
renderCollabChips();
});
});
}
function initCollabPicker() {
const input = document.getElementById("akte-collab-input") as HTMLInputElement;
const suggestions = document.getElementById("akte-collab-suggestions")!;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
if (!q) {
suggestions.innerHTML = "";
suggestions.style.display = "none";
return;
}
const matches = allUsers
.filter((u) => !selectedCollabs.has(u.id) && (!me || u.id !== me.id))
.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)),
)
.slice(0, 8);
if (matches.length === 0) {
suggestions.innerHTML = "";
suggestions.style.display = "none";
return;
}
suggestions.innerHTML = matches
.map(
(u) =>
`<button type="button" class="akten-suggestion" data-id="${esc(u.id)}">${esc(u.display_name || u.email)}<span class="akten-suggestion-meta">${esc(u.email)}</span></button>`,
)
.join("");
suggestions.style.display = "block";
suggestions.querySelectorAll<HTMLButtonElement>(".akten-suggestion").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.id!;
const user = allUsers.find((u) => u.id === id);
if (user) {
selectedCollabs.set(id, user);
renderCollabChips();
}
input.value = "";
suggestions.innerHTML = "";
suggestions.style.display = "none";
});
});
});
// Hide suggestions on outside click
document.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).closest(".akten-collab")) {
suggestions.style.display = "none";
}
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function showError(msg: string) {
const el = document.getElementById("akten-neu-msg")!;
el.textContent = msg;
el.className = "form-msg form-msg-error";
}
function disableForm() {
const form = document.getElementById("akten-neu-form") as HTMLFormElement;
form.querySelectorAll<HTMLInputElement>("input, select, textarea, button[type=submit]").forEach((el) => {
el.disabled = true;
});
}
async function submitForm(e: Event) {
e.preventDefault();
const msg = document.getElementById("akten-neu-msg")!;
const submitBtn = document.querySelector<HTMLButtonElement>("#akten-neu-form button[type=submit]")!;
const title = (document.getElementById("akte-title") as HTMLInputElement).value.trim();
const ref = (document.getElementById("akte-ref") as HTMLInputElement).value.trim();
const office = (document.getElementById("akte-office") as HTMLSelectElement).value;
const status = (document.getElementById("akte-status") as HTMLSelectElement).value;
const court = (document.getElementById("akte-court") as HTMLInputElement).value.trim();
const courtRef = (document.getElementById("akte-courtref") as HTMLInputElement).value.trim();
const akteType = (document.getElementById("akte-type") as HTMLInputElement).value.trim();
const firmWide =
me &&
(me.role === "partner" || me.role === "admin") &&
(document.getElementById("akte-firmwide") as HTMLInputElement).checked;
if (!title || !ref) {
showError(t("akten.error.required"));
return;
}
msg.textContent = "";
msg.className = "form-msg";
submitBtn.disabled = true;
const payload: Record<string, unknown> = {
title,
aktenzeichen: ref,
owning_office: office,
status,
};
if (court) payload.court = court;
if (courtRef) payload.court_ref = courtRef;
if (akteType) payload.akte_type = akteType;
try {
const resp = await fetch("/api/akten", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 401 || resp.status === 403) {
showError(t("akten.error.forbidden"));
submitBtn.disabled = false;
return;
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
showError(data.error || t("akten.error.generic"));
submitBtn.disabled = false;
return;
}
const akte = await resp.json();
const collabIds = Array.from(selectedCollabs.keys());
if (collabIds.length > 0 || firmWide) {
const patch: Record<string, unknown> = {};
if (collabIds.length > 0) patch.collaborators = collabIds;
if (firmWide) patch.firm_wide_visible = true;
await fetch(`/api/akten/${akte.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
}
window.location.href = `/akten/${akte.id}`;
} catch {
showError(t("akten.error.generic"));
submitBtn.disabled = false;
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initCollabPicker();
document.getElementById("akten-neu-form")!.addEventListener("submit", submitForm);
loadMe();
loadUsers();
});

View File

@@ -1,167 +0,0 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Akte {
id: string;
aktenzeichen: string;
title: string;
status: string;
owning_office: string;
firm_wide_visible: boolean;
updated_at: string;
}
let allAkten: Akte[] = [];
let officeFilter = "";
let statusFilter = "";
let searchQuery = "";
let loadedOK = false;
async function loadAkten() {
const unavailable = document.getElementById("akten-unavailable")!;
const table = document.querySelector<HTMLElement>(".akten-table-wrap")!;
try {
const resp = await fetch("/api/akten");
if (resp.status === 503) {
unavailable.style.display = "block";
table.style.display = "none";
document.getElementById("akten-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
table.style.display = "none";
return;
}
allAkten = await resp.json();
loadedOK = true;
render();
} catch {
unavailable.style.display = "block";
table.style.display = "none";
}
}
function getFiltered(): Akte[] {
let rows = allAkten;
if (officeFilter) rows = rows.filter((a) => a.owning_office === officeFilter);
if (statusFilter) rows = rows.filter((a) => a.status === statusFilter);
if (searchQuery) {
const q = searchQuery.toLowerCase();
rows = rows.filter(
(a) =>
a.title.toLowerCase().includes(q) ||
a.aktenzeichen.toLowerCase().includes(q),
);
}
return rows;
}
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("akten-body")!;
const empty = document.getElementById("akten-empty")!;
const emptyFiltered = document.getElementById("akten-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
const count = document.getElementById("akten-count")!;
const filtered = getFiltered();
count.textContent = `${filtered.length} / ${allAkten.length}`;
if (allAkten.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "block";
emptyFiltered.style.display = "none";
return;
}
if (filtered.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "block";
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
emptyFiltered.style.display = "none";
tbody.innerHTML = filtered
.map((a) => {
const statusKey = `akten.status.${a.status}`;
const statusLabel = t(statusKey);
const officeLabel = t(`office.${a.owning_office}`) || a.owning_office;
const firmWide = a.firm_wide_visible
? `<span class="akten-firmwide-dot" title="${escAttr(t("akten.detail.firmwide.on"))}">\u2737</span>`
: "";
return `<tr class="akten-row" data-id="${esc(a.id)}">
<td class="akten-col-title">${esc(a.title)} ${firmWide}</td>
<td class="akten-col-ref">${esc(a.aktenzeichen)}</td>
<td><span class="akten-office-chip akten-office-${esc(a.owning_office)}">${esc(officeLabel)}</span></td>
<td><span class="akten-status-chip akten-status-${esc(a.status)}">${esc(statusLabel)}</span></td>
<td class="akten-col-updated">${fmtDate(a.updated_at)}</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".akten-row").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.id!;
window.location.href = `/akten/${id}`;
});
});
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function initSearch() {
const input = document.getElementById("akten-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value.trim();
render();
});
}
function initFilters() {
const office = document.getElementById("akten-office") as HTMLSelectElement;
const status = document.getElementById("akten-status") as HTMLSelectElement;
office.addEventListener("change", () => {
officeFilter = office.value;
render();
});
status.addEventListener("change", () => {
statusFilter = status.value;
render();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initFilters();
onLangChange(render);
loadAkten();
});

View File

@@ -0,0 +1,47 @@
// app.ts — universal client bundle injected on every page. Four jobs:
// 1. Wire the BottomNav (was previously written but never bundled — m
// reproduced the broken [+] and Menü buttons in production).
// 2. Register the service worker so the site qualifies for PWA install.
// 3. Surface the install prompt (Chromium banner / iOS share-sheet hint).
// 4. Init the theme listener so the OS-level prefers-color-scheme change
// flips the page when the user's pref is "auto" (m/paliad#2).
//
// Per-page bundles still register their own behaviour; this script is
// orthogonal and only touches DOM nodes it owns.
import { initBottomNav } from "./bottom-nav";
import { initInstallPrompt } from "./pwa-install";
import { initTheme } from "./theme";
function registerServiceWorker(): void {
if (!("serviceWorker" in navigator)) return;
// Don't bother in non-secure contexts (localhost is a secure context, so
// local dev still registers).
if (!window.isSecureContext) return;
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => {
// Surface registration failures in the console — do not throw, since the
// site has to keep working without the SW.
console.warn("paliad: service worker registration failed", err);
});
}
function boot(): void {
initBottomNav();
initInstallPrompt();
initTheme();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
// Register the SW after the page has had a chance to paint so it never
// competes with critical resources on first paint.
if (document.readyState === "complete") {
registerServiceWorker();
} else {
window.addEventListener("load", registerServiceWorker);
}

View File

@@ -0,0 +1,193 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Appointment {
id: string;
project_id?: string;
title: string;
start_at: string;
end_at?: string;
appointment_type?: string;
project_reference?: string;
project_title?: string;
}
let allAppointments: Appointment[] = [];
let viewYear = 0;
let viewMonth = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
async function loadAppointments() {
// Pull a wide window (current month plus a little buffer either side).
// We could narrow this, but the user typically navigates ±1-2 months
// and the dataset is small.
try {
const resp = await fetch("/api/appointments");
if (resp.ok) allAppointments = await resp.json();
} catch {
/* non-fatal */
}
}
function appointmentsForDate(iso: string): Appointment[] {
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
}
function typeClass(t?: string): string {
return t ? `termin-type-${t}` : "termin-type-default";
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = appointmentsForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("appointment-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allAppointments.some((tt) => {
const iso = tt.start_at.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("appointment-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = appointmentsForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((tt) => {
const akteRef = tt.project_id
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
return `<li class="frist-cal-popup-item">
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
${akteRef}
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadAppointments();
render();
});

View File

@@ -0,0 +1,274 @@
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
interface Appointment {
id: string;
project_id?: string;
title: string;
description?: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
created_by?: string;
}
interface Project {
id: string;
reference?: string | null;
title: string;
path?: string;
}
let appointment: Appointment | null = null;
let project: Project | null = null;
let allProjects: Project[] = [];
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "appointments" || !parts[1]) return null;
return parts[1];
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function toLocalInput(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function loadAppointment(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/appointments/${id}`);
if (!resp.ok) return false;
appointment = await resp.json();
return true;
} catch {
return false;
}
}
async function loadProject(id: string) {
try {
const resp = await fetch(`/api/projects/${id}`);
if (resp.ok) project = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadAllProjects() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) allProjects = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjectPicker() {
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
if (!sel) return;
const none = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (none) sel.appendChild(none);
for (const p of allProjects) {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${projectIndent(p.path)}${p.reference || ""}${p.title}`;
sel.appendChild(opt);
}
if (appointment) {
sel.value = appointment.project_id ?? "";
}
}
function renderHeader() {
if (!appointment) return;
document.getElementById("appointment-title-display")!.textContent = appointment.title;
const time = appointment.end_at
? `${fmtDateTime(appointment.start_at)}${fmtDateTime(appointment.end_at)}`
: fmtDateTime(appointment.start_at);
document.getElementById("appointment-time-display")!.textContent = time;
const badge = document.getElementById("appointment-type-badge")!;
if (appointment.appointment_type) {
badge.textContent = tDyn(`appointments.type.${appointment.appointment_type}`) || appointment.appointment_type;
badge.className = `termin-type-badge termin-type-${appointment.appointment_type}`;
badge.style.display = "";
} else {
badge.style.display = "none";
}
const projectRow = document.getElementById("appointment-project-row")!;
if (appointment.project_id && project) {
const link = document.getElementById("appointment-project-link") as HTMLAnchorElement;
link.href = `/projects/${project.id}`;
link.textContent = `${project.reference || ""}${project.title}`;
projectRow.style.display = "";
} else {
projectRow.style.display = "none";
}
}
function fillEditForm() {
if (!appointment) return;
(document.getElementById("appointment-title-edit") as HTMLInputElement).value = appointment.title;
(document.getElementById("appointment-start-edit") as HTMLInputElement).value = toLocalInput(appointment.start_at);
(document.getElementById("appointment-end-edit") as HTMLInputElement).value = toLocalInput(appointment.end_at);
(document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? "";
(document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? "";
(document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? "";
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
if (projectSel) projectSel.value = appointment.project_id ?? "";
}
async function saveEdit(ev: Event) {
ev.preventDefault();
if (!appointment) return;
const msg = document.getElementById("appointment-edit-msg")!;
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
msg.textContent = "";
const title = (document.getElementById("appointment-title-edit") as HTMLInputElement).value.trim();
const startRaw = (document.getElementById("appointment-start-edit") as HTMLInputElement).value;
const endRaw = (document.getElementById("appointment-end-edit") as HTMLInputElement).value;
const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value;
const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim();
const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value;
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
const newProjectID = projectSel ? projectSel.value : "";
const currentProjectID = appointment.project_id ?? "";
const payload: Record<string, unknown> = {
title,
start_at: new Date(startRaw).toISOString(),
end_at: endRaw ? new Date(endRaw).toISOString() : null,
appointment_type: type,
location,
description,
};
if (newProjectID !== currentProjectID) {
if (newProjectID === "") {
payload.clear_project = true;
} else {
payload.project_id = newProjectID;
}
}
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const prevProjectID = appointment.project_id ?? "";
appointment = await resp.json();
const nextProjectID = appointment?.project_id ?? "";
if (nextProjectID !== prevProjectID) {
project = null;
if (appointment?.project_id) await loadProject(appointment.project_id);
}
renderHeader();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
async function deleteAppointment() {
if (!appointment) return;
if (!confirm(t("appointments.detail.delete.confirm"))) return;
try {
const resp = await fetch(`/api/appointments/${appointment.id}`, { method: "DELETE" });
if (resp.ok || resp.status === 204) {
window.location.href = "/events?type=appointment";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
}
async function main() {
const id = parseAppointmentID();
const loading = document.getElementById("appointment-loading")!;
const body = document.getElementById("appointment-body")!;
const notFound = document.getElementById("appointment-not-found")!;
if (!id) {
loading.style.display = "none";
notFound.style.display = "block";
return;
}
const ok = await loadAppointment(id);
if (!ok || !appointment) {
loading.style.display = "none";
notFound.style.display = "block";
return;
}
await Promise.all([
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
loadAllProjects(),
]);
loading.style.display = "none";
body.style.display = "";
renderHeader();
populateProjectPicker();
fillEditForm();
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
const notes = document.getElementById("notes-container");
if (notes) {
notes.setAttribute("data-parent-id", id);
void initNotes(notes as HTMLElement, "appointment", id);
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
main();
});

View File

@@ -0,0 +1,117 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
}
let allProjects: Project[] = [];
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function loadProjects() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) allProjects = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjects() {
const sel = document.getElementById("appointment-project") as HTMLSelectElement;
const opts: string[] = [
`<option value="">${esc(t("appointments.field.akte.none"))}</option>`,
];
for (const a of allProjects) {
const indent = projectIndent(a.path);
opts.push(
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = opts.join("");
const params = new URLSearchParams(window.location.search);
const ak = params.get("project_id");
if (ak) sel.value = ak;
}
function preFillStart() {
const start = document.getElementById("appointment-start") as HTMLInputElement;
const now = new Date();
now.setMinutes(now.getMinutes() + (15 - (now.getMinutes() % 15)));
now.setSeconds(0);
now.setMilliseconds(0);
const pad = (n: number) => String(n).padStart(2, "0");
start.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
async function submitForm(ev: Event) {
ev.preventDefault();
const msg = document.getElementById("appointment-new-msg")!;
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
msg.textContent = "";
const title = (document.getElementById("appointment-title") as HTMLInputElement).value.trim();
const startRaw = (document.getElementById("appointment-start") as HTMLInputElement).value;
const endRaw = (document.getElementById("appointment-end") as HTMLInputElement).value;
const type = (document.getElementById("appointment-type") as HTMLSelectElement).value;
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement).value;
const location = (document.getElementById("appointment-location") as HTMLInputElement).value.trim();
const description = (document.getElementById("appointment-description") as HTMLTextAreaElement).value.trim();
if (!title || !startRaw) {
msg.textContent = t("appointments.error.required");
msg.className = "form-msg form-msg-error";
return;
}
const payload: Record<string, unknown> = {
title,
start_at: new Date(startRaw).toISOString(),
};
if (endRaw) payload.end_at = new Date(endRaw).toISOString();
if (type) payload.appointment_type = type;
if (projectID) payload.project_id = projectID;
if (location) payload.location = location;
if (description) payload.description = description;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/appointments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const created = await resp.json();
window.location.href = `/appointments/${created.id}`;
return;
}
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
} catch {
msg.textContent = t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
await loadProjects();
populateProjects();
preFillStart();
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
});

View File

@@ -0,0 +1,127 @@
import { toggleMobileSidebar } from "./sidebar";
import { t } from "./i18n";
const KEYBOARD_THRESHOLD_PX = 100;
const BADGE_REFRESH_MS = 60_000;
export function initBottomNav(): void {
const nav = document.getElementById("bottom-nav");
if (!nav) return;
initMenuSlot();
initQuickAddSheet();
initKeyboardWatcher();
initAgendaBadge();
}
function initMenuSlot(): void {
const btn = document.getElementById("bottom-nav-menu");
btn?.addEventListener("click", (e) => {
e.preventDefault();
toggleMobileSidebar();
});
}
function initQuickAddSheet(): void {
const trigger = document.getElementById("bottom-nav-add") as HTMLButtonElement | null;
const dialog = document.getElementById("quick-add-sheet") as HTMLDialogElement | null;
const cancel = document.getElementById("quick-add-cancel") as HTMLButtonElement | null;
if (!trigger || !dialog) return;
trigger.addEventListener("click", (e) => {
e.preventDefault();
if (typeof dialog.showModal === "function") {
dialog.showModal();
} else {
dialog.setAttribute("open", "");
}
dialog.classList.add("is-open");
});
function close(): void {
dialog!.classList.remove("is-open");
if (typeof dialog!.close === "function") {
dialog!.close();
} else {
dialog!.removeAttribute("open");
}
}
cancel?.addEventListener("click", close);
dialog.addEventListener("click", (e) => {
if (e.target === dialog) close();
});
dialog.addEventListener("close", () => {
dialog.classList.remove("is-open");
});
dialog.querySelectorAll<HTMLAnchorElement>(".quick-add-row").forEach((row) => {
row.addEventListener("click", () => {
// Native <a> navigation handles routing; close sheet first so it
// does not flash on next page paint via bfcache.
close();
});
});
}
function initKeyboardWatcher(): void {
const vv = window.visualViewport;
if (!vv) return;
let baseHeight = window.innerHeight;
window.addEventListener("orientationchange", () => {
setTimeout(() => {
baseHeight = window.innerHeight;
document.body.classList.remove("keyboard-open");
}, 250);
});
const handler = () => {
const delta = baseHeight - vv.height;
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD_PX);
};
vv.addEventListener("resize", handler);
}
function initAgendaBadge(): void {
const badge = document.getElementById("bottom-nav-agenda-badge");
if (!badge) return;
function refresh(): void {
fetch("/api/deadlines/summary", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { overdue?: number; today?: number } | null) => {
if (!data) return;
const overdue = typeof data.overdue === "number" ? data.overdue : 0;
const today = typeof data.today === "number" ? data.today : 0;
const total = overdue + today;
if (total <= 0) {
badge!.style.display = "none";
badge!.classList.remove("bottom-nav-badge-overdue");
badge!.removeAttribute("title");
badge!.removeAttribute("aria-label");
return;
}
badge!.textContent = total > 9 ? "9+" : String(total);
badge!.style.display = "";
badge!.classList.toggle("bottom-nav-badge-overdue", overdue > 0);
// F-38: the badge counts "actionable" items only — overdue + due
// today. The accessible label spells that out so the "2" never
// reads as ambiguous (e.g. "2 things this week").
const label = t("bottomnav.badge.deadlines")
.replace("{overdue}", String(overdue))
.replace("{today}", String(today));
badge!.setAttribute("title", label);
badge!.setAttribute("aria-label", label);
badge!.setAttribute("aria-hidden", "false");
})
.catch(() => {
// Badge is decorative; never break the page.
});
}
refresh();
setInterval(refresh, BADGE_REFRESH_MS);
}

View File

@@ -0,0 +1,15 @@
// Shared localStorage tracker for the "What's New" badge.
//
// sidebar.ts reads the stamp on every page to ask the backend how many
// entries are newer; changelog.ts writes the stamp when the user visits
// /changelog so the badge clears on their next page load.
export const SEEN_KEY = "paliad-changelog-seen";
export function getChangelogSeen(): string {
return localStorage.getItem(SEEN_KEY) ?? "";
}
export function markChangelogSeen(): void {
localStorage.setItem(SEEN_KEY, new Date().toISOString());
}

View File

@@ -0,0 +1,89 @@
import { getLang, initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { markChangelogSeen } from "./changelog-seen";
interface Entry {
date: string;
title_de: string;
title_en: string;
body_de: string;
body_en: string;
tag: "feature" | "content" | "fix";
}
let entries: Entry[] = [];
async function load(): Promise<void> {
const resp = await fetch("/api/changelog");
if (!resp.ok) return;
entries = await resp.json();
render();
}
function formatDate(iso: string): string {
// iso = YYYY-MM-DD. Render locale-aware without Intl allocations per row:
// "20. April 2026" (DE) or "20 April 2026" (EN). Cheap and deterministic.
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
const monthsDE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
const monthsEN = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const monthIdx = parseInt(m, 10) - 1;
const day = parseInt(d, 10);
if (getLang() === "en") {
return `${day} ${monthsEN[monthIdx] ?? m} ${y}`;
}
return `${day}. ${monthsDE[monthIdx] ?? m} ${y}`;
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function render(): void {
const list = document.getElementById("changelog-list") as HTMLOListElement | null;
const empty = document.getElementById("changelog-empty") as HTMLElement | null;
if (!list || !empty) return;
if (entries.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
const lang = getLang();
list.innerHTML = entries.map((e) => {
const title = lang === "en" ? e.title_en : e.title_de;
const body = lang === "en" ? e.body_en : e.body_de;
const tagLabel = tDyn(`changelog.tag.${e.tag}`);
return (
`<li class="changelog-entry">` +
`<div class="changelog-meta">` +
`<time class="changelog-date" datetime="${escapeHTML(e.date)}">${escapeHTML(formatDate(e.date))}</time>` +
`<span class="changelog-tag changelog-tag-${escapeHTML(e.tag)}">${escapeHTML(tagLabel)}</span>` +
`</div>` +
`<h2 class="changelog-title">${escapeHTML(title)}</h2>` +
`<p class="changelog-body">${escapeHTML(body)}</p>` +
`</li>`
);
}).join("");
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// Stamp the visit immediately so the sidebar badge clears even if the
// user navigates away before /api/changelog returns.
markChangelogSeen();
// Also clear any locally-rendered badge in the current DOM so it
// disappears without waiting for a reload.
document.querySelectorAll<HTMLElement>(".sidebar-badge").forEach((el) => {
el.remove();
});
onLangChange(render);
load();
});

View File

@@ -1,272 +0,0 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistItem {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface ChecklistGroup {
titleDE: string;
titleEN: string;
items: ChecklistItem[];
}
interface Checklist {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
deadlineDE?: string;
deadlineEN?: string;
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
}
let checklist: Checklist | null = null;
let state: Record<string, boolean> = {};
function storageKey(slug: string): string {
return `patholo:checklist:${slug}`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function itemKey(groupIdx: number, itemIdx: number): string {
return `g${groupIdx}-i${itemIdx}`;
}
function loadState() {
if (!checklist) return;
try {
const raw = localStorage.getItem(storageKey(checklist.slug));
state = raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
} catch {
state = {};
}
}
function saveState() {
if (!checklist) return;
localStorage.setItem(storageKey(checklist.slug), JSON.stringify(state));
}
function totalItems(): number {
if (!checklist) return 0;
return checklist.groups.reduce((n, g) => n + g.items.length, 0);
}
function doneItems(): number {
return Object.values(state).filter(Boolean).length;
}
async function load() {
const slug = window.location.pathname.split("/").pop() ?? "";
const resp = await fetch(`/api/checklisten/${encodeURIComponent(slug)}`);
if (!resp.ok) {
document.title = "404 — Paliad";
const title = document.getElementById("checklist-title")!;
title.textContent = t("checklisten.notfound");
return;
}
checklist = await resp.json();
loadState();
renderAll();
}
function renderAll() {
if (!checklist) return;
renderHeader();
renderGroups();
updateProgress();
}
function renderHeader() {
if (!checklist) return;
const isEN = getLang() === "en";
const title = isEN ? checklist.titleEN : checklist.titleDE;
const desc = isEN ? checklist.descriptionEN : checklist.descriptionDE;
const court = isEN ? checklist.courtEN : checklist.courtDE;
const deadline = isEN ? checklist.deadlineEN : checklist.deadlineDE;
const reference = isEN ? checklist.referenceEN : checklist.referenceDE;
document.title = `${title} — Paliad`;
document.getElementById("checklist-title")!.textContent = title;
document.getElementById("checklist-subtitle")!.textContent = desc;
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
const deadlineLabel = isEN ? "Deadline" : "Frist";
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
const regimeLabel = isEN ? "Regime" : "Bereich";
const parts: string[] = [];
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(checklist.regime)}">${esc(checklist.regime)}</span></dd></div>`);
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
if (deadline) {
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
}
if (reference) {
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
}
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
}
function renderGroups() {
if (!checklist) return;
const isEN = getLang() === "en";
const container = document.getElementById("checklist-groups")!;
container.innerHTML = checklist.groups.map((g, gi) => {
const groupTitle = isEN ? g.titleEN : g.titleDE;
const items = g.items.map((item, ii) => {
const key = itemKey(gi, ii);
const checked = !!state[key];
const label = isEN ? item.labelEN : item.labelDE;
const note = isEN ? item.noteEN : item.noteDE;
const rule = item.rule;
const noteHTML = note ? `<p class="checklist-item-note">${esc(note)}</p>` : "";
const ruleHTML = rule ? `<span class="checklist-item-rule">${esc(rule)}</span>` : "";
return `<li class="checklist-item${checked ? " checked" : ""}" data-key="${key}">
<label class="checklist-item-label">
<input type="checkbox" class="checklist-checkbox" data-key="${key}"${checked ? " checked" : ""} />
<span class="checklist-item-body">
<span class="checklist-item-row">
<span class="checklist-item-text">${esc(label)}</span>
${ruleHTML}
</span>
${noteHTML}
</span>
</label>
</li>`;
}).join("");
return `<section class="checklist-group">
<h2 class="checklist-group-title">${esc(groupTitle)}</h2>
<ol class="checklist-list">${items}</ol>
</section>`;
}).join("");
container.querySelectorAll<HTMLInputElement>(".checklist-checkbox").forEach((cb) => {
cb.addEventListener("change", () => {
const key = cb.dataset.key!;
state[key] = cb.checked;
saveState();
const li = cb.closest(".checklist-item");
if (li) li.classList.toggle("checked", cb.checked);
updateProgress();
});
});
}
function updateProgress() {
const total = totalItems();
const done = doneItems();
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const fill = document.getElementById("progress-fill");
if (fill) (fill as HTMLElement).style.width = `${pct}%`;
const label = document.getElementById("progress-label");
if (label) {
const doneLabel = getLang() === "en" ? "done" : "erledigt";
label.textContent = `${done} / ${total} ${doneLabel}`;
}
}
function initReset() {
document.getElementById("btn-reset")!.addEventListener("click", () => {
if (!checklist) return;
const ok = confirm(t("checklisten.reset.confirm"));
if (!ok) return;
state = {};
saveState();
renderGroups();
updateProgress();
});
}
function initPrint() {
document.getElementById("btn-print")!.addEventListener("click", () => {
window.print();
});
}
function initFeedback() {
const modal = document.getElementById("feedback-modal")!;
const form = document.getElementById("feedback-form")!;
const msg = document.getElementById("feedback-msg")!;
document.getElementById("btn-feedback")!.addEventListener("click", () => {
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
});
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!checklist) return;
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
checklist: checklist.slug,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("checklisten.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/checklisten/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.feedback.success");
msg.className = "form-msg form-msg-success";
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initReset();
initPrint();
initFeedback();
onLangChange(renderAll);
load();
});

View File

@@ -1,104 +0,0 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
itemCount: number;
}
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
function storageKey(slug: string): string {
return `patholo:checklist:${slug}`;
}
function progressFor(slug: string, total: number): { done: number; total: number } {
try {
const raw = localStorage.getItem(storageKey(slug));
if (!raw) return { done: 0, total };
const obj = JSON.parse(raw) as Record<string, boolean>;
const done = Object.values(obj).filter(Boolean).length;
return { done: Math.min(done, total), total };
} catch {
return { done: 0, total };
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function load() {
const resp = await fetch("/api/checklisten");
if (!resp.ok) return;
allChecklists = await resp.json();
render();
}
function render() {
const grid = document.getElementById("checklist-grid")!;
const isEN = getLang() === "en";
const filtered = activeRegime === "all"
? allChecklists
: allChecklists.filter((c) => c.regime === activeRegime);
if (filtered.length === 0) {
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${t("checklisten.empty")}</p>`;
return;
}
grid.innerHTML = filtered.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const { done, total } = progressFor(c.slug, c.itemCount);
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const doneLabel = isEN ? "done" : "erledigt";
return `<a href="/checklisten/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${total} ${isEN ? "items" : "Punkte"}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
<div class="checklist-card-progress">
<div class="checklist-progress-bar">
<div class="checklist-progress-fill" style="width:${pct}%"></div>
</div>
<span class="checklist-progress-label">${done} / ${total} ${doneLabel}</span>
</div>
</a>`;
}).join("");
}
function initFilters() {
const container = document.getElementById("checklist-filters")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
activeRegime = btn.dataset.regime ?? "all";
render();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
onLangChange(render);
load();
});

View File

@@ -0,0 +1,383 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface ChecklistItem {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface ChecklistGroup {
titleDE: string;
titleEN: string;
items: ChecklistItem[];
}
interface Checklist {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
deadlineDE?: string;
deadlineEN?: string;
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
}
interface ChecklistInstance {
id: string;
template_slug: string;
name: string;
project_id?: string | null;
state: Record<string, boolean>;
created_by: string;
created_at: string;
updated_at: string;
project_reference?: string | null;
project_title?: string | null;
}
interface AkteSummary {
id: string;
reference?: string | null;
title: string;
path: string;
}
let template: Checklist | null = null;
let instances: ChecklistInstance[] = [];
let projects: AkteSummary[] = [];
let totalItems = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function templateSlug(): string {
// /checklisten/{slug}
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1] ?? "";
}
async function loadTemplate() {
const slug = templateSlug();
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
if (!resp.ok) {
document.title = "404 — Paliad";
document.getElementById("checklist-title")!.textContent = t("checklisten.notfound");
return;
}
template = await resp.json();
if (template) {
totalItems = template.groups.reduce((n, g) => n + g.items.length, 0);
}
renderHeader();
}
async function loadInstances() {
const slug = templateSlug();
try {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`);
if (!resp.ok) {
instances = [];
} else {
instances = await resp.json();
}
} catch {
instances = [];
}
renderInstances();
}
async function loadAkten() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) projects = await resp.json();
} catch {
projects = [];
}
renderAkteOptions();
}
function renderHeader() {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const desc = isEN ? template.descriptionEN : template.descriptionDE;
const court = isEN ? template.courtEN : template.courtDE;
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
const reference = isEN ? template.referenceEN : template.referenceDE;
document.title = `${title} — Paliad`;
document.getElementById("checklist-title")!.textContent = title;
document.getElementById("checklist-subtitle")!.textContent = desc;
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
const deadlineLabel = isEN ? "Deadline" : "Deadline";
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
const regimeLabel = isEN ? "Regime" : "Bereich";
const itemsLabel = isEN ? "Items" : "Punkte";
const parts: string[] = [];
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
if (deadline) {
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
}
if (reference) {
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
}
parts.push(`<div class="checklist-meta-item"><dt>${itemsLabel}</dt><dd>${totalItems}</dd></div>`);
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
}
function progress(inst: ChecklistInstance): { done: number; pct: number } {
const done = Object.values(inst.state || {}).filter(Boolean).length;
const pct = totalItems === 0 ? 0 : Math.round((done / totalItems) * 100);
return { done, pct };
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const lang = getLang();
return d.toLocaleDateString(lang === "en" ? "en-GB" : "de-DE", {
year: "numeric", month: "2-digit", day: "2-digit",
});
}
function renderInstances() {
const loading = document.getElementById("instances-loading")!;
const empty = document.getElementById("instances-empty")!;
const wrap = document.getElementById("instances-tablewrap")!;
const body = document.getElementById("instances-body")!;
loading.style.display = "none";
if (instances.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = getLang() === "en";
const deleteLabel = isEN ? "Delete" : "Löschen";
const openLabel = isEN ? "Öffnen" : "Öffnen";
const personalLabel = isEN ? "personal" : "persönlich";
body.innerHTML = instances.map((inst) => {
const { done, pct } = progress(inst);
const akteCell = inst.project_id && inst.project_reference
? `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project-link">${esc(inst.project_reference)}</a>`
: `<span class="entity-muted">${personalLabel}</span>`;
return `<tr data-id="${esc(inst.id)}" class="checklist-instance-row">
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
<td>
<div class="checklist-progress-inline">
<div class="checklist-progress-bar">
<div class="checklist-progress-fill" style="width:${pct}%"></div>
</div>
<span class="checklist-progress-label">${done} / ${totalItems}</span>
</div>
</td>
<td>${akteCell}</td>
<td>${esc(formatDate(inst.created_at))}</td>
<td class="checklist-instance-actions">
<a class="btn-small btn-ghost" href="/checklists/instances/${esc(inst.id)}">${esc(openLabel)}</a>
<button type="button" class="btn-small btn-ghost btn-delete-instance" data-id="${esc(inst.id)}" data-name="${esc(inst.name)}">${esc(deleteLabel)}</button>
</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLButtonElement>(".btn-delete-instance").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const id = btn.dataset.id!;
const name = btn.dataset.name ?? "";
const msg = (t("checklisten.instances.delete.confirm") || "").replace("{name}", name);
if (!confirm(msg)) return;
void deleteInstance(id);
});
});
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button")) return;
window.location.href = `/checklists/instances/${id}`;
});
});
}
function renderAkteOptions() {
const sel = document.getElementById("new-instance-project") as HTMLSelectElement;
if (!sel) return;
const none = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (none) sel.appendChild(none);
projects.forEach((a) => {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = `${projectIndent(a.path)}${a.reference || ""}${a.title}`;
sel.appendChild(opt);
});
}
function initNewInstance() {
const modal = document.getElementById("new-instance-modal")!;
const form = document.getElementById("new-instance-form")! as HTMLFormElement;
const msg = document.getElementById("new-instance-msg")!;
const nameInput = document.getElementById("new-instance-name") as HTMLInputElement;
const akteSel = document.getElementById("new-instance-project") as HTMLSelectElement;
const open = () => {
msg.textContent = "";
msg.className = "form-msg";
nameInput.value = "";
akteSel.value = "";
modal.style.display = "flex";
nameInput.focus();
};
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-new-instance")!.addEventListener("click", open);
document.getElementById("new-instance-close")!.addEventListener("click", close);
document.getElementById("new-instance-cancel")!.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
form.addEventListener("submit", async (e) => {
e.preventDefault();
const name = nameInput.value.trim();
if (!name) {
msg.textContent = t("checklisten.newInstance.error.name");
msg.className = "form-msg form-msg-error";
return;
}
const akteID = akteSel.value || null;
const payload: { name: string; project_id?: string } = { name };
if (akteID) payload.project_id = akteID;
const slug = templateSlug();
const submitBtn = form.querySelector(".btn-primary") as HTMLButtonElement;
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.newInstance.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
const created = await resp.json() as ChecklistInstance;
window.location.href = `/checklists/instances/${encodeURIComponent(created.id)}`;
} catch {
msg.textContent = t("checklisten.newInstance.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
async function deleteInstance(id: string) {
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
alert(t("checklisten.instances.delete.error"));
return;
}
instances = instances.filter((i) => i.id !== id);
renderInstances();
} catch {
alert(t("checklisten.instances.delete.error"));
}
}
function initFeedback() {
const modal = document.getElementById("feedback-modal")!;
const form = document.getElementById("feedback-form")!;
const msg = document.getElementById("feedback-msg")!;
document.getElementById("btn-feedback")!.addEventListener("click", () => {
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
});
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!template) return;
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
checklist: template.slug,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("checklisten.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/checklists/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.feedback.success");
msg.className = "form-msg form-msg-success";
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function rerenderAll() {
renderHeader();
renderInstances();
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initNewInstance();
initFeedback();
onLangChange(rerenderAll);
void loadTemplate();
void loadInstances();
void loadAkten();
});

View File

@@ -0,0 +1,394 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistItem {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface ChecklistGroup {
titleDE: string;
titleEN: string;
items: ChecklistItem[];
}
interface Checklist {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
deadlineDE?: string;
deadlineEN?: string;
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
}
interface Instance {
id: string;
template_slug: string;
name: string;
project_id?: string | null;
state: Record<string, boolean>;
created_by: string;
created_at: string;
updated_at: string;
}
let template: Checklist | null = null;
let instance: Instance | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function itemKey(groupIdx: number, itemIdx: number): string {
return `g${groupIdx}-i${itemIdx}`;
}
function instanceID(): string {
// /checklisten/instances/{id}
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[2] ?? "";
}
function totalItems(): number {
if (!template) return 0;
return template.groups.reduce((n, g) => n + g.items.length, 0);
}
function doneItems(): number {
if (!instance) return 0;
return Object.values(instance.state || {}).filter(Boolean).length;
}
async function loadInstance(): Promise<boolean> {
const id = instanceID();
if (!id) return false;
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`);
if (!resp.ok) return false;
instance = await resp.json();
if (instance && typeof instance.state !== "object") instance.state = {};
return true;
}
async function loadTemplate(slug: string): Promise<boolean> {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
if (!resp.ok) return false;
template = await resp.json();
return true;
}
async function bootstrap() {
const loading = document.getElementById("instance-loading")!;
const notfound = document.getElementById("instance-notfound")!;
const body = document.getElementById("instance-body")!;
const okInst = await loadInstance();
if (!okInst || !instance) {
loading.style.display = "none";
notfound.style.display = "";
document.title = t("checklisten.instance.notfound");
return;
}
const okTpl = await loadTemplate(instance.template_slug);
if (!okTpl || !template) {
loading.style.display = "none";
notfound.style.display = "";
return;
}
loading.style.display = "none";
body.style.display = "";
// Back link goes to the template page.
const back = document.getElementById("instance-back") as HTMLAnchorElement;
back.href = `/checklists/${encodeURIComponent(instance.template_slug)}`;
renderAll();
}
function renderAll() {
if (!template || !instance) return;
renderHeader();
renderGroups();
updateProgress();
}
function renderHeader() {
if (!template || !instance) return;
const isEN = getLang() === "en";
const tplTitle = isEN ? template.titleEN : template.titleDE;
const court = isEN ? template.courtEN : template.courtDE;
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
const reference = isEN ? template.referenceEN : template.referenceDE;
document.title = `${instance.name} — Paliad`;
(document.getElementById("instance-name-display") as HTMLElement).textContent = instance.name;
(document.getElementById("instance-name-edit") as HTMLInputElement).value = instance.name;
(document.getElementById("instance-template-title") as HTMLElement).textContent = tplTitle;
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
const deadlineLabel = isEN ? "Deadline" : "Deadline";
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
const regimeLabel = isEN ? "Regime" : "Bereich";
const parts: string[] = [];
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
if (deadline) {
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
}
if (reference) {
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
}
if (instance.project_id) {
const akteLabel = isEN ? "Project" : "Projekt";
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
}
document.getElementById("instance-meta")!.innerHTML = parts.join("");
}
function renderGroups() {
if (!template || !instance) return;
const isEN = getLang() === "en";
const container = document.getElementById("checklist-groups")!;
const state = instance.state || {};
container.innerHTML = template.groups.map((g, gi) => {
const groupTitle = isEN ? g.titleEN : g.titleDE;
const items = g.items.map((item, ii) => {
const key = itemKey(gi, ii);
const checked = !!state[key];
const label = isEN ? item.labelEN : item.labelDE;
const note = isEN ? item.noteEN : item.noteDE;
const rule = item.rule;
const noteHTML = note ? `<p class="checklist-item-note">${esc(note)}</p>` : "";
const ruleHTML = rule ? `<span class="checklist-item-rule">${esc(rule)}</span>` : "";
return `<li class="checklist-item${checked ? " checked" : ""}" data-key="${key}">
<label class="checklist-item-label">
<input type="checkbox" class="checklist-checkbox" data-key="${key}"${checked ? " checked" : ""} />
<span class="checklist-item-body">
<span class="checklist-item-row">
<span class="checklist-item-text">${esc(label)}</span>
${ruleHTML}
</span>
${noteHTML}
</span>
</label>
</li>`;
}).join("");
return `<section class="checklist-group">
<h2 class="checklist-group-title">${esc(groupTitle)}</h2>
<ol class="checklist-list">${items}</ol>
</section>`;
}).join("");
container.querySelectorAll<HTMLInputElement>(".checklist-checkbox").forEach((cb) => {
cb.addEventListener("change", () => {
if (!instance) return;
const key = cb.dataset.key!;
instance.state[key] = cb.checked;
const li = cb.closest(".checklist-item");
if (li) li.classList.toggle("checked", cb.checked);
updateProgress();
void patchState({ [key]: cb.checked });
});
});
}
function updateProgress() {
const total = totalItems();
const done = doneItems();
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const fill = document.getElementById("progress-fill");
if (fill) (fill as HTMLElement).style.width = `${pct}%`;
const label = document.getElementById("progress-label");
if (label) {
const doneLabel = getLang() === "en" ? "done" : "erledigt";
label.textContent = `${done} / ${total} ${doneLabel}`;
}
}
async function patchState(patch: Record<string, boolean>) {
if (!instance) return;
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: patch }),
});
if (!resp.ok) {
console.warn("patchState failed", resp.status);
// Revert local state on server failure.
for (const k of Object.keys(patch)) instance.state[k] = !patch[k];
renderGroups();
updateProgress();
}
} catch (e) {
console.warn("patchState error", e);
}
}
function initReset() {
const btn = document.getElementById("btn-reset");
if (!btn) return;
btn.addEventListener("click", async () => {
if (!instance) return;
const ok = confirm(t("checklisten.reset.confirm"));
if (!ok) return;
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}/reset`, {
method: "POST",
});
if (!resp.ok) {
alert(t("checklisten.reset.error"));
return;
}
const updated = await resp.json() as Instance;
instance = updated;
if (typeof instance.state !== "object" || instance.state === null) instance.state = {};
renderGroups();
updateProgress();
} catch {
alert(t("checklisten.reset.error"));
}
});
}
function initPrint() {
const btn = document.getElementById("btn-print");
if (!btn) return;
btn.addEventListener("click", () => window.print());
}
function initRename() {
const display = document.getElementById("instance-name-display") as HTMLElement;
const editInput = document.getElementById("instance-name-edit") as HTMLInputElement;
const editBtn = document.getElementById("instance-rename-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("instance-name-save") as HTMLButtonElement;
if (!display || !editInput || !editBtn || !saveBtn) return;
const enterEdit = () => {
if (!instance) return;
editInput.value = instance.name;
display.style.display = "none";
editBtn.style.display = "none";
editInput.style.display = "";
saveBtn.style.display = "";
editInput.focus();
editInput.select();
};
const exitEdit = () => {
display.style.display = "";
editBtn.style.display = "";
editInput.style.display = "none";
saveBtn.style.display = "none";
};
editBtn.addEventListener("click", enterEdit);
saveBtn.addEventListener("click", async () => {
if (!instance) return;
const newName = editInput.value.trim();
if (!newName || newName === instance.name) {
exitEdit();
return;
}
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName }),
});
if (!resp.ok) {
alert(t("checklisten.instance.rename.error"));
return;
}
instance = await resp.json();
renderHeader();
exitEdit();
} catch {
alert(t("checklisten.instance.rename.error"));
}
});
editInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
if (e.key === "Escape") { e.preventDefault(); exitEdit(); }
});
}
function initFeedback() {
const modal = document.getElementById("feedback-modal")!;
const form = document.getElementById("feedback-form")!;
const msg = document.getElementById("feedback-msg")!;
document.getElementById("btn-feedback")!.addEventListener("click", () => {
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
});
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!template) return;
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
checklist: template.slug,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("checklisten.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/checklists/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.feedback.success");
msg.className = "form-msg form-msg-success";
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initReset();
initPrint();
initRename();
initFeedback();
onLangChange(renderAll);
void bootstrap();
});

View File

@@ -0,0 +1,244 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
itemCount: number;
}
interface ChecklistInstance {
id: string;
template_slug: string;
name: string;
project_id?: string | null;
state: Record<string, boolean>;
created_by: string;
created_at: string;
updated_at: string;
project_reference?: string | null;
project_title?: string | null;
}
type TabId = "templates" | "instances";
const VALID_TABS: TabId[] = ["templates", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let activeTab: TabId = "templates";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function parseTab(): TabId {
const params = new URLSearchParams(window.location.search);
const candidate = params.get("tab");
if (candidate && (VALID_TABS as string[]).includes(candidate)) {
return candidate as TabId;
}
return "templates";
}
async function loadTemplates() {
const resp = await fetch("/api/checklists");
if (!resp.ok) return;
allChecklists = await resp.json();
templatesBySlug = {};
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
renderTemplates();
}
function renderTemplates() {
const grid = document.getElementById("checklist-grid")!;
const isEN = getLang() === "en";
const filtered = activeRegime === "all"
? allChecklists
: allChecklists.filter((c) => c.regime === activeRegime);
if (filtered.length === 0) {
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${esc(t("checklisten.empty"))}</p>`;
return;
}
grid.innerHTML = filtered.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
</a>`;
}).join("");
}
function initFilters() {
const container = document.getElementById("checklist-filters")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
activeRegime = btn.dataset.regime ?? "all";
renderTemplates();
});
}
async function loadInstances() {
if (instancesLoaded) return;
instancesLoaded = true;
// Templates may not be loaded yet if the user lands directly on
// ?tab=instances — fetch in parallel so the join below has names.
const [instResp, tplResp] = await Promise.all([
fetch("/api/checklist-instances"),
allChecklists.length === 0 ? fetch("/api/checklists") : Promise.resolve(null),
]);
if (instResp.ok) {
allInstances = (await instResp.json()) ?? [];
} else {
allInstances = [];
}
if (tplResp && tplResp.ok) {
allChecklists = (await tplResp.json()) ?? [];
templatesBySlug = {};
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
}
renderInstances();
}
function renderInstances() {
const loading = document.getElementById("checklists-instances-loading")!;
const empty = document.getElementById("checklists-instances-empty")!;
const wrap = document.getElementById("checklists-instances-tablewrap")!;
const body = document.getElementById("checklists-instances-body")!;
loading.style.display = "none";
if (allInstances.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = getLang() === "en";
const fmtDate = (iso: string) => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
year: "numeric", month: "2-digit", day: "2-digit",
});
};
body.innerHTML = allInstances.map((inst) => {
const tpl = templatesBySlug[inst.template_slug];
const tplName = tpl
? (isEN ? tpl.titleEN : tpl.titleDE)
: inst.template_slug;
const total = tpl ? tpl.itemCount : 0;
const done = Object.values(inst.state || {}).filter(Boolean).length;
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
let projectCell: string;
if (inst.project_id && inst.project_title) {
const ref = inst.project_reference ? esc(inst.project_reference) : "";
const title = esc(inst.project_title);
const refPart = ref ? `<span class="entity-ref">${ref}</span> ` : "";
projectCell = `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project">${refPart}${title}</a>`;
} else {
projectCell = `<span class="form-hint" data-i18n="checklisten.instances.all.personal">Pers&ouml;nlich</span>`;
}
return `<tr class="checklist-instance-row" data-id="${esc(inst.id)}">
<td>${esc(tplName)}</td>
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
<td>${projectCell}</td>
<td>
<div class="checklist-progress-inline">
<div class="checklist-progress-bar">
<div class="checklist-progress-fill" style="width:${pct}%"></div>
</div>
<span class="checklist-progress-label">${done} / ${total}</span>
</div>
</td>
<td>${esc(fmtDate(inst.created_at))}</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
// Let inner links (project, instance name) handle their own navigation.
if ((e.target as HTMLElement).closest("a")) return;
window.location.href = `/checklists/instances/${id}`;
});
});
}
function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
activeTab = tab;
document.querySelectorAll<HTMLElement>("#checklists-tabs .entity-tab").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tab);
});
document.querySelectorAll<HTMLElement>(".entity-tab-panel").forEach((el) => {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
}
if (tab === "instances") {
void loadInstances();
}
}
function initTabs() {
document.querySelectorAll<HTMLAnchorElement>("#checklists-tabs .entity-tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
// Let middle-click / cmd-click open in new tab via the real href.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
const id = tab.dataset.tab as TabId;
if (VALID_TABS.includes(id)) showTab(id);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
});
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});

View File

@@ -109,7 +109,7 @@ function countryName(code: string): string {
}
async function loadCourts() {
const resp = await fetch("/api/gerichte");
const resp = await fetch("/api/courts");
if (!resp.ok) return;
const data: ApiResponse = await resp.json();
allCourts = data.courts;
@@ -291,6 +291,13 @@ function initSearch() {
searchQuery = input.value;
render();
});
// Honor `?q=` from the global search-palette deep links. render() runs
// after loadCourts() resolves and reads the module-level searchQuery.
const q = new URLSearchParams(location.search).get("q");
if (q) {
input.value = q;
searchQuery = q;
}
}
// --- Feedback modal ---
@@ -335,7 +342,7 @@ async function submitFeedback(e: Event) {
submitBtn.disabled = true;
try {
const resp = await fetch("/api/gerichte/feedback", {
const resp = await fetch("/api/courts/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),

View File

@@ -1,4 +1,4 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
interface DashboardUser {
@@ -11,9 +11,10 @@ interface DashboardUser {
interface DeadlineSummary {
overdue: number;
today: number;
this_week: number;
upcoming: number;
completed_this_week: number;
next_week: number;
later: number;
}
interface MatterSummary {
@@ -26,9 +27,9 @@ interface UpcomingDeadline {
id: string;
title: string;
due_date: string;
akte_id: string;
akte_title: string;
akte_ref: string;
project_id: string;
project_title: string;
project_reference: string;
urgency: "overdue" | "today" | "urgent" | "soon";
}
@@ -38,21 +39,22 @@ interface UpcomingAppointment {
start_at: string;
end_at: string | null;
type: string | null;
akte_id: string | null;
akte_title: string | null;
akte_ref: string | null;
project_id: string | null;
project_title: string | null;
project_reference: string | null;
}
interface ActivityEntry {
timestamp: string;
actor_email: string | null;
actor_name: string | null;
akte_id: string;
akte_title: string;
akte_ref: string;
project_id: string;
project_title: string;
project_reference: string;
action: string | null;
details: string;
description: string | null;
metadata: Record<string, unknown> | null;
}
interface DashboardData {
@@ -110,9 +112,9 @@ function renderGreeting(user: DashboardUser | null): void {
if (user) {
nameEl.textContent = user.display_name ? `, ${user.display_name}` : "";
const officeLabel = t(`office.${user.office}`) || user.office;
const officeLabel = tDyn(`office.${user.office}`) || user.office;
chip.textContent = officeLabel;
chip.className = `dashboard-office-chip akten-office-chip akten-office-${user.office}`;
chip.className = `dashboard-office-chip office-chip office-${user.office}`;
chip.style.display = "inline-block";
} else {
nameEl.textContent = "";
@@ -128,14 +130,18 @@ function renderGreeting(user: DashboardUser | null): void {
function renderSummary(s: DeadlineSummary): void {
setCount("dashboard-count-overdue", s.overdue);
setCount("dashboard-count-today", s.today);
setCount("dashboard-count-this-week", s.this_week);
setCount("dashboard-count-upcoming", s.upcoming);
setCount("dashboard-count-completed", s.completed_this_week);
setCount("dashboard-count-next-week", s.next_week);
setCount("dashboard-count-later", s.later);
// Tone down the red card when there's nothing overdue — reduces alarm
// fatigue when the user has a clean slate.
// Überfällig is an emergency category — hide the card entirely on a clean
// slate (the .dashboard-summary-grid uses auto-fit so the row re-flows to
// 4 cards) and trip the alarm styling when there's anything overdue. See
// t-paliad-105 / t-paliad-106 / t-paliad-110.
const overdueCard = document.getElementById("dashboard-card-overdue")!;
overdueCard.classList.toggle("dashboard-card-quiet", s.overdue === 0);
overdueCard.classList.toggle("dashboard-card-overdue-hidden", s.overdue === 0);
overdueCard.classList.toggle("dashboard-card-alarm", s.overdue > 0);
}
function renderMatters(s: MatterSummary): void {
@@ -158,12 +164,12 @@ function renderDeadlines(items: UpcomingDeadline[]): void {
empty.style.display = "none";
list.innerHTML = items.map((d) => {
const urgencyClass = `dashboard-urgency-${d.urgency}`;
const urgencyLabel = t(`dashboard.urgency.${d.urgency}`);
const urgencyLabel = tDyn(`dashboard.urgency.${d.urgency}`);
return `<li class="dashboard-list-item">
<a href="/akten/${esc(d.akte_id)}/fristen" class="dashboard-list-link">
<a href="/projects/${esc(d.project_id)}/deadlines" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(d.title)}</span>
<span class="dashboard-list-ref">${esc(d.akte_ref)} &middot; ${esc(d.akte_title)}</span>
<span class="dashboard-list-ref" title="${escAttr(`${d.project_reference} · ${d.project_title}`)}">${esc(d.project_reference)} &middot; ${esc(d.project_title)}</span>
</div>
<div class="dashboard-list-meta">
<span class="dashboard-urgency-badge ${urgencyClass}" title="${escAttr(urgencyLabel)}">${esc(formatRelative(d.due_date))}</span>
@@ -189,16 +195,16 @@ function renderAppointments(items: UpcomingAppointment[]): void {
const dot = a.type
? `<span class="dashboard-termin-dot dashboard-termin-${esc(a.type)}" aria-hidden="true"></span>`
: `<span class="dashboard-termin-dot" aria-hidden="true"></span>`;
const href = a.akte_id ? `/akten/${esc(a.akte_id)}/termine` : "#";
const tag = a.akte_id ? "a" : "div";
const akteLine = a.akte_ref && a.akte_title
? `<span class="dashboard-list-ref">${esc(a.akte_ref)} &middot; ${esc(a.akte_title)}</span>`
const href = a.project_id ? `/projects/${esc(a.project_id)}/appointments` : "#";
const tag = a.project_id ? "a" : "div";
const projectLine = a.project_reference && a.project_title
? `<span class="dashboard-list-ref" title="${escAttr(`${a.project_reference} · ${a.project_title}`)}">${esc(a.project_reference)} &middot; ${esc(a.project_title)}</span>`
: "";
return `<li class="dashboard-list-item">
<${tag} href="${href}" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${dot}${esc(a.title)}</span>
${akteLine}
${projectLine}
</div>
<div class="dashboard-list-meta">
<span class="dashboard-appt-time">${esc(formatDateTime(a.start_at))}</span>
@@ -222,24 +228,97 @@ function renderActivity(items: ActivityEntry[]): void {
empty.style.display = "none";
list.innerHTML = items.map((e) => {
const actor = e.actor_name || e.actor_email || t("dashboard.activity.system");
const actionLabel = e.action
? (t(`dashboard.action.${e.action}`) || e.action)
: t("dashboard.activity.event");
const shortKey = e.action ? `dashboard.action.short.${e.action}` : "";
const translated = shortKey ? tDyn(shortKey) : "";
const hasI18n = translated !== "" && translated !== shortKey;
const shortAction = hasI18n
? translated
: (e.action || t("dashboard.activity.event"));
// Localize the muted detail line so it speaks DE in DE and EN in EN —
// historical rows carry English nouns inside DE narrative ("Deadline „ok"
// geändert", "Note zu deadline hinzugefügt"); translateEvent parses both
// legacy and new (value-only) shapes.
const stored = e.description ?? (hasI18n ? "" : e.details);
const { description: detail } = translateEvent(e.action, "", stored);
// For checklist_* events with a known instance_id, deep-link the project
// ref straight to the instance — saves a click vs. landing on the project.
// Falls back to /projects/{id} for any other event or when the instance
// ID is missing (older rows pre-metadata, or checklist_deleted).
const ref = activityHref(e);
return `<li class="dashboard-activity-item">
<span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
<span class="dashboard-activity-body">
<span class="dashboard-activity-actor">${esc(actor)}</span>
<span class="dashboard-activity-action">${esc(actionLabel)}</span>
<a href="/akten/${esc(e.akte_id)}" class="dashboard-activity-akte">${esc(e.akte_ref)}</a>
<span class="dashboard-activity-details">${esc(e.details)}</span>
</span>
<div class="dashboard-activity-body">
<p class="dashboard-activity-summary"><strong>${esc(actor)}</strong> ${esc(shortAction)}</p>
<p class="dashboard-activity-detail">
<a href="${escAttr(ref)}" class="dashboard-activity-project">${esc(e.project_reference)}</a>${detail ? ` <span>${esc(detail)}</span>` : ""}
</p>
</div>
</li>`;
}).join("");
// Row-level click handler: clicking anywhere on the row navigates to the
// same target as the inner .dashboard-activity-project link. Inner <a>/
// <button> clicks bubble through unchanged (Cmd-click → new tab still
// works) and text remains selectable — same pattern as .entity-table rows
// (t-098/099) and the project Verlauf cards (t-paliad-103).
list.querySelectorAll<HTMLLIElement>(".dashboard-activity-item").forEach((row) => {
const link = row.querySelector<HTMLAnchorElement>(".dashboard-activity-project");
if (!link) return;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button")) return;
window.location.href = link.href;
});
});
}
// Resolve an activity row to the most-specific deep-link target. Mirrors the
// rules in projects-detail.ts:eventDetailHref so the activity feed and the
// project Verlauf agree on where each event family points. Falls back to the
// owning project page when no metadata is wired (older rows or _deleted/
// deadlines_imported events). Wired families: checklist_*, deadline_*,
// appointment_*, note_created — see t-paliad-097/102.
function activityHref(e: ActivityEntry): string {
const action = e.action ?? "";
const meta = (e.metadata ?? null) as Record<string, unknown> | null;
if (meta) {
if (action.startsWith("checklist_") && action !== "checklist_deleted") {
const id = meta["checklist_instance_id"];
if (typeof id === "string" && id) return `/checklists/instances/${id}`;
}
if (
action.startsWith("deadline_") &&
action !== "deadline_deleted" &&
action !== "deadlines_imported"
) {
const id = meta["deadline_id"];
if (typeof id === "string" && id) return `/deadlines/${id}`;
}
if (action.startsWith("appointment_") && action !== "appointment_deleted") {
const id = meta["appointment_id"];
if (typeof id === "string" && id) return `/appointments/${id}`;
}
if (action === "note_created") {
const apptID = meta["appointment_id"];
if (typeof apptID === "string" && apptID) return `/appointments/${apptID}`;
const deadlineID = meta["deadline_id"];
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${deadlineID}`;
}
}
return `/projects/${e.project_id}`;
}
function toggleOnboardingHint(user: DashboardUser | null): void {
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
// already redirects users without a paliad.users row to /onboarding before
// the dashboard HTML is served. If the gate ever misses (e.g. DB lookup
// errored and we fell through), push the user to /onboarding here so they
// don't get stuck on a blank dashboard.
if (!user) {
window.location.href = "/onboarding";
return;
}
const onboarding = document.getElementById("dashboard-onboarding")!;
onboarding.style.display = user ? "none" : "block";
onboarding.style.display = "none";
}
function setCount(id: string, n: number): void {

View File

@@ -1,17 +1,17 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Frist {
interface Deadline {
id: string;
akte_id: string;
project_id: string;
title: string;
due_date: string;
status: string;
akte_aktenzeichen: string;
akte_title: string;
project_reference: string;
project_title: string;
}
let allFristen: Frist[] = [];
let allDeadlines: Deadline[] = [];
let viewYear = 0;
let viewMonth = 0; // 0-11
@@ -22,32 +22,31 @@ function esc(s: string): string {
}
function fmtMonth(year: number, month: number): string {
return `${t(`cal.month.${month}`)} ${year}`;
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due + "T00:00:00");
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadFristen() {
async function loadDeadlines() {
try {
// Load all (open + completed) — calendar shows everything for context.
const resp = await fetch("/api/fristen?status=all");
if (resp.ok) allFristen = await resp.json();
const resp = await fetch("/api/deadlines?status=all");
if (resp.ok) allDeadlines = await resp.json();
} catch {
/* non-fatal */
}
}
function fristenForDate(iso: string): Frist[] {
return allFristen.filter((f) => f.due_date.slice(0, 10) === iso);
function deadlinesForDate(iso: string): Deadline[] {
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
}
function isoDate(year: number, month: number, day: number): string {
@@ -59,10 +58,9 @@ function isoDate(year: number, month: number, day: number): string {
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
// First weekday of month (Mon=0..Sun=6)
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay(); // Sun=0..Sat=6
const offset = (jsWeekday + 6) % 7; // Mon=0..Sun=6
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
@@ -73,34 +71,43 @@ function render() {
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const fristen = fristenForDate(iso);
const items = deadlinesForDate(iso);
const isToday = iso === todayISO;
const dots = fristen
const dots = items
.slice(0, 4)
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
.join("");
const more = fristen.length > 4 ? `<span class="frist-cal-more">+${fristen.length - 4}</span>` : "";
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${fristen.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("frist-cal-grid")!;
const grid = document.getElementById("deadline-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allDeadlines.some((f) => {
const iso = f.due_date.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("deadline-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const fristen = fristenForDate(iso);
if (fristen.length === 0) return;
const items = deadlinesForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
@@ -113,13 +120,13 @@ function openPopup(iso: string) {
day: "numeric",
});
list.innerHTML = fristen
list.innerHTML = items
.map((f) => {
const cls = urgencyClass(f.due_date, f.status);
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="/fristen/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
<a href="/akten/${esc(f.akte_id)}" class="frist-cal-popup-akte">${esc(f.akte_aktenzeichen)}</a>
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
</li>`;
})
.join("");
@@ -169,6 +176,6 @@ document.addEventListener("DOMContentLoaded", async () => {
initNav();
initPopup();
onLangChange(render);
await loadFristen();
await loadDeadlines();
render();
});

View File

@@ -0,0 +1,502 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import {
attachEventTypePicker,
fetchEventTypes,
eventTypeLabel,
type EventType,
type PickerHandle,
} from "./event-types";
interface Deadline {
id: string;
project_id: string;
title: string;
description?: string;
due_date: string;
status: string;
source: string;
rule_id?: string;
rule_code?: string;
notes?: string;
created_at: string;
completed_at?: string;
event_type_ids?: string[];
}
let eventTypePicker: PickerHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
interface Project {
id: string;
reference?: string | null;
title: string;
path?: string;
}
interface DeadlineRule {
id: string;
code?: string;
name: string;
rule_code?: string;
}
interface Me {
id: string;
job_title: string | null;
global_role: string;
}
let deadline: Deadline | null = null;
let project: Project | null = null;
let rule: DeadlineRule | null = null;
let me: Me | null = null;
let allProjects: Project[] = [];
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "deadlines" || !parts[1]) return null;
return parts[1];
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso + (iso.length === 10 ? "T00:00:00" : ""));
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadDeadline(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/deadlines/${id}`);
if (!resp.ok) return false;
deadline = await resp.json();
return true;
} catch {
return false;
}
}
async function loadProject(projectID: string) {
try {
const resp = await fetch(`/api/projects/${projectID}`);
if (resp.ok) project = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadAllProjects() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) allProjects = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjectPicker() {
const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
if (!sel || !deadline) return;
const opts: string[] = [];
for (const p of allProjects) {
const indent = projectIndent(p.path);
const ref = p.reference || "";
opts.push(
`<option value="${esc(p.id)}">${indent}${esc(ref)}${esc(p.title)}</option>`,
);
}
sel.innerHTML = opts.join("");
sel.value = deadline.project_id;
}
async function loadRule(ruleID: string) {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
} catch {
/* non-fatal */
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
function render() {
if (!deadline) return;
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
(document.getElementById("deadline-title-edit") as HTMLInputElement).value = deadline.title;
const dueChip = document.getElementById("deadline-due-chip")!;
dueChip.className = `frist-due-chip ${urgencyClass(deadline.due_date, deadline.status)}`;
dueChip.textContent = fmtDate(deadline.due_date);
(document.getElementById("deadline-due-display") as HTMLElement).textContent = fmtDate(deadline.due_date);
(document.getElementById("deadline-due-edit") as HTMLInputElement).value = deadline.due_date.slice(0, 10);
const statusChip = document.getElementById("deadline-status-chip")!;
statusChip.className = `entity-status-chip entity-status-${deadline.status}`;
statusChip.textContent = tDyn(`deadlines.status.${deadline.status}`) || deadline.status;
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
if (project) {
projectLink.href = `/projects/${project.id}`;
projectLink.textContent = `${project.reference || ""}${project.title}`;
} else {
projectLink.href = `/projects/${deadline.project_id}`;
projectLink.textContent = "—";
}
const ruleEl = document.getElementById("deadline-rule-display")!;
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code}${rule.name}` : rule.name;
} else if (deadline.rule_code) {
// Fristenrechner-saved deadlines carry rule_code directly without
// a rule_id (no rule UUID round-trips through the public API).
ruleEl.textContent = deadline.rule_code;
} else {
ruleEl.textContent = "—";
}
(document.getElementById("deadline-source-display") as HTMLElement).textContent =
tDyn(`deadlines.source.${deadline.source}`) || deadline.source;
(document.getElementById("deadline-notes-display") as HTMLElement).textContent = deadline.notes || "—";
(document.getElementById("deadline-notes-edit") as HTMLTextAreaElement).value = deadline.notes || "";
// Event-Type display & picker (display always, picker only in edit mode).
const etDisplay = document.getElementById("deadline-event-types-display");
if (etDisplay) {
const ids = deadline.event_type_ids ?? [];
if (ids.length === 0) {
etDisplay.innerHTML = "&mdash;";
} else {
etDisplay.innerHTML = ids
.map((id) => {
const et = eventTypeByID.get(id);
if (!et) return "";
return `<span class="entity-event-type-pill">${esc(eventTypeLabel(et))}</span>`;
})
.filter(Boolean)
.join(" ");
if (etDisplay.innerHTML === "") etDisplay.innerHTML = "&mdash;";
}
}
if (eventTypePicker) {
eventTypePicker.setIDs(deadline.event_type_ids ?? []);
}
(document.getElementById("deadline-created-display") as HTMLElement).textContent = fmtDateTime(deadline.created_at);
const completedLabel = document.getElementById("deadline-completed-row-label")!;
const completedDD = document.getElementById("deadline-completed-display")!;
if (deadline.completed_at) {
completedLabel.style.display = "";
completedDD.style.display = "";
completedDD.textContent = fmtDateTime(deadline.completed_at);
} else {
completedLabel.style.display = "none";
completedDD.style.display = "none";
}
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
if (deadline.status === "completed") {
completeBtn.style.display = "none";
// Reopen is admin-gated server-side; the button is shown for global
// admins/partners here as a client-side hint. Project leads who lack a
// global admin/partner role won't see the inline button — they get a 403
// only if they try, but the button itself stays hidden. They can still
// PATCH the endpoint directly.
if (me && (me.global_role === "global_admin")) {
reopenBtn.style.display = "";
reopenBtn.disabled = false;
} else {
reopenBtn.style.display = "none";
}
} else {
completeBtn.style.display = "";
completeBtn.disabled = false;
completeBtn.textContent = t("deadlines.detail.complete");
reopenBtn.style.display = "none";
}
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
if (me && (me.global_role === "global_admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
}
function initEdit() {
const titleDisplay = document.getElementById("deadline-title-display")!;
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
const dueDisplay = document.getElementById("deadline-due-display")!;
const dueEdit = document.getElementById("deadline-due-edit") as HTMLInputElement;
const notesDisplay = document.getElementById("deadline-notes-display")!;
const notesEdit = document.getElementById("deadline-notes-edit") as HTMLTextAreaElement;
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement;
const etDisplay = document.getElementById("deadline-event-types-display");
const etEdit = document.getElementById("deadline-event-types-edit");
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
titleEdit.style.display = "";
dueDisplay.style.display = "none";
dueEdit.style.display = "";
notesDisplay.style.display = "none";
notesEdit.style.display = "";
if (etDisplay) etDisplay.style.display = "none";
if (etEdit) etEdit.style.display = "";
if (projectEdit && deadline) {
projectLink.style.display = "none";
projectEdit.style.display = "";
projectEdit.value = deadline.project_id;
}
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
titleEdit.select();
}
function exitEdit() {
titleDisplay.style.display = "";
titleEdit.style.display = "none";
dueDisplay.style.display = "";
dueEdit.style.display = "none";
notesDisplay.style.display = "";
notesEdit.style.display = "none";
if (etDisplay) etDisplay.style.display = "";
if (etEdit) etEdit.style.display = "none";
if (projectEdit) {
projectEdit.style.display = "none";
projectLink.style.display = "";
}
saveBtn.style.display = "none";
editBtn.style.display = "";
}
editBtn.addEventListener("click", enterEdit);
saveBtn.addEventListener("click", async () => {
if (!deadline) return;
const newTitle = titleEdit.value.trim();
const newDue = dueEdit.value;
const newNotes = notesEdit.value;
if (!newTitle || !newDue) return;
saveBtn.disabled = true;
try {
const payload: Record<string, unknown> = {
title: newTitle,
due_date: newDue,
notes: newNotes,
};
if (eventTypePicker) {
payload.event_type_ids = eventTypePicker.getIDs();
}
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const prevProjectID = deadline.project_id;
deadline = await resp.json();
if (deadline && deadline.project_id !== prevProjectID) {
await loadProject(deadline.project_id);
}
render();
}
} finally {
saveBtn.disabled = false;
exitEdit();
}
});
}
function initComplete() {
const btn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
btn.addEventListener("click", async () => {
if (!deadline || deadline.status === "completed") return;
btn.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
if (resp.ok) {
deadline = await resp.json();
render();
} else {
btn.disabled = false;
}
} catch {
btn.disabled = false;
}
});
}
function initReopen() {
const btn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
btn.addEventListener("click", async () => {
if (!deadline || deadline.status !== "completed") return;
btn.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${deadline.id}/reopen`, { method: "PATCH" });
if (resp.ok) {
deadline = await resp.json();
render();
} else {
btn.disabled = false;
}
} catch {
btn.disabled = false;
}
});
}
function initDelete() {
const btn = document.getElementById("deadline-delete-btn")!;
const modal = document.getElementById("deadline-delete-modal")!;
const close = document.getElementById("deadline-delete-modal-close")!;
const cancel = document.getElementById("deadline-delete-modal-cancel")!;
const confirmBtn = document.getElementById("deadline-delete-modal-confirm") as HTMLButtonElement;
btn.addEventListener("click", () => {
modal.style.display = "flex";
});
const closeModal = () => {
modal.style.display = "none";
};
close.addEventListener("click", closeModal);
cancel.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeModal();
});
confirmBtn.addEventListener("click", async () => {
if (!deadline) return;
confirmBtn.disabled = true;
const resp = await fetch(`/api/deadlines/${deadline.id}`, { method: "DELETE" });
if (resp.ok) {
const target = project ? `/projects/${project.id}/deadlines` : "/events?type=deadline";
window.location.href = target;
} else {
confirmBtn.disabled = false;
closeModal();
}
});
}
async function main() {
const id = parseDeadlineID();
const loading = document.getElementById("deadline-loading")!;
const notfound = document.getElementById("deadline-notfound")!;
const body = document.getElementById("deadline-body")!;
if (!id) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await loadMe();
const ok = await loadDeadline(id);
if (!ok || !deadline) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
// chips off the cached map, and the display element re-renders on the
// next render() call after data lands).
try {
const types = await fetchEventTypes();
eventTypeByID = new Map(types.map((et) => [et.id, et]));
} catch {
/* non-fatal */
}
loading.style.display = "none";
body.style.display = "";
// Mount the picker (hidden until enterEdit()).
const pickerHost = document.getElementById("deadline-event-types-edit");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
initialIDs: deadline.event_type_ids ?? [],
currentUserAdmin: me?.global_role === "global_admin",
});
}
populateProjectPicker();
render();
initEdit();
initComplete();
initReopen();
initDelete();
const notes = document.getElementById("notes-container");
if (notes) {
notes.setAttribute("data-parent-id", id);
void initNotes(notes as HTMLElement, "deadline", id);
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(render);
main();
});

View File

@@ -0,0 +1,190 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypePicker, type PickerHandle } from "./event-types";
import { projectIndent } from "./project-indent";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
}
interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en: string;
rule_code?: string;
}
let preselectedProjectID = "";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function showError(msg: string) {
const el = document.getElementById("deadline-new-msg")!;
el.textContent = msg;
el.className = "form-msg form-msg-error";
}
async function loadProjects() {
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
const hint = document.getElementById("deadline-project-empty-hint")!;
try {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const projects: Project[] = await resp.json();
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
return;
}
const options: string[] = [
`<option value="" disabled${preselectedProjectID ? "" : " selected"} data-i18n="deadlines.field.akte.choose">${esc(t("deadlines.field.akte.choose"))}</option>`,
];
for (const p of projects) {
const isSelected = preselectedProjectID === p.id ? " selected" : "";
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");
} catch {
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
} catch {
/* non-fatal — rule select stays at "no rule" */
}
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
}
async function submitForm(e: Event) {
e.preventDefault();
const submitBtn = document.querySelector<HTMLButtonElement>("#deadline-new-form button[type=submit]")!;
const msg = document.getElementById("deadline-new-msg")!;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
if (!projectID || !title || !due) {
showError(t("deadlines.error.required"));
return;
}
msg.textContent = "";
msg.className = "form-msg";
submitBtn.disabled = true;
const payload: Record<string, unknown> = {
title,
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
if (notes) payload.notes = notes;
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
showError(data.error || t("deadlines.error.generic"));
submitBtn.disabled = false;
return;
}
const created = await resp.json();
if (preselectedProjectID) {
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
} else {
window.location.href = `/deadlines/${created.id}`;
}
} catch {
showError(t("deadlines.error.generic"));
submitBtn.disabled = false;
}
}
function detectPreselect() {
// Path /projects/{id}/deadlines/new pre-selects that project.
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] === "projects" && parts[1] && parts[2] === "deadlines" && parts[3] === "new") {
preselectedProjectID = parts[1];
}
// Or ?project_id= query string
const qp = new URLSearchParams(window.location.search);
const fromQuery = qp.get("project_id");
if (fromQuery) preselectedProjectID = fromQuery;
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return;
const me = await resp.json();
currentUserAdmin = me?.global_role === "global_admin";
} catch {
/* non-fatal */
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
detectPreselect();
initBackLinks();
document.getElementById("deadline-new-form")!.addEventListener("submit", submitForm);
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadProjects(), loadRules(), loadMe()]);
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
});
}
});

View File

@@ -0,0 +1,831 @@
// t-paliad-088: Event Types — shared client module.
//
// Four surfaces share this module:
// 1. EventTypePicker — multi-tag chip cluster on /deadlines/new and the
// /deadlines/{id} edit form. Lets the user pick 0..N event types via
// search-as-you-type, "Alle anzeigen" (browse-all), or "+ Neuer Typ".
// 2. EventTypeMultiSelectFilter — listbox-panel filter on /deadlines and
// /agenda. Multi-select with search + "Alle" + "Ohne Typ" specials.
// 3. AddEventTypeModal — opened from inside the picker via a
// "+ Neuen Typ hinzufügen…" affordance. Any authenticated user may
// publish firm-wide types (per m's Q6); admins moderate via archive.
// 4. BrowseAllEventTypesModal (t-paliad-107) — opened from the picker via
// "Alle anzeigen". Lists every type grouped by category with sticky
// search and multi-select checkboxes pre-populated from the picker.
//
// Backend contract: see internal/handlers/event_types.go and
// internal/services/event_type_service.go.
import { t, tDyn, getLang, onLangChange } from "./i18n";
export interface EventType {
id: string;
slug: string;
label_de: string;
label_en: string;
category: string;
jurisdiction?: string | null;
description: string;
trigger_event_id?: number | null;
created_by?: string | null;
is_firm_wide: boolean;
archived_at?: string | null;
created_at: string;
updated_at: string;
}
export const CATEGORY_ORDER = [
"submission",
"decision",
"order",
"service",
"fee",
"hearing",
"other",
] as const;
export type Category = (typeof CATEGORY_ORDER)[number];
export function eventTypeLabel(et: EventType): string {
const lang = getLang();
const primary = lang === "en" ? et.label_en : et.label_de;
return primary?.trim() || et.label_en || et.label_de || et.slug;
}
export function categoryLabel(category: string): string {
return tDyn(`event_types.cat.${category}`) || category;
}
let cache: EventType[] | null = null;
let cachePromise: Promise<EventType[]> | null = null;
export async function fetchEventTypes(force = false): Promise<EventType[]> {
if (!force && cache) return cache;
if (!force && cachePromise) return cachePromise;
cachePromise = (async () => {
const resp = await fetch("/api/event-types");
if (!resp.ok) {
cachePromise = null;
return [];
}
cache = (await resp.json()) as EventType[];
cachePromise = null;
return cache;
})();
return cachePromise;
}
export function invalidateEventTypeCache() {
cache = null;
cachePromise = null;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function groupByCategory(types: EventType[]): Map<string, EventType[]> {
const m = new Map<string, EventType[]>();
for (const cat of CATEGORY_ORDER) m.set(cat, []);
for (const et of types) {
const list = m.get(et.category) ?? [];
list.push(et);
m.set(et.category, list);
}
// Sort each bucket by label.
for (const [k, v] of m) {
v.sort((a, b) => eventTypeLabel(a).localeCompare(eventTypeLabel(b)));
m.set(k, v);
}
return m;
}
// ============================================================================
// Picker (multi-tag chip cluster) — used on deadline create/edit
// ============================================================================
interface PickerOptions {
initialIDs?: string[];
onChange?: (ids: string[]) => void;
currentUserAdmin?: boolean;
}
export interface PickerHandle {
getIDs(): string[];
setIDs(ids: string[]): void;
refresh(): Promise<void>;
}
export function attachEventTypePicker(container: HTMLElement, opts: PickerOptions): PickerHandle {
let selected = new Set<string>(opts.initialIDs ?? []);
let allTypes: EventType[] = [];
container.classList.add("event-type-picker");
container.innerHTML = `
<div class="event-type-chips" data-role="chips"></div>
<div class="event-type-search-row">
<input type="text" class="event-type-search" data-role="search" placeholder="${esc(t("event_types.picker.search"))}" />
<button type="button" class="event-type-browse-btn" data-role="browse">${esc(t("event_types.picker.browse_all"))}</button>
<button type="button" class="event-type-add-btn" data-role="add">${esc(t("event_types.picker.add"))}</button>
</div>
<div class="event-type-suggest" data-role="suggest" hidden></div>
`;
const chipsEl = container.querySelector<HTMLElement>("[data-role=chips]")!;
const searchEl = container.querySelector<HTMLInputElement>("[data-role=search]")!;
const suggestEl = container.querySelector<HTMLElement>("[data-role=suggest]")!;
const browseBtn = container.querySelector<HTMLButtonElement>("[data-role=browse]")!;
const addBtn = container.querySelector<HTMLButtonElement>("[data-role=add]")!;
function notify() {
opts.onChange?.(Array.from(selected));
}
function renderChips() {
const byID = new Map(allTypes.map((et) => [et.id, et]));
chipsEl.innerHTML = Array.from(selected)
.map((id) => {
const et = byID.get(id);
if (!et) return "";
return `<span class="event-type-chip" data-id="${esc(et.id)}">
<span class="event-type-chip-label">${esc(eventTypeLabel(et))}</span>
<button type="button" class="event-type-chip-remove" aria-label="${esc(t("event_types.picker.remove"))}">×</button>
</span>`;
})
.join("");
chipsEl.querySelectorAll<HTMLButtonElement>(".event-type-chip-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.parentElement?.dataset.id;
if (id) {
selected.delete(id);
renderChips();
notify();
}
});
});
}
function renderSuggest(query: string) {
const q = query.trim().toLowerCase();
if (q.length < 1) {
suggestEl.hidden = true;
suggestEl.innerHTML = "";
return;
}
const matches = allTypes
.filter((et) => !selected.has(et.id))
.filter((et) => {
const lde = et.label_de.toLowerCase();
const len = et.label_en.toLowerCase();
return lde.includes(q) || len.includes(q);
})
.slice(0, 12);
if (matches.length === 0) {
suggestEl.hidden = false;
suggestEl.innerHTML = `<div class="event-type-suggest-empty">${esc(t("event_types.picker.no_match"))}</div>`;
return;
}
suggestEl.hidden = false;
suggestEl.innerHTML = matches
.map(
(et) => `<button type="button" class="event-type-suggest-row" data-id="${esc(et.id)}">
<span class="event-type-suggest-label">${esc(eventTypeLabel(et))}</span>
<span class="event-type-suggest-cat">${esc(categoryLabel(et.category))}</span>
</button>`,
)
.join("");
suggestEl.querySelectorAll<HTMLButtonElement>(".event-type-suggest-row").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.id!;
selected.add(id);
searchEl.value = "";
renderSuggest("");
renderChips();
notify();
searchEl.focus();
});
});
}
searchEl.addEventListener("input", () => renderSuggest(searchEl.value));
searchEl.addEventListener("focus", () => renderSuggest(searchEl.value));
searchEl.addEventListener("blur", () => {
// Delay so the click lands first.
setTimeout(() => {
suggestEl.hidden = true;
}, 200);
});
browseBtn.addEventListener("click", async () => {
if (allTypes.length === 0) {
// Cache might still be hydrating on a slow first paint — make sure
// the modal opens against the freshest data we have.
allTypes = await fetchEventTypes();
}
const result = await openBrowseEventTypesModal({
types: allTypes,
initialIDs: Array.from(selected),
});
if (result) {
selected = new Set(result);
searchEl.value = "";
renderSuggest("");
renderChips();
notify();
}
});
addBtn.addEventListener("click", async () => {
const created = await openAddEventTypeModal({
prefillLabel: searchEl.value.trim(),
isAdmin: !!opts.currentUserAdmin,
});
if (created) {
await fetchEventTypes(true);
allTypes = (await fetchEventTypes()) ?? [];
selected.add(created.id);
searchEl.value = "";
renderSuggest("");
renderChips();
notify();
}
});
const handle: PickerHandle = {
getIDs: () => Array.from(selected),
setIDs: (ids) => {
selected = new Set(ids);
renderChips();
},
refresh: async () => {
invalidateEventTypeCache();
allTypes = await fetchEventTypes(true);
renderChips();
},
};
void (async () => {
allTypes = await fetchEventTypes();
renderChips();
})();
return handle;
}
// ============================================================================
// Multi-select filter (listbox panel) — used on /deadlines + /agenda
// ============================================================================
interface FilterOptions {
initialIDs?: string[];
initialIncludeUntyped?: boolean;
onChange?: (ids: string[], includeUntyped: boolean) => void;
}
export interface FilterHandle {
getIDs(): string[];
getIncludeUntyped(): boolean;
setSelection(ids: string[], includeUntyped: boolean): void;
refresh(): Promise<void>;
/** Serialise to the `?event_type=` query-param value (or "" when "Alle"). */
toQueryValue(): string;
}
export function attachEventTypeMultiSelectFilter(
trigger: HTMLButtonElement,
panel: HTMLElement,
opts: FilterOptions = {},
): FilterHandle {
let selected = new Set<string>(opts.initialIDs ?? []);
let includeUntyped = !!opts.initialIncludeUntyped;
let allTypes: EventType[] = [];
let searchQuery = "";
trigger.classList.add("multi-trigger");
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
trigger.innerHTML = `
<span class="multi-label" data-role="label"></span>
<span class="multi-chevron" aria-hidden="true">▾</span>
`;
panel.classList.add("multi-panel");
panel.hidden = true;
function updateLabel() {
const labelEl = trigger.querySelector<HTMLElement>("[data-role=label]")!;
const total = selected.size + (includeUntyped ? 1 : 0);
if (total === 0) {
labelEl.textContent = t("event_types.filter.all");
} else if (total === 1 && includeUntyped) {
labelEl.textContent = t("event_types.filter.untyped");
} else if (total === 1 && selected.size === 1) {
const id = Array.from(selected)[0];
const et = allTypes.find((x) => x.id === id);
labelEl.textContent = et ? eventTypeLabel(et) : t("event_types.filter.n_selected").replace("{n}", "1");
} else {
labelEl.textContent = t("event_types.filter.n_selected").replace("{n}", String(total));
}
}
function renderPanel() {
const groups = groupByCategory(allTypes);
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
et.label_en.toLowerCase().includes(q) ||
et.slug.toLowerCase().includes(q)
);
};
const renderGroup = (cat: string) => {
const list = (groups.get(cat) ?? []).filter(matches);
if (list.length === 0) return "";
return `<div class="multi-group">
<div class="multi-group-label">${esc(categoryLabel(cat))}</div>
${list
.map(
(et) => `<label class="multi-option">
<input type="checkbox" data-id="${esc(et.id)}" ${selected.has(et.id) ? "checked" : ""} />
<span>${esc(eventTypeLabel(et))}</span>
</label>`,
)
.join("")}
</div>`;
};
panel.innerHTML = `
<div class="multi-search-row">
<input type="text" class="multi-search" data-role="search" placeholder="${esc(t("event_types.filter.search"))}" value="${esc(searchQuery)}" />
</div>
<div class="multi-specials">
<label class="multi-option multi-special">
<input type="checkbox" data-role="all" ${selected.size === 0 && !includeUntyped ? "checked" : ""} />
<span>${esc(t("event_types.filter.all"))}</span>
</label>
<label class="multi-option multi-special">
<input type="checkbox" data-role="untyped" ${includeUntyped ? "checked" : ""} />
<span>${esc(t("event_types.filter.untyped"))}</span>
</label>
</div>
<div class="multi-list">
${CATEGORY_ORDER.map(renderGroup).join("")}
</div>
<div class="multi-actions">
<button type="button" class="btn-cancel" data-role="reset">${esc(t("event_types.filter.reset"))}</button>
<button type="button" class="btn-primary btn-cta-lime" data-role="close">${esc(t("event_types.filter.apply"))}</button>
</div>
`;
const searchInput = panel.querySelector<HTMLInputElement>("[data-role=search]")!;
searchInput.addEventListener("input", () => {
searchQuery = searchInput.value;
renderPanel();
// re-focus the search after re-render
const fresh = panel.querySelector<HTMLInputElement>("[data-role=search]")!;
fresh.focus();
fresh.setSelectionRange(searchInput.selectionStart ?? 0, searchInput.selectionEnd ?? 0);
});
const allCb = panel.querySelector<HTMLInputElement>("[data-role=all]")!;
allCb.addEventListener("change", () => {
if (allCb.checked) {
selected.clear();
includeUntyped = false;
renderPanel();
updateLabel();
opts.onChange?.([], false);
} else {
// Re-tick "Alle" if user tried to uncheck it without ticking anything
// else — a "no filter" state with everything off doesn't exist.
allCb.checked = true;
}
});
const untypedCb = panel.querySelector<HTMLInputElement>("[data-role=untyped]")!;
untypedCb.addEventListener("change", () => {
includeUntyped = untypedCb.checked;
renderPanel();
updateLabel();
opts.onChange?.(Array.from(selected), includeUntyped);
});
panel.querySelectorAll<HTMLInputElement>(".multi-list input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.id!;
if (cb.checked) selected.add(id);
else selected.delete(id);
renderPanel();
updateLabel();
opts.onChange?.(Array.from(selected), includeUntyped);
});
});
panel.querySelector<HTMLButtonElement>("[data-role=reset]")!.addEventListener("click", () => {
selected.clear();
includeUntyped = false;
searchQuery = "";
renderPanel();
updateLabel();
opts.onChange?.([], false);
});
panel.querySelector<HTMLButtonElement>("[data-role=close]")!.addEventListener("click", () => {
closePanel();
});
}
function openPanel() {
panel.hidden = false;
trigger.setAttribute("aria-expanded", "true");
renderPanel();
setTimeout(() => {
const search = panel.querySelector<HTMLInputElement>("[data-role=search]");
search?.focus();
}, 0);
}
function closePanel() {
panel.hidden = true;
trigger.setAttribute("aria-expanded", "false");
}
trigger.addEventListener("click", () => {
if (panel.hidden) openPanel();
else closePanel();
});
document.addEventListener("click", (e) => {
const target = e.target as Node;
if (panel.hidden) return;
if (panel.contains(target) || trigger.contains(target)) return;
closePanel();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !panel.hidden) closePanel();
});
const handle: FilterHandle = {
getIDs: () => Array.from(selected),
getIncludeUntyped: () => includeUntyped,
setSelection: (ids, untyped) => {
selected = new Set(ids);
includeUntyped = untyped;
updateLabel();
if (!panel.hidden) renderPanel();
},
refresh: async () => {
invalidateEventTypeCache();
allTypes = await fetchEventTypes(true);
updateLabel();
if (!panel.hidden) renderPanel();
},
toQueryValue: () => {
const tokens: string[] = [];
for (const id of selected) tokens.push(id);
if (includeUntyped) tokens.push("none");
return tokens.join(",");
},
};
void (async () => {
allTypes = await fetchEventTypes();
updateLabel();
})();
// Trigger label and (when open) panel content come from t() — re-render
// when the language changes so "Alle Typen" / "All types" stays in sync
// with the active locale (t-paliad-117).
onLangChange(() => {
updateLabel();
if (!panel.hidden) renderPanel();
});
return handle;
}
// ============================================================================
// Add modal
// ============================================================================
interface AddModalOptions {
prefillLabel?: string;
isAdmin: boolean;
}
export function openAddEventTypeModal(opts: AddModalOptions): Promise<EventType | null> {
return new Promise<EventType | null>((resolve) => {
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-modal-overlay";
overlay.innerHTML = `
<div class="modal event-type-add-modal" role="dialog" aria-modal="true" aria-labelledby="event-type-add-title">
<h2 id="event-type-add-title">${esc(t("event_types.add.title"))}</h2>
<div class="event-type-suggest-warn" data-role="warn" hidden></div>
<div class="form-field">
<label for="event-type-add-label-de">${esc(t("event_types.add.label_de"))}</label>
<input type="text" id="event-type-add-label-de" autofocus value="${esc(opts.prefillLabel || "")}" />
</div>
<div class="form-field">
<label for="event-type-add-label-en">${esc(t("event_types.add.label_en"))}</label>
<input type="text" id="event-type-add-label-en" />
</div>
<div class="form-field-row">
<div class="form-field">
<label for="event-type-add-category">${esc(t("event_types.add.category"))}</label>
<select id="event-type-add-category">
${CATEGORY_ORDER.map((c) => `<option value="${c}">${esc(categoryLabel(c))}</option>`).join("")}
</select>
</div>
<div class="form-field">
<label for="event-type-add-jurisdiction">${esc(t("event_types.add.jurisdiction"))}</label>
<select id="event-type-add-jurisdiction">
<option value="">${esc(t("event_types.add.jurisdiction.none"))}</option>
<option value="UPC">UPC</option>
<option value="EPO">EPA</option>
<option value="DPMA">DPMA</option>
<option value="DE">DE</option>
<option value="any">${esc(t("event_types.add.jurisdiction.any"))}</option>
</select>
</div>
</div>
<div class="form-field event-type-add-firm-wide" ${opts.isAdmin ? "" : "hidden"}>
<label class="checkbox-label">
<input type="checkbox" id="event-type-add-firm-wide" />
<span>${esc(t("event_types.add.firm_wide"))}</span>
</label>
<p class="form-hint">${esc(t("event_types.add.firm_wide.hint"))}</p>
</div>
<p class="form-msg" id="event-type-add-msg"></p>
<div class="form-actions">
<button type="button" class="btn-cancel" data-role="cancel">${esc(t("common.cancel"))}</button>
<button type="button" class="btn-primary btn-cta-lime" data-role="submit">${esc(t("event_types.add.submit"))}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const labelDE = overlay.querySelector<HTMLInputElement>("#event-type-add-label-de")!;
const labelEN = overlay.querySelector<HTMLInputElement>("#event-type-add-label-en")!;
const catSel = overlay.querySelector<HTMLSelectElement>("#event-type-add-category")!;
const jurSel = overlay.querySelector<HTMLSelectElement>("#event-type-add-jurisdiction")!;
const firmWide = overlay.querySelector<HTMLInputElement>("#event-type-add-firm-wide")!;
const msg = overlay.querySelector<HTMLElement>("#event-type-add-msg")!;
const warnEl = overlay.querySelector<HTMLElement>("[data-role=warn]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-role=submit]")!;
let suggestTimer: number | null = null;
function checkDuplicates(query: string) {
if (suggestTimer) window.clearTimeout(suggestTimer);
const q = query.trim();
if (q.length < 2) {
warnEl.hidden = true;
warnEl.innerHTML = "";
return;
}
suggestTimer = window.setTimeout(async () => {
try {
const resp = await fetch(`/api/event-types/suggest?q=${encodeURIComponent(q)}`);
if (!resp.ok) return;
const matches = (await resp.json()) as EventType[];
if (matches.length === 0) {
warnEl.hidden = true;
warnEl.innerHTML = "";
return;
}
warnEl.hidden = false;
warnEl.innerHTML = `<strong>${esc(t("event_types.add.duplicate_warn"))}</strong> ` +
matches.map((m) => `<span class="event-type-suggest-pill">${esc(eventTypeLabel(m))}</span>`).join(" ");
} catch {
/* non-fatal */
}
}, 250);
}
labelDE.addEventListener("input", () => checkDuplicates(labelDE.value));
function close(value: EventType | null) {
overlay.remove();
resolve(value);
}
cancelBtn.addEventListener("click", () => close(null));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
document.addEventListener(
"keydown",
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
document.removeEventListener("keydown", onKey);
close(null);
}
},
);
submitBtn.addEventListener("click", async () => {
const labelDEv = labelDE.value.trim();
if (!labelDEv) {
msg.textContent = t("event_types.add.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const payload: Record<string, unknown> = {
label_de: labelDEv,
category: catSel.value,
is_firm_wide: opts.isAdmin ? firmWide.checked : false,
};
if (labelEN.value.trim()) payload.label_en = labelEN.value.trim();
if (jurSel.value) payload.jurisdiction = jurSel.value;
const resp = await fetch("/api/event-types", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 409) {
msg.textContent = t("event_types.add.error.slug_taken");
msg.className = "form-msg form-msg-error";
submitBtn.disabled = false;
return;
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("event_types.add.error.generic");
msg.className = "form-msg form-msg-error";
submitBtn.disabled = false;
return;
}
const created = (await resp.json()) as EventType;
invalidateEventTypeCache();
close(created);
} catch {
msg.textContent = t("event_types.add.error.generic");
msg.className = "form-msg form-msg-error";
submitBtn.disabled = false;
}
});
});
}
// ============================================================================
// Browse-all modal — t-paliad-107
// ============================================================================
//
// Companion to the picker's search-as-you-type flow. Lists every available
// event type grouped by category, multi-select checkboxes, sticky search,
// pre-populated from the picker's current selection. Apply replaces; Cancel
// discards.
interface BrowseModalOptions {
types: EventType[];
initialIDs: string[];
}
export function openBrowseEventTypesModal(
opts: BrowseModalOptions,
): Promise<string[] | null> {
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
overlay.innerHTML = `
<div class="modal event-type-browse-modal" role="dialog" aria-modal="true" aria-labelledby="event-type-browse-title">
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
<span class="event-type-browse-count" data-role="count" aria-live="polite"></span>
<button type="button" class="btn-cancel" data-role="cancel">${esc(t("event_types.browse.cancel"))}</button>
<button type="button" class="btn-primary btn-cta-lime" data-role="apply">${esc(t("event_types.browse.apply"))}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const modalEl = overlay.querySelector<HTMLElement>(".event-type-browse-modal")!;
const searchEl = overlay.querySelector<HTMLInputElement>("[data-role=search]")!;
const listEl = overlay.querySelector<HTMLElement>("[data-role=list]")!;
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const groups = groupByCategory(opts.types);
function jurisdictionLabel(j: string | null | undefined): string {
if (!j) return "";
if (j === "any") return t("event_types.browse.jurisdiction.none");
if (j === "EPO") return "EPA";
return j;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
String(selected.size),
);
}
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
et.label_en.toLowerCase().includes(q) ||
et.slug.toLowerCase().includes(q)
);
};
const sections = CATEGORY_ORDER.map((cat) => {
const list = (groups.get(cat) ?? []).filter(matches);
if (list.length === 0) return "";
return `<section class="event-type-browse-group">
<h3 class="event-type-browse-group-label">${esc(categoryLabel(cat))}</h3>
<ul class="event-type-browse-options" role="group" aria-label="${esc(categoryLabel(cat))}">
${list
.map((et) => {
const checked = selected.has(et.id) ? "checked" : "";
const jur = jurisdictionLabel(et.jurisdiction);
const jurBadge = jur
? `<span class="event-type-browse-jurisdiction">${esc(jur)}</span>`
: "";
return `<li>
<label class="event-type-browse-option">
<input type="checkbox" data-id="${esc(et.id)}" ${checked} />
<span class="event-type-browse-option-label">${esc(eventTypeLabel(et))}</span>
${jurBadge}
</label>
</li>`;
})
.join("")}
</ul>
</section>`;
}).join("");
listEl.innerHTML =
sections ||
`<div class="event-type-browse-empty">${esc(t("event_types.browse.empty"))}</div>`;
listEl.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.id!;
if (cb.checked) selected.add(id);
else selected.delete(id);
updateCount();
});
});
}
searchEl.addEventListener("input", () => {
searchQuery = searchEl.value;
renderList();
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();
resolve(value);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
close(null);
return;
}
if (e.key === "Tab") {
// Lightweight focus trap: keep tabbing inside the modal.
const focusables = modalEl.querySelectorAll<HTMLElement>(
'input, button, [tabindex]:not([tabindex="-1"])',
);
const visible = Array.from(focusables).filter(
(el) => !el.hasAttribute("disabled") && el.offsetParent !== null,
);
if (visible.length === 0) return;
const first = visible[0];
const last = visible[visible.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener("keydown", onKey);
cancelBtn.addEventListener("click", () => close(null));
applyBtn.addEventListener("click", () => close(Array.from(selected)));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
updateCount();
renderList();
setTimeout(() => searchEl.focus(), 0);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,343 +0,0 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Frist {
id: string;
akte_id: string;
title: string;
description?: string;
due_date: string;
status: string;
source: string;
rule_id?: string;
notes?: string;
created_at: string;
completed_at?: string;
}
interface Akte {
id: string;
aktenzeichen: string;
title: string;
}
interface DeadlineRule {
id: string;
code?: string;
name: string;
rule_code?: string;
}
interface Me {
id: string;
role: string;
}
let frist: Frist | null = null;
let akte: Akte | null = null;
let rule: DeadlineRule | null = null;
let me: Me | null = null;
function parseFristID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "fristen" || !parts[1]) return null;
return parts[1];
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso + (iso.length === 10 ? "T00:00:00" : ""));
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadFrist(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/fristen/${id}`);
if (!resp.ok) return false;
frist = await resp.json();
return true;
} catch {
return false;
}
}
async function loadAkte(akteID: string) {
try {
const resp = await fetch(`/api/akten/${akteID}`);
if (resp.ok) akte = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadRule(ruleID: string) {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
} catch {
/* non-fatal */
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
function render() {
if (!frist) return;
(document.getElementById("frist-title-display") as HTMLElement).textContent = frist.title;
(document.getElementById("frist-title-edit") as HTMLInputElement).value = frist.title;
const dueChip = document.getElementById("frist-due-chip")!;
dueChip.className = `frist-due-chip ${urgencyClass(frist.due_date, frist.status)}`;
dueChip.textContent = fmtDate(frist.due_date);
(document.getElementById("frist-due-display") as HTMLElement).textContent = fmtDate(frist.due_date);
(document.getElementById("frist-due-edit") as HTMLInputElement).value = frist.due_date.slice(0, 10);
const statusChip = document.getElementById("frist-status-chip")!;
statusChip.className = `akten-status-chip akten-status-${frist.status}`;
statusChip.textContent = t(`fristen.status.${frist.status}`) || frist.status;
const akteLink = document.getElementById("frist-akte-link") as HTMLAnchorElement;
if (akte) {
akteLink.href = `/akten/${akte.id}`;
akteLink.textContent = `${akte.aktenzeichen} \u2014 ${akte.title}`;
} else {
akteLink.href = `/akten/${frist.akte_id}`;
akteLink.textContent = "\u2014";
}
const ruleEl = document.getElementById("frist-rule-display")!;
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code} \u2014 ${rule.name}` : rule.name;
} else {
ruleEl.textContent = "\u2014";
}
(document.getElementById("frist-source-display") as HTMLElement).textContent =
t(`fristen.source.${frist.source}`) || frist.source;
(document.getElementById("frist-notes-display") as HTMLElement).textContent = frist.notes || "\u2014";
(document.getElementById("frist-notes-edit") as HTMLTextAreaElement).value = frist.notes || "";
(document.getElementById("frist-created-display") as HTMLElement).textContent = fmtDateTime(frist.created_at);
const completedLabel = document.getElementById("frist-completed-row-label")!;
const completedDD = document.getElementById("frist-completed-display")!;
if (frist.completed_at) {
completedLabel.style.display = "";
completedDD.style.display = "";
completedDD.textContent = fmtDateTime(frist.completed_at);
} else {
completedLabel.style.display = "none";
completedDD.style.display = "none";
}
const completeBtn = document.getElementById("frist-complete-btn") as HTMLButtonElement;
if (frist.status === "completed") {
completeBtn.disabled = true;
completeBtn.textContent = t("fristen.detail.completed.already");
} else {
completeBtn.disabled = false;
completeBtn.textContent = t("fristen.detail.complete");
}
const deleteWrap = document.getElementById("frist-delete-wrap")!;
if (me && (me.role === "partner" || me.role === "admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
}
function initEdit() {
const titleDisplay = document.getElementById("frist-title-display")!;
const titleEdit = document.getElementById("frist-title-edit") as HTMLInputElement;
const dueDisplay = document.getElementById("frist-due-display")!;
const dueEdit = document.getElementById("frist-due-edit") as HTMLInputElement;
const notesDisplay = document.getElementById("frist-notes-display")!;
const notesEdit = document.getElementById("frist-notes-edit") as HTMLTextAreaElement;
const editBtn = document.getElementById("frist-edit-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("frist-save-btn") as HTMLButtonElement;
function enterEdit() {
titleDisplay.style.display = "none";
titleEdit.style.display = "";
dueDisplay.style.display = "none";
dueEdit.style.display = "";
notesDisplay.style.display = "none";
notesEdit.style.display = "";
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
titleEdit.select();
}
function exitEdit() {
titleDisplay.style.display = "";
titleEdit.style.display = "none";
dueDisplay.style.display = "";
dueEdit.style.display = "none";
notesDisplay.style.display = "";
notesEdit.style.display = "none";
saveBtn.style.display = "none";
editBtn.style.display = "";
}
editBtn.addEventListener("click", enterEdit);
saveBtn.addEventListener("click", async () => {
if (!frist) return;
const newTitle = titleEdit.value.trim();
const newDue = dueEdit.value;
const newNotes = notesEdit.value;
if (!newTitle || !newDue) return;
saveBtn.disabled = true;
try {
const resp = await fetch(`/api/fristen/${frist.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle, due_date: newDue, notes: newNotes }),
});
if (resp.ok) {
frist = await resp.json();
render();
}
} finally {
saveBtn.disabled = false;
exitEdit();
}
});
}
function initComplete() {
const btn = document.getElementById("frist-complete-btn") as HTMLButtonElement;
btn.addEventListener("click", async () => {
if (!frist || frist.status === "completed") return;
btn.disabled = true;
try {
const resp = await fetch(`/api/fristen/${frist.id}/complete`, { method: "PATCH" });
if (resp.ok) {
frist = await resp.json();
render();
} else {
btn.disabled = false;
}
} catch {
btn.disabled = false;
}
});
}
function initDelete() {
const btn = document.getElementById("frist-delete-btn")!;
const modal = document.getElementById("frist-delete-modal")!;
const close = document.getElementById("frist-delete-modal-close")!;
const cancel = document.getElementById("frist-delete-modal-cancel")!;
const confirmBtn = document.getElementById("frist-delete-modal-confirm") as HTMLButtonElement;
btn.addEventListener("click", () => {
modal.style.display = "flex";
});
const closeModal = () => {
modal.style.display = "none";
};
close.addEventListener("click", closeModal);
cancel.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeModal();
});
confirmBtn.addEventListener("click", async () => {
if (!frist) return;
confirmBtn.disabled = true;
const resp = await fetch(`/api/fristen/${frist.id}`, { method: "DELETE" });
if (resp.ok) {
const target = akte ? `/akten/${akte.id}/fristen` : "/fristen";
window.location.href = target;
} else {
confirmBtn.disabled = false;
closeModal();
}
});
}
async function main() {
const id = parseFristID();
const loading = document.getElementById("frist-loading")!;
const notfound = document.getElementById("frist-notfound")!;
const body = document.getElementById("frist-body")!;
if (!id) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await loadMe();
const ok = await loadFrist(id);
if (!ok || !frist) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await loadAkte(frist.akte_id);
if (frist.rule_id) await loadRule(frist.rule_id);
loading.style.display = "none";
body.style.display = "";
render();
initEdit();
initComplete();
initDelete();
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(render);
main();
});

View File

@@ -1,163 +0,0 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface Akte {
id: string;
aktenzeichen: string;
title: string;
}
interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en: string;
rule_code?: string;
}
let preselectedAkteID = "";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function showError(msg: string) {
const el = document.getElementById("frist-neu-msg")!;
el.textContent = msg;
el.className = "form-msg form-msg-error";
}
async function loadAkten() {
const sel = document.getElementById("frist-akte") as HTMLSelectElement;
const hint = document.getElementById("frist-akte-empty-hint")!;
try {
const resp = await fetch("/api/akten");
if (!resp.ok) return;
const akten: Akte[] = await resp.json();
if (akten.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("fristen.field.akte.empty"))} <a href="/akten/neu">${esc(t("fristen.field.akte.empty.link"))}</a>`;
return;
}
const options: string[] = [
`<option value="" disabled${preselectedAkteID ? "" : " selected"} data-i18n="fristen.field.akte.choose">${esc(t("fristen.field.akte.choose"))}</option>`,
];
for (const a of akten) {
const isSelected = preselectedAkteID === a.id ? " selected" : "";
options.push(
`<option value="${esc(a.id)}"${isSelected}>${esc(a.aktenzeichen)} \u2014 ${esc(a.title)}</option>`,
);
}
sel.innerHTML = options.join("");
} catch {
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("frist-rule") as HTMLSelectElement;
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
const opts: string[] = [
`<option value="" data-i18n="fristen.field.rule.none">${esc(t("fristen.field.rule.none"))}</option>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
} catch {
/* non-fatal — rule select stays at "no rule" */
}
}
function initBackLinks() {
if (preselectedAkteID) {
const back = document.getElementById("frist-neu-back") as HTMLAnchorElement;
const cancel = document.getElementById("frist-neu-cancel") as HTMLAnchorElement;
back.href = `/akten/${preselectedAkteID}/fristen`;
cancel.href = `/akten/${preselectedAkteID}/fristen`;
}
}
async function submitForm(e: Event) {
e.preventDefault();
const submitBtn = document.querySelector<HTMLButtonElement>("#frist-neu-form button[type=submit]")!;
const msg = document.getElementById("frist-neu-msg")!;
const akteID = (document.getElementById("frist-akte") as HTMLSelectElement).value;
const title = (document.getElementById("frist-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("frist-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("frist-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("frist-notes") as HTMLTextAreaElement).value.trim();
if (!akteID || !title || !due) {
showError(t("fristen.error.required"));
return;
}
msg.textContent = "";
msg.className = "form-msg";
submitBtn.disabled = true;
const payload: Record<string, unknown> = {
title,
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
if (notes) payload.notes = notes;
try {
const resp = await fetch(`/api/akten/${encodeURIComponent(akteID)}/fristen`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
showError(data.error || t("fristen.error.generic"));
submitBtn.disabled = false;
return;
}
const created = await resp.json();
if (preselectedAkteID) {
window.location.href = `/akten/${preselectedAkteID}/fristen`;
} else {
window.location.href = `/fristen/${created.id}`;
}
} catch {
showError(t("fristen.error.generic"));
submitBtn.disabled = false;
}
}
function detectPreselect() {
// Path /akten/{id}/fristen/neu pre-selects that akte.
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] === "akten" && parts[1] && parts[2] === "fristen" && parts[3] === "neu") {
preselectedAkteID = parts[1];
}
// Or ?akte_id= query string
const qp = new URLSearchParams(window.location.search);
const fromQuery = qp.get("akte_id");
if (fromQuery) preselectedAkteID = fromQuery;
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
detectPreselect();
initBackLinks();
document.getElementById("frist-neu-form")!.addEventListener("submit", submitForm);
// Default due to today
const dueInput = document.getElementById("frist-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadAkten(), loadRules()]);
});

View File

@@ -1,265 +0,0 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Frist {
id: string;
akte_id: string;
title: string;
due_date: string;
status: string;
source: string;
rule_id?: string;
akte_aktenzeichen: string;
akte_title: string;
akte_office: string;
rule_code?: string;
}
interface Akte {
id: string;
aktenzeichen: string;
title: string;
}
interface Summary {
overdue: number;
this_week: number;
upcoming: number;
completed: number;
total: number;
}
let allFristen: Frist[] = [];
let allAkten: Akte[] = [];
let statusFilter = "pending";
let akteFilter = "";
let loadedOK = false;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
}
async function loadAkten() {
try {
const resp = await fetch("/api/akten");
if (resp.ok) allAkten = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadSummary() {
try {
const url = akteFilter
? `/api/fristen/summary?akte_id=${encodeURIComponent(akteFilter)}`
: `/api/fristen/summary`;
const resp = await fetch(url);
if (!resp.ok) return;
const sum: Summary = await resp.json();
setCount("sum-overdue", sum.overdue);
setCount("sum-week", sum.this_week);
setCount("sum-upcoming", sum.upcoming);
setCount("sum-completed", sum.completed);
} catch {
/* non-fatal */
}
}
function setCount(id: string, n: number) {
const el = document.getElementById(id);
if (el) el.textContent = String(n);
}
async function loadFristen() {
const unavailable = document.getElementById("fristen-unavailable")!;
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
try {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
if (akteFilter) params.set("akte_id", akteFilter);
const resp = await fetch(`/api/fristen?${params.toString()}`);
if (resp.status === 503) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
document.getElementById("fristen-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
return;
}
allFristen = await resp.json();
loadedOK = true;
render();
} catch {
unavailable.style.display = "block";
tableWrap.style.display = "none";
}
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("fristen-body")!;
const empty = document.getElementById("fristen-empty")!;
const emptyFiltered = document.getElementById("fristen-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
if (allFristen.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
if (statusFilter === "all" && !akteFilter) {
empty.style.display = "block";
emptyFiltered.style.display = "none";
} else {
empty.style.display = "none";
emptyFiltered.style.display = "block";
}
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
emptyFiltered.style.display = "none";
tbody.innerHTML = allFristen
.map((f) => {
const urgency = urgencyClass(f.due_date, f.status);
const statusLabel = t(`fristen.status.${f.status}`) || f.status;
const ruleLabel = f.rule_code ? esc(f.rule_code) : "&mdash;";
const checked = f.status === "completed" ? "checked" : "";
const disabled = f.status === "completed" ? "disabled" : "";
const titleClass = f.status === "completed" ? "frist-title-done" : "";
return `<tr class="frist-row" data-id="${esc(f.id)}">
<td class="frist-col-check">
<input type="checkbox" class="frist-complete-cb" ${checked} ${disabled}
aria-label="${esc(t("fristen.complete.action"))}" />
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDate(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
<td class="frist-col-akte">
<a class="akten-ref-link" href="/akten/${esc(f.akte_id)}">${esc(f.akte_aktenzeichen)}</a>
<span class="frist-akte-title">${esc(f.akte_title)}</span>
</td>
<td class="frist-col-rule">${ruleLabel}</td>
<td><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
// Don't navigate if clicking the checkbox or a link
const target = e.target as HTMLElement;
if (target.closest(".frist-complete-cb") || target.closest("a")) return;
window.location.href = `/fristen/${id}`;
});
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
if (cb) {
cb.addEventListener("change", async () => {
if (!cb.checked) return;
cb.disabled = true;
try {
const resp = await fetch(`/api/fristen/${id}/complete`, { method: "PATCH" });
if (resp.ok) {
await Promise.all([loadFristen(), loadSummary()]);
} else {
cb.checked = false;
cb.disabled = false;
}
} catch {
cb.checked = false;
cb.disabled = false;
}
});
}
});
}
function initFilters() {
const status = document.getElementById("frist-filter-status") as HTMLSelectElement;
const akte = document.getElementById("frist-filter-akte") as HTMLSelectElement;
// Pre-fill from URL
const params = urlParams();
if (params.has("status")) statusFilter = params.get("status")!;
if (params.has("akte_id")) akteFilter = params.get("akte_id")!;
status.value = statusFilter;
status.addEventListener("change", async () => {
statusFilter = status.value;
await Promise.all([loadFristen(), loadSummary()]);
});
akte.addEventListener("change", async () => {
akteFilter = akte.value;
await Promise.all([loadFristen(), loadSummary()]);
});
}
function populateAkteFilter() {
const sel = document.getElementById("frist-filter-akte") as HTMLSelectElement;
// Keep the first "all" option, then append sorted Akten.
const options: string[] = [
`<option value="" data-i18n="fristen.filter.akte.all">${esc(t("fristen.filter.akte.all"))}</option>`,
];
for (const a of allAkten) {
options.push(
`<option value="${esc(a.id)}">${esc(a.aktenzeichen)} \u2014 ${esc(a.title)}</option>`,
);
}
sel.innerHTML = options.join("");
if (akteFilter) sel.value = akteFilter;
}
function initSummaryCards() {
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
card.addEventListener("click", async () => {
const newStatus = card.dataset.status!;
statusFilter = newStatus;
(document.getElementById("frist-filter-status") as HTMLSelectElement).value = newStatus;
await Promise.all([loadFristen(), loadSummary()]);
});
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
initFilters();
initSummaryCards();
onLangChange(render);
await loadAkten();
populateAkteFilter();
await Promise.all([loadFristen(), loadSummary()]);
});

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ let searchQuery = "";
const ICON_FEEDBACK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
async function loadTerms() {
const resp = await fetch("/api/glossar");
const resp = await fetch("/api/glossary");
if (!resp.ok) return;
allTerms = await resp.json();
render();
@@ -85,6 +85,13 @@ function initSearch() {
searchQuery = input.value;
render();
});
// Honor `?q=` from the global search-palette deep links. render() runs
// after loadTerms() resolves and reads the module-level searchQuery.
const q = new URLSearchParams(location.search).get("q");
if (q) {
input.value = q;
searchQuery = q;
}
}
// --- Category filters ---
@@ -162,7 +169,7 @@ async function submitSuggestion(e: Event) {
submitBtn.disabled = true;
try {
const resp = await fetch("/api/glossar/suggest", {
const resp = await fetch("/api/glossary/suggest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
import { initI18n, t, getLang, type I18nKey } from "./i18n";
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
// lifecycle events.
//
// State is URL-driven via ?tab= so back/forward buttons work and the bell
// badge can deep-link to either tab. The badge in the sidebar (id
// sidebar-inbox-badge) is updated by the shared global polling loop in
// sidebar.ts; this module just keeps the page content in sync.
type Lifecycle = "create" | "update" | "complete" | "delete";
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
type DecisionKind = "peer" | "admin_override";
interface ApprovalRequestView {
id: string;
project_id: string;
project_title: string;
entity_type: "deadline" | "appointment";
entity_id: string;
entity_title?: string;
lifecycle_event: Lifecycle;
pre_image?: Record<string, unknown> | null;
payload?: Record<string, unknown> | null;
required_role: string;
status: RequestStatus;
requested_at: string;
requested_by: string;
requester_name: string;
decided_at?: string;
decided_by?: string;
decider_name?: string;
decision_kind?: DecisionKind;
decision_note?: string;
}
type Tab = "pending-mine" | "mine";
let currentTab: Tab = "pending-mine";
initI18n();
document.addEventListener("DOMContentLoaded", () => {
const url = new URL(window.location.href);
const t = url.searchParams.get("tab");
if (t === "mine") currentTab = "mine";
bindTabs();
refresh();
});
function bindTabs() {
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab as Tab) || "pending-mine";
if (tab === currentTab) return;
currentTab = tab;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
history.replaceState({}, "", url.toString());
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
refresh();
});
});
}
async function refresh() {
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
if (!loading || !empty || !list) return;
loading.style.display = "";
empty.style.display = "none";
list.innerHTML = "";
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
let rows: ApprovalRequestView[] = [];
try {
const r = await fetch(path, { credentials: "include" });
if (r.ok) rows = (await r.json()) as ApprovalRequestView[];
} catch (_e) {
// Network errors fall through to empty render.
}
loading.style.display = "none";
if (rows.length === 0) {
empty.textContent = t(
currentTab === "pending-mine"
? "approvals.empty.pending_mine"
: "approvals.empty.mine"
);
empty.style.display = "";
return;
}
for (const row of rows) list.appendChild(renderRow(row));
}
function renderRow(row: ApprovalRequestView): HTMLLIElement {
const li = document.createElement("li");
li.className = "inbox-row";
// Header: project / entity / lifecycle / required-role
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
const entityTitle = row.entity_title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete (date-bearing fields)
const diff = renderDiff(row);
if (diff) li.appendChild(diff);
// Decision note if any
if (row.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = row.decision_note;
li.appendChild(note);
}
// Action row
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (row.status === "pending" && currentTab === "pending-mine") {
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
} else if (row.status === "pending" && currentTab === "mine") {
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
} else {
// historic — show status pill
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
if (row.decider_name && row.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
return li;
}
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
const before = (row.pre_image || {}) as Record<string, unknown>;
const after = (row.payload || {}) as Record<string, unknown>;
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
if (keys.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "inbox-row-diff";
for (const k of keys) {
const line = document.createElement("div");
line.className = "inbox-row-diff-line";
const label = document.createElement("span");
label.className = "inbox-row-diff-key";
label.textContent = k;
line.appendChild(label);
const span = document.createElement("span");
span.className = "inbox-row-diff-values";
const fmt = (v: unknown) =>
v === null || v === undefined ? "—" : String(v);
if (k in before && k in after) {
span.textContent = `${fmt(before[k])}${fmt(after[k])}`;
} else if (k in before) {
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
} else {
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
}
line.appendChild(span);
wrap.appendChild(line);
}
return wrap;
}
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
btn.addEventListener("click", onClick);
return btn;
}
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
}
let r: Response;
try {
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
} catch (_e) {
alert("Network error");
return;
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
const errKey = (body && body.error) || "internal";
const msg = mapApprovalError(errKey);
alert(msg);
return;
}
refresh();
// Update sidebar bell count.
refreshInboxBadge();
}
function mapApprovalError(key: string): string {
switch (key) {
case "self_approval_blocked":
return t("approvals.error.self_approval");
case "no_qualified_approver":
return t("approvals.error.no_qualified_approver");
case "concurrent_pending":
return t("approvals.error.concurrent_pending");
case "not_authorized":
return t("approvals.error.not_authorized");
case "request_not_pending":
return t("approvals.error.request_not_pending");
default:
return key;
}
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = Date.now() - t0;
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
const day = Math.floor(hr / 24);
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
}
// Update the sidebar inbox badge (shared with sidebar.ts polling).
async function refreshInboxBadge() {
const badge = document.getElementById("sidebar-inbox-badge");
if (!badge) return;
try {
const r = await fetch("/api/inbox/count", { credentials: "include" });
if (!r.ok) return;
const data = (await r.json()) as { count: number };
if (data.count > 0) {
badge.textContent = String(data.count);
badge.style.display = "";
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
}

View File

@@ -1,7 +1,9 @@
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { initBottomNav } from "./bottom-nav";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initBottomNav();
});

Some files were not shown because too many files have changed in this diff Show More