first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
# calendar
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.