248 lines
8.3 KiB
TypeScript
248 lines
8.3 KiB
TypeScript
/* ============================================================================
|
|||
|
|
MONTH GRID - the events calendar's "Month" view
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
PURE and client-safe: no Strapi, no `~/lib/env.server`, no React. The same
|
||
|
|
helpers run in the loader (which decides which month to render) and in the
|
||
|
|
component (which draws it), which is what keeps the URL and the grid in
|
||
|
|
step.
|
||
|
|
|
||
|
|
The redesign draft ships a list/month switch for the calendar, and the
|
||
|
|
approved review note asks for exactly that ("Events as a calendar/list
|
||
|
|
toggle plus an individual event page"). The month is a URL parameter -
|
||
|
|
?view=month&month=2026-03 - so a particular month is a shareable link and
|
||
|
|
the arrows are ordinary <a>s that work with scripting off.
|
||
|
|
|
||
|
|
NOTHING HERE INVENTS A DATE. The grid is built from the entries the caller
|
||
|
|
passes in, which come from Strapi; an empty CMS produces an empty month,
|
||
|
|
never a sample event.
|
||
|
|
|
||
|
|
TIME ZONE: every calendar day is resolved in the zone the caller names
|
||
|
|
(the calendar uses Libya time), so an evening event lands on the day it is
|
||
|
|
actually held rather than on whatever day it is in the reader's browser.
|
||
|
|
The arithmetic itself is done on "YYYY-MM-DD" strings through UTC, which has
|
||
|
|
no daylight saving to fall through.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
/** "YYYY-MM". */
|
||
|
|
export type CalendarMonthKey = string;
|
||
|
|
|
||
|
|
/** "YYYY-MM-DD". */
|
||
|
|
export type CalendarDayKey = string;
|
||
|
|
|
||
|
|
const MONTH_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
||
|
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
|
|
||
|
|
/** Anything that can sit in a day cell. `href` is already localised. */
|
||
|
|
export interface CalendarEntry {
|
||
|
|
readonly id: string;
|
||
|
|
readonly title: string;
|
||
|
|
readonly href: string;
|
||
|
|
/** ISO 8601 instant, or a date-only value when `allDay`. */
|
||
|
|
readonly start: string;
|
||
|
|
readonly end?: string;
|
||
|
|
readonly allDay: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface CalendarDay {
|
||
|
|
readonly key: CalendarDayKey;
|
||
|
|
/** 1-31. Padding cells from the neighbouring months keep their own number. */
|
||
|
|
readonly dayNumber: number;
|
||
|
|
readonly inMonth: boolean;
|
||
|
|
readonly isToday: boolean;
|
||
|
|
readonly entries: readonly CalendarEntry[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface CalendarMonth {
|
||
|
|
readonly month: CalendarMonthKey;
|
||
|
|
/** First day of the month, for formatting the month's own name. */
|
||
|
|
readonly firstDay: CalendarDayKey;
|
||
|
|
readonly previous: CalendarMonthKey;
|
||
|
|
readonly next: CalendarMonthKey;
|
||
|
|
/**
|
||
|
|
* Weekday numbers in display order, 0 = Sunday, so the component can label
|
||
|
|
* the header row with `Intl` instead of a hard-coded English list.
|
||
|
|
*/
|
||
|
|
readonly weekdays: readonly number[];
|
||
|
|
readonly days: readonly CalendarDay[];
|
||
|
|
/** How many of `entries` actually landed in this month. */
|
||
|
|
readonly entryCount: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
PARSING AND ARITHMETIC
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** "2026-03" from a query parameter, or undefined when it is not one. */
|
||
|
|
export function parseMonthKey(value: string | null | undefined): CalendarMonthKey | undefined {
|
||
|
|
if (typeof value !== "string") return undefined;
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return MONTH_PATTERN.test(trimmed) ? trimmed : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The calendar day an instant falls on, read in `timeZone`. */
|
||
|
|
export function calendarDayIn(
|
||
|
|
value: Date | string,
|
||
|
|
timeZone: string,
|
||
|
|
): CalendarDayKey | undefined {
|
||
|
|
if (typeof value === "string" && DAY_PATTERN.test(value.trim())) {
|
||
|
|
// A CMS `date` field is already a calendar day - re-reading it in a zone
|
||
|
|
// would shift it by the offset.
|
||
|
|
return value.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
const date = value instanceof Date ? value : new Date(value);
|
||
|
|
if (Number.isNaN(date.getTime())) return undefined;
|
||
|
|
|
||
|
|
return new Intl.DateTimeFormat("en-CA", {
|
||
|
|
timeZone,
|
||
|
|
year: "numeric",
|
||
|
|
month: "2-digit",
|
||
|
|
day: "2-digit",
|
||
|
|
}).format(date);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The month a calendar day belongs to. */
|
||
|
|
export function monthKeyOf(day: CalendarDayKey): CalendarMonthKey {
|
||
|
|
return day.slice(0, 7);
|
||
|
|
}
|
||
|
|
|
||
|
|
function utcOf(day: CalendarDayKey): number {
|
||
|
|
return Date.parse(`${day}T00:00:00Z`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function dayKeyOf(time: number): CalendarDayKey {
|
||
|
|
return new Date(time).toISOString().slice(0, 10);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** "2026-03" plus `delta` months, rolling the year over. */
|
||
|
|
export function shiftMonth(
|
||
|
|
month: CalendarMonthKey,
|
||
|
|
delta: number,
|
||
|
|
): CalendarMonthKey {
|
||
|
|
const match = MONTH_PATTERN.exec(month);
|
||
|
|
if (!match) return month;
|
||
|
|
|
||
|
|
const index = Number(match[1]) * 12 + (Number(match[2]) - 1) + delta;
|
||
|
|
const year = Math.floor(index / 12);
|
||
|
|
const zeroBased = index - year * 12;
|
||
|
|
|
||
|
|
return `${String(year).padStart(4, "0")}-${String(zeroBased + 1).padStart(2, "0")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MS_PER_DAY = 86_400_000;
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
THE GRID
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export interface BuildCalendarOptions {
|
||
|
|
readonly month: CalendarMonthKey;
|
||
|
|
readonly entries: readonly CalendarEntry[];
|
||
|
|
readonly timeZone: string;
|
||
|
|
/** 0 = Sunday … 6 = Saturday. The column the header row starts on. */
|
||
|
|
readonly weekStartsOn: number;
|
||
|
|
/** "Today" as an instant - passed in so the loader and the grid agree. */
|
||
|
|
readonly now: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Six weeks of cells, always: a fixed grid never reflows as the reader pages
|
||
|
|
* through the year, and the padding cells carry their real dates so a run of
|
||
|
|
* events across a month boundary stays readable.
|
||
|
|
*/
|
||
|
|
export function buildCalendarMonth({
|
||
|
|
month,
|
||
|
|
entries,
|
||
|
|
timeZone,
|
||
|
|
weekStartsOn,
|
||
|
|
now,
|
||
|
|
}: BuildCalendarOptions): CalendarMonth {
|
||
|
|
const first = `${month}-01`;
|
||
|
|
const firstTime = utcOf(first);
|
||
|
|
const today = calendarDayIn(now, timeZone);
|
||
|
|
|
||
|
|
/* Every day each entry occupies, so a two-day workshop appears twice. */
|
||
|
|
const byDay = new Map<CalendarDayKey, CalendarEntry[]>();
|
||
|
|
for (const entry of entries) {
|
||
|
|
const from = calendarDayIn(entry.start, timeZone);
|
||
|
|
if (!from) continue;
|
||
|
|
|
||
|
|
const to = entry.end ? (calendarDayIn(entry.end, timeZone) ?? from) : from;
|
||
|
|
const start = utcOf(from);
|
||
|
|
const end = Math.max(start, utcOf(to));
|
||
|
|
// A month is 31 cells; a mis-entered end date cannot be allowed to spin.
|
||
|
|
const last = Math.min(end, start + 62 * MS_PER_DAY);
|
||
|
|
|
||
|
|
for (let time = start; time <= last; time += MS_PER_DAY) {
|
||
|
|
const key = dayKeyOf(time);
|
||
|
|
const bucket = byDay.get(key);
|
||
|
|
if (bucket) bucket.push(entry);
|
||
|
|
else byDay.set(key, [entry]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Back up from the first of the month to the column the week starts on. */
|
||
|
|
const firstWeekday = new Date(firstTime).getUTCDay();
|
||
|
|
const lead = (firstWeekday - weekStartsOn + 7) % 7;
|
||
|
|
const gridStart = firstTime - lead * MS_PER_DAY;
|
||
|
|
|
||
|
|
const days: CalendarDay[] = [];
|
||
|
|
let entryCount = 0;
|
||
|
|
|
||
|
|
for (let cell = 0; cell < 42; cell += 1) {
|
||
|
|
const time = gridStart + cell * MS_PER_DAY;
|
||
|
|
const key = dayKeyOf(time);
|
||
|
|
const cellEntries = byDay.get(key) ?? [];
|
||
|
|
const inMonth = monthKeyOf(key) === month;
|
||
|
|
if (inMonth) entryCount += cellEntries.length;
|
||
|
|
|
||
|
|
days.push({
|
||
|
|
key,
|
||
|
|
dayNumber: new Date(time).getUTCDate(),
|
||
|
|
inMonth,
|
||
|
|
isToday: key === today,
|
||
|
|
entries: cellEntries,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
month,
|
||
|
|
firstDay: first,
|
||
|
|
previous: shiftMonth(month, -1),
|
||
|
|
next: shiftMonth(month, 1),
|
||
|
|
weekdays: Array.from({ length: 7 }, (_, index) => (weekStartsOn + index) % 7),
|
||
|
|
days,
|
||
|
|
entryCount,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
LABELS
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The day the reader's week starts on. Libya's week starts on Saturday, and
|
||
|
|
* an Arabic calendar that opened on Monday would read wrong; English keeps the
|
||
|
|
* Monday-first grid the draft draws.
|
||
|
|
*/
|
||
|
|
export function weekStartFor(locale: string): number {
|
||
|
|
return locale === "ar" ? 6 : 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* "Mon", "Tue", … in the page's language. Built from a reference week that
|
||
|
|
* happens to start on a Sunday (2024-01-07 was one), so no weekday name is
|
||
|
|
* ever hard-coded in either language.
|
||
|
|
*/
|
||
|
|
export function weekdayLabel(
|
||
|
|
weekday: number,
|
||
|
|
intlLocale: string,
|
||
|
|
width: "short" | "narrow" | "long" = "short",
|
||
|
|
): string {
|
||
|
|
const reference = new Date(Date.UTC(2024, 0, 7 + weekday));
|
||
|
|
return new Intl.DateTimeFormat(intlLocale, {
|
||
|
|
weekday: width,
|
||
|
|
timeZone: "UTC",
|
||
|
|
}).format(reference);
|
||
|
|
}
|