Decisions section §12 filled in per head msg #2087. Status → ACCEPTED. Coder shift proceeds on same branch per Q7(R): single PR.
36 KiB
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, 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:
- One calendar component, mounted from both the events-page surface and the custom-views surface.
- Identical visual output when the same items land in either surface.
- No duplicate code path — orphaned standalone calendar TSX + client + dist pages go.
- 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.
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 fromclient/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.tsbecomes a 30-line wrapper that callsmountCalendarwithurlState: trueand the spec's calendar config. Most of the existing 525 lines move intomount-calendar.tsverbatim. - 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)
// 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/writeURLaccept theurlPrefixso embedded calendars on/events?…&don't clobber other pages'?cal_view.urlState: falseskips the URL read/write entirely — initial state comes fromopts.defaultViewand "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:
- /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). - /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_fallbackand 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. Addfrontend/src/client/calendar/mount-calendar.test.tsfor the extracted module.- No Go tests touch handler dispatch for
/deadlines/calendaror/appointments/calendarspecifically (verified by grep). internal/services/render_spec_test.gocoversCalendarConfig.validate()— unchanged.
New test plan:
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 correctviews-calendar-pill--{kind}class.?cal_view=week→ week column grid renders.- Today bucket flagged with
--todayclass on the correct cell. +Noverflow 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).
- Empty
client/events.ts— light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away callsdestroy(). No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.- 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.
- Build gate:
go build ./... && go test ./internal/... && cd frontend && bun run buildmust 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
/eventsto 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.mdanddesign-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)
- Date-windowed loading for /events Kalender. Pass
?from=…&to=…to/api/eventsmatched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already acceptsfrom/toperinternal/handlers/events.go. Small. - 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. - 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. - /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 perdesign-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
AskUserQuestionfor this task. Each question below has a defaulted answer marked (R); head/m can confirm or override viamai 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
mountCalendarinternally — 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_colorsflag 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 extractedmountCalendar(); 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. Addsview=calendartoclient/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_fallbackkey 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:
- Create
frontend/src/client/calendar/mount-calendar.ts+frontend/src/client/calendar/mount-calendar.test.ts. Lift the shape-calendar internals; addupdate/destroyto the returned handle; pipeurlState+urlPrefixthrough. - Update
frontend/src/client/views/shape-calendar.tsto delegate tomountCalendar(≈30 lines after the lift). - Update
frontend/src/client/events.ts: importmountCalendar, replacerenderCalendar/openCalPopupand nav handlers with amountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })call inside the existingapplyView()branch. Add theview=calendarURL state handling per Q3. - Update
frontend/src/events.tsx: strip theevents-calendar-wrapinline DOM (toolbar + grid + modal). The empty container<div id="events-shape-calendar" />plus a wrapper class is enough —mountCalendarbuilds the DOM. - Delete
frontend/src/appointments-calendar.tsx,frontend/src/deadlines-calendar.tsx,frontend/src/client/appointments-calendar.ts,frontend/src/client/deadlines-calendar.ts. - Update
frontend/build.ts: remove the*-calendar.tsentry-point lines (≈250s) and the*-calendar.htmlwrites (≈387s). - Update
internal/handlers/deadlines_pages.go+internal/handlers/appointments_pages.go: turn the two calendar handlers into 301 redirects to/events?type=…&view=calendar. - 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). - Update i18n: drop
appointments.kalender.*,deadlines.kalender.*,appointments.type.*(legend keys only — keep type values used elsewhere),events.calendar.emptyper Q8. Make surecal.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. paliadin-context.ts: optional one-line addition to mapevents?view=calendarto the new context label.- Run
go build ./... && go test ./internal/... && cd frontend && bun run build. - Manual smoke per §7.3.
- Commit.
mai report completedwith SHA per task brief.
Estimated coder shift: one PR per Q7 (R).