Files
skills/docs/foundation/timing/datetime/src/calendar/calendar.md
2026-05-29 08:20:56 +01:00

4.9 KiB
Raw Blame History

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 — coerce Date | string | number to Date, throws DatetimeError for invalid inputs
  • isLeapYear — true for proleptic Gregorian leap years
  • daysInMonth — correct day-count for any month including February on leap years
  • isSameDay / isSameMonth — calendar equality predicates
  • addDuration / subtractDuration — month-clamping arithmetic (Jan 31 + 1 month = Feb 28/29)
  • startOf / endOf — precise boundary computation for years, months, weeks, days, hours, minutes, seconds, milliseconds
  • getCalendarMonth — full grid of CalendarDay entries with isToday driven by injected Clock
  • asTimezone — brands a raw string as a Timezone opaque 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.