From 668558380d76782574806f3010d7d63aba035e44 Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 20 May 2026 15:02:41 +0200 Subject: [PATCH] =?UTF-8?q?docs(design):=20t-paliad-224=20=E2=80=94=20alig?= =?UTF-8?q?n=20calendar=20views=20(m/paliad#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit + refactor plan: three calendar implementations live today — /events tab, standalone /deadlines|appointments/calendar pages, and Custom Views shape-calendar.ts. Canonicalise on shape-calendar.ts by extracting a shared mount-calendar.ts module, fold /events into it, retire the standalone pages as 301 redirects, delete ~180 lines of duplicated CSS. Net: ~700 LOC removed, ~100 added, zero schema/endpoint changes. 8 open questions for head in §11; AskUserQuestion is disabled for this task per role brief, so head answers via mai instruct and decisions land in §12. --- docs/design-calendar-view-align-2026-05-20.md | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 docs/design-calendar-view-align-2026-05-20.md diff --git a/docs/design-calendar-view-align-2026-05-20.md b/docs/design-calendar-view-align-2026-05-20.md new file mode 100644 index 0000000..61b8e2e --- /dev/null +++ b/docs/design-calendar-view-align-2026-05-20.md @@ -0,0 +1,446 @@ +# Design: Align calendar-view rendering between Events/Termine and Custom Views + +**Task:** t-paliad-224 — m/paliad#55 +**Author:** bohr (inventor) +**Date:** 2026-05-20 +**Status:** DRAFT — awaiting m+head go/no-go on §11 questions +**Branch:** `mai/bohr/calendar-view-align` + +--- + +## 0. Premise check (verified against live source 2026-05-20) + +m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two: + +| | A — Events tab | B — Standalone | C — Custom Views | +|---|---|---|---| +| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` | +| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) | +| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) | +| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) | +| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage` — `internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` | + +**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context. + +The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3. + +--- + +## 1. m's intent (as I read it) + +> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!" + +The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is: + +1. **One calendar component**, mounted from both the events-page surface and the custom-views surface. +2. **Identical visual output** when the same items land in either surface. +3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go. +4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body. + +The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3. + +--- + +## 2. What actually diverges today + +Side-by-side after reading all three implementations (cited line numbers above): + +| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) | +|---|---|---|---| +| Views offered | month only | month only | month + week + day | +| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` | +| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" | +| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded** — `views-calendar-pill--{deadline|appointment|project_event|approval_request}` | +| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) | +| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal | +| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) | +| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view | +| Toolbar | inline ‹ month-label › + Heute button | identical | view-switcher chips (M/W/D) + ‹ range-label › + (in day/week) "Zurück zum Monat" link | +| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) | +| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) | +| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) | +| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) | +| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup | +| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` | + +The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem. + +CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away. + +--- + +## 3. Recommended design (TL;DR) + +| Area | Recommendation | Smallest-diff alternative considered & rejected | +|---|---|---| +| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. | +| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. | +| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. | +| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type` → `kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. | +| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. | +| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. | +| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. | +| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. | + +**Net code change (estimated by file):** + +- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines. +- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state). +- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block). +- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types). +- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim. +- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`). + +Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.** + +--- + +## 4. Architecture sketch + +``` + ┌─────────────────────────────┐ + │ frontend/src/client/ │ + │ calendar/ │ + │ mount-calendar.ts ★ │ ← new shared module + │ types.ts (CalendarItem)│ + └──────────────┬──────────────┘ + │ + ┌────────────────────────┼─────────────────────────┐ + │ │ │ + client/events.ts (Kalender tab) client/views/ │ + │ shape-calendar.ts │ + │ (thin wrapper) │ + │ │ │ + │ ▼ │ + │ client/views.ts │ + │ paintRows(…, "calendar") │ + │ │ + └──────────────────────────────────────────────────┘ + + Data flows: + A: /events → fetch /api/events?type=…&status=… → EventListItem[] + → toCalendarItem(items) → CalendarItem[] + → mountCalendar(host, items, opts) + + C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[] + → toCalendarItem(rows) (noop-ish: rename ‘type’→‘kind’ already done) + → renderCalendarShape() → mountCalendar(host, items, opts) +``` + +### 4.1 The shared module (`mount-calendar.ts`) + +```ts +// frontend/src/client/calendar/mount-calendar.ts +import { t, tDyn, getLang, type I18nKey } from "../i18n"; + +export type CalendarKind = + | "deadline" | "appointment" | "project_event" | "approval_request"; + +export interface CalendarItem { + kind: CalendarKind; + id: string; + title: string; + event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd + project_id?: string; + project_title?: string; + project_reference?: string; +} + +export interface CalendarOpts { + defaultView?: "month" | "week" | "day"; + /** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed + * equivalents); if false, state is in-memory only (use for embedded + * calendars where URL state belongs to the host page). */ + urlState?: boolean; + /** Optional prefix for URL params (default: empty). Set if more than + * one calendar might live on the same URL. */ + urlPrefix?: string; + /** Optional override: how to render a row's href. Default uses the + * kind→/deadlines|/appointments|/inbox|/projects routing the existing + * shape-calendar.ts ships with. */ + hrefFor?: (item: CalendarItem) => string; +} + +export interface CalendarHandle { + /** Re-render with a new item set (e.g. after a filter change in /events). */ + update(items: CalendarItem[]): void; + /** Tear down listeners + clear host. */ + destroy(): void; +} + +export function mountCalendar( + host: HTMLElement, + items: CalendarItem[], + opts?: CalendarOpts, +): CalendarHandle; +``` + +Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks: + +- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`. +- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today". + +### 4.2 `shape-calendar.ts` (after refactor) + +```ts +import type { RenderSpec, ViewRow } from "./types"; +import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar"; + +export function renderCalendarShape( + host: HTMLElement, rows: ViewRow[], render: RenderSpec, +): void { + const items: CalendarItem[] = rows.map(r => ({ + kind: r.kind, + id: r.id, title: r.title, + event_date: r.event_date, + project_id: r.project_id, + project_title: r.project_title, + project_reference: r.project_reference, + })); + mountCalendar(host, items, { + defaultView: render.calendar?.default_view ?? "month", + urlState: true, + }); +} +``` + +### 4.3 `client/events.ts` (calendar arm only) + +```ts +// near the top +import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar"; + +// state +let calendar: CalendarHandle | null = null; + +// inside applyView() when switching to calendar view: +function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) { + if (calendar) { calendar.update(items); return; } + calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" }); +} + +// inside applyView() when switching AWAY from calendar: +function teardownCalendar() { + if (calendar) { calendar.destroy(); calendar = null; } +} + +function toCalendarItem(it: EventListItem): CalendarItem { + return { + kind: it.type as CalendarKind, // type "deadline" | "appointment" + id: it.id, title: it.title, + event_date: itemDateISO(it) + "T00:00:00", + project_id: it.project_id, + project_title: it.project_title, + project_reference: it.project_reference, + }; +} +``` + +`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.) + +### 4.4 Standalone calendar redirects + +```go +// internal/handlers/deadlines_pages.go +func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently) +} + +// internal/handlers/appointments_pages.go +func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently) +} +``` + +The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view). + +Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context). + +--- + +## 5. Visual + interaction parity audit + +Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor): + +| Brief item | Today (A) | After refactor | Matches /views? | +|---|---|---|---| +| Event tile shape | dot | **pill with text** | ✓ | +| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ | +| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ | +| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ | +| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ | +| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract | + +Two surfaces still differ after the refactor — and that's by design: + +1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`). +2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way. + +--- + +## 6. Mobile parity + +`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `

` notice — "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns). + +After this refactor: + +- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically. +- /views Kalender shape: behaviour unchanged from today. + +Mobile audit boxes ticked: + +| | Today A | Today B | Today C | After | +|---|---|---|---|---| +| Cell shrinks on narrow viewport | ✓ (min-height 64px) | ✓ | partial (cells stay 80px) | ✓ (carry the C behaviour, plus the @media min-height shrink ported) | +| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) — but verify on a real phone during coder smoke | OK | +| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL — natural back button) | drill-down across both surfaces | +| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical | + +One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke). + +--- + +## 7. Tests + smoke + +Existing test coverage relevant to this refactor: + +- `frontend/src/client/views/shape-timeline-cv.test.ts` — sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module. +- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep). +- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` — unchanged. + +New test plan: + +1. **`mount-calendar.test.ts` (new)** — table-driven: + - Empty `items[]` → month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views. + - `items[]` with mixed kinds → pills get the correct `views-calendar-pill--{kind}` class. + - `?cal_view=week` → week column grid renders. + - Today bucket flagged with `--today` class on the correct cell. + - `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3). + - `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click). +2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment. +3. **Smoke (manual, with `bun run build` + dev server)**: + - /events Kalender tab loads, shows pills, click pill navigates to detail. + - Day-num click → day view (URL changes if urlState is on for /events per Q3). + - /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down. + - /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab. + - /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab. + - DE + EN language toggle on both surfaces. + - Light + dark theme on both. +4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief). + +--- + +## 8. Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. | +| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. | +| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. | +| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. | +| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. | +| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. | + +--- + +## 9. What stays "out of scope" (consistent with the issue body) + +- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans. +- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side). +- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here. +- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it. +- Subtype dot colouring (deferred per §3 trade-off row). + +--- + +## 10. Follow-ups (file as separate issues after this lands) + +1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small. +2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks. +3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested. +4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope. + +--- + +## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct) + +> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12. + +**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`? +- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes. +- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each. +- *(answer: yes / keep-standalone / something-else)* + +**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely? +- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path. +- Alternative: keep the modal on /events only (parity break — defeats the point of the issue). +- *(answer: drop / keep)* + +**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)? +- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events. +- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal. +- *(answer: persist / in-memory)* + +**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring? +- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested. +- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours. +- *(answer: drop / preserve)* + +**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape? +- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives. +- Alternative: dedicated key per surface — clearer copy but more strings to maintain. +- *(answer: reuse / dedicated)* + +**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces? +- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar. +- Alternative: unit + Playwright. +- *(answer: unit-only / unit-plus-playwright)* + +**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)? +- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes. +- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point. +- *(answer: one-pr / three-pr)* + +**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release? +- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views). +- Alternative: leave for one release as a soft-deprecate. +- *(answer: drop / leave)* + +--- + +## 12. m's decisions (filled in after head answers) + +*[Pending head's responses to §11. Each line below will be filled in with `m picked …` once head confirms.]* + +- Q1 — Canonical renderer: +- Q2 — Drill-down vs modal: +- Q3 — URL state on /events: +- Q4 — Subtype dot colouring: +- Q5 — Mobile fallback text: +- Q6 — Test approach: +- Q7 — Sequencing: +- Q8 — Empty-state i18n key: + +--- + +## 13. Coder hand-off (after m's go on §11) + +Once §12 is filled in, the coder shift can proceed in this order: + +1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through. +2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift). +3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: , defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3. +4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `

` plus a wrapper class is enough — `mountCalendar` builds the DOM. +5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`. +6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s). +7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`. +8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none). +9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do. +10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label. +11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`. +12. Manual smoke per §7.3. +13. Commit. `mai report completed` with SHA per task brief. + +Estimated coder shift: one PR per Q7 (R). + +---