# 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