Files
paliad/docs/design-events-unification-2026-05-04.md
m 25efce0c76 design(t-paliad-109): unify Fristen + Termine as filtered Events views
Design doc only — no code touched. Recommends keeping /deadlines + /appointments
URLs but rendering one EventsPage component (smallest-diff Option A1), backed
by a new EventService that delegates to existing Deadline/AppointmentService
(Option B1). Two-rail bucket summary on Beides (5 deadline + 3 appointment),
detail pages stay separate, /agenda timeline left alone. §F lists 17 questions
gating m's greenlight, including a premise correction: the brief described
/agenda as the appointment list — actually it's a pre-existing cross-type
timeline; the appointment list is /appointments.
2026-05-04 13:14:52 +02:00

38 KiB
Raw Permalink Blame History

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.


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.

// 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.

// 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:

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:

[
  {
    "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:
{
  "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

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