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).
580 lines
20 KiB
TypeScript
580 lines
20 KiB
TypeScript
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());
|
||
}
|