Files
paliad/frontend/src/client/calendar/mount-calendar.ts
mAi e83b150eda refactor(calendar): t-paliad-224 — extract mountCalendar() canon module
Lift the month/week/day renderer out of shape-calendar.ts into a new
frontend/src/client/calendar/mount-calendar.ts module so /events
Kalender (next commit) and Custom Views shape=calendar both go
through the same code path.

shape-calendar.ts becomes a thin adapter (ViewRow → CalendarItem +
defaultView=render.calendar.default_view, urlState=true). The
extracted module adds:

- update(items) on the returned handle so /events can re-mount on
  filter changes without rebuilding state.
- destroy() for clean teardown when /events switches shapes.
- A 'Heute' button in the toolbar (cal.today, DE+EN added to i18n.ts
  + i18n-keys.ts).
- Optional opts.urlPrefix for surfaces that may share a URL with
  another calendar.

mountCalendar reads ?cal_view / ?cal_date when opts.urlState=true.
/events will mount with urlState=true after the next commit so the
Kalender tab + day-view drill remain refresh-stable (per §11 Q3 in
the design doc).

Pure-helper test suite (mount-calendar.test.ts) covers isoDate,
startOfDay, startOfWeek, shift, bucketByDate, filterByDay, isToday —
12 assertions, all green. DOM rendering covered by manual smoke (no
jsdom in this repo's bun test setup; see verfahrensablauf-core.test.
ts comment for the convention).
2026-05-20 15:23:28 +02:00

580 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { t, tDyn, getLang, type I18nKey } from "../i18n";
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
// Lifted from the original shape-calendar.ts so both Custom Views
// (shape=calendar) and /events Kalender tab render through the same DOM.
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
//
// Surfaces wire in via mountCalendar(host, items, opts). The returned
// handle exposes update(items) for re-render after a filter change and
// destroy() for teardown when the host swaps to a different view.
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
/** ISO-8601 timestamp or date string. First 10 chars are read as the
* calendar bucket (yyyy-mm-dd). */
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
}
export type CalendarView = "month" | "week" | "day";
export interface CalendarOpts {
/** Initial view if URL has no override (or urlState is disabled). */
defaultView?: CalendarView;
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
* Surfaces that own their own URL contract pass urlState=false. */
urlState?: boolean;
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
* meaningful when urlState=true. Leave empty for the default
* ?cal_view / ?cal_date contract. */
urlPrefix?: string;
/** Override how a row's href is built. Default routes by kind. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Replace the item set and re-paint at the current view+anchor. */
update(items: CalendarItem[]): void;
/** Clear host + drop the keep-alive state. After destroy(), the handle
* is dead; create a fresh one with mountCalendar(). */
destroy(): void;
}
const MAX_PILLS_PER_MONTH_CELL = 3;
export function mountCalendar(
host: HTMLElement,
initialItems: CalendarItem[],
opts: CalendarOpts = {},
): CalendarHandle {
let items = initialItems;
let view: CalendarView;
let anchor: Date;
let destroyed = false;
const urlEnabled = opts.urlState ?? false;
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
view = urlEnabled
? readView(viewParam, opts.defaultView ?? "month")
: (opts.defaultView ?? "month");
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
paint();
return {
update(nextItems) {
if (destroyed) return;
items = nextItems;
paint();
},
destroy() {
destroyed = true;
host.innerHTML = "";
},
};
// --- paint -----------------------------------------------------------
function paint(): void {
if (destroyed) return;
host.innerHTML = "";
// Mobile fallback notice (<600px). Documented in design-calendar-
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
// notice just nudges users toward a friendlier view.
if (typeof window !== "undefined" && window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar());
if (view === "month") {
wrap.appendChild(renderMonth());
} else if (view === "week") {
wrap.appendChild(renderWeek());
} else {
wrap.appendChild(renderDay());
}
host.appendChild(wrap);
}
function setView(nextView: CalendarView, nextAnchor: Date): void {
view = nextView;
anchor = nextAnchor;
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
paint();
}
// --- Toolbar ---------------------------------------------------------
function renderToolbar(): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalendarView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
setView(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
nav.appendChild(next);
// "Heute" button — jump back to today in the current view. Adds a
// recognisable affordance for the /events Kalender users who relied
// on the old toolbar's "Heute" button.
const today = document.createElement("button");
today.type = "button";
today.className = "btn-secondary btn-small views-calendar-nav-btn";
today.textContent = t("cal.today");
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
nav.appendChild(today);
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => setView("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
// --- Month -----------------------------------------------------------
function renderMonth(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
const byDate = bucketByDate(items, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) ul.appendChild(renderPill(row));
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week ------------------------------------------------------------
function renderWeek(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
grid.appendChild(renderWeekColumn(day));
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
const dayRows = filterByDay(items, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day -------------------------------------------------------------
function renderDay(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(items, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering ---------------------------------------------------
function renderPill(row: CalendarItem): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = hrefFor(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = hrefFor(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function hrefFor(row: CalendarItem): string {
if (opts.hrefFor) return opts.hrefFor(row);
return defaultHrefFor(row);
}
}
// --- Pure helpers (shared, not closure-bound) ----------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
function defaultHrefFor(row: CalendarItem): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
export function bucketByDate(
rows: CalendarItem[], filter: (d: Date) => boolean,
): Map<string, CalendarItem[]> {
const out = new Map<string, CalendarItem[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
export function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
}
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function shift(d: Date, view: CalendarView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
export function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
export function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalendarView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
function firstAnchor(rows: CalendarItem[]): Date {
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return startOfDay(d);
}
return startOfDay(new Date());
}
function paramName(prefix: string | undefined, base: string): string {
if (!prefix) return base;
return `${prefix}_${base}`;
}
function readView(viewParam: string, fallback: CalendarView): CalendarView {
if (typeof window === "undefined") return fallback;
const params = new URLSearchParams(window.location.search);
const raw = params.get(viewParam);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return fallback;
}
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
if (typeof window === "undefined") return firstAnchor(rows);
const params = new URLSearchParams(window.location.search);
const raw = params.get(dateParam);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
return firstAnchor(rows);
}
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(viewParam, view);
url.searchParams.set(dateParam, isoDate(anchor));
history.replaceState(null, "", url.toString());
}