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.
This commit is contained in:
m
2026-05-04 13:14:52 +02:00
parent 1def9e86b9
commit 25efce0c76

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