blob: 974c0fe0501c2fdb52878db02be20876521511dc [file]
/**
* Date helpers.
*
* Everything here works in the browser's local timezone, which is what a
* calendar grid should do. The backend stores and returns UTC; the conversion
* happens when a date string is parsed.
*
* All-day events are the exception: the backend snaps them to UTC midnight, so
* we read their date parts in UTC to decide which days they cover. Otherwise a
* whole-day event would appear to start the evening before for anyone west of
* Greenwich.
*/
export const MINUTE = 60_000;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEKDAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
/** Monday. ISO weeks are the ASF norm and match the WEEKDAY_NAMES above. */
export const WEEK_STARTS_ON = 1;
export function startOfDay(date: Date): Date {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy;
}
export function endOfDay(date: Date): Date {
const copy = startOfDay(date);
copy.setDate(copy.getDate() + 1);
return copy;
}
export function addDays(date: Date, count: number): Date {
const copy = new Date(date);
copy.setDate(copy.getDate() + count);
return copy;
}
export function addMonths(date: Date, count: number): Date {
const copy = new Date(date);
const targetDay = copy.getDate();
copy.setDate(1);
copy.setMonth(copy.getMonth() + count);
// Clamp: 31 January plus one month is 28/29 February, not 3 March.
const lastDay = daysInMonth(copy.getFullYear(), copy.getMonth());
copy.setDate(Math.min(targetDay, lastDay));
return copy;
}
export function addYears(date: Date, count: number): Date {
return addMonths(date, count * 12);
}
export function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
export function startOfWeek(date: Date, weekStartsOn = WEEK_STARTS_ON): Date {
const copy = startOfDay(date);
const shift = (copy.getDay() - weekStartsOn + 7) % 7;
copy.setDate(copy.getDate() - shift);
return copy;
}
export function startOfMonth(date: Date): Date {
const copy = startOfDay(date);
copy.setDate(1);
return copy;
}
export function startOfYear(date: Date): Date {
const copy = startOfMonth(date);
copy.setMonth(0);
return copy;
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
);
}
export function isToday(date: Date, now: Date = new Date()): boolean {
return isSameDay(date, now);
}
export function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
/**
* The six-week grid a month view shows: always 42 days, starting on the first
* WEEK_STARTS_ON on or before the 1st. A fixed size keeps the grid from
* jumping about as the user pages through months.
*/
export function monthGrid(date: Date, weekStartsOn = WEEK_STARTS_ON): Date[] {
const first = startOfWeek(startOfMonth(date), weekStartsOn);
return Array.from({ length: 42 }, (_, index) => addDays(first, index));
}
/** The seven days of the week containing `date`. */
export function weekDays(date: Date, weekStartsOn = WEEK_STARTS_ON): Date[] {
const first = startOfWeek(date, weekStartsOn);
return Array.from({ length: 7 }, (_, index) => addDays(first, index));
}
/** The twelve first-of-the-month dates for the year containing `date`. */
export function yearMonths(date: Date): Date[] {
const january = startOfYear(date);
return Array.from({ length: 12 }, (_, index) => new Date(january.getFullYear(), index, 1));
}
export interface DateRange {
start: Date;
end: Date;
}
/**
* The window of time a view needs loaded. The month view asks for its whole
* 42-day grid, not just the calendar month, so events from the neighbouring
* months that are visible in the grid are there too.
*/
export function rangeForView(view: string, cursor: Date): DateRange {
switch (view) {
case "day":
return { start: startOfDay(cursor), end: endOfDay(cursor) };
case "week": {
const first = startOfWeek(cursor);
return { start: first, end: addDays(first, 7) };
}
case "year": {
const first = startOfYear(cursor);
return { start: first, end: new Date(first.getFullYear() + 1, 0, 1) };
}
case "agenda": {
const first = startOfDay(cursor);
return { start: first, end: addDays(first, 90) };
}
case "month":
default: {
const grid = monthGrid(cursor);
return { start: grid[0], end: addDays(grid[41], 1) };
}
}
}
/** Moves the cursor one view-sized step forwards or backwards. */
export function step(view: string, cursor: Date, direction: number): Date {
switch (view) {
case "day":
return addDays(cursor, direction);
case "week":
return addDays(cursor, 7 * direction);
case "year":
return addYears(cursor, direction);
case "agenda":
return addDays(cursor, 30 * direction);
case "month":
default:
return addMonths(cursor, direction);
}
}
/** A stable YYYY-MM-DD key for a local date, used to bucket events by day. */
export function dayKey(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** The inverse of dayKey(), for reading dates back out of form fields. */
export function fromDayKey(key: string): Date {
const [year, month, day] = key.split("-").map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
// ---- formatting -----------------------------------------------------------
export function formatTime(date: Date): string {
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
export function formatDate(date: Date): string {
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
}
export function formatMonthYear(date: Date): string {
return `${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
}
/** The heading shown above each view. */
export function formatViewTitle(view: string, cursor: Date): string {
switch (view) {
case "day":
return formatDate(cursor);
case "week": {
const days = weekDays(cursor);
const first = days[0];
const last = days[6];
if (first.getMonth() === last.getMonth()) {
return `${first.getDate()} - ${last.getDate()} ${formatMonthYear(first)}`;
}
if (first.getFullYear() === last.getFullYear()) {
return `${first.getDate()} ${MONTH_NAMES[first.getMonth()]} - ${last.getDate()} ${formatMonthYear(last)}`;
}
return `${formatDate(first)} - ${formatDate(last)}`;
}
case "year":
return String(cursor.getFullYear());
case "agenda":
return `From ${formatDate(cursor)}`;
case "month":
default:
return formatMonthYear(cursor);
}
}
/** Formats a value for an <input type="datetime-local">. */
export function toLocalInput(date: Date): string {
return `${dayKey(date)}T${formatTime(date)}`;
}
/** Formats a value for an <input type="date">. */
export function toDateInput(date: Date): string {
return dayKey(date);
}
/** Reads an ISO 8601 string from the API into a Date. */
export function parseISO(value: string): Date {
return new Date(value);
}
/** Serialises a Date for the API. */
export function toISO(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
}