4.9 KiB
calendar
(no submodules)
Purpose
The calendar module is the arithmetic core of @suaveplan/datetime. It provides the fundamental building blocks for working with dates: coercing heterogeneous DateInput values into Date objects, adding and subtracting Duration bags with correct month-end clamping, computing start-of / end-of boundaries for any time unit, and generating full calendar-month grids with today-awareness injected via a pluggable Clock.
All functions are pure and stateless. They accept local-time Date objects and return local-time Date objects. Timezone conversion (IANA) is not in scope for this module — it belongs to consumer code that formats dates for display.
Features
toDate— coerceDate | string | numbertoDate, throwsDatetimeErrorfor invalid inputsisLeapYear— true for proleptic Gregorian leap yearsdaysInMonth— correct day-count for any month including February on leap yearsisSameDay/isSameMonth— calendar equality predicatesaddDuration/subtractDuration— month-clamping arithmetic (Jan 31 + 1 month = Feb 28/29)startOf/endOf— precise boundary computation for years, months, weeks, days, hours, minutes, seconds, millisecondsgetCalendarMonth— full grid ofCalendarDayentries withisTodaydriven by injectedClockasTimezone— brands a raw string as aTimezoneopaque type
Usage: Basic Example
import { toDate, addDuration, isSameDay } from "@suaveplan/datetime/calendar";
const jan31 = toDate("2024-01-31");
const feb29 = addDuration(jan31, { months: 1 });
console.log(feb29.toISOString()); // 2024-02-29T00:00:00.000Z (leap year)
console.log(isSameDay(feb29, toDate("2024-02-29"))); // true
Usage: Advanced Example
import {
getCalendarMonth,
startOf,
endOf,
subtractDuration,
} from "@suaveplan/datetime/calendar";
// Inject a fake clock for deterministic "isToday" rendering in tests
const fakeClock = () => new Date("2024-03-15T12:00:00Z").getTime();
const march = getCalendarMonth(2024, 3, { clock: fakeClock });
const today = march.days.find((d) => d.isToday);
console.log(today?.date.getDate()); // 15
// Fiscal quarter boundaries
const q1Start = startOf("2024-03-15", "years"); // 2024-01-01
const q1End = endOf("2024-03-31", "months"); // 2024-03-31T23:59:59.999
// Rolling 90-day window
const ninetyDaysAgo = subtractDuration(new Date(), { days: 90 });
console.log(ninetyDaysAgo.toISOString());
API Reference
toDate(input: DateInput): Date
Coerce input to a Date. Accepts Date, ISO string, or epoch millis.
Throws DatetimeError("DATETIME.PARSE.INVALID_INPUT") for unparseable strings or NaN inputs.
asTimezone(tz: string): Timezone
Cast tz to the branded Timezone type. No runtime validation.
isLeapYear(year: number): boolean
Returns true for proleptic Gregorian leap years.
daysInMonth(year: number, month: number): number
Returns the number of days in the given month (1-indexed). Handles February leap-year logic.
isSameDay(a: DateInput, b: DateInput): boolean
Returns true when a and b share the same calendar year, month, and day.
isSameMonth(a: DateInput, b: DateInput): boolean
Returns true when a and b share the same calendar year and month.
addDuration(date: DateInput, duration: Duration): Date
Add duration to date. Year/month fields are applied first with overflow normalisation. Day/week/hour/minute/second/millisecond fields are added as millisecond offsets. Month-end clamping prevents invalid dates (e.g. March 31 - 1 month = Feb 28/29).
subtractDuration(date: DateInput, duration: Duration): Date
Subtract duration from date. Internally negates all fields and delegates to addDuration.
startOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date
Returns the first moment of the given unit period. For weeks, Sunday is the start of the week.
endOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date
Returns the last millisecond of the given unit period.
getCalendarMonth(year: number, month: number, opts?: CalendarMonthOptions): CalendarMonth
Build a CalendarMonth grid. opts.clock defaults to Date.now. Each CalendarDay includes date, dayOfWeek, isWeekend, and isToday.
Implementation Notes
Month arithmetic is performed in calendar coordinates (year/month/day integers) rather than millisecond arithmetic to avoid the 28–31 day ambiguity. Day clamping uses daysInMonth to find the last valid day after year/month adjustment, then the remaining sub-day duration components are added as a single millisecond offset to the clamped local-midnight date.
startOf("weeks") uses Sunday as the week start (index 0) to match Date.prototype.getDay(). Callers that need Monday-start weeks should offset the result by one day.