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