diff --git a/docs/design-events-unification-2026-05-04.md b/docs/design-events-unification-2026-05-04.md new file mode 100644 index 0000000..b90eadd --- /dev/null +++ b/docs/design-events-unification-2026-05-04.md @@ -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:111–122`): + +``` +Ü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 `` (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 `

` 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 `

` stays whichever they came from (don't rewrite it on toggle — would jitter). The page `` 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:00–15: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:00–11: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:00–11: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:00–11: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:00–11: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