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,99 @@
# calendar-boundary
## Purpose
`calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and last moments of any calendar unit (year, month, week, day, hour, minute, second, or millisecond) for a given date. These are the building blocks for date-range queries, fiscal period calculations, and UI calendar display logic.
Both functions are pure and stateless. They return new `Date` objects without mutating their inputs and operate in local time.
## Features
- `startOf` — first moment of the given unit (midnight for day, 00:00:00.000 for hours, etc.)
- `endOf` — last millisecond of the given unit (23:59:59.999 for day)
- Supports all `DurationUnit` values: `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds`
- `weeks` uses Sunday as the week start (matching `Date.prototype.getDay()`)
- `BoundaryOptions` interface with optional `clock` for future extension
- `milliseconds` unit returns the date unchanged (identity)
## Usage: Basic Example
```ts
import { startOf, endOf } from "@suaveplan/datetime";
const d = new Date(2024, 2, 15, 14, 30, 45, 500); // Mar 15 2024 14:30:45.500
const dayStart = startOf(d, "days");
// 2024-03-15 00:00:00.000
const monthEnd = endOf(d, "months");
// 2024-03-31 23:59:59.999
```
## Usage: Advanced Example
```ts
import { startOf, endOf } from "@suaveplan/datetime";
// Fiscal year boundaries
const q1Start = startOf("2024-03-15", "years"); // Jan 1 2024
const q1End = endOf("2024-03-31", "months"); // Mar 31 2024 23:59:59.999
// Current week range (Sunday–Saturday)
const today = new Date();
const weekStart = startOf(today, "weeks");
const weekEnd = endOf(today, "weeks");
// Hour slot boundaries
const slotStart = startOf(new Date(2024, 0, 1, 9, 45, 0), "hours");
// 2024-01-01 09:00:00.000
const slotEnd = endOf(new Date(2024, 0, 1, 9, 45, 0), "hours");
// 2024-01-01 09:59:59.999
```
## API Reference
### `BoundaryOptions`
```ts
interface BoundaryOptions {
clock?: Clock;
}
```
Reserved for future use (e.g. timezone-aware boundaries). Currently unused in the function bodies.
### `startOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date`
Returns the first moment of the `unit` period containing `date`.
| Unit | Result |
|------|--------|
| `years` | Jan 1 00:00:00.000 |
| `months` | 1st of month 00:00:00.000 |
| `weeks` | Sunday of the week 00:00:00.000 |
| `days` | midnight 00:00:00.000 |
| `hours` | :00:00.000 of the hour |
| `minutes` | :00.000 of the minute |
| `seconds` | .000 of the second |
| `milliseconds` | unchanged |
### `endOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date`
Returns the last millisecond of the `unit` period containing `date`.
| Unit | Result |
|------|--------|
| `years` | Dec 31 23:59:59.999 |
| `months` | Last day 23:59:59.999 |
| `weeks` | Saturday of the week 23:59:59.999 |
| `days` | 23:59:59.999 |
| `hours` | :59:59.999 |
| `minutes` | :59.999 |
| `seconds` | .999 |
| `milliseconds` | unchanged |
## Implementation Notes
`startOf("weeks")` subtracts `date.getDay()` days (0 = Sunday) to find the Sunday anchor. `endOf("weeks")` adds `6 - date.getDay()` to reach Saturday. This matches the JavaScript `Date.getDay()` convention of Sunday = 0. Callers needing Monday-start weeks should offset by one day using `addDuration`.
`endOf("months")` calls `daysInMonth` to find the last valid day of the month, correctly handling February on leap years.

View File

@@ -0,0 +1,82 @@
# calendar-core
## Purpose
`calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput` values into `Date` objects, predicates for same-day and same-month equality, leap year detection, and days-in-month calculation. These are the lowest-level building blocks consumed by every other calendar sub-module.
All functions are pure and stateless. They accept local-time values and return local-time `Date` objects. There are no side effects and no external dependencies beyond `@suaveplan/error`.
## Features
- `toDate` — coerce `Date | string | number` to `Date`, throws `DatetimeError` for invalid inputs
- `asTimezone` — brands a raw string as the opaque `Timezone` type
- `isLeapYear` — true for proleptic Gregorian leap years
- `daysInMonth` — correct day-count for any month, including February on leap years
- `isSameDay` — calendar equality predicate (year + month + date)
- `isSameMonth` — calendar month equality predicate (year + month)
## Usage: Basic Example
```ts
import { toDate, isLeapYear, daysInMonth, isSameDay } from "@suaveplan/datetime";
const d = toDate("2024-02-15");
console.log(isLeapYear(2024)); // true
console.log(daysInMonth(2024, 2)); // 29
console.log(isSameDay(d, new Date(2024, 1, 15))); // true
```
## Usage: Advanced Example
```ts
import { toDate, isSameMonth, asTimezone } from "@suaveplan/datetime";
const jan1 = toDate(new Date(2024, 0, 1));
const jan31 = toDate("2024-01-31");
const feb1 = toDate(1706745600000); // epoch ms for 2024-02-01
console.log(isSameMonth(jan1, jan31)); // true
console.log(isSameMonth(jan31, feb1)); // false
// Error on invalid input
try {
toDate("not-a-date");
} catch (e) {
console.log((e as { code: string }).code); // DATETIME.PARSE.INVALID_INPUT
}
const tz = asTimezone("America/New_York");
```
## 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 is performed.
### `isLeapYear(year: number): boolean`
Returns `true` for proleptic Gregorian leap years (divisible by 4, not 100, or divisible by 400).
### `daysInMonth(year: number, month: number): number`
Returns the number of days in the given month (1-indexed). Handles February leap-year logic via `new Date(year, month, 0).getDate()`.
### `isSameDay(a: DateInput, b: DateInput): boolean`
Returns `true` when `a` and `b` share the same calendar year, month, and date.
### `isSameMonth(a: DateInput, b: DateInput): boolean`
Returns `true` when `a` and `b` share the same calendar year and month.
## Implementation Notes
`daysInMonth` uses the "day 0 of the following month" trick: `new Date(year, month, 0).getDate()` returns the last day of the prior month, which equals the number of days in that month. This avoids a lookup table and handles leap years correctly via the JavaScript `Date` constructor.
`toDate` copies `Date` inputs (returns `new Date(input.getTime())`) to prevent mutation aliasing. String inputs are passed directly to `new Date()` which follows ISO 8601 parsing rules in all modern runtimes.

View File

@@ -0,0 +1,73 @@
# calendar-duration
## Purpose
`calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from dates with correct month-end clamping and overflow normalisation. It is the arithmetic engine that powers use cases such as "one month from January 31" and "subtract 90 days".
The module separates year/month arithmetic (done in calendar coordinates to avoid day-count ambiguity) from sub-day arithmetic (done as millisecond offsets on a local-midnight base date).
## Features
- `addDuration` — add a `Duration` bag to a date with month-end clamping
- `subtractDuration` — subtract a `Duration` bag (negates fields, delegates to `addDuration`)
- Handles all `DurationUnit` fields: `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds`
- Month overflow/underflow normalisation (e.g. month 13 → January of next year)
- Day clamping prevents invalid dates (e.g. Jan 31 + 1 month = Feb 28/29)
## Usage: Basic Example
```ts
import { addDuration, subtractDuration } from "@suaveplan/datetime";
const jan31 = new Date(2024, 0, 31);
const feb29 = addDuration(jan31, { months: 1 });
console.log(feb29.toDateString()); // Thu Feb 29 2024 (leap year)
const yesterday = subtractDuration(new Date(), { days: 1 });
```
## Usage: Advanced Example
```ts
import { addDuration } from "@suaveplan/datetime";
// Combined duration: 1 year, 2 months, 3 days, 4 hours
const base = new Date(2023, 10, 30, 12, 0, 0, 0); // Nov 30 2023 12:00
const result = addDuration(base, {
years: 1,
months: 2,
days: 3,
hours: 4,
});
// Feb 2, 2025 16:00 — Nov 30 + 1y2m = Jan 30 2025, then +3d = Feb 2, +4h = 16:00
// Month underflow normalisation: subtracting months past January
const dec1 = new Date(2024, 11, 1);
const oct = addDuration(dec1, { months: -2 });
console.log(oct.getMonth()); // 9 (October)
```
## API Reference
### `addDuration(date: DateInput, duration: Duration): Date`
Add `duration` to `date`. Year/month fields are applied first with overflow/underflow normalisation. Day/week/hour/minute/second/millisecond fields are added as a millisecond offset to the clamped local-midnight date.
**Params:**
- `date` — `Date`, ISO string, or epoch number
- `duration` — `Duration` bag; all fields are optional and default to `0`
**Returns:** New `Date` (does not mutate input).
### `subtractDuration(date: DateInput, duration: Duration): Date`
Subtract `duration` from `date`. Internally negates all fields and delegates to `addDuration`.
**Params:** same as `addDuration`.
**Returns:** New `Date`.
## Implementation Notes
Year and month arithmetic is performed in integer calendar coordinates rather than millisecond arithmetic to avoid the 28–31 day ambiguity. After clamping the day to the last valid day of the resulting month, the remaining duration components (weeks, days, hours, minutes, seconds, milliseconds) are totalled as a single millisecond value and added to a local-midnight `Date` object. This approach avoids DST-induced errors that would occur if each component were added sequentially in timestamp space.

View File

@@ -0,0 +1,78 @@
# calendar-grid
## Purpose
`calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components and scheduling displays. It returns a `CalendarMonth` value containing one `CalendarDay` entry per day in the month, each annotated with its day-of-week index, whether it is a weekend, and whether it represents today (driven by an injectable `Clock`).
The module is intentionally narrow: it answers "what are the days in this month and which one is today?" — layout concerns (week rows, leading/trailing days from adjacent months) are left to the consumer.
## Features
- `getCalendarMonth` — builds a `CalendarMonth` grid for a given year and 1-indexed month
- Each `CalendarDay` includes `date`, `dayOfWeek` (0=Sun … 6=Sat), `isWeekend`, and `isToday`
- Injectable `Clock` for deterministic rendering in tests and server-side snapshots
- Zero side effects; all computation is local
## Usage: Basic Example
```ts
import { getCalendarMonth } from "@suaveplan/datetime";
const march = getCalendarMonth(2024, 3);
console.log(march.days.length); // 31
console.log(march.days[0]?.dayOfWeek); // 5 (Friday — Mar 1 2024)
const weekends = march.days.filter((d) => d.isWeekend);
console.log(weekends.length); // 10
```
## Usage: Advanced Example
```ts
import { getCalendarMonth } from "@suaveplan/datetime";
// Deterministic "today" for SSR or snapshot tests
const fakeClock = () => new Date(2024, 2, 15).getTime(); // Mar 15 2024
const march = getCalendarMonth(2024, 3, { clock: fakeClock });
const today = march.days.find((d) => d.isToday);
console.log(today?.date.getDate()); // 15
// Render a simple ASCII calendar header
for (const day of march.days) {
const marker = day.isToday ? "*" : day.isWeekend ? "W" : " ";
process.stdout.write(`${marker}${String(day.date.getDate()).padStart(2)} `);
if (day.dayOfWeek === 6) process.stdout.write("\n");
}
```
## API Reference
### `CalendarMonthOptions`
```ts
interface CalendarMonthOptions {
clock?: Clock;
}
```
- `clock` — injectable `Clock` (returns epoch ms). Defaults to `Date.now`.
### `getCalendarMonth(year: number, month: number, opts?: CalendarMonthOptions): CalendarMonth`
Build a `CalendarMonth` for the given `year` and 1-indexed `month`.
**Params:**
- `year` — full calendar year (e.g. 2024)
- `month` — 1-indexed month (1 = January, 12 = December)
- `opts.clock` — optional injectable clock
**Returns:** `{ year, month, days: CalendarDay[] }` where each `CalendarDay` is:
```ts
{ date: Date; dayOfWeek: Weekday; isWeekend: boolean; isToday: boolean }
```
## Implementation Notes
`isToday` is computed by comparing a `"year-month-date"` string derived from `clock()` to the same string for each grid day. This string comparison avoids UTC/local timezone confusion that would arise from comparing epoch timestamps directly. The `clock` default (`Date.now`) is captured once per call to ensure consistent "today" across all days in the grid.

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.