Files
paliad/docs/design-calendar-view-align-2026-05-20.md
mAi 2320cb765d docs(design): t-paliad-224 — head accepted all 8 (R) defaults
Decisions section §12 filled in per head msg #2087. Status → ACCEPTED.
Coder shift proceeds on same branch per Q7(R): single PR.
2026-05-20 15:23:28 +02:00

36 KiB
Raw Blame History

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: ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch. 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, handleAppointmentsCalendarPageinternal/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
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.


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 (typekind) — 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 typekind already done)
                            → renderCalendarShape() → mountCalendar(host, items, opts)

4.1 The shared module (mount-calendar.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)

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)

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

// 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 <p> 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 (2026-05-20, via head msg #2087)

Head accepted all 8 (R) defaults in one round-trip ("Design accepted in full — all 8 (R) defaults stand"). Recorded verbatim below; each entry is the (R) pick from §11.

  • Q1 — Canonical renderer: Yes. Canonicalise on shape-calendar.ts; fold A into it via extracted mountCalendar(); retire B as 301 redirects to /events?type=…&view=calendar.
  • Q2 — Drill-down vs modal: Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
  • Q3 — URL state on /events: Persist. /events Kalender reads/writes ?cal_view=…&cal_date=… like /views does. Adds view=calendar to client/events.ts:readURLState() so refreshes/redirects land on the Kalender tab.
  • Q4 — Subtype dot colouring: Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
  • Q5 — Mobile fallback text: Reuse the existing views.calendar.mobile_fallback key on /events as well — generic phrasing covers both surfaces.
  • Q6 — Test approach: Unit tests (mount-calendar.test.ts) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
  • Q7 — Sequencing: One PR. Extract + adopt + retire + CSS prune land together on mai/bohr/calendar-view-align.
  • Q8 — Empty-state i18n key: Drop dead keys now (events.calendar.empty, appointments.kalender.*, deadlines.kalender.*, appointment-type legend keys not used elsewhere).

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: <per Q3>, 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 <div id="events-shape-calendar" /> 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).