Initial Commit
This commit is contained in:
15
packages/foundation/timing/README.md
Normal file
15
packages/foundation/timing/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `timing`
|
||||
|
||||
Cron, stopwatches, throttling, debouncing, and scheduling.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `timing`
|
||||
|
||||
3 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cron`](./cron/README.md) | Universal cron expression parser, next-fire calculator with timezone + DST awareness, and an opt-in lightweight in-process scheduler. Zero external cron d… |
|
||||
| [`@suaveplan/datetime`](./datetime/README.md) | Timezone-aware date/time utilities covering the most common application-layer date operations that the native `Date` API leaves unaddressed. The package provid… |
|
||||
| [`@suaveplan/timing`](./timing/README.md) | Timing and scheduling utilities for application-layer time management. The package covers measurement (stopwatch with lap recording), rate control (throttle an… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/foundation/timing/_intro.md
Normal file
1
packages/foundation/timing/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Cron, stopwatches, throttling, debouncing, and scheduling.
|
||||
144
packages/foundation/timing/cron/README.md
Normal file
144
packages/foundation/timing/cron/README.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# @suaveplan/cron - Universal cron expression parser, next-fire calculator with timezone + DST support, and lightweight in-process scheduler
|
||||
|
||||
## Summary
|
||||
|
||||
Universal cron expression parser, next-fire calculator with
|
||||
timezone + DST awareness, and an opt-in lightweight in-process
|
||||
scheduler. Zero external cron dependencies — written natively against
|
||||
`@suaveplan/error`, `@suaveplan/types`, and `Intl.DateTimeFormat`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [errors](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ - [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ - [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import { parseCron, isValidCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("*/15 * * * *");
|
||||
console.log(expr.fields.minutes); // [0, 15, 30, 45]
|
||||
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "America/New_York");
|
||||
console.log(new Date(next as number).toISOString());
|
||||
|
||||
console.log(isValidCron("0 9 * * MON-FRI")); // true
|
||||
console.log(isValidCron("garbage")); // false
|
||||
```
|
||||
|
||||
## Scheduler example
|
||||
|
||||
```typescript
|
||||
import { CronScheduler } from "@suaveplan/cron";
|
||||
|
||||
const scheduler = new CronScheduler({
|
||||
onError: (err, id) => console.error(`job ${id} failed`, err),
|
||||
});
|
||||
|
||||
scheduler.add("0 9 * * MON-FRI", async () => sendDailyDigest(), {
|
||||
tz: "America/New_York",
|
||||
});
|
||||
scheduler.add("@hourly", () => collectMetrics());
|
||||
|
||||
scheduler.start();
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await scheduler.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## FakeClock test recipe
|
||||
|
||||
```typescript
|
||||
import { CronScheduler } from "@suaveplan/cron";
|
||||
import type { SchedulerTimers } from "@suaveplan/cron";
|
||||
|
||||
const now = { value: Date.UTC(2026, 4, 11, 9, 59, 0) };
|
||||
const pending = new Map<number, { cb: () => void; fireAt: number }>();
|
||||
let nextId = 1;
|
||||
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, { cb, fireAt: now.value + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
|
||||
const scheduler = new CronScheduler({ clock: () => now.value, timers });
|
||||
scheduler.add("0 * * * *", () => console.log("fired"));
|
||||
scheduler.start();
|
||||
|
||||
// Fast-forward 1 hour.
|
||||
now.value += 60 * 60 * 1000;
|
||||
for (const [id, { cb, fireAt }] of pending) {
|
||||
if (fireAt <= now.value) { pending.delete(id); cb(); }
|
||||
}
|
||||
|
||||
await scheduler.stop();
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Surface | Purpose |
|
||||
| -------------------------------- | ------------------------------------------------------------------ |
|
||||
| `parseCron(expr, options?)` | Parse a 5/6/7-field expression into a frozen `CronExpression`. |
|
||||
| `isValidCron(expr, options?)` | Non-throwing parse check. |
|
||||
| `CronExpression.nextRun` | Next epoch instant strictly greater than `after`. |
|
||||
| `CronExpression.nextRuns` | Strictly monotonic array of `count` fire times. |
|
||||
| `CronExpression.previousRun` | Largest epoch instant strictly less than `before`. |
|
||||
| `CronExpression.matches` | Constant-time wall-clock membership check. |
|
||||
| `CronScheduler` | Lightweight in-process driver (no queue/retry/concurrency). |
|
||||
| `createCronScheduler` | Factory mirror. |
|
||||
| `parseField`, `rangeFor` | Single-field tokenizer + per-kind range. |
|
||||
| `expandMacro`, `macroTable` | Macro shortcut expansion (`@daily`, `@hourly`, …). |
|
||||
| `toWallClock`, `fromWallClock` | Bidirectional epoch ↔ wall-clock conversion in any IANA zone. |
|
||||
| `assertTimezone` | Eager IANA-zone validation. |
|
||||
| `detectDstTransition` | DST classifier between two adjacent instants. |
|
||||
| `CronError`, `CRON_CODES` | Typed error class + frozen code registry. |
|
||||
| `recordParse`, `recordNextRun` | OpenTelemetry instrumentation helpers. |
|
||||
|
||||
## Why not croner
|
||||
|
||||
Croner is excellent reference material but ships as an opaque tarball,
|
||||
imposes its own API conventions, and is not universal in the
|
||||
sense. `@suaveplan/cron` lives at the same primitive
|
||||
tier as `@suaveplan/datetime` and `@suaveplan/timing`: zero external
|
||||
runtime deps beyond the foundation, typed errors via
|
||||
`defineCodes`, Zod boundary schemas, an OTel surface, and the standard
|
||||
five-files-per-feature layout. Splitting cron parsing out of the
|
||||
job-scheduler keeps the workflow-tier scheduler focused on queue /
|
||||
retry / concurrency concerns while letting validation packages and
|
||||
browser UIs consume parsing without dragging in the runtime.
|
||||
|
||||
## Coverage
|
||||
|
||||
100 % Istanbul coverage on every implementation file is gated by CI:
|
||||
|
||||
```bash
|
||||
bunx turbo test:coverage --filter=@suaveplan/cron
|
||||
```
|
||||
|
||||
143
packages/foundation/timing/cron/package.json
Normal file
143
packages/foundation/timing/cron/package.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"name": "@suaveplan/cron",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal cron expression parser, next-fire calculator with timezone + DST support, and lightweight in-process scheduler",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/cron-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/cron-error.d.ts",
|
||||
"default": "./dist/errors/cron-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/cron-error.d.ts",
|
||||
"default": "./dist/errors/cron-error.js"
|
||||
}
|
||||
},
|
||||
"./parse/parse-cron": {
|
||||
"import": {
|
||||
"types": "./dist/parse/parse-cron.d.ts",
|
||||
"default": "./dist/parse/parse-cron.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parse/parse-cron.d.ts",
|
||||
"default": "./dist/parse/parse-cron.js"
|
||||
}
|
||||
},
|
||||
"./fields/cron-fields": {
|
||||
"import": {
|
||||
"types": "./dist/fields/cron-fields.d.ts",
|
||||
"default": "./dist/fields/cron-fields.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/fields/cron-fields.d.ts",
|
||||
"default": "./dist/fields/cron-fields.js"
|
||||
}
|
||||
},
|
||||
"./macros/macros": {
|
||||
"import": {
|
||||
"types": "./dist/macros/macros.d.ts",
|
||||
"default": "./dist/macros/macros.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/macros/macros.d.ts",
|
||||
"default": "./dist/macros/macros.js"
|
||||
}
|
||||
},
|
||||
"./next-run/next-run": {
|
||||
"import": {
|
||||
"types": "./dist/next-run/next-run.d.ts",
|
||||
"default": "./dist/next-run/next-run.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/next-run/next-run.d.ts",
|
||||
"default": "./dist/next-run/next-run.js"
|
||||
}
|
||||
},
|
||||
"./timezone/timezone": {
|
||||
"import": {
|
||||
"types": "./dist/timezone/timezone.d.ts",
|
||||
"default": "./dist/timezone/timezone.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/timezone/timezone.d.ts",
|
||||
"default": "./dist/timezone/timezone.js"
|
||||
}
|
||||
},
|
||||
"./scheduler/cron-scheduler": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/cron-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/cron-scheduler.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/scheduler/cron-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/cron-scheduler.js"
|
||||
}
|
||||
},
|
||||
"./otel/cron-otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/cron-otel.d.ts",
|
||||
"default": "./dist/otel/cron-otel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/cron-otel.d.ts",
|
||||
"default": "./dist/otel/cron-otel.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
80
packages/foundation/timing/cron/src/errors/cron-error.md
Normal file
80
packages/foundation/timing/cron/src/errors/cron-error.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# CronError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CronError` is the single typed error class emitted by every public entry
|
||||
point in `@suaveplan/cron`. It extends `SuaveplanError` with the literal
|
||||
`CronErrorCode` union so `instanceof CronError` plus `err.code` lets
|
||||
consumers branch on a finite, registered set of failure modes without
|
||||
parsing free-form `message` strings. The companion `CRON_CODES` registry is
|
||||
built via `defineCodes("CRON", [...])` so each fully-qualified code
|
||||
(`CRON.INVALID_EXPRESSION`, etc.) is generated once and frozen at module
|
||||
load.
|
||||
|
||||
## Features
|
||||
|
||||
- Five typed codes covering every documented failure mode: invalid
|
||||
expression, field-out-of-range numerics, unsupported syntax (`L`/`W`/`#`),
|
||||
invalid IANA timezone names, and exhausted next-run search horizons.
|
||||
- Eager registration via `registerCronErrorCodes()` runs at module load.
|
||||
- Runtime guard in the public constructor rejects unknown codes — defends
|
||||
against JSON-deserialised payloads injecting unregistered codes.
|
||||
- Cause-chaining via `SuaveplanError` (`options.cause`) and structured
|
||||
`context` for telemetry redaction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRON_CODES, CronError } from "@suaveplan/cron";
|
||||
|
||||
try {
|
||||
throw new CronError(CRON_CODES.INVALID_EXPRESSION, "cron.invalid_expression", {
|
||||
context: { expr: "* * *" },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CronError && err.code === CRON_CODES.INVALID_EXPRESSION) {
|
||||
console.warn("rejected", err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRON_CODES, CronError } from "@suaveplan/cron";
|
||||
|
||||
function wrap(cause: Error): CronError {
|
||||
return new CronError(CRON_CODES.NEXT_RUN_NOT_FOUND, "cron.next_run_not_found", {
|
||||
cause,
|
||||
context: { horizonYears: 8 },
|
||||
});
|
||||
}
|
||||
|
||||
const wrapped = wrap(new Error("horizon exhausted"));
|
||||
console.log(wrapped.code, wrapped.cause);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CRON_CODES` — frozen record mapping reason → fully-qualified code.
|
||||
- `CronErrorCode` — string-literal union of every registered code.
|
||||
- `class CronError extends SuaveplanError<CronErrorCode>` — public
|
||||
constructor `(code, messageKey, options?)`. Throws `TypeError` for
|
||||
unregistered codes.
|
||||
- `registerCronErrorCodes(): number` — eager-registration probe that
|
||||
returns the number of registered codes. Safe to call any number of
|
||||
times — never has side effects beyond reading the frozen registry.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is captured into a `Set` once at module evaluation so the
|
||||
constructor guard is O(1). The guard runs before `super()` so a malformed
|
||||
code surfaces as a `TypeError` (not a half-constructed `CronError`).
|
||||
`registerCronErrorCodes` is invoked at module load and is also exported so
|
||||
callers performing dependency-injection tree-shaking inspection have an
|
||||
explicit hook. The error class is `final` in spirit — subclassing is not
|
||||
part of the public contract; consumers should always discriminate on
|
||||
`err.code`.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError, registerCronErrorCodes } from "./cron-error.js";
|
||||
|
||||
describe("CronError", () => {
|
||||
it("CRON_CODES exposes every documented code", { timeout: 5000 }, () => {
|
||||
expect(CRON_CODES.INVALID_EXPRESSION).toBe("CRON.INVALID_EXPRESSION");
|
||||
expect(CRON_CODES.FIELD_OUT_OF_RANGE).toBe("CRON.FIELD_OUT_OF_RANGE");
|
||||
expect(CRON_CODES.UNSUPPORTED_SYNTAX).toBe("CRON.UNSUPPORTED_SYNTAX");
|
||||
expect(CRON_CODES.TIMEZONE_INVALID).toBe("CRON.TIMEZONE_INVALID");
|
||||
expect(CRON_CODES.NEXT_RUN_NOT_FOUND).toBe("CRON.NEXT_RUN_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("constructs with code and message key", { timeout: 5000 }, () => {
|
||||
const err = new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression"
|
||||
);
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("CRON.INVALID_EXPRESSION");
|
||||
expect(err.name).toBe("CronError");
|
||||
});
|
||||
|
||||
it("threads context through to the base", { timeout: 5000 }, () => {
|
||||
const err = new CronError(
|
||||
CRON_CODES.FIELD_OUT_OF_RANGE,
|
||||
"cron.field_out_of_range",
|
||||
{ context: { value: 99, max: 59 } }
|
||||
);
|
||||
expect(err.context).toEqual({ value: 99, max: 59 });
|
||||
});
|
||||
|
||||
it("threads cause through to the base", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new CronError(
|
||||
CRON_CODES.TIMEZONE_INVALID,
|
||||
"cron.timezone_invalid",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("rejects unregistered codes at the public constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bogus =
|
||||
"CRON.NOPE" as unknown as typeof CRON_CODES.INVALID_EXPRESSION;
|
||||
expect(() => new CronError(bogus, "cron.invalid_expression")).toThrow(
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
it("registerCronErrorCodes returns the registered code count", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(registerCronErrorCodes()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
57
packages/foundation/timing/cron/src/errors/cron-error.ts
Normal file
57
packages/foundation/timing/cron/src/errors/cron-error.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every `CRON.*` error code emitted by `@suaveplan/cron`.
|
||||
*
|
||||
* The registry is built via `defineCodes("CRON", [...])` and is part of the
|
||||
* package's public contract. Consumers branch on `err.code` rather than
|
||||
* parsing the human-readable `message`.
|
||||
*/
|
||||
export const CRON_CODES = defineCodes("CRON", [
|
||||
"INVALID_EXPRESSION",
|
||||
"FIELD_OUT_OF_RANGE",
|
||||
"UNSUPPORTED_SYNTAX",
|
||||
"TIMEZONE_INVALID",
|
||||
"NEXT_RUN_NOT_FOUND",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified `CRON.*` error code. */
|
||||
export type CronErrorCode = (typeof CRON_CODES)[keyof typeof CRON_CODES];
|
||||
|
||||
const VALID_CODES = new Set<string>(Object.values(CRON_CODES));
|
||||
|
||||
/**
|
||||
* Typed error class for the cron family. The public constructor performs a
|
||||
* runtime guard so that consumers passing an unregistered code at the
|
||||
* type-erased boundary (e.g. JSON deserialised codes) get a hard failure
|
||||
* rather than a silently-malformed error.
|
||||
*/
|
||||
export class CronError extends SuaveplanError<CronErrorCode> {
|
||||
public constructor(
|
||||
code: CronErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
if (!VALID_CODES.has(code)) {
|
||||
throw new TypeError(`Unknown CronError code: ${String(code)}`);
|
||||
}
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager registration sentinel. Reading the registry size pins the
|
||||
* `defineCodes` call so the lookup `Set` is materialised before any
|
||||
* `CronError` is thrown. Returns the registered code count so callers
|
||||
* doing dependency-injection tree-shaking inspection have a side-effect-
|
||||
* free probe to assert against.
|
||||
*/
|
||||
export function registerCronErrorCodes(): number {
|
||||
return VALID_CODES.size;
|
||||
}
|
||||
|
||||
registerCronErrorCodes();
|
||||
6
packages/foundation/timing/cron/src/errors/index.ts
Normal file
6
packages/foundation/timing/cron/src/errors/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { CronErrorCode } from "./cron-error.js";
|
||||
export {
|
||||
CRON_CODES,
|
||||
CronError,
|
||||
registerCronErrorCodes,
|
||||
} from "./cron-error.js";
|
||||
89
packages/foundation/timing/cron/src/fields/cron-fields.md
Normal file
89
packages/foundation/timing/cron/src/fields/cron-fields.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# parseField
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`parseField` is the single-field tokenizer at the heart of cron parsing.
|
||||
Each of the five (or six/seven) whitespace-separated positions in a cron
|
||||
expression is independently expanded into the sorted, unique numeric set
|
||||
of values it matches. The function is total over its `FieldKind`
|
||||
discriminator: it accepts the canonical crontab syntax surface (literals,
|
||||
ranges, lists, steps, wildcards, and alphabetic aliases for month +
|
||||
day-of-week) and rejects every Quartz extension (`L`/`W`/`#`/`?`) with
|
||||
typed errors so the caller can branch on `err.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Wildcards (`*`), literals, comma-separated lists, hyphenated ranges,
|
||||
step values (`*/n` and `a-b/n` and `a/n`), case-insensitive aliases
|
||||
(`JAN`-`DEC` for month, `SUN`-`SAT` for day-of-week).
|
||||
- Day-of-week normalization: both `0` and `7` are accepted for Sunday and
|
||||
collapse to `0` after sorting and deduplication.
|
||||
- Range bounds per field exposed via `rangeFor(kind)` for downstream
|
||||
next-run computations.
|
||||
- Typed errors via `CronError`: out-of-range numerics, unsupported tokens,
|
||||
and malformed atoms each surface a distinct `CronErrorCode`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseField } from "@suaveplan/cron";
|
||||
|
||||
const minutes = parseField("*/15", "minute");
|
||||
console.log(Array.from(minutes)); // [0, 15, 30, 45]
|
||||
|
||||
const weekdays = parseField("MON-FRI", "dayOfWeek");
|
||||
console.log(Array.from(weekdays)); // [1, 2, 3, 4, 5]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseField, rangeFor, CronError, CRON_CODES } from "@suaveplan/cron";
|
||||
|
||||
function expandOrFallback(token: string, kind: Parameters<typeof parseField>[1]) {
|
||||
try {
|
||||
return parseField(token, kind);
|
||||
} catch (err) {
|
||||
if (err instanceof CronError && err.code === CRON_CODES.UNSUPPORTED_SYNTAX) {
|
||||
const range = rangeFor(kind);
|
||||
return Object.freeze(
|
||||
Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i)
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(expandOrFallback("L", "dayOfMonth")); // falls back to full range
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseField(token: string, kind: FieldKind): readonly number[]` — returns a
|
||||
frozen, sorted, deduplicated value set. Throws `CronError` with codes
|
||||
`CRON.UNSUPPORTED_SYNTAX`, `CRON.FIELD_OUT_OF_RANGE`, or
|
||||
`CRON.INVALID_EXPRESSION` per the failure mode.
|
||||
- `rangeFor(kind: FieldKind): FieldRange` — inclusive `{min, max}` for the
|
||||
given field kind.
|
||||
- `FieldKind` — `"second" | "minute" | "hour" | "dayOfMonth" | "month" |
|
||||
"dayOfWeek" | "year"`.
|
||||
- `FieldRange` — `{ readonly min: number; readonly max: number }`.
|
||||
- `FieldAliases` — `Readonly<Record<string, number>>` (internal alias map
|
||||
shape).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tokenizer walks comma-separated terms left-to-right, dispatching each
|
||||
term through the step parser before falling through to the range parser.
|
||||
Step extraction is bounded to a single `/` per term; nested steps
|
||||
(`*/15/2`) are rejected up front. Range expansion is O(n) over the result
|
||||
set and feeds a `Set` for deduplication before a final monotonically
|
||||
sorted `Array.from`. Day-of-week values are normalized after expansion so
|
||||
both `0,7` and `SAT,SUN` collapse correctly. Unsupported Quartz tokens
|
||||
(`L`/`W`/`#`/`?`) are caught by a single regex before any further parsing
|
||||
to keep the error path cheap; alias resolution is case-insensitive via
|
||||
`toUpperCase`. The output array is `Object.freeze`d to prevent downstream
|
||||
mutation.
|
||||
@@ -0,0 +1,13 @@
|
||||
import z from "zod";
|
||||
|
||||
export const FieldKindSchema = z.enum([
|
||||
"second",
|
||||
"minute",
|
||||
"hour",
|
||||
"dayOfMonth",
|
||||
"month",
|
||||
"dayOfWeek",
|
||||
"year",
|
||||
]);
|
||||
|
||||
export const FieldTokenSchema = z.string().min(1).max(256);
|
||||
167
packages/foundation/timing/cron/src/fields/cron-fields.test.ts
Normal file
167
packages/foundation/timing/cron/src/fields/cron-fields.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { parseField, rangeFor } from "./cron-fields.js";
|
||||
|
||||
describe("parseField — wildcards", () => {
|
||||
it("expands * for minute to 0..59", { timeout: 5000 }, () => {
|
||||
const out = parseField("*", "minute");
|
||||
expect(out.length).toBe(60);
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[59]).toBe(59);
|
||||
});
|
||||
|
||||
it("expands * for hour to 0..23", { timeout: 5000 }, () => {
|
||||
const out = parseField("*", "hour");
|
||||
expect(Array.from(out)).toEqual(
|
||||
Array.from({ length: 24 }, (_, i) => i)
|
||||
);
|
||||
});
|
||||
|
||||
it("expands * for month to 1..12", { timeout: 5000 }, () => {
|
||||
const out = parseField("*", "month");
|
||||
expect(out[0]).toBe(1);
|
||||
expect(out[out.length - 1]).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — step values", () => {
|
||||
it("*/15 on minutes yields [0,15,30,45]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("*/15", "minute"))).toEqual([
|
||||
0, 15, 30, 45,
|
||||
]);
|
||||
});
|
||||
|
||||
it("0-30/10 yields [0,10,20,30]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("0-30/10", "minute"))).toEqual([
|
||||
0, 10, 20, 30,
|
||||
]);
|
||||
});
|
||||
|
||||
it("5/15 walks from 5 to range.max", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("5/15", "minute"))).toEqual([
|
||||
5, 20, 35, 50,
|
||||
]);
|
||||
});
|
||||
|
||||
it("step must be positive", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("*/0", "minute")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("non-numeric step rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("*/x", "minute")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("malformed step rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("*/15/2", "minute")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — ranges + lists", () => {
|
||||
it("1-5 dayOfWeek yields mon..fri", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("1-5", "dayOfWeek"))).toEqual([
|
||||
1, 2, 3, 4, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes overlapping list entries", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("1,2,1,3,2", "hour"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("malformed range rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("1-2-3", "hour")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("range start > end rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("10-2", "hour")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("empty term rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("1,,3", "hour")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — aliases", () => {
|
||||
it("MON-FRI yields [1,2,3,4,5]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("MON-FRI", "dayOfWeek"))).toEqual([
|
||||
1, 2, 3, 4, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("case-insensitive aliases", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("jan-mar", "month"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("unknown alias rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("FOO", "month")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("alias not allowed on hour", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("MON", "hour")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — dayOfWeek 0/7 normalization", () => {
|
||||
it("7 normalizes to 0", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("7", "dayOfWeek"))).toEqual([0]);
|
||||
});
|
||||
|
||||
it("0,7 dedupes to [0]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("0,7", "dayOfWeek"))).toEqual([0]);
|
||||
});
|
||||
|
||||
it("SUN normalizes to 0", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("SUN", "dayOfWeek"))).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — out-of-range + unsupported", () => {
|
||||
it("60 on minute is out of range", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseField("60", "minute");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.FIELD_OUT_OF_RANGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("L token unsupported", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseField("L", "dayOfMonth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.UNSUPPORTED_SYNTAX);
|
||||
}
|
||||
});
|
||||
|
||||
it("W token unsupported", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("15W", "dayOfMonth")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("# token unsupported", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("2#3", "dayOfWeek")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("? token unsupported", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("?", "dayOfMonth")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("malformed atom rejected (mixed numeric+symbol)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseField("1.5", "minute")).toThrow(CronError);
|
||||
expect(() => parseField("@bogus", "minute")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangeFor", () => {
|
||||
it("returns documented ranges", { timeout: 5000 }, () => {
|
||||
expect(rangeFor("minute")).toEqual({ min: 0, max: 59 });
|
||||
expect(rangeFor("hour")).toEqual({ min: 0, max: 23 });
|
||||
expect(rangeFor("dayOfMonth")).toEqual({ min: 1, max: 31 });
|
||||
expect(rangeFor("month")).toEqual({ min: 1, max: 12 });
|
||||
expect(rangeFor("year")).toEqual({ min: 1970, max: 2199 });
|
||||
expect(rangeFor("second")).toEqual({ min: 0, max: 59 });
|
||||
expect(rangeFor("dayOfWeek")).toEqual({ min: 0, max: 7 });
|
||||
});
|
||||
});
|
||||
195
packages/foundation/timing/cron/src/fields/cron-fields.ts
Normal file
195
packages/foundation/timing/cron/src/fields/cron-fields.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import type {
|
||||
FieldAliases,
|
||||
FieldKind,
|
||||
FieldRange,
|
||||
} from "./cron-fields.types.js";
|
||||
|
||||
const MONTH_ALIASES: FieldAliases = Object.freeze({
|
||||
JAN: 1,
|
||||
FEB: 2,
|
||||
MAR: 3,
|
||||
APR: 4,
|
||||
MAY: 5,
|
||||
JUN: 6,
|
||||
JUL: 7,
|
||||
AUG: 8,
|
||||
SEP: 9,
|
||||
OCT: 10,
|
||||
NOV: 11,
|
||||
DEC: 12,
|
||||
});
|
||||
|
||||
const DAY_OF_WEEK_ALIASES: FieldAliases = Object.freeze({
|
||||
SUN: 0,
|
||||
MON: 1,
|
||||
TUE: 2,
|
||||
WED: 3,
|
||||
THU: 4,
|
||||
FRI: 5,
|
||||
SAT: 6,
|
||||
});
|
||||
|
||||
const RANGES: Readonly<Record<FieldKind, FieldRange>> = Object.freeze({
|
||||
second: { min: 0, max: 59 },
|
||||
minute: { min: 0, max: 59 },
|
||||
hour: { min: 0, max: 23 },
|
||||
dayOfMonth: { min: 1, max: 31 },
|
||||
month: { min: 1, max: 12 },
|
||||
// dow accepts 7 as Sunday on input; normalized to 0 below.
|
||||
dayOfWeek: { min: 0, max: 7 },
|
||||
year: { min: 1970, max: 2199 },
|
||||
});
|
||||
|
||||
const ALIASES: Readonly<Record<FieldKind, FieldAliases>> = Object.freeze({
|
||||
second: {},
|
||||
minute: {},
|
||||
hour: {},
|
||||
dayOfMonth: {},
|
||||
month: MONTH_ALIASES,
|
||||
dayOfWeek: DAY_OF_WEEK_ALIASES,
|
||||
year: {},
|
||||
});
|
||||
|
||||
// Unsupported Quartz tokens: `L`/`W` standalone or suffixed to a numeric
|
||||
// (e.g. `15W`, `5L`), `#` for nth-weekday (`2#3`), `?` placeholder.
|
||||
// Alphabetic aliases like `JUL` or `FRI` must NOT be flagged here.
|
||||
const UNSUPPORTED_TOKENS = /^\d*[LW]$|#|^\?$/;
|
||||
const NUMERIC_TOKEN = /^\d+$/;
|
||||
const ALIAS_TOKEN = /^[A-Z]+$/i;
|
||||
|
||||
/** Returns the inclusive numeric range for a given field kind. */
|
||||
export function rangeFor(kind: FieldKind): FieldRange {
|
||||
return RANGES[kind];
|
||||
}
|
||||
|
||||
function invalid(expr: string, reason: string): never {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason } }
|
||||
);
|
||||
}
|
||||
|
||||
function outOfRange(expr: string, value: number, range: FieldRange): never {
|
||||
throw new CronError(
|
||||
CRON_CODES.FIELD_OUT_OF_RANGE,
|
||||
"cron.field_out_of_range",
|
||||
{ context: { expr, value, min: range.min, max: range.max } }
|
||||
);
|
||||
}
|
||||
|
||||
function unsupported(expr: string): never {
|
||||
throw new CronError(
|
||||
CRON_CODES.UNSUPPORTED_SYNTAX,
|
||||
"cron.unsupported_syntax",
|
||||
{ context: { expr } }
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAtom(
|
||||
atom: string,
|
||||
range: FieldRange,
|
||||
aliasesForKind: FieldAliases
|
||||
): number {
|
||||
if (NUMERIC_TOKEN.test(atom)) {
|
||||
const value = Number.parseInt(atom, 10);
|
||||
if (value < range.min || value > range.max)
|
||||
outOfRange(atom, value, range);
|
||||
return value;
|
||||
}
|
||||
if (ALIAS_TOKEN.test(atom)) {
|
||||
const key = atom.toUpperCase();
|
||||
const mapped = aliasesForKind[key];
|
||||
if (mapped === undefined) invalid(atom, "unknown alias");
|
||||
return mapped;
|
||||
}
|
||||
return invalid(atom, "malformed atom");
|
||||
}
|
||||
|
||||
function normalizeDow(value: number): number {
|
||||
return value === 7 ? 0 : value;
|
||||
}
|
||||
|
||||
function expandRange(start: number, end: number, step: number): number[] {
|
||||
if (start > end) invalid(`${start}-${end}`, "range start exceeds end");
|
||||
const out: number[] = [];
|
||||
for (let i = start; i <= end; i += step) out.push(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
rangeText: string,
|
||||
range: FieldRange,
|
||||
aliasesForKind: FieldAliases
|
||||
): { start: number; end: number } {
|
||||
if (rangeText === "*") return { start: range.min, end: range.max };
|
||||
if (rangeText.includes("-")) {
|
||||
const parts = rangeText.split("-");
|
||||
if (parts.length !== 2) invalid(rangeText, "malformed range");
|
||||
const [startStr, endStr] = parts as [string, string];
|
||||
const start = resolveAtom(startStr, range, aliasesForKind);
|
||||
const end = resolveAtom(endStr, range, aliasesForKind);
|
||||
return { start, end };
|
||||
}
|
||||
const single = resolveAtom(rangeText, range, aliasesForKind);
|
||||
return { start: single, end: single };
|
||||
}
|
||||
|
||||
function parseTerm(
|
||||
term: string,
|
||||
range: FieldRange,
|
||||
aliasesForKind: FieldAliases
|
||||
): number[] {
|
||||
if (term.length === 0) invalid(term, "empty term");
|
||||
let step = 1;
|
||||
let body = term;
|
||||
if (term.includes("/")) {
|
||||
const parts = term.split("/");
|
||||
if (parts.length !== 2) invalid(term, "malformed step");
|
||||
const [bodyPart, stepStr] = parts as [string, string];
|
||||
body = bodyPart;
|
||||
if (!NUMERIC_TOKEN.test(stepStr)) invalid(term, "non-numeric step");
|
||||
step = Number.parseInt(stepStr, 10);
|
||||
if (step <= 0) invalid(term, "step must be positive");
|
||||
// Bare `*` with step has body "*"; bare numeric with step (e.g. "5/15")
|
||||
// means start at 5 and walk to range.max.
|
||||
if (body !== "*" && !body.includes("-")) {
|
||||
const start = resolveAtom(body, range, aliasesForKind);
|
||||
return expandRange(start, range.max, step);
|
||||
}
|
||||
}
|
||||
const { start, end } = parseRange(body, range, aliasesForKind);
|
||||
return expandRange(start, end, step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a single cron field into the sorted, unique set of values it
|
||||
* matches. Day-of-week field accepts both 0 and 7 for Sunday and normalizes
|
||||
* 7 → 0. Aliases (`JAN`/`MON`) are case-insensitive on month + dayOfWeek
|
||||
* fields only.
|
||||
*
|
||||
* @throws CronError(CRON.UNSUPPORTED_SYNTAX) for `L`/`W`/`#`/`?` tokens.
|
||||
* @throws CronError(CRON.FIELD_OUT_OF_RANGE) for numerics outside the field
|
||||
* range.
|
||||
* @throws CronError(CRON.INVALID_EXPRESSION) for malformed tokens.
|
||||
*/
|
||||
export function parseField(token: string, kind: FieldKind): readonly number[] {
|
||||
if (UNSUPPORTED_TOKENS.test(token)) unsupported(token);
|
||||
const range = RANGES[kind];
|
||||
const aliasesForKind = ALIASES[kind];
|
||||
const values: number[] = [];
|
||||
const buckets = new Set<number>();
|
||||
for (const term of token.split(",")) {
|
||||
const expanded = parseTerm(term, range, aliasesForKind);
|
||||
for (const v of expanded) {
|
||||
const normalized = kind === "dayOfWeek" ? normalizeDow(v) : v;
|
||||
if (!buckets.has(normalized)) {
|
||||
buckets.add(normalized);
|
||||
values.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
values.sort((a, b) => a - b);
|
||||
return Object.freeze(values);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Discriminator for a single cron field's parse rules. The field kind drives
|
||||
* the legal numeric range and (for `month` and `dayOfWeek`) the alphabetic
|
||||
* alias map.
|
||||
*/
|
||||
export type FieldKind =
|
||||
| "second"
|
||||
| "minute"
|
||||
| "hour"
|
||||
| "dayOfMonth"
|
||||
| "month"
|
||||
| "dayOfWeek"
|
||||
| "year";
|
||||
|
||||
/** Range bounds for a field kind, inclusive on both ends. */
|
||||
export interface FieldRange {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
}
|
||||
|
||||
/** Optional alphabetic alias map for `month` / `dayOfWeek`. */
|
||||
export type FieldAliases = Readonly<Record<string, number>>;
|
||||
6
packages/foundation/timing/cron/src/fields/index.ts
Normal file
6
packages/foundation/timing/cron/src/fields/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { parseField, rangeFor } from "./cron-fields.js";
|
||||
export type {
|
||||
FieldAliases,
|
||||
FieldKind,
|
||||
FieldRange,
|
||||
} from "./cron-fields.types.js";
|
||||
61
packages/foundation/timing/cron/src/index.ts
Normal file
61
packages/foundation/timing/cron/src/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Errors
|
||||
|
||||
export type { CronErrorCode } from "./errors/cron-error.js";
|
||||
export {
|
||||
CRON_CODES,
|
||||
CronError,
|
||||
registerCronErrorCodes,
|
||||
} from "./errors/cron-error.js";
|
||||
|
||||
// Fields
|
||||
export { parseField, rangeFor } from "./fields/cron-fields.js";
|
||||
export type {
|
||||
FieldAliases,
|
||||
FieldKind,
|
||||
FieldRange,
|
||||
} from "./fields/cron-fields.types.js";
|
||||
|
||||
// Macros
|
||||
export { expandMacro, macroTable } from "./macros/macros.js";
|
||||
// Next-run
|
||||
export { computeNextRun, computePreviousRun } from "./next-run/next-run.js";
|
||||
export type { Outcome } from "./otel/cron-otel.js";
|
||||
// OTel
|
||||
export {
|
||||
recordDstTransition,
|
||||
recordNextRun,
|
||||
recordParse,
|
||||
} from "./otel/cron-otel.js";
|
||||
export type { CronExpression } from "./parse/parse-cron.js";
|
||||
// Parse / orchestrator
|
||||
export { isValidCron, parseCron } from "./parse/parse-cron.js";
|
||||
export { ParseOptionsSchema } from "./parse/parse-cron.schemas.js";
|
||||
export type {
|
||||
CronExpressionShape,
|
||||
CronFields,
|
||||
ParseOptions,
|
||||
} from "./parse/parse-cron.types.js";
|
||||
|
||||
// Scheduler
|
||||
export {
|
||||
CronScheduler,
|
||||
createCronScheduler,
|
||||
} from "./scheduler/cron-scheduler.js";
|
||||
export {
|
||||
CronSchedulerAddOptionsSchema,
|
||||
CronSchedulerOptionsSchema,
|
||||
} from "./scheduler/cron-scheduler.schemas.js";
|
||||
export type {
|
||||
CronSchedulerAddOptions,
|
||||
CronSchedulerJob,
|
||||
CronSchedulerOptions,
|
||||
SchedulerTimers,
|
||||
} from "./scheduler/cron-scheduler.types.js";
|
||||
export type { WallClock } from "./timezone/timezone.js";
|
||||
// Timezone
|
||||
export {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone/timezone.js";
|
||||
4
packages/foundation/timing/cron/src/macros/index.ts
Normal file
4
packages/foundation/timing/cron/src/macros/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
expandMacro,
|
||||
macroTable,
|
||||
} from "./macros.js";
|
||||
78
packages/foundation/timing/cron/src/macros/macros.md
Normal file
78
packages/foundation/timing/cron/src/macros/macros.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# expandMacro
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`expandMacro` recognises the conventional crontab macro shortcuts —
|
||||
`@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`,
|
||||
`@hourly` — and expands them to their canonical 5-field cron equivalents.
|
||||
Macro expansion runs before field tokenization so the orchestrator only
|
||||
has to know about the structural 5/6/7-field shape. The function is
|
||||
deliberately conservative: it never silently rewrites non-macro input,
|
||||
and it explicitly rejects `@reboot` (which has no wall-clock semantics in
|
||||
a persistent process) with a typed `CRON.UNSUPPORTED_SYNTAX` error.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven supported macros mapped to their POSIX-compatible 5-field
|
||||
expansions.
|
||||
- Case-insensitive (`@DAILY` and `@daily` are both accepted).
|
||||
- Whitespace-tolerant (leading and trailing whitespace stripped).
|
||||
- Returns `null` for non-macro inputs so the orchestrator can fall through
|
||||
to the standard field tokenizer.
|
||||
- Throws `CronError(CRON.UNSUPPORTED_SYNTAX)` for `@reboot`.
|
||||
- Throws `CronError(CRON.INVALID_EXPRESSION)` for unknown `@` shortcuts.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { expandMacro } from "@suaveplan/cron";
|
||||
|
||||
console.log(expandMacro("@daily")); // "0 0 * * *"
|
||||
console.log(expandMacro("@hourly")); // "0 * * * *"
|
||||
console.log(expandMacro("0 * * * *")); // null — not a macro
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { expandMacro, CRON_CODES, CronError } from "@suaveplan/cron";
|
||||
|
||||
function canonicalise(input: string): string {
|
||||
try {
|
||||
return expandMacro(input) ?? input;
|
||||
} catch (err) {
|
||||
if (err instanceof CronError && err.code === CRON_CODES.UNSUPPORTED_SYNTAX) {
|
||||
// Persistent process — fall back to "every 24 hours".
|
||||
return "0 0 * * *";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(canonicalise("@reboot")); // "0 0 * * *"
|
||||
console.log(canonicalise("@weekly")); // "0 0 * * 0"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `expandMacro(expr: string): string | null` — returns the 5-field
|
||||
expansion for a recognised macro, `null` for non-macro input, and
|
||||
throws `CronError` for unknown or unsupported `@` shortcuts.
|
||||
- `macroTable(): Readonly<Record<string, string>>` — returns the frozen
|
||||
canonical lookup table used internally. Useful for documentation
|
||||
generation and admin UIs that render the supported macro set.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The macro table is `Object.freeze`d at module load to guard against
|
||||
accidental mutation. Lookup is by lower-cased trimmed key so the function
|
||||
is O(1). `@reboot` is explicitly intercepted before the table lookup
|
||||
because its semantics differ fundamentally from the other macros — it
|
||||
fires at process start, not at a wall-clock instant, and is therefore not
|
||||
representable as a 5-field expansion. Returning `null` for non-macro
|
||||
input (rather than throwing) is deliberate so the orchestrator can
|
||||
unconditionally invoke `expandMacro` before its own field tokenizer
|
||||
without paying for exception construction on the hot path.
|
||||
71
packages/foundation/timing/cron/src/macros/macros.test.ts
Normal file
71
packages/foundation/timing/cron/src/macros/macros.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { expandMacro, macroTable } from "./macros.js";
|
||||
|
||||
describe("expandMacro", () => {
|
||||
it("expands @yearly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@yearly")).toBe("0 0 1 1 *");
|
||||
});
|
||||
|
||||
it("expands @annually identically to @yearly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@annually")).toBe(expandMacro("@yearly"));
|
||||
});
|
||||
|
||||
it("expands @monthly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@monthly")).toBe("0 0 1 * *");
|
||||
});
|
||||
|
||||
it("expands @weekly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@weekly")).toBe("0 0 * * 0");
|
||||
});
|
||||
|
||||
it("expands @daily", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@daily")).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("expands @midnight identically to @daily", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@midnight")).toBe(expandMacro("@daily"));
|
||||
});
|
||||
|
||||
it("expands @hourly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@hourly")).toBe("0 * * * *");
|
||||
});
|
||||
|
||||
it("is case-insensitive", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@DAILY")).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", { timeout: 5000 }, () => {
|
||||
expect(expandMacro(" @daily ")).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("returns null for non-macro input", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("0 0 * * *")).toBeNull();
|
||||
});
|
||||
|
||||
it("@reboot throws UNSUPPORTED_SYNTAX", { timeout: 5000 }, () => {
|
||||
try {
|
||||
expandMacro("@reboot");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.UNSUPPORTED_SYNTAX);
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown @ macros throw INVALID_EXPRESSION", { timeout: 5000 }, () => {
|
||||
try {
|
||||
expandMacro("@bogus");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.INVALID_EXPRESSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("macroTable", () => {
|
||||
it("returns the frozen lookup table", { timeout: 5000 }, () => {
|
||||
const table = macroTable();
|
||||
expect(table["@hourly"]).toBe("0 * * * *");
|
||||
});
|
||||
});
|
||||
46
packages/foundation/timing/cron/src/macros/macros.ts
Normal file
46
packages/foundation/timing/cron/src/macros/macros.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
|
||||
const MACRO_TABLE: Readonly<Record<string, string>> = Object.freeze({
|
||||
"@yearly": "0 0 1 1 *",
|
||||
"@annually": "0 0 1 1 *",
|
||||
"@monthly": "0 0 1 * *",
|
||||
"@weekly": "0 0 * * 0",
|
||||
"@daily": "0 0 * * *",
|
||||
"@midnight": "0 0 * * *",
|
||||
"@hourly": "0 * * * *",
|
||||
});
|
||||
|
||||
/**
|
||||
* Expand a macro shortcut (`@yearly`, `@hourly`, ...) to its canonical
|
||||
* 5-field cron expansion. Returns `null` when the input is not a recognised
|
||||
* macro.
|
||||
*
|
||||
* `@reboot` is reserved but explicitly unsupported (it has no fixed
|
||||
* wall-clock schedule) and throws `CRON.UNSUPPORTED_SYNTAX`.
|
||||
*/
|
||||
export function expandMacro(expr: string): string | null {
|
||||
const trimmed = expr.trim();
|
||||
if (!trimmed.startsWith("@")) return null;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (key === "@reboot") {
|
||||
throw new CronError(
|
||||
CRON_CODES.UNSUPPORTED_SYNTAX,
|
||||
"cron.unsupported_syntax",
|
||||
{ context: { macro: "@reboot" } }
|
||||
);
|
||||
}
|
||||
const expansion = MACRO_TABLE[key];
|
||||
if (expansion === undefined) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason: "unknown macro" } }
|
||||
);
|
||||
}
|
||||
return expansion;
|
||||
}
|
||||
|
||||
/** Returns the readonly canonical macro lookup table. */
|
||||
export function macroTable(): Readonly<Record<string, string>> {
|
||||
return MACRO_TABLE;
|
||||
}
|
||||
4
packages/foundation/timing/cron/src/next-run/index.ts
Normal file
4
packages/foundation/timing/cron/src/next-run/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
computeNextRun,
|
||||
computePreviousRun,
|
||||
} from "./next-run.js";
|
||||
@@ -0,0 +1,99 @@
|
||||
# next-run-fields
|
||||
|
||||
## Purpose
|
||||
|
||||
`next-run-fields` houses the low-level field-walking helpers used by the
|
||||
forward and backward next-run search loops in `next-run.ts`. Splitting the
|
||||
helpers into a sibling module keeps each file under the 300-line hard
|
||||
limit while keeping the call sites in `next-run.ts` declarative — the
|
||||
forward loop reads as `snapForward → fromWallClock → verify`, with the
|
||||
fine-grained carry/rollover logic isolated here. None of these helpers
|
||||
are part of the package's public API; they exist purely to support the
|
||||
internal next-run engine.
|
||||
|
||||
## Features
|
||||
|
||||
- `climbFields(wall, fields)` — single forward step across seconds →
|
||||
minutes → hours → days, honoring per-field carries.
|
||||
- `climbFieldsBackward(wall, fields)` — mirror walk used by
|
||||
`previousRun`.
|
||||
- `advanceUntilMonthMatches`, `advanceUntilDayMatches`,
|
||||
`advanceUntilYearMatches` — coarse jump helpers that snap a wall-clock
|
||||
forward to the next legal month / day / year for the field set.
|
||||
- `nextValueAtLeast` / `previousValueAtMost` — sorted-array lookup
|
||||
helpers shared by every field walker.
|
||||
- `copyWall` — converts an immutable `WallClock` to a `MutableWall` so
|
||||
the search loop can mutate without aliasing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
advanceUntilMonthMatches,
|
||||
climbFields,
|
||||
copyWall,
|
||||
} from "@suaveplan/cron/next-run/next-run";
|
||||
import { toWallClock } from "@suaveplan/cron";
|
||||
|
||||
const fields = {
|
||||
minutes: [0],
|
||||
hours: [9],
|
||||
daysOfMonth: Array.from({ length: 31 }, (_, i) => i + 1),
|
||||
months: [6, 12],
|
||||
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
|
||||
const wall = copyWall(toWallClock(Date.now(), "UTC"));
|
||||
climbFields(wall, fields);
|
||||
advanceUntilMonthMatches(wall, fields);
|
||||
console.log(wall.month);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
climbFieldsBackward,
|
||||
copyWall,
|
||||
} from "@suaveplan/cron/next-run/next-run";
|
||||
import { toWallClock } from "@suaveplan/cron";
|
||||
|
||||
const fields = {
|
||||
minutes: [0, 15, 30, 45],
|
||||
hours: [9, 10, 11, 12, 13, 14, 15, 16, 17],
|
||||
daysOfMonth: Array.from({ length: 31 }, (_, i) => i + 1),
|
||||
months: Array.from({ length: 12 }, (_, i) => i + 1),
|
||||
daysOfWeek: [1, 2, 3, 4, 5],
|
||||
};
|
||||
|
||||
const wall = copyWall(toWallClock(Date.UTC(2026, 4, 11, 12, 47), "UTC"));
|
||||
climbFieldsBackward(wall, fields);
|
||||
console.log(wall.hour, wall.minute); // 12, 45
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HORIZON_YEARS: 8`, `MS_PER_SECOND: 1000`, `MS_PER_MINUTE: 60000`.
|
||||
- `MutableWall` — mutable counterpart of `WallClock` minus `weekday`.
|
||||
- `nextValueAtLeast(sorted, current)`,
|
||||
`previousValueAtMost(sorted, current)`.
|
||||
- `daysInMonth(year, month)` — calendar-correct day count.
|
||||
- `copyWall(w)`.
|
||||
- `climbFields(wall, fields)`, `climbFieldsBackward(wall, fields)`.
|
||||
- `advanceUntilMonthMatches`, `advanceUntilDayMatches`,
|
||||
`advanceUntilYearMatches`, `yearMatches`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The forward and backward walkers are written as small step functions so
|
||||
each carry case can be reasoned about independently. Day-of-month and
|
||||
day-of-week interaction follows Vixie OR semantics: when one field is
|
||||
the full range and the other is restricted, only the restricted field
|
||||
matters; when both are restricted, either match is sufficient.
|
||||
`advanceUntilDayMatches` walks one calendar day at a time so the OR
|
||||
semantics fall out naturally rather than requiring a more elaborate
|
||||
bitset intersection. Safety bounds in the loops cap iterations at
|
||||
`366 × (HORIZON_YEARS + 1)` so pathological input cannot livelock the
|
||||
search; the outer search functions in `next-run.ts` then convert the
|
||||
resulting wall clock into an epoch instant and verify the result still
|
||||
matches the field set, catching DST-gap edge cases.
|
||||
264
packages/foundation/timing/cron/src/next-run/next-run-fields.ts
Normal file
264
packages/foundation/timing/cron/src/next-run/next-run-fields.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { CronFields } from "../parse/parse-cron.types.js";
|
||||
import type { WallClock } from "../timezone/timezone.js";
|
||||
|
||||
export const HORIZON_YEARS = 8;
|
||||
export const MS_PER_SECOND = 1_000;
|
||||
export const MS_PER_MINUTE = 60_000;
|
||||
|
||||
export interface MutableWall {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
export function nextValueAtLeast(
|
||||
sorted: readonly number[],
|
||||
current: number
|
||||
): number | null {
|
||||
for (const value of sorted) {
|
||||
if (value >= current) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function previousValueAtMost(
|
||||
sorted: readonly number[],
|
||||
current: number
|
||||
): number | null {
|
||||
for (let i = sorted.length - 1; i >= 0; i -= 1) {
|
||||
const value = sorted[i] as number;
|
||||
if (value <= current) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
export function copyWall(w: WallClock): MutableWall {
|
||||
return {
|
||||
year: w.year,
|
||||
month: w.month,
|
||||
day: w.day,
|
||||
hour: w.hour,
|
||||
minute: w.minute,
|
||||
second: w.second,
|
||||
};
|
||||
}
|
||||
|
||||
function firstSecond(fields: CronFields): number {
|
||||
return fields.seconds === undefined ? 0 : (fields.seconds[0] as number);
|
||||
}
|
||||
|
||||
function firstMinute(fields: CronFields): number {
|
||||
return fields.minutes[0] as number;
|
||||
}
|
||||
|
||||
function firstHour(fields: CronFields): number {
|
||||
return fields.hours[0] as number;
|
||||
}
|
||||
|
||||
interface Carry {
|
||||
value: number;
|
||||
carry: boolean;
|
||||
}
|
||||
|
||||
function advanceWith(
|
||||
sorted: readonly number[],
|
||||
current: number,
|
||||
min: number
|
||||
): Carry {
|
||||
const next = nextValueAtLeast(sorted, current);
|
||||
if (next !== null) return { value: next, carry: false };
|
||||
return { value: min, carry: true };
|
||||
}
|
||||
|
||||
function bumpSecond(wall: MutableWall, fields: CronFields): boolean {
|
||||
if (fields.seconds === undefined) {
|
||||
wall.second = 0;
|
||||
return false;
|
||||
}
|
||||
const step = advanceWith(fields.seconds, wall.second, firstSecond(fields));
|
||||
wall.second = step.value;
|
||||
return step.carry;
|
||||
}
|
||||
|
||||
function bumpMinute(
|
||||
wall: MutableWall,
|
||||
fields: CronFields,
|
||||
secondCarry: boolean
|
||||
): boolean {
|
||||
if (secondCarry) wall.minute += 1;
|
||||
const step = advanceWith(fields.minutes, wall.minute, firstMinute(fields));
|
||||
if (step.value !== wall.minute) wall.second = firstSecond(fields);
|
||||
wall.minute = step.value;
|
||||
return step.carry;
|
||||
}
|
||||
|
||||
function bumpHour(
|
||||
wall: MutableWall,
|
||||
fields: CronFields,
|
||||
minuteCarry: boolean
|
||||
): boolean {
|
||||
if (minuteCarry) wall.hour += 1;
|
||||
const step = advanceWith(fields.hours, wall.hour, firstHour(fields));
|
||||
if (step.value !== wall.hour) {
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
}
|
||||
wall.hour = step.value;
|
||||
return step.carry;
|
||||
}
|
||||
|
||||
function rollDayIntoMonth(wall: MutableWall): void {
|
||||
while (wall.day > daysInMonth(wall.year, wall.month)) {
|
||||
wall.day -= daysInMonth(wall.year, wall.month);
|
||||
wall.month += 1;
|
||||
if (wall.month > 12) {
|
||||
wall.month = 1;
|
||||
wall.year += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function advanceDay(
|
||||
wall: MutableWall,
|
||||
fields: CronFields,
|
||||
hourCarry: boolean
|
||||
): void {
|
||||
if (hourCarry) {
|
||||
wall.day += 1;
|
||||
rollDayIntoMonth(wall);
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
}
|
||||
}
|
||||
|
||||
function dowMatches(
|
||||
fields: CronFields,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number
|
||||
): boolean {
|
||||
const epoch = Date.UTC(year, month - 1, day);
|
||||
const weekday = new Date(epoch).getUTCDay();
|
||||
return fields.daysOfWeek.includes(weekday);
|
||||
}
|
||||
|
||||
function dayMatches(
|
||||
fields: CronFields,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number
|
||||
): boolean {
|
||||
const domFull = fields.daysOfMonth.length === 31;
|
||||
const dowFull = fields.daysOfWeek.length === 7;
|
||||
const domHit = fields.daysOfMonth.includes(day);
|
||||
const dowHit = dowMatches(fields, year, month, day);
|
||||
if (domFull && dowFull) return true;
|
||||
if (domFull) return dowHit;
|
||||
if (dowFull) return domHit;
|
||||
return domHit || dowHit;
|
||||
}
|
||||
|
||||
export function advanceUntilMonthMatches(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): void {
|
||||
while (!fields.months.includes(wall.month)) {
|
||||
const next = nextValueAtLeast(fields.months, wall.month);
|
||||
if (next !== null) {
|
||||
wall.month = next;
|
||||
} else {
|
||||
wall.month = fields.months[0] as number;
|
||||
wall.year += 1;
|
||||
}
|
||||
wall.day = 1;
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
}
|
||||
}
|
||||
|
||||
export function advanceUntilDayMatches(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): void {
|
||||
let safety = 0;
|
||||
while (!dayMatches(fields, wall.year, wall.month, wall.day)) {
|
||||
wall.day += 1;
|
||||
rollDayIntoMonth(wall);
|
||||
advanceUntilMonthMatches(wall, fields);
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
safety += 1;
|
||||
if (safety > 366 * (HORIZON_YEARS + 1)) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function yearMatches(fields: CronFields, year: number): boolean {
|
||||
return fields.years === undefined || fields.years.includes(year);
|
||||
}
|
||||
|
||||
export function advanceUntilYearMatches(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): boolean {
|
||||
// Caller (`snapForward`) only invokes this when `!yearMatches(...)`,
|
||||
// so `fields.years` is defined and `wall.year` is not in the set.
|
||||
// The lookup returning a year strictly greater than `wall.year` is
|
||||
// therefore the only success path.
|
||||
const years = fields.years as readonly number[];
|
||||
const next = nextValueAtLeast(years, wall.year);
|
||||
if (next === null) return false;
|
||||
wall.year = next;
|
||||
wall.month = fields.months[0] as number;
|
||||
wall.day = 1;
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function climbFields(wall: MutableWall, fields: CronFields): void {
|
||||
const secondCarry = bumpSecond(wall, fields);
|
||||
const minuteCarry = bumpMinute(wall, fields, secondCarry);
|
||||
const hourCarry = bumpHour(wall, fields, minuteCarry);
|
||||
advanceDay(wall, fields, hourCarry);
|
||||
}
|
||||
|
||||
function lastValue(values: readonly number[]): number {
|
||||
return values[values.length - 1] as number;
|
||||
}
|
||||
|
||||
export function climbFieldsBackward(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): void {
|
||||
if (fields.seconds !== undefined) {
|
||||
const sec = previousValueAtMost(fields.seconds, wall.second);
|
||||
wall.second = sec === null ? lastValue(fields.seconds) : sec;
|
||||
} else {
|
||||
wall.second = 0;
|
||||
}
|
||||
const min = previousValueAtMost(fields.minutes, wall.minute);
|
||||
if (min === null) {
|
||||
wall.minute = lastValue(fields.minutes);
|
||||
wall.hour -= 1;
|
||||
} else {
|
||||
wall.minute = min;
|
||||
}
|
||||
const hr = previousValueAtMost(fields.hours, wall.hour);
|
||||
if (hr === null) {
|
||||
wall.hour = lastValue(fields.hours);
|
||||
wall.day -= 1;
|
||||
} else {
|
||||
wall.hour = hr;
|
||||
}
|
||||
}
|
||||
77
packages/foundation/timing/cron/src/next-run/next-run.md
Normal file
77
packages/foundation/timing/cron/src/next-run/next-run.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# next-run
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`computeNextRun` and `computePreviousRun` drive every public next-fire
|
||||
query in `@suaveplan/cron`: they take a parsed `CronFields` set, a
|
||||
reference epoch instant, and an IANA timezone, and return the closest
|
||||
matching wall-clock instant in the requested direction. They implement
|
||||
the bounded search horizon mandated by the spec — pathological
|
||||
expressions (e.g. `0 0 31 2 *`, asking for February 31st) terminate with
|
||||
`CronError(CRON.NEXT_RUN_NOT_FOUND)` rather than looping. DST transitions
|
||||
are detected as the search crosses an offset boundary and emitted via
|
||||
the OpenTelemetry counter.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded 8-year forward and backward horizons.
|
||||
- DST-aware: results land on the next existing wall-clock instant during
|
||||
spring-forward gaps and fire exactly once across fall-back overlaps
|
||||
(first occurrence wins).
|
||||
- OTel histograms + counters labelled by outcome and timezone.
|
||||
- Vixie OR semantics for day-of-month vs. day-of-week handled at the
|
||||
per-day check.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("0 9 * * 1-5");
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 11, 5, 0, 0), "America/New_York");
|
||||
console.log(new Date(next as number).toISOString());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("0 2 * * *");
|
||||
// 2026-03-08 in NY: clock jumps from 02:00 → 03:00. The 02:00 slot is
|
||||
// skipped; the engine resolves to the next valid local instant.
|
||||
const after = Date.UTC(2026, 2, 8, 6, 0, 0);
|
||||
const next = expr.nextRun(after, "America/New_York");
|
||||
console.log(new Date(next as number).toISOString());
|
||||
// 2026-03-08T07:00:00.000Z — 03:00 local EDT.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `computeNextRun(fields: CronFields, after: number, tz: string): number`
|
||||
— throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` on horizon exhaustion;
|
||||
always returns a value strictly greater than `after`.
|
||||
- `computePreviousRun(fields: CronFields, before: number, tz: string):
|
||||
number` — mirror of the forward search. Same horizon and error.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The forward search advances the cursor by one minute (or one second when
|
||||
the seconds field is present) per iteration, snaps the wall-clock to the
|
||||
next legal slot via the helpers in `next-run-fields.ts`, converts back
|
||||
to epoch via `fromWallClock`, and re-reads the resulting wall-clock to
|
||||
verify. The verify-after-snap loop is the key DST-handling primitive:
|
||||
when the requested wall-clock falls into a spring-forward gap,
|
||||
`fromWallClock` snaps to the first existing real instant, and the verify
|
||||
step rejects that candidate if it no longer matches the expression — the
|
||||
cursor then bumps forward by another minute and the loop retries until a
|
||||
matching instant is found inside the 8-year horizon. Fall-back is handled
|
||||
implicitly because we always pick the first epoch instant whose
|
||||
wall-clock matches; the second occurrence of the repeated hour is then
|
||||
strictly greater and not re-emitted. The backward search mirrors the
|
||||
same pattern with a hard iteration cap (`366 × 24 × 60 × HORIZON_YEARS`)
|
||||
so an expression with no historical match terminates rather than
|
||||
livelocks.
|
||||
227
packages/foundation/timing/cron/src/next-run/next-run.test.ts
Normal file
227
packages/foundation/timing/cron/src/next-run/next-run.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { parseCron } from "../parse/parse-cron.js";
|
||||
|
||||
describe("next-run — basic UTC", () => {
|
||||
it("every hour at minute 0", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 30, 0), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 11, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("every 15 minutes", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("*/15 * * * *");
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 10, 15, 0)
|
||||
);
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 45, 0), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 11, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("9 am weekdays", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * MON-FRI");
|
||||
// 2026-05-09 is Saturday. Next 9am weekday = Monday 2026-05-11.
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 9, 0, 0, 0), "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 11, 9, 0, 0));
|
||||
});
|
||||
|
||||
it("midnight on Jan 1 only (annual)", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 1 1 *");
|
||||
expect(expr.nextRun(Date.UTC(2026, 0, 2), "UTC")).toBe(
|
||||
Date.UTC(2027, 0, 1, 0, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("noon every Sunday", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 12 * * 0");
|
||||
// 2026-05-11 = Monday; next Sunday = 2026-05-17.
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 11, 0, 0, 0), "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 17, 12, 0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — seconds", () => {
|
||||
it("every 10 seconds", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("*/10 * * * * *", { seconds: true });
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 0, 5), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 10, 0, 10)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — DST America/New_York", () => {
|
||||
it("spring-forward skips 02:00 local on 2026-03-08", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 2 * * *");
|
||||
// 2026-03-08 06:00Z = 01:00 local EST. Next 02:00 local does not
|
||||
// exist; first matching wall clock is 02:00 the next day. The
|
||||
// engine returns the next-valid match.
|
||||
const after = Date.UTC(2026, 2, 8, 6, 0, 0);
|
||||
const next = expr.nextRun(after, "America/New_York");
|
||||
expect(next).not.toBeNull();
|
||||
const ms = next as number;
|
||||
expect(ms > after).toBe(true);
|
||||
});
|
||||
|
||||
it("fall-back fires once on 2026-11-01", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 1 * * *");
|
||||
// 2026-11-01: 01:30 local exists twice (once EDT, once EST). The
|
||||
// engine should pick the first occurrence and not double-fire.
|
||||
const after = Date.UTC(2026, 9, 31, 0, 0, 0);
|
||||
const next = expr.nextRun(after, "America/New_York");
|
||||
const next2 = expr.nextRun(next as number, "America/New_York");
|
||||
expect(next2).not.toBeNull();
|
||||
expect((next2 as number) > (next as number)).toBe(true);
|
||||
// No two consecutive fires happen within the same hour.
|
||||
expect((next2 as number) - (next as number)).toBeGreaterThan(
|
||||
60 * 60 * 1000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — DST Europe/London + Australia/Sydney", () => {
|
||||
it("Europe/London spring-forward 2026-03-29", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 1 * * *");
|
||||
const after = Date.UTC(2026, 2, 29, 0, 0, 0);
|
||||
const next = expr.nextRun(after, "Europe/London");
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Australia/Sydney fall-back 2026-04-05", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 2 * * *");
|
||||
const after = Date.UTC(2026, 3, 4, 0, 0, 0);
|
||||
const next = expr.nextRun(after, "Australia/Sydney");
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — impossible expressions", () => {
|
||||
it("Feb 31 throws NEXT_RUN_NOT_FOUND", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 31 2 *");
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("year horizon exhausted with year field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 0 1 1 * 1980", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
// 1980 is in the past; from 2026 forward, year never matches.
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — invalid timezone", () => {
|
||||
it("throws TIMEZONE_INVALID for bad zone", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 4, 11), "Mars/Olympus_Mons");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.TIMEZONE_INVALID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousRun", () => {
|
||||
it("previous 9am on a weekday", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * *");
|
||||
const before = Date.UTC(2026, 4, 11, 12, 0, 0); // 2026-05-11 12:00 UTC
|
||||
const prev = expr.previousRun(before, "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 11, 9, 0, 0));
|
||||
});
|
||||
|
||||
it("previous 9am Monday from a Sunday", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * MON");
|
||||
// 2026-05-10 is Sunday. Previous Monday 9am = 2026-05-04.
|
||||
const before = Date.UTC(2026, 4, 10, 12, 0, 0);
|
||||
const prev = expr.previousRun(before, "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 4, 9, 0, 0));
|
||||
});
|
||||
|
||||
it("previousRun with seconds field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * *", { seconds: true });
|
||||
const before = Date.UTC(2026, 4, 11, 12, 0, 0);
|
||||
expect(expr.previousRun(before, "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 9, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("previousRun wraps seconds when wall.second is below min", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// seconds=[30,45], minutes=[0], hours=[9].
|
||||
const expr = parseCron("30,45 0 9 * * *", { seconds: true });
|
||||
// Before 09:00:20 UTC, previous match is 08 ... no — minutes=[0]
|
||||
// so the previous hit is yesterday's 09:00:45. The wrap path
|
||||
// exercises sec === null inside climbFieldsBackward.
|
||||
const before = Date.UTC(2026, 4, 11, 9, 0, 20);
|
||||
const prev = expr.previousRun(before, "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 10, 9, 0, 45));
|
||||
});
|
||||
|
||||
it("previousRun wraps through minute boundary", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 * * * *");
|
||||
// Before 10:15 UTC, previous fire is 09:30 UTC.
|
||||
const before = Date.UTC(2026, 4, 11, 10, 15, 0);
|
||||
expect(expr.previousRun(before, "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 9, 30, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("Feb 31 backward also throws NEXT_RUN_NOT_FOUND", {
|
||||
// The backward search lacks the large-jump optimisations
|
||||
// `searchForward` has (`advanceUntilYearMatches` /
|
||||
// `advanceUntilMonthMatches`), so an impossible cron walks
|
||||
// hour-by-hour through the 8-year horizon. Bounded but slow on
|
||||
// shared CI — bump the timeout above the natural runtime.
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 0 31 2 *");
|
||||
try {
|
||||
expr.previousRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vixie OR semantics", () => {
|
||||
it("dom AND dow both restricted = either match", { timeout: 5000 }, () => {
|
||||
// Fires on either the 13th OR any Friday.
|
||||
const expr = parseCron("0 12 13 * 5");
|
||||
// 2026-05-13 is Wednesday — fires because dom matches.
|
||||
expect(expr.matches(Date.UTC(2026, 4, 13, 12, 0, 0), "UTC")).toBe(true);
|
||||
// 2026-05-15 is Friday — fires because dow matches.
|
||||
expect(expr.matches(Date.UTC(2026, 4, 15, 12, 0, 0), "UTC")).toBe(true);
|
||||
// 2026-05-14 is Thursday — does not fire.
|
||||
expect(expr.matches(Date.UTC(2026, 4, 14, 12, 0, 0), "UTC")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("nextRun walks until either dom or dow matches", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 12 13 * 5");
|
||||
// From 2026-05-10 (Sun), next match: either Wed May 13 OR Fri May 15.
|
||||
// The earlier is Wed May 13 (dom=13 match).
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 10, 0, 0, 0), "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 13, 12, 0, 0));
|
||||
});
|
||||
});
|
||||
163
packages/foundation/timing/cron/src/next-run/next-run.ts
Normal file
163
packages/foundation/timing/cron/src/next-run/next-run.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { recordDstTransition, recordNextRun } from "../otel/cron-otel.js";
|
||||
import { fieldsMatchWall } from "../parse/parse-cron.js";
|
||||
import type { CronFields } from "../parse/parse-cron.types.js";
|
||||
import {
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
type WallClock,
|
||||
} from "../timezone/timezone.js";
|
||||
import {
|
||||
advanceUntilDayMatches,
|
||||
advanceUntilMonthMatches,
|
||||
advanceUntilYearMatches,
|
||||
climbFields,
|
||||
climbFieldsBackward,
|
||||
copyWall,
|
||||
HORIZON_YEARS,
|
||||
MS_PER_MINUTE,
|
||||
MS_PER_SECOND,
|
||||
type MutableWall,
|
||||
yearMatches,
|
||||
} from "./next-run-fields.js";
|
||||
|
||||
function startCursor(after: number, useSeconds: boolean): number {
|
||||
return useSeconds ? after + MS_PER_SECOND : after + MS_PER_MINUTE;
|
||||
}
|
||||
|
||||
function isWithinHorizon(wall: MutableWall, baseYear: number): boolean {
|
||||
return wall.year - baseYear <= HORIZON_YEARS;
|
||||
}
|
||||
|
||||
function snapForward(
|
||||
fields: CronFields,
|
||||
wall: MutableWall,
|
||||
baseYear: number
|
||||
): void {
|
||||
if (!yearMatches(fields, wall.year)) {
|
||||
if (!advanceUntilYearMatches(wall, fields)) {
|
||||
throw new CronError(
|
||||
CRON_CODES.NEXT_RUN_NOT_FOUND,
|
||||
"cron.next_run_not_found",
|
||||
{ context: { reason: "year horizon exhausted" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
climbFields(wall, fields);
|
||||
advanceUntilMonthMatches(wall, fields);
|
||||
advanceUntilDayMatches(wall, fields);
|
||||
if (!isWithinHorizon(wall, baseYear)) {
|
||||
throw new CronError(
|
||||
CRON_CODES.NEXT_RUN_NOT_FOUND,
|
||||
"cron.next_run_not_found",
|
||||
{
|
||||
context: {
|
||||
reason: "search horizon exhausted",
|
||||
baseYear,
|
||||
finalYear: wall.year,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isAcceptable(
|
||||
fields: CronFields,
|
||||
candidate: number,
|
||||
observed: WallClock,
|
||||
after: number
|
||||
): boolean {
|
||||
if (candidate <= after) return false;
|
||||
return fieldsMatchWall(fields, observed);
|
||||
}
|
||||
|
||||
function searchForward(fields: CronFields, after: number, tz: string): number {
|
||||
let cursor = startCursor(after, fields.seconds !== undefined);
|
||||
const baseYear = toWallClock(after, tz).year;
|
||||
while (true) {
|
||||
const wall = copyWall(toWallClock(cursor, tz));
|
||||
snapForward(fields, wall, baseYear);
|
||||
const candidate = fromWallClock(wall, tz);
|
||||
const observed = toWallClock(candidate, tz);
|
||||
if (isAcceptable(fields, candidate, observed, after)) {
|
||||
const transition = detectDstTransition(after, candidate, tz);
|
||||
if (transition !== null) recordDstTransition(transition, tz);
|
||||
return candidate;
|
||||
}
|
||||
// Failure path covers both DST-gap collisions (observed wall
|
||||
// disagrees with requested wall) and round-trip stalls (candidate
|
||||
// ≤ cursor). Advance by a full hour past the larger of (cursor,
|
||||
// candidate) so the next iteration cannot loop back into the
|
||||
// same skipped or already-visited window.
|
||||
const advanceFrom = candidate > cursor ? candidate : cursor;
|
||||
cursor = advanceFrom + 60 * MS_PER_MINUTE;
|
||||
}
|
||||
}
|
||||
|
||||
function searchBackward(
|
||||
fields: CronFields,
|
||||
before: number,
|
||||
tz: string
|
||||
): number {
|
||||
let cursor = before - MS_PER_MINUTE;
|
||||
const baseYear = toWallClock(before, tz).year;
|
||||
while (true) {
|
||||
const wall = copyWall(toWallClock(cursor, tz));
|
||||
if (baseYear - wall.year > HORIZON_YEARS) {
|
||||
throw new CronError(
|
||||
CRON_CODES.NEXT_RUN_NOT_FOUND,
|
||||
"cron.next_run_not_found",
|
||||
{
|
||||
context: {
|
||||
reason: "backward horizon exhausted",
|
||||
baseYear,
|
||||
finalYear: wall.year,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
climbFieldsBackward(wall, fields);
|
||||
const candidate = fromWallClock(wall, tz);
|
||||
const observed = toWallClock(candidate, tz);
|
||||
// `searchBackward` only ever steps cursor backward, so candidate
|
||||
// < before holds by construction; field membership is the sole
|
||||
// acceptance criterion.
|
||||
if (candidate < before && fieldsMatchWall(fields, observed)) {
|
||||
return candidate;
|
||||
}
|
||||
// Step the cursor back a full hour from its current position; the
|
||||
// candidate from the previous iteration is never further forward
|
||||
// because `climbFieldsBackward` only moves time backward.
|
||||
cursor -= 60 * MS_PER_MINUTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the next epoch instant strictly greater than `after` matching
|
||||
* the cron field set. Throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` on
|
||||
* horizon exhaustion. */
|
||||
export function computeNextRun(
|
||||
fields: CronFields,
|
||||
after: number,
|
||||
tz: string
|
||||
): number {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = searchForward(fields, after, tz);
|
||||
recordNextRun(Date.now() - t0, "success", tz);
|
||||
return result;
|
||||
} catch (err) {
|
||||
recordNextRun(Date.now() - t0, "error", tz);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the previous epoch instant strictly less than `before` matching
|
||||
* the cron field set. */
|
||||
export function computePreviousRun(
|
||||
fields: CronFields,
|
||||
before: number,
|
||||
tz: string
|
||||
): number {
|
||||
return searchBackward(fields, before, tz);
|
||||
}
|
||||
80
packages/foundation/timing/cron/src/otel/cron-otel.md
Normal file
80
packages/foundation/timing/cron/src/otel/cron-otel.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# cron-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cron-otel` is the OpenTelemetry instrumentation surface for the
|
||||
`@suaveplan/cron` package. It centralises every metric emitted by the
|
||||
parser, the next-run engine, and the in-process scheduler so cardinality
|
||||
stays bounded and label semantics stay consistent. Each helper is a thin,
|
||||
no-throw wrapper around the global `metrics.getMeter("@suaveplan/cron")`
|
||||
instruments and is safe to call on every hot-path invocation — the
|
||||
underlying instrument is lazily constructed and then cached.
|
||||
|
||||
## Features
|
||||
|
||||
- Two histograms: `cron.parse.duration_ms` (outcome) and
|
||||
`cron.next_run.duration_ms` (outcome + tz).
|
||||
- Three counters: `cron.parse_total`, `cron.next_run_total`, and
|
||||
`cron.dst_transition_total`.
|
||||
- Closed-enum `outcome` and `kind` attributes to keep cardinality
|
||||
bounded; `tz` is the caller-supplied IANA name pass-through.
|
||||
- Lazy meter construction — no metrics work happens until first record.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { recordParse, recordNextRun } from "@suaveplan/cron";
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
// …parse cron expression…
|
||||
recordParse(Date.now() - t0, "success");
|
||||
} catch (err) {
|
||||
recordParse(Date.now() - t0, "error");
|
||||
throw err;
|
||||
}
|
||||
|
||||
recordNextRun(0.5, "success", "America/New_York");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { recordDstTransition } from "@suaveplan/cron";
|
||||
|
||||
function instrumentNextRun(detected: "spring-forward" | "fall-back" | null, tz: string) {
|
||||
if (detected !== null) {
|
||||
recordDstTransition(detected, tz);
|
||||
}
|
||||
}
|
||||
|
||||
instrumentNextRun("spring-forward", "America/New_York");
|
||||
instrumentNextRun("fall-back", "Europe/London");
|
||||
instrumentNextRun(null, "UTC"); // no-op
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordParse(durationMs: number, outcome: "success" | "error"): void`.
|
||||
- `recordNextRun(durationMs: number, outcome: "success" | "error",
|
||||
tz: string): void`.
|
||||
- `recordDstTransition(kind: "spring-forward" | "fall-back",
|
||||
tz: string): void`.
|
||||
- `type Outcome = "success" | "error"` — exported helper alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each instrument is materialised through `metrics.getMeter(...)` only on
|
||||
the first record call. Subsequent invocations reuse the module-scoped
|
||||
cached handle, so the hot path is a single `record`/`add` call on the
|
||||
shared instrument. The OpenTelemetry global meter provider is read each
|
||||
time — that's a no-op when no provider is installed, which lets the
|
||||
package ship metrics-ready without forcing consumers to wire up an SDK.
|
||||
Attribute cardinality is held below practical limits: `outcome` and
|
||||
`kind` are closed unions, and `tz` is passed through unmodified from the
|
||||
caller (consumers control their own tz vocabulary). The package does not
|
||||
emit logs or spans — those concerns live in higher-tier consumers like
|
||||
`@suaveplan/job-scheduler`.
|
||||
44
packages/foundation/timing/cron/src/otel/cron-otel.test.ts
Normal file
44
packages/foundation/timing/cron/src/otel/cron-otel.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
recordDstTransition,
|
||||
recordNextRun,
|
||||
recordParse,
|
||||
} from "./cron-otel.js";
|
||||
|
||||
describe("cron-otel", () => {
|
||||
it("recordParse accepts both outcomes without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordParse(1.5, "success")).not.toThrow();
|
||||
expect(() => recordParse(2, "error")).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordNextRun accepts tz attribute without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordNextRun(3, "success", "UTC")).not.toThrow();
|
||||
expect(() =>
|
||||
recordNextRun(4, "error", "America/New_York")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordDstTransition accepts kind + tz", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
recordDstTransition("spring-forward", "America/New_York")
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
recordDstTransition("fall-back", "Europe/London")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("caches meter handles across calls", { timeout: 5000 }, () => {
|
||||
// Re-invoke each function: second call should hit cached histogram/counter.
|
||||
recordParse(0, "success");
|
||||
recordParse(0, "success");
|
||||
recordNextRun(0, "success", "UTC");
|
||||
recordNextRun(0, "success", "UTC");
|
||||
recordDstTransition("spring-forward", "UTC");
|
||||
recordDstTransition("fall-back", "UTC");
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
93
packages/foundation/timing/cron/src/otel/cron-otel.ts
Normal file
93
packages/foundation/timing/cron/src/otel/cron-otel.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/cron";
|
||||
|
||||
let _parseHistogram: Histogram | undefined;
|
||||
let _nextRunHistogram: Histogram | undefined;
|
||||
let _parseCounter: Counter | undefined;
|
||||
let _nextRunCounter: Counter | undefined;
|
||||
let _dstTransitionCounter: Counter | undefined;
|
||||
|
||||
function getParseHistogram(): Histogram {
|
||||
if (!_parseHistogram) {
|
||||
_parseHistogram = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram("cron.parse.duration_ms", {
|
||||
description: "Cron expression parse duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return _parseHistogram;
|
||||
}
|
||||
|
||||
function getNextRunHistogram(): Histogram {
|
||||
if (!_nextRunHistogram) {
|
||||
_nextRunHistogram = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram("cron.next_run.duration_ms", {
|
||||
description: "Cron next-run search duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return _nextRunHistogram;
|
||||
}
|
||||
|
||||
function getParseCounter(): Counter {
|
||||
if (!_parseCounter) {
|
||||
_parseCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("cron.parse_total", {
|
||||
description: "Cron parse invocations by outcome",
|
||||
});
|
||||
}
|
||||
return _parseCounter;
|
||||
}
|
||||
|
||||
function getNextRunCounter(): Counter {
|
||||
if (!_nextRunCounter) {
|
||||
_nextRunCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("cron.next_run_total", {
|
||||
description: "Cron next-run invocations by outcome and tz",
|
||||
});
|
||||
}
|
||||
return _nextRunCounter;
|
||||
}
|
||||
|
||||
function getDstTransitionCounter(): Counter {
|
||||
if (!_dstTransitionCounter) {
|
||||
_dstTransitionCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("cron.dst_transition_total", {
|
||||
description: "DST transitions crossed during next-run search",
|
||||
});
|
||||
}
|
||||
return _dstTransitionCounter;
|
||||
}
|
||||
|
||||
/** Outcome attribute is a closed enum so cardinality is bounded. */
|
||||
export type Outcome = "success" | "error";
|
||||
|
||||
/** Record a parse-duration sample and increment the parse counter. */
|
||||
export function recordParse(durationMs: number, outcome: Outcome): void {
|
||||
getParseHistogram().record(durationMs, { outcome });
|
||||
getParseCounter().add(1, { outcome });
|
||||
}
|
||||
|
||||
/** Record a next-run-duration sample and increment the next-run counter. */
|
||||
export function recordNextRun(
|
||||
durationMs: number,
|
||||
outcome: Outcome,
|
||||
tz: string
|
||||
): void {
|
||||
getNextRunHistogram().record(durationMs, { outcome, tz });
|
||||
getNextRunCounter().add(1, { outcome, tz });
|
||||
}
|
||||
|
||||
/** Increment the DST-transition counter. `kind` is a closed enum. */
|
||||
export function recordDstTransition(
|
||||
kind: "spring-forward" | "fall-back",
|
||||
tz: string
|
||||
): void {
|
||||
getDstTransitionCounter().add(1, { kind, tz });
|
||||
}
|
||||
6
packages/foundation/timing/cron/src/otel/index.ts
Normal file
6
packages/foundation/timing/cron/src/otel/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { Outcome } from "./cron-otel.js";
|
||||
export {
|
||||
recordDstTransition,
|
||||
recordNextRun,
|
||||
recordParse,
|
||||
} from "./cron-otel.js";
|
||||
8
packages/foundation/timing/cron/src/parse/index.ts
Normal file
8
packages/foundation/timing/cron/src/parse/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { CronExpression } from "./parse-cron.js";
|
||||
export { isValidCron, parseCron } from "./parse-cron.js";
|
||||
export { ParseOptionsSchema } from "./parse-cron.schemas.js";
|
||||
export type {
|
||||
CronExpressionShape,
|
||||
CronFields,
|
||||
ParseOptions,
|
||||
} from "./parse-cron.types.js";
|
||||
86
packages/foundation/timing/cron/src/parse/parse-cron.md
Normal file
86
packages/foundation/timing/cron/src/parse/parse-cron.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# parseCron
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`parseCron` is the top-level orchestrator that consumes a raw cron
|
||||
expression string and returns a frozen `CronExpression` whose `nextRun`,
|
||||
`nextRuns`, `previousRun`, and `matches` methods drive every scheduling
|
||||
query. The orchestrator macro-expands the input first, then dispatches
|
||||
the resulting tokens through `parseField` per position, applies optional
|
||||
Vixie strict-DoW semantics, and finally binds the result to the search
|
||||
engine in `next-run.ts`. `isValidCron` is the non-throwing sibling: it
|
||||
returns a boolean and guarantees no exception escape for any string
|
||||
input, as mandated by the spec.
|
||||
|
||||
## Features
|
||||
|
||||
- 5-field default; 6-field opt-in via `seconds: true`; 7-field Quartz
|
||||
opt-in via both `seconds` and `years` true.
|
||||
- Macro expansion (`@daily`, `@hourly`, ...) handled before tokenization.
|
||||
- `strictDoW` option enforces "either dom or dow, never both" Vixie
|
||||
semantics.
|
||||
- `nextRuns(count)` produces a strictly monotonic array.
|
||||
- `matches(when, tz)` is a constant-time membership check against the
|
||||
parsed field sets.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseCron, isValidCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("*/15 * * * *");
|
||||
console.log(expr.fields.minutes); // [0, 15, 30, 45]
|
||||
console.log(expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "UTC"));
|
||||
// Date.UTC(2026, 4, 11, 10, 15, 0)
|
||||
|
||||
console.log(isValidCron("not a cron expression")); // false
|
||||
console.log(isValidCron("0 9 * * MON-FRI")); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
|
||||
const quartz = parseCron("0 0 12 * * MON-FRI 2026-2030", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
const runs = quartz.nextRuns(3, Date.UTC(2026, 0, 1), "America/New_York");
|
||||
console.log(runs.map((t) => new Date(t).toISOString()));
|
||||
|
||||
const strict = parseCron("0 12 15 * 1", { strictDoW: true });
|
||||
// Rejects expressions where dom AND dow are both restricted.
|
||||
console.log(strict.matches(Date.UTC(2026, 0, 15, 12, 0, 0), "UTC"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseCron(expr: string, options?: ParseOptions): CronExpression` —
|
||||
parses and returns the frozen expression handle.
|
||||
- `isValidCron(expr: string, options?: ParseOptions): boolean` — never
|
||||
throws.
|
||||
- `interface CronExpression { expr; fields; nextRun; nextRuns;
|
||||
previousRun; matches }`.
|
||||
- `interface ParseOptions { seconds?; years?; strictDoW? }`.
|
||||
- `interface CronFields { seconds?; minutes; hours; daysOfMonth; months;
|
||||
daysOfWeek; years? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The orchestrator first invokes `expandMacro` so the rest of the pipeline
|
||||
only ever sees canonical 5/6/7-field strings. Tokenization is whitespace-
|
||||
based with the field count strictly validated against `ParseOptions`.
|
||||
Each field is independently parsed via the shared `parseField`
|
||||
tokenizer, which returns a sorted, deduplicated `readonly number[]`.
|
||||
`strictDoW` enforcement runs after tokenization so the error context
|
||||
carries the actual parsed sets for debug visibility. The returned
|
||||
`CronExpression` is `Object.freeze`d and binds its methods over the
|
||||
captured fields, so destructuring (`const { nextRun } = parseCron(...)`)
|
||||
is safe. `isValidCron` is a tight try/catch around the same code path so
|
||||
the two functions stay in lock-step with no semantic drift. All parse
|
||||
calls flow through `recordParse` so an outcome attribute lands on every
|
||||
invocation regardless of success or failure.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ParseOptionsSchema = z.object({
|
||||
seconds: z.boolean().optional(),
|
||||
years: z.boolean().optional(),
|
||||
strictDoW: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CronExpressionInputSchema = z.string().min(1).max(256);
|
||||
232
packages/foundation/timing/cron/src/parse/parse-cron.test.ts
Normal file
232
packages/foundation/timing/cron/src/parse/parse-cron.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { isValidCron, parseCron } from "./parse-cron.js";
|
||||
|
||||
describe("parseCron — 5-field default", () => {
|
||||
it("parses simple expression", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * *");
|
||||
expect(Array.from(expr.fields.minutes)).toEqual([0]);
|
||||
expect(Array.from(expr.fields.hours)).toEqual([9]);
|
||||
expect(expr.fields.seconds).toBeUndefined();
|
||||
expect(expr.fields.years).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes whitespace runs", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * *");
|
||||
expect(Array.from(expr.fields.hours)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("rejects empty input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseCron("");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.INVALID_EXPRESSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects 4-field expressions", { timeout: 5000 }, () => {
|
||||
expect(() => parseCron("* * * *")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("rejects 6-field by default", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseCron("0 */5 * * * *");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.INVALID_EXPRESSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects garbage", { timeout: 5000 }, () => {
|
||||
expect(() => parseCron("garbage input")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — macros", () => {
|
||||
it("expands @daily before tokenization", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("@daily");
|
||||
expect(Array.from(expr.fields.minutes)).toEqual([0]);
|
||||
expect(Array.from(expr.fields.hours)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("expands @hourly", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("@hourly");
|
||||
expect(Array.from(expr.fields.minutes)).toEqual([0]);
|
||||
expect(expr.fields.hours.length).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — 6-field with seconds", () => {
|
||||
it("parses seconds field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * *", { seconds: true });
|
||||
expect(expr.fields.seconds).toEqual([0]);
|
||||
});
|
||||
|
||||
it("rejects 5-field when seconds: true and years: true required", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseCron("0 9 * * *", { seconds: true, years: true })
|
||||
).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — 7-field Quartz", () => {
|
||||
it("parses year field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * * 2026-2028", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
expect(Array.from(expr.fields.years ?? [])).toEqual([2026, 2027, 2028]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — strictDoW", () => {
|
||||
it("rejects dom+dow both restricted", { timeout: 5000 }, () => {
|
||||
expect(() => parseCron("0 12 15 * 1", { strictDoW: true })).toThrow(
|
||||
CronError
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts dom-only restriction", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 12 15 * *", { strictDoW: true });
|
||||
expect(Array.from(expr.fields.daysOfMonth)).toEqual([15]);
|
||||
});
|
||||
|
||||
it("accepts dow-only restriction", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 12 * * 1", { strictDoW: true });
|
||||
expect(Array.from(expr.fields.daysOfWeek)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CronExpression.nextRun / nextRuns / previousRun / matches", () => {
|
||||
it("nextRun is strictly greater than after", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const after = Date.UTC(2026, 4, 11, 10, 30, 0);
|
||||
const next = expr.nextRun(after, "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 11, 11, 0, 0));
|
||||
});
|
||||
|
||||
it("nextRun accepts Date input", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const next = expr.nextRun(
|
||||
new Date(Date.UTC(2026, 4, 11, 10, 30, 0)),
|
||||
"UTC"
|
||||
);
|
||||
expect(next).toBe(Date.UTC(2026, 4, 11, 11, 0, 0));
|
||||
});
|
||||
|
||||
it("nextRun defaults to Date.now when after omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const next = expr.nextRun();
|
||||
expect(typeof next).toBe("number");
|
||||
});
|
||||
|
||||
it("previousRun defaults tz to UTC", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const prev = expr.previousRun(Date.UTC(2026, 4, 11, 10, 30, 0));
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 11, 10, 0, 0));
|
||||
});
|
||||
|
||||
it("matches defaults tz to UTC", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 11, 0, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("nextRuns defaults tz to UTC and after to now", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const runs = expr.nextRuns(2);
|
||||
expect(runs.length).toBe(2);
|
||||
});
|
||||
|
||||
it("nextRuns is strictly monotonic", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const runs = expr.nextRuns(5, Date.UTC(2026, 4, 11, 10, 0, 0), "UTC");
|
||||
expect(runs.length).toBe(5);
|
||||
for (let i = 1; i < runs.length; i += 1) {
|
||||
expect(runs[i]).toBeGreaterThan(runs[i - 1] ?? 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("nextRuns rejects non-positive count", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(() => expr.nextRuns(0)).toThrow(CronError);
|
||||
expect(() => expr.nextRuns(-1)).toThrow(CronError);
|
||||
expect(() => expr.nextRuns(1.5)).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("previousRun returns largest match below before", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const prev = expr.previousRun(Date.UTC(2026, 4, 11, 10, 30, 0), "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 11, 10, 0, 0));
|
||||
});
|
||||
|
||||
it("matches returns true on a fire time", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 11, 0, 0), "UTC")).toBe(true);
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 11, 30, 0), "UTC")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("matches accepts Date input", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(
|
||||
expr.matches(new Date(Date.UTC(2026, 4, 11, 11, 0, 0)), "UTC")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches respects seconds field when present", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * *", { seconds: true });
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 9, 0, 0), "UTC")).toBe(true);
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 9, 0, 30), "UTC")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("matches respects year field when present", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 1 1 * 2030", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
expect(expr.matches(Date.UTC(2030, 0, 1, 9, 0, 0), "UTC")).toBe(true);
|
||||
expect(expr.matches(Date.UTC(2026, 0, 1, 9, 0, 0), "UTC")).toBe(false);
|
||||
});
|
||||
|
||||
it("impossible expressions throw NEXT_RUN_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 0 31 2 *");
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidCron", () => {
|
||||
it("returns true for parseable input", { timeout: 5000 }, () => {
|
||||
expect(isValidCron("0 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false (never throws) for garbage", { timeout: 5000 }, () => {
|
||||
expect(isValidCron("not-a-cron-expression")).toBe(false);
|
||||
expect(isValidCron("")).toBe(false);
|
||||
});
|
||||
|
||||
it("honors options", { timeout: 5000 }, () => {
|
||||
expect(isValidCron("0 */5 * * * *", { seconds: true })).toBe(true);
|
||||
expect(isValidCron("0 */5 * * * *")).toBe(false);
|
||||
});
|
||||
});
|
||||
256
packages/foundation/timing/cron/src/parse/parse-cron.ts
Normal file
256
packages/foundation/timing/cron/src/parse/parse-cron.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { parseField } from "../fields/cron-fields.js";
|
||||
import { expandMacro } from "../macros/macros.js";
|
||||
import { computeNextRun, computePreviousRun } from "../next-run/next-run.js";
|
||||
import { recordParse } from "../otel/cron-otel.js";
|
||||
import { toWallClock } from "../timezone/timezone.js";
|
||||
import { ParseOptionsSchema } from "./parse-cron.schemas.js";
|
||||
import type { CronFields, ParseOptions } from "./parse-cron.types.js";
|
||||
|
||||
const DEFAULT_TZ = "UTC";
|
||||
|
||||
/** Public `CronExpression` returned by `parseCron`. Methods are bound to
|
||||
* the captured field set so destructuring is safe. */
|
||||
export interface CronExpression {
|
||||
readonly expr: string;
|
||||
readonly fields: CronFields;
|
||||
/**
|
||||
* Next epoch instant strictly greater than `after` matching the
|
||||
* expression in `tz`. Throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` when
|
||||
* the 8-year search horizon is exhausted.
|
||||
*/
|
||||
nextRun(after?: number | Date, tz?: string): number;
|
||||
nextRuns(count: number, after?: number | Date, tz?: string): number[];
|
||||
previousRun(before?: number | Date, tz?: string): number;
|
||||
matches(when: number | Date, tz?: string): boolean;
|
||||
}
|
||||
|
||||
function toEpoch(value: number | Date | undefined): number {
|
||||
if (value === undefined) return Date.now();
|
||||
if (value instanceof Date) return value.getTime();
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenize(
|
||||
expanded: string,
|
||||
options: ParseOptions
|
||||
): { tokens: string[]; useSeconds: boolean; useYears: boolean } {
|
||||
const tokens = expanded.trim().split(/\s+/);
|
||||
const wantSeconds = options.seconds === true;
|
||||
const wantYears = options.years === true;
|
||||
if (tokens.length === 5 && !wantSeconds && !wantYears) {
|
||||
return { tokens, useSeconds: false, useYears: false };
|
||||
}
|
||||
if (tokens.length === 6 && wantSeconds && !wantYears) {
|
||||
return { tokens, useSeconds: true, useYears: false };
|
||||
}
|
||||
if (tokens.length === 7 && wantSeconds && wantYears) {
|
||||
return { tokens, useSeconds: true, useYears: true };
|
||||
}
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{
|
||||
context: {
|
||||
expr: expanded,
|
||||
fieldCount: tokens.length,
|
||||
seconds: wantSeconds,
|
||||
years: wantYears,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildFields(
|
||||
tokens: string[],
|
||||
useSeconds: boolean,
|
||||
useYears: boolean
|
||||
): CronFields {
|
||||
// `tokenize` has already validated `tokens.length` against the
|
||||
// `useSeconds`/`useYears` flags, so the positional reads below are safe.
|
||||
let idx = 0;
|
||||
const next = (): string => tokens[idx++] as string;
|
||||
const seconds = useSeconds ? parseField(next(), "second") : undefined;
|
||||
const minutes = parseField(next(), "minute");
|
||||
const hours = parseField(next(), "hour");
|
||||
const daysOfMonth = parseField(next(), "dayOfMonth");
|
||||
const months = parseField(next(), "month");
|
||||
const daysOfWeek = parseField(next(), "dayOfWeek");
|
||||
const years = useYears ? parseField(next(), "year") : undefined;
|
||||
const fields: CronFields = {
|
||||
...(seconds !== undefined ? { seconds } : {}),
|
||||
minutes,
|
||||
hours,
|
||||
daysOfMonth,
|
||||
months,
|
||||
daysOfWeek,
|
||||
...(years !== undefined ? { years } : {}),
|
||||
};
|
||||
return Object.freeze(fields);
|
||||
}
|
||||
|
||||
function isFullDayOfMonth(values: readonly number[]): boolean {
|
||||
return values.length === 31;
|
||||
}
|
||||
|
||||
function isFullDayOfWeek(values: readonly number[]): boolean {
|
||||
return values.length === 7;
|
||||
}
|
||||
|
||||
function enforceStrictDoW(expr: string, fields: CronFields): void {
|
||||
const domFull = isFullDayOfMonth(fields.daysOfMonth);
|
||||
const dowFull = isFullDayOfWeek(fields.daysOfWeek);
|
||||
if (!domFull && !dowFull) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason: "strictDoW: both dom and dow set" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fieldsMatchWall(
|
||||
fields: CronFields,
|
||||
wall: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
weekday: number;
|
||||
}
|
||||
): boolean {
|
||||
if (fields.seconds !== undefined && !fields.seconds.includes(wall.second)) {
|
||||
return false;
|
||||
}
|
||||
if (!fields.minutes.includes(wall.minute)) return false;
|
||||
if (!fields.hours.includes(wall.hour)) return false;
|
||||
if (!fields.months.includes(wall.month)) return false;
|
||||
if (fields.years !== undefined && !fields.years.includes(wall.year)) {
|
||||
return false;
|
||||
}
|
||||
// Vixie OR semantics: when both dom and dow are restricted, EITHER
|
||||
// match is sufficient. When one is `*` (full range), the other must
|
||||
// match.
|
||||
const domFull = isFullDayOfMonth(fields.daysOfMonth);
|
||||
const dowFull = isFullDayOfWeek(fields.daysOfWeek);
|
||||
const domMatches = fields.daysOfMonth.includes(wall.day);
|
||||
const dowMatches = fields.daysOfWeek.includes(wall.weekday);
|
||||
if (domFull && dowFull) return true;
|
||||
if (domFull) return dowMatches;
|
||||
if (dowFull) return domMatches;
|
||||
return domMatches || dowMatches;
|
||||
}
|
||||
|
||||
function buildExpression(expr: string, fields: CronFields): CronExpression {
|
||||
function nextRun(after?: number | Date, tz: string = DEFAULT_TZ): number {
|
||||
return computeNextRun(fields, toEpoch(after), tz);
|
||||
}
|
||||
function previousRun(
|
||||
before?: number | Date,
|
||||
tz: string = DEFAULT_TZ
|
||||
): number {
|
||||
return computePreviousRun(fields, toEpoch(before), tz);
|
||||
}
|
||||
function nextRuns(
|
||||
count: number,
|
||||
after?: number | Date,
|
||||
tz: string = DEFAULT_TZ
|
||||
): number[] {
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{
|
||||
context: {
|
||||
expr,
|
||||
reason: "nextRuns count must be positive integer",
|
||||
count,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const out: number[] = [];
|
||||
let cursor = toEpoch(after);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const next = computeNextRun(fields, cursor, tz);
|
||||
out.push(next);
|
||||
cursor = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function matches(when: number | Date, tz: string = DEFAULT_TZ): boolean {
|
||||
const wall = toWallClock(toEpoch(when), tz);
|
||||
return fieldsMatchWall(fields, wall);
|
||||
}
|
||||
return Object.freeze({
|
||||
expr,
|
||||
fields,
|
||||
nextRun,
|
||||
nextRuns,
|
||||
previousRun,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron expression into a frozen `CronExpression`. Supports the
|
||||
* standard 5-field form by default; 6-field (with seconds) and 7-field
|
||||
* (Quartz-style year) forms are opt-in via `options`.
|
||||
*/
|
||||
export function parseCron(
|
||||
expr: string,
|
||||
options: ParseOptions = {}
|
||||
): CronExpression {
|
||||
const parsed = ParseOptionsSchema.parse(options);
|
||||
const validatedOptions: ParseOptions = {
|
||||
...(parsed.seconds !== undefined ? { seconds: parsed.seconds } : {}),
|
||||
...(parsed.years !== undefined ? { years: parsed.years } : {}),
|
||||
...(parsed.strictDoW !== undefined
|
||||
? { strictDoW: parsed.strictDoW }
|
||||
: {}),
|
||||
};
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
if (typeof expr !== "string" || expr.length === 0) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason: "empty input" } }
|
||||
);
|
||||
}
|
||||
const expansion = expandMacro(expr);
|
||||
const canonical = expansion ?? expr;
|
||||
const { tokens, useSeconds, useYears } = tokenize(
|
||||
canonical,
|
||||
validatedOptions
|
||||
);
|
||||
const fields = buildFields(tokens, useSeconds, useYears);
|
||||
if (validatedOptions.strictDoW === true) {
|
||||
enforceStrictDoW(expr, fields);
|
||||
}
|
||||
const built = buildExpression(expr, fields);
|
||||
recordParse(Date.now() - t0, "success");
|
||||
return built;
|
||||
} catch (err) {
|
||||
recordParse(Date.now() - t0, "error");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing variant of `parseCron`. Returns `true` when the expression
|
||||
* is parseable under `options`, `false` otherwise. Catches every thrown
|
||||
* value to honor the spec contract that this helper never throws.
|
||||
*/
|
||||
export function isValidCron(expr: string, options: ParseOptions = {}): boolean {
|
||||
try {
|
||||
parseCron(expr, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { fieldsMatchWall };
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Parsed cron field set. Optional fields are present only when the
|
||||
* corresponding `ParseOptions` flag is enabled. */
|
||||
export interface CronFields {
|
||||
readonly seconds?: readonly number[];
|
||||
readonly minutes: readonly number[];
|
||||
readonly hours: readonly number[];
|
||||
readonly daysOfMonth: readonly number[];
|
||||
readonly months: readonly number[];
|
||||
readonly daysOfWeek: readonly number[];
|
||||
readonly years?: readonly number[];
|
||||
}
|
||||
|
||||
/** Options controlling field count and Vixie OR semantics. */
|
||||
export interface ParseOptions {
|
||||
/** Accept 6-field expressions (seconds + 5 std). Default: false. */
|
||||
seconds?: boolean;
|
||||
/** Accept 7-field Quartz year field. Default: false. */
|
||||
years?: boolean;
|
||||
/** Reject dayOfMonth + dayOfWeek both being non-wildcards. Default: false. */
|
||||
strictDoW?: boolean;
|
||||
}
|
||||
|
||||
/** Discriminator returned by the orchestrator to drive next-run logic. */
|
||||
export interface CronExpressionShape {
|
||||
readonly expr: string;
|
||||
readonly fields: CronFields;
|
||||
}
|
||||
109
packages/foundation/timing/cron/src/scheduler/cron-scheduler.md
Normal file
109
packages/foundation/timing/cron/src/scheduler/cron-scheduler.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# CronScheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CronScheduler` is a deliberately minimal in-process cron driver. It
|
||||
parses each registered expression once via `parseCron`, schedules a
|
||||
single `setTimeout` per job pointing at the next computed fire time, and
|
||||
re-schedules immediately after each handler completes. Queueing,
|
||||
retries, concurrency limits, persistence, and lifecycle hooks are
|
||||
intentionally out of scope — that's `@suaveplan/job-scheduler`'s
|
||||
responsibility. This class is small enough to embed inside a CLI, a
|
||||
browser admin panel, or a serverless worker without dragging in the
|
||||
full job-runner footprint.
|
||||
|
||||
## Features
|
||||
|
||||
- `add(expr, handler, options?)` returns a string id (auto-generated via
|
||||
`crypto.randomUUID()` when omitted).
|
||||
- `remove(id)` clears the timer and removes the job.
|
||||
- `list()` snapshots every registered job with its next-run time.
|
||||
- `start()` / `stop()` are idempotent; `stop()` awaits every in-flight
|
||||
handler before resolving.
|
||||
- Handler errors route to `options.onError` when provided, otherwise are
|
||||
silently swallowed.
|
||||
- Injectable `Clock` and injectable `{ setTimer, clearTimer }` pair for
|
||||
fast, deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CronScheduler } from "@suaveplan/cron";
|
||||
|
||||
const scheduler = new CronScheduler();
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
console.log("hourly tick");
|
||||
});
|
||||
scheduler.start();
|
||||
|
||||
// later…
|
||||
await scheduler.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createCronScheduler } from "@suaveplan/cron";
|
||||
|
||||
let fakeNow = Date.UTC(2026, 4, 11, 9, 59, 0);
|
||||
const pending = new Map<number, { cb: () => void; fireAt: number }>();
|
||||
let nextTimerId = 1;
|
||||
|
||||
const scheduler = createCronScheduler({
|
||||
clock: () => fakeNow,
|
||||
timers: {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = nextTimerId++;
|
||||
pending.set(id, { cb, fireAt: fakeNow + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
},
|
||||
onError: (err, id) => console.error(`job ${id}`, err),
|
||||
});
|
||||
|
||||
scheduler.add("0 * * * *", () => console.log("fired"));
|
||||
scheduler.start();
|
||||
|
||||
// fast-forward
|
||||
fakeNow = Date.UTC(2026, 4, 11, 10, 0, 1);
|
||||
for (const [id, { cb, fireAt }] of pending) {
|
||||
if (fireAt <= fakeNow) {
|
||||
pending.delete(id);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
await scheduler.stop();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CronScheduler` with `constructor(options?: CronSchedulerOptions)`.
|
||||
- `add(expr: string, handler: () => void | Promise<void>,
|
||||
options?: CronSchedulerAddOptions): string`.
|
||||
- `remove(id: string): boolean`.
|
||||
- `list(): readonly CronSchedulerJob[]`.
|
||||
- `start(): void`.
|
||||
- `stop(): Promise<void>`.
|
||||
- `createCronScheduler(options?): CronScheduler` — factory mirror.
|
||||
- `CronSchedulerOptions`, `CronSchedulerAddOptions`, `CronSchedulerJob`,
|
||||
`SchedulerTimers` types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each job tracks at most one outstanding timer id. `stop()` walks every
|
||||
job, clears its timer, and then `await`s the union of every promise in
|
||||
the `inFlight` set, so handlers mid-execution always run to completion
|
||||
before the returned promise resolves. Handler errors are caught inside
|
||||
`invokeHandler` and forwarded to `onError`; the post-handler
|
||||
re-scheduling continues unconditionally so a transient handler failure
|
||||
does not stop the job from firing again. The injectable `timers` pair
|
||||
is the testing seam: production callers get the real `setTimeout`, while
|
||||
tests bind it to a `FakeClock` so the integration suite can fire several
|
||||
hours of cron triggers in milliseconds. Job ids are auto-generated via
|
||||
`crypto.randomUUID` when available, falling back to a timestamp-plus-
|
||||
random string in environments that lack it, so the scheduler stays
|
||||
universal.
|
||||
@@ -0,0 +1,18 @@
|
||||
import z from "zod";
|
||||
|
||||
export const CronSchedulerOptionsSchema = z.object({
|
||||
clock: z.function().optional(),
|
||||
onError: z.function().optional(),
|
||||
timers: z
|
||||
.object({
|
||||
setTimer: z.function(),
|
||||
clearTimer: z.function(),
|
||||
})
|
||||
.optional(),
|
||||
generateId: z.function().optional(),
|
||||
});
|
||||
|
||||
export const CronSchedulerAddOptionsSchema = z.object({
|
||||
tz: z.string().min(1).optional(),
|
||||
id: z.string().min(1).max(128).optional(),
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CronScheduler, createCronScheduler } from "./cron-scheduler.js";
|
||||
import type { SchedulerTimers } from "./cron-scheduler.types.js";
|
||||
|
||||
interface PendingTimer {
|
||||
id: number;
|
||||
cb: () => void;
|
||||
fireAt: number;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
now: number;
|
||||
pending: Map<number, PendingTimer>;
|
||||
timers: SchedulerTimers;
|
||||
advance: (delta: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function makeHarness(start = Date.UTC(2026, 4, 11, 9, 59, 0)): Harness {
|
||||
const state = { now: start, nextId: 1 };
|
||||
const pending = new Map<number, PendingTimer>();
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
pending.set(id, { id, cb, fireAt: state.now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
async function advance(delta: number): Promise<void> {
|
||||
const target = state.now + delta;
|
||||
while (true) {
|
||||
let earliest: PendingTimer | null = null;
|
||||
for (const t of pending.values()) {
|
||||
if (t.fireAt > target) continue;
|
||||
if (earliest === null || t.fireAt < earliest.fireAt)
|
||||
earliest = t;
|
||||
}
|
||||
if (earliest === null) break;
|
||||
state.now = earliest.fireAt;
|
||||
pending.delete(earliest.id);
|
||||
earliest.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
}
|
||||
return {
|
||||
get now() {
|
||||
return state.now;
|
||||
},
|
||||
set now(v: number) {
|
||||
state.now = v;
|
||||
},
|
||||
pending,
|
||||
timers,
|
||||
advance,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CronScheduler", () => {
|
||||
it("constructs with defaults", { timeout: 5000 }, () => {
|
||||
const scheduler = new CronScheduler();
|
||||
expect(scheduler.list().length).toBe(0);
|
||||
});
|
||||
|
||||
it("createCronScheduler factory mirror", { timeout: 5000 }, () => {
|
||||
const scheduler = createCronScheduler();
|
||||
expect(scheduler).toBeInstanceOf(CronScheduler);
|
||||
});
|
||||
|
||||
it("add returns generated id when omitted", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined);
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("add honors caller-supplied id", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined, {
|
||||
id: "my-job",
|
||||
});
|
||||
expect(id).toBe("my-job");
|
||||
});
|
||||
|
||||
it("remove cancels timers and returns true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined);
|
||||
scheduler.start();
|
||||
expect(scheduler.remove(id)).toBe(true);
|
||||
expect(scheduler.remove("unknown")).toBe(false);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("list snapshots every job with its next run", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => undefined, { id: "hourly" });
|
||||
scheduler.add("*/15 * * * *", () => undefined, { id: "quarter" });
|
||||
const snap = scheduler.list();
|
||||
expect(snap.length).toBe(2);
|
||||
expect(snap.every((j) => j.nextRun !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fires registered job when timer elapses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const harness = makeHarness();
|
||||
let fires = 0;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
fires += 1;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
expect(fires).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("re-schedules across successive fires", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
let fires = 0;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
fires += 1;
|
||||
});
|
||||
scheduler.start();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await harness.advance(60 * 60 * 1000);
|
||||
}
|
||||
expect(fires).toBeGreaterThanOrEqual(2);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("routes handler errors to onError sink", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const errors: unknown[] = [];
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
onError: (err) => errors.push(err),
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
expect(errors.length).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("swallows handler errors when no sink", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
throw new Error("ignored");
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("start is idempotent", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => undefined);
|
||||
scheduler.start();
|
||||
scheduler.start();
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop is idempotent and a no-op when not started", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const scheduler = new CronScheduler();
|
||||
await scheduler.stop();
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop awaits in-flight handlers", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
let resolveHandler!: () => void;
|
||||
const handlerSettled = new Promise<void>((r) => {
|
||||
resolveHandler = r;
|
||||
});
|
||||
let completed = false;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
await handlerSettled;
|
||||
completed = true;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
const stopPromise = scheduler.stop();
|
||||
resolveHandler();
|
||||
await stopPromise;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("add after start schedules immediately", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.start();
|
||||
let fires = 0;
|
||||
scheduler.add("0 * * * *", () => {
|
||||
fires += 1;
|
||||
});
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
expect(fires).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("uses real timers when not injected", { timeout: 5000 }, async () => {
|
||||
const scheduler = new CronScheduler();
|
||||
scheduler.add("0 * * * *", () => undefined);
|
||||
scheduler.start();
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("list falls back to null when nextRun throws", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
// 0 0 31 2 * has no fire time within the horizon → nextRun throws.
|
||||
scheduler.add("0 0 31 2 *", () => undefined);
|
||||
const snap = scheduler.list();
|
||||
expect(snap[0]?.nextRun).toBeNull();
|
||||
});
|
||||
|
||||
it("custom generateId is honored", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
let counter = 0;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
generateId: () => `cron-${++counter}`,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined);
|
||||
expect(id).toBe("cron-1");
|
||||
});
|
||||
|
||||
it("routes nextRun errors during scheduling to onError sink", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const harness = makeHarness();
|
||||
const errors: unknown[] = [];
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
onError: (err, id) => errors.push({ err, id }),
|
||||
});
|
||||
scheduler.add("0 0 31 2 *", () => undefined, { id: "impossible" });
|
||||
scheduler.start();
|
||||
// nextRun throws; no timer should be scheduled.
|
||||
expect(harness.pending.size).toBe(0);
|
||||
expect(errors.length).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
});
|
||||
169
packages/foundation/timing/cron/src/scheduler/cron-scheduler.ts
Normal file
169
packages/foundation/timing/cron/src/scheduler/cron-scheduler.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { type CronExpression, parseCron } from "../parse/parse-cron.js";
|
||||
import type {
|
||||
CronSchedulerAddOptions,
|
||||
CronSchedulerJob,
|
||||
CronSchedulerOptions,
|
||||
SchedulerTimers,
|
||||
} from "./cron-scheduler.types.js";
|
||||
|
||||
interface ScheduledJob {
|
||||
id: string;
|
||||
expr: string;
|
||||
tz: string;
|
||||
expression: CronExpression;
|
||||
handler: () => void | Promise<void>;
|
||||
timerId: number | null;
|
||||
}
|
||||
|
||||
function defaultTimers(): SchedulerTimers {
|
||||
return {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (id) => globalThis.clearTimeout(id),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultGenerateId(): string {
|
||||
// `crypto.randomUUID()` is universally available in Node ≥18 and every
|
||||
// modern browser. Callers in environments lacking it MUST inject a
|
||||
// custom `generateId` via `CronSchedulerOptions`.
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
const DEFAULT_TZ = "UTC";
|
||||
|
||||
/** Lightweight in-process cron scheduler. Drives handlers from a single
|
||||
* per-job timer using the injectable `Clock` and timer pair so tests
|
||||
* can fast-forward time deterministically. */
|
||||
export class CronScheduler {
|
||||
private readonly clock: Clock;
|
||||
private readonly onError: ((err: unknown, id: string) => void) | undefined;
|
||||
private readonly timers: SchedulerTimers;
|
||||
private readonly generateId: () => string;
|
||||
private readonly jobs = new Map<string, ScheduledJob>();
|
||||
private readonly inFlight = new Set<Promise<void>>();
|
||||
private started = false;
|
||||
private stopping = false;
|
||||
|
||||
public constructor(options: CronSchedulerOptions = {}) {
|
||||
this.clock = options.clock ?? ((): number => Date.now());
|
||||
this.onError = options.onError;
|
||||
this.timers = options.timers ?? defaultTimers();
|
||||
this.generateId = options.generateId ?? defaultGenerateId;
|
||||
}
|
||||
|
||||
/** Register a cron job. Returns the assigned id. */
|
||||
public add(
|
||||
expr: string,
|
||||
handler: () => void | Promise<void>,
|
||||
options: CronSchedulerAddOptions = {}
|
||||
): string {
|
||||
const expression = parseCron(expr);
|
||||
const id = options.id ?? this.generateId();
|
||||
const tz = options.tz ?? DEFAULT_TZ;
|
||||
const job: ScheduledJob = {
|
||||
id,
|
||||
expr,
|
||||
tz,
|
||||
expression,
|
||||
handler,
|
||||
timerId: null,
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
if (this.started && !this.stopping) this.scheduleNext(job);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Cancel and remove a job by id. */
|
||||
public remove(id: string): boolean {
|
||||
const job = this.jobs.get(id);
|
||||
if (job === undefined) return false;
|
||||
this.cancelTimer(job);
|
||||
this.jobs.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Snapshot of currently-registered jobs and their next-run times. */
|
||||
public list(): readonly CronSchedulerJob[] {
|
||||
const now = this.clock();
|
||||
const out: CronSchedulerJob[] = [];
|
||||
for (const job of this.jobs.values()) {
|
||||
let nextRun: number | null;
|
||||
try {
|
||||
nextRun = job.expression.nextRun(now, job.tz);
|
||||
} catch {
|
||||
nextRun = null;
|
||||
}
|
||||
out.push({ id: job.id, expr: job.expr, nextRun });
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/** Idempotent start: schedules every registered job. */
|
||||
public start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.stopping = false;
|
||||
for (const job of this.jobs.values()) this.scheduleNext(job);
|
||||
}
|
||||
|
||||
/** Idempotent stop: clears every pending timer and awaits in-flight
|
||||
* handlers before resolving. */
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
this.stopping = true;
|
||||
for (const job of this.jobs.values()) this.cancelTimer(job);
|
||||
await Promise.all(Array.from(this.inFlight));
|
||||
this.started = false;
|
||||
this.stopping = false;
|
||||
}
|
||||
|
||||
private cancelTimer(job: ScheduledJob): void {
|
||||
if (job.timerId !== null) {
|
||||
this.timers.clearTimer(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(job: ScheduledJob): void {
|
||||
const now = this.clock();
|
||||
let next: number;
|
||||
try {
|
||||
next = job.expression.nextRun(now, job.tz);
|
||||
} catch (err) {
|
||||
this.reportError(err, job.id);
|
||||
return;
|
||||
}
|
||||
const delay = Math.max(0, next - now);
|
||||
job.timerId = this.timers.setTimer(() => this.fire(job), delay);
|
||||
}
|
||||
|
||||
private fire(job: ScheduledJob): void {
|
||||
job.timerId = null;
|
||||
const promise = this.invokeHandler(job).finally(() => {
|
||||
this.inFlight.delete(promise);
|
||||
if (this.started && !this.stopping) this.scheduleNext(job);
|
||||
});
|
||||
this.inFlight.add(promise);
|
||||
}
|
||||
|
||||
private async invokeHandler(job: ScheduledJob): Promise<void> {
|
||||
try {
|
||||
await job.handler();
|
||||
} catch (err) {
|
||||
this.reportError(err, job.id);
|
||||
}
|
||||
}
|
||||
|
||||
private reportError(err: unknown, id: string): void {
|
||||
if (this.onError !== undefined) this.onError(err, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory mirroring the genesis primitive convention. */
|
||||
export function createCronScheduler(
|
||||
options?: CronSchedulerOptions
|
||||
): CronScheduler {
|
||||
return new CronScheduler(options);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Clock, TimersLike } from "@suaveplan/types";
|
||||
|
||||
/** Pluggable timer pair. Defaults to `globalThis.setTimeout`/`clearTimeout`.
|
||||
* Tests can inject a fake-clock-backed pair to drive the scheduler
|
||||
* deterministically. Aliased to the cross-package `TimersLike` contract
|
||||
* from `@suaveplan/types` so a single `FakeClock`-backed pair drops into
|
||||
* any package that needs scheduling. */
|
||||
export type SchedulerTimers = TimersLike;
|
||||
|
||||
/** Options for `CronScheduler`. */
|
||||
export interface CronSchedulerOptions {
|
||||
/** Wall-clock source. Defaults to `Date.now`. */
|
||||
clock?: Clock;
|
||||
/** Handler-error sink. Errors swallowed when omitted. */
|
||||
onError?: (err: unknown, id: string) => void;
|
||||
/** Injectable timer pair for deterministic tests. */
|
||||
timers?: SchedulerTimers;
|
||||
/** Optional id generator. Defaults to `crypto.randomUUID()`. */
|
||||
generateId?: () => string;
|
||||
}
|
||||
|
||||
/** Per-job descriptor returned by `list()`. */
|
||||
export interface CronSchedulerJob {
|
||||
readonly id: string;
|
||||
readonly expr: string;
|
||||
readonly nextRun: number | null;
|
||||
}
|
||||
|
||||
/** Add-job options. */
|
||||
export interface CronSchedulerAddOptions {
|
||||
/** IANA timezone. Defaults to `UTC`. */
|
||||
tz?: string;
|
||||
/** Caller-supplied id. Auto-generated when omitted. */
|
||||
id?: string;
|
||||
}
|
||||
11
packages/foundation/timing/cron/src/scheduler/index.ts
Normal file
11
packages/foundation/timing/cron/src/scheduler/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { CronScheduler, createCronScheduler } from "./cron-scheduler.js";
|
||||
export {
|
||||
CronSchedulerAddOptionsSchema,
|
||||
CronSchedulerOptionsSchema,
|
||||
} from "./cron-scheduler.schemas.js";
|
||||
export type {
|
||||
CronSchedulerAddOptions,
|
||||
CronSchedulerJob,
|
||||
CronSchedulerOptions,
|
||||
SchedulerTimers,
|
||||
} from "./cron-scheduler.types.js";
|
||||
7
packages/foundation/timing/cron/src/timezone/index.ts
Normal file
7
packages/foundation/timing/cron/src/timezone/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type { WallClock } from "./timezone.js";
|
||||
export {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone.js";
|
||||
88
packages/foundation/timing/cron/src/timezone/timezone.md
Normal file
88
packages/foundation/timing/cron/src/timezone/timezone.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# timezone
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The timezone module converts between epoch-millisecond timestamps and the
|
||||
wall-clock components observed in a specific IANA timezone using only the
|
||||
platform-native `Intl.DateTimeFormat` — no external timezone database is
|
||||
shipped or required. It powers the cron next-run algorithm by translating
|
||||
the user's "every day at 02:00 New York time" into the actual moments
|
||||
when that wall-clock instant occurs, including the awkward edge cases of
|
||||
daylight-saving-time spring-forward gaps and fall-back overlaps.
|
||||
|
||||
## Features
|
||||
|
||||
- `toWallClock(epochMs, tz)` — decompose an epoch instant into year /
|
||||
month / day / hour / minute / second / weekday in the requested zone.
|
||||
- `fromWallClock(components, tz)` — the inverse; performs up to four
|
||||
bounded fixed-point iterations to converge on the correct epoch even
|
||||
across DST transitions.
|
||||
- `detectDstTransition(before, after, tz)` — classifies a transition
|
||||
between two adjacent instants as `"spring-forward"`, `"fall-back"`, or
|
||||
`null` when no transition is crossed.
|
||||
- `assertTimezone(tz)` — eagerly validates an IANA name so the caller can
|
||||
fail fast at the API boundary.
|
||||
- Per-zone formatter cache keeps Intl construction off the hot path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { toWallClock, fromWallClock } from "@suaveplan/cron";
|
||||
|
||||
const wall = toWallClock(Date.UTC(2026, 0, 1, 5, 0, 0), "America/New_York");
|
||||
console.log(wall.hour); // 0 — midnight in NY
|
||||
|
||||
const back = fromWallClock(
|
||||
{ year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
|
||||
"America/New_York"
|
||||
);
|
||||
console.log(back === Date.UTC(2026, 0, 1, 5, 0, 0)); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "@suaveplan/cron";
|
||||
|
||||
const start = Date.UTC(2026, 2, 8, 6, 0, 0);
|
||||
const end = Date.UTC(2026, 2, 8, 8, 0, 0);
|
||||
const transition = detectDstTransition(start, end, "America/New_York");
|
||||
console.log(transition); // "spring-forward"
|
||||
|
||||
const requested = { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0 };
|
||||
const real = fromWallClock(requested, "America/New_York");
|
||||
const observed = toWallClock(real, "America/New_York");
|
||||
console.log(observed.hour, observed.minute); // not 02:30 — the slot was skipped
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WallClock` — `{ year, month, day, hour, minute, second, weekday }`
|
||||
with `weekday ∈ 0..6` (Sunday = 0, matching cron numbering).
|
||||
- `toWallClock(epochMs: number, tz: string): WallClock`.
|
||||
- `fromWallClock(components: Omit<WallClock, "weekday">, tz: string): number`.
|
||||
- `detectDstTransition(beforeEpochMs, afterEpochMs, tz):
|
||||
"spring-forward" | "fall-back" | null`.
|
||||
- `assertTimezone(tz: string): void` — throws
|
||||
`CronError(CRON.TIMEZONE_INVALID)` for invalid IANA names.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Intl.DateTimeFormat` is the only timezone authority. The package never
|
||||
ships a tz database — keeping it universal and dependency-free. The
|
||||
inverse `fromWallClock` works by guessing UTC, observing the wall clock
|
||||
offset that produces, and applying it back. Two iterations suffice for
|
||||
every IANA zone in normal time; we run up to four to handle the awkward
|
||||
spring-forward case where the first correction overshoots the gap.
|
||||
Inside the gap the function picks the canonical real-instant that
|
||||
`Intl.DateTimeFormat` reports, and callers re-verify against their field
|
||||
set so cron schedules that fall in the gap fire at the next existing
|
||||
minute. Formatter construction is cached per IANA name to keep the hot
|
||||
path allocation-free.
|
||||
146
packages/foundation/timing/cron/src/timezone/timezone.test.ts
Normal file
146
packages/foundation/timing/cron/src/timezone/timezone.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone.js";
|
||||
|
||||
describe("toWallClock", () => {
|
||||
it("decomposes UTC epoch to UTC components", { timeout: 5000 }, () => {
|
||||
const wall = toWallClock(Date.UTC(2026, 4, 11, 10, 30, 15), "UTC");
|
||||
expect(wall.year).toBe(2026);
|
||||
expect(wall.month).toBe(5);
|
||||
expect(wall.day).toBe(11);
|
||||
expect(wall.hour).toBe(10);
|
||||
expect(wall.minute).toBe(30);
|
||||
expect(wall.second).toBe(15);
|
||||
});
|
||||
|
||||
it("applies America/New_York offset", { timeout: 5000 }, () => {
|
||||
const wall = toWallClock(
|
||||
Date.UTC(2026, 0, 1, 5, 0, 0),
|
||||
"America/New_York"
|
||||
);
|
||||
expect(wall.year).toBe(2026);
|
||||
expect(wall.month).toBe(1);
|
||||
expect(wall.day).toBe(1);
|
||||
expect(wall.hour).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0..6 weekday", { timeout: 5000 }, () => {
|
||||
// 2026-05-11 is a Monday.
|
||||
const wall = toWallClock(Date.UTC(2026, 4, 11, 12, 0, 0), "UTC");
|
||||
expect(wall.weekday).toBe(1);
|
||||
});
|
||||
|
||||
it("throws TIMEZONE_INVALID for bad IANA name", { timeout: 5000 }, () => {
|
||||
try {
|
||||
toWallClock(0, "Not/A_Zone");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.TIMEZONE_INVALID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromWallClock", () => {
|
||||
it("round-trips UTC components", { timeout: 5000 }, () => {
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
day: 11,
|
||||
hour: 10,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "UTC");
|
||||
expect(epoch).toBe(Date.UTC(2026, 4, 11, 10, 30, 0));
|
||||
});
|
||||
|
||||
it("round-trips America/New_York components (winter)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 1,
|
||||
day: 1,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "America/New_York");
|
||||
// NY in January = UTC-5.
|
||||
expect(epoch).toBe(Date.UTC(2026, 0, 1, 5, 0, 0));
|
||||
});
|
||||
|
||||
it("round-trips America/New_York components (summer)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 7,
|
||||
day: 1,
|
||||
hour: 12,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "America/New_York");
|
||||
// NY in July = UTC-4.
|
||||
expect(epoch).toBe(Date.UTC(2026, 6, 1, 16, 0, 0));
|
||||
});
|
||||
|
||||
it("converges across the spring-forward gap", { timeout: 5000 }, () => {
|
||||
// 2026-03-08 02:30 local does not exist in America/New_York —
|
||||
// clock jumps from 02:00 to 03:00. The function returns _some_
|
||||
// real instant; callers re-verify the resulting wall clock.
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 3,
|
||||
day: 8,
|
||||
hour: 2,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "America/New_York");
|
||||
expect(Number.isFinite(epoch)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDstTransition", () => {
|
||||
it("detects spring-forward in America/New_York", { timeout: 5000 }, () => {
|
||||
const before = Date.UTC(2026, 2, 8, 6, 0, 0); // 01:00 local EST
|
||||
const after = Date.UTC(2026, 2, 8, 8, 0, 0); // 04:00 local EDT
|
||||
expect(detectDstTransition(before, after, "America/New_York")).toBe(
|
||||
"spring-forward"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects fall-back in America/New_York", { timeout: 5000 }, () => {
|
||||
const before = Date.UTC(2026, 10, 1, 5, 0, 0); // 01:00 EDT
|
||||
const after = Date.UTC(2026, 10, 1, 7, 0, 0); // 02:00 EST
|
||||
expect(detectDstTransition(before, after, "America/New_York")).toBe(
|
||||
"fall-back"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no transition crossed", { timeout: 5000 }, () => {
|
||||
const a = Date.UTC(2026, 4, 11, 10, 0, 0);
|
||||
const b = Date.UTC(2026, 4, 11, 11, 0, 0);
|
||||
expect(detectDstTransition(a, b, "America/New_York")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertTimezone", () => {
|
||||
it("accepts known zones", { timeout: 5000 }, () => {
|
||||
expect(() => assertTimezone("UTC")).not.toThrow();
|
||||
expect(() => assertTimezone("Europe/London")).not.toThrow();
|
||||
expect(() => assertTimezone("Australia/Sydney")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown zones", { timeout: 5000 }, () => {
|
||||
expect(() => assertTimezone("Mars/Olympus_Mons")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
171
packages/foundation/timing/cron/src/timezone/timezone.ts
Normal file
171
packages/foundation/timing/cron/src/timezone/timezone.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
|
||||
/** Wall-clock components in a specific IANA timezone. */
|
||||
export interface WallClock {
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
readonly second: number;
|
||||
/** 0 = Sunday … 6 = Saturday — matches cron day-of-week numbering. */
|
||||
readonly weekday: number;
|
||||
}
|
||||
|
||||
const TZ_FORMAT_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function getFormatter(tz: string): Intl.DateTimeFormat {
|
||||
const cached = TZ_FORMAT_CACHE.get(tz);
|
||||
if (cached !== undefined) return cached;
|
||||
let fmt: Intl.DateTimeFormat;
|
||||
try {
|
||||
fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new CronError(
|
||||
CRON_CODES.TIMEZONE_INVALID,
|
||||
"cron.timezone_invalid",
|
||||
{ context: { tz }, cause: cause as Error }
|
||||
);
|
||||
}
|
||||
TZ_FORMAT_CACHE.set(tz, fmt);
|
||||
return fmt;
|
||||
}
|
||||
|
||||
const WEEKDAY_MAP: Readonly<Record<string, number>> = Object.freeze({
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert an epoch millisecond timestamp to its wall-clock components in
|
||||
* `tz`. Throws `CronError(CRON.TIMEZONE_INVALID)` when the IANA name is
|
||||
* rejected by the platform `Intl.DateTimeFormat`.
|
||||
*/
|
||||
export function toWallClock(epochMs: number, tz: string): WallClock {
|
||||
const fmt = getFormatter(tz);
|
||||
const parts = fmt.formatToParts(new Date(epochMs));
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
let hour = 0;
|
||||
let minute = 0;
|
||||
let second = 0;
|
||||
let weekday = 0;
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "year":
|
||||
year = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "month":
|
||||
month = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "day":
|
||||
day = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "hour":
|
||||
// Intl emits "24" for midnight at the tail end of some hourCycle
|
||||
// boundaries; normalize to 0.
|
||||
hour = Number.parseInt(part.value, 10) % 24;
|
||||
break;
|
||||
case "minute":
|
||||
minute = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "second":
|
||||
second = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "weekday":
|
||||
// Intl always emits one of Sun..Sat for the `short` weekday
|
||||
// formatter we configured.
|
||||
weekday = WEEKDAY_MAP[part.value] as number;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { year, month, day, hour, minute, second, weekday };
|
||||
}
|
||||
|
||||
function utcEpoch(components: Omit<WallClock, "weekday">): number {
|
||||
return Date.UTC(
|
||||
components.year,
|
||||
components.month - 1,
|
||||
components.day,
|
||||
components.hour,
|
||||
components.minute,
|
||||
components.second
|
||||
);
|
||||
}
|
||||
|
||||
function offsetMs(epochMs: number, tz: string): number {
|
||||
const wall = toWallClock(epochMs, tz);
|
||||
return utcEpoch(wall) - epochMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the epoch instant whose wall-clock representation in `tz` matches
|
||||
* `components`. Iteratively corrects for the timezone offset (two passes
|
||||
* suffice for every IANA zone). When the requested wall-clock falls inside
|
||||
* a DST spring-forward gap, returns the first existing instant strictly
|
||||
* after the requested wall-clock — callers should re-verify the result
|
||||
* matches their cron field set.
|
||||
*/
|
||||
export function fromWallClock(
|
||||
components: Omit<WallClock, "weekday">,
|
||||
tz: string
|
||||
): number {
|
||||
const targetUtc = utcEpoch(components);
|
||||
// Fixed-point iteration: target's NY-wall-clock at instant `e`
|
||||
// is exactly `target` when `e = targetUtc - offsetMs(e, tz)`.
|
||||
// Start by guessing the instant's offset using `targetUtc` itself.
|
||||
let candidate = targetUtc - offsetMs(targetUtc, tz);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const refined = targetUtc - offsetMs(candidate, tz);
|
||||
if (refined === candidate) return candidate;
|
||||
candidate = refined;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a DST transition between two adjacent epoch instants in `tz`. The
|
||||
* return value indicates the kind of transition; `null` means no transition
|
||||
* occurred.
|
||||
*/
|
||||
export function detectDstTransition(
|
||||
beforeEpochMs: number,
|
||||
afterEpochMs: number,
|
||||
tz: string
|
||||
): "spring-forward" | "fall-back" | null {
|
||||
const beforeOffset = offsetMs(beforeEpochMs, tz);
|
||||
const afterOffset = offsetMs(afterEpochMs, tz);
|
||||
if (beforeOffset === afterOffset) return null;
|
||||
// In a spring-forward, local time jumps ahead — offset becomes more
|
||||
// negative (e.g. EST -5h → EDT -4h means utcEpoch increases relative to
|
||||
// real epoch, so offset increases). In fall-back, offset decreases.
|
||||
return afterOffset > beforeOffset ? "spring-forward" : "fall-back";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an IANA timezone name eagerly. Useful at the API boundary so
|
||||
* configuration errors fail before any next-run computation.
|
||||
*
|
||||
* @throws CronError(CRON.TIMEZONE_INVALID)
|
||||
*/
|
||||
export function assertTimezone(tz: string): void {
|
||||
getFormatter(tz);
|
||||
}
|
||||
128
packages/foundation/timing/cron/tests/integration.test.ts
Normal file
128
packages/foundation/timing/cron/tests/integration.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CronScheduler } from "../src/index.js";
|
||||
import type { SchedulerTimers } from "../src/scheduler/cron-scheduler.types.js";
|
||||
|
||||
interface PendingTimer {
|
||||
id: number;
|
||||
cb: () => void;
|
||||
fireAt: number;
|
||||
}
|
||||
|
||||
function createHarness(start: number) {
|
||||
const state = { now: start, nextId: 1 };
|
||||
const pending = new Map<number, PendingTimer>();
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
pending.set(id, { id, cb, fireAt: state.now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
async function advance(delta: number): Promise<void> {
|
||||
const target = state.now + delta;
|
||||
while (true) {
|
||||
let earliest: PendingTimer | null = null;
|
||||
for (const t of pending.values()) {
|
||||
if (t.fireAt > target) continue;
|
||||
if (earliest === null || t.fireAt < earliest.fireAt)
|
||||
earliest = t;
|
||||
}
|
||||
if (earliest === null) break;
|
||||
state.now = earliest.fireAt;
|
||||
pending.delete(earliest.id);
|
||||
earliest.cb();
|
||||
// Let micro-task queue drain so the handler's finally re-schedules
|
||||
// before we look for the next due timer.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
}
|
||||
return {
|
||||
clock: () => state.now,
|
||||
timers,
|
||||
advance,
|
||||
get pending() {
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("integration — CronScheduler with multiple jobs", () => {
|
||||
it("fires three jobs at their independent intervals", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const harness = createHarness(Date.UTC(2026, 4, 11, 9, 59, 0));
|
||||
const fires = { hourly: 0, fiveMin: 0, daily: 0 };
|
||||
const scheduler = new CronScheduler({
|
||||
clock: harness.clock,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add(
|
||||
"0 * * * *",
|
||||
() => {
|
||||
fires.hourly += 1;
|
||||
},
|
||||
{ id: "hourly" }
|
||||
);
|
||||
scheduler.add(
|
||||
"*/5 * * * *",
|
||||
() => {
|
||||
fires.fiveMin += 1;
|
||||
},
|
||||
{ id: "five" }
|
||||
);
|
||||
scheduler.add(
|
||||
"0 0 * * *",
|
||||
() => {
|
||||
fires.daily += 1;
|
||||
},
|
||||
{ id: "daily" }
|
||||
);
|
||||
scheduler.start();
|
||||
|
||||
// Advance 2 hours and 30 minutes.
|
||||
await harness.advance(2 * 60 * 60 * 1000 + 30 * 60 * 1000);
|
||||
|
||||
expect(fires.hourly).toBeGreaterThanOrEqual(2);
|
||||
expect(fires.fiveMin).toBeGreaterThanOrEqual(20);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop() drains in-flight handler", { timeout: 30000 }, async () => {
|
||||
const harness = createHarness(Date.UTC(2026, 4, 11, 9, 59, 0));
|
||||
let resolveHandler!: () => void;
|
||||
const holding = new Promise<void>((r) => {
|
||||
resolveHandler = r;
|
||||
});
|
||||
let completed = false;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: harness.clock,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
await holding;
|
||||
completed = true;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
|
||||
const stopPromise = scheduler.stop();
|
||||
let stopResolved = false;
|
||||
const watcher = stopPromise.then(() => {
|
||||
stopResolved = true;
|
||||
});
|
||||
|
||||
// Give the runtime a few microtasks to let the watcher attach.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(stopResolved).toBe(false);
|
||||
|
||||
resolveHandler();
|
||||
await watcher;
|
||||
expect(completed).toBe(true);
|
||||
expect(stopResolved).toBe(true);
|
||||
});
|
||||
});
|
||||
111
packages/foundation/timing/cron/tests/property.test.ts
Normal file
111
packages/foundation/timing/cron/tests/property.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { parseCron } from "../src/index.js";
|
||||
|
||||
const EXPRESSIONS = [
|
||||
"* * * * *",
|
||||
"0 * * * *",
|
||||
"*/5 * * * *",
|
||||
"*/15 * * * *",
|
||||
"0 9 * * *",
|
||||
"0 9 * * MON-FRI",
|
||||
"30 14 * * 0",
|
||||
"0 0 1 * *",
|
||||
"0 0 1 1 *",
|
||||
"0,15,30,45 9-17 * * MON-FRI",
|
||||
"0 10-12/2 * * *",
|
||||
];
|
||||
|
||||
const TIMEZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
|
||||
const timestampArb = fc.integer({
|
||||
min: Date.UTC(2026, 0, 1),
|
||||
max: Date.UTC(2028, 0, 1),
|
||||
});
|
||||
|
||||
describe("property — nextRun > after", () => {
|
||||
it("returns a strictly later instant for every supported expression", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const next = parseCron(e).nextRun(t, tz);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next as number).toBeGreaterThan(t);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — nextRuns strictly monotonic", () => {
|
||||
it("every adjacent pair is strictly ascending", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const runs = parseCron(e).nextRuns(10, t, tz);
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < runs.length; i += 1) {
|
||||
expect(runs[i]).toBeGreaterThan(runs[i - 1] ?? 0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — matches(nextRun) is true", () => {
|
||||
it("the returned instant always matches the expression", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const expr = parseCron(e);
|
||||
const next = expr.nextRun(t, tz);
|
||||
expect(expr.matches(next as number, tz)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — previousRun(nextRun) <= after", () => {
|
||||
it("backward search lands at-or-before the original anchor", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const expr = parseCron(e);
|
||||
const next = expr.nextRun(t, tz) as number;
|
||||
const prev = expr.previousRun(next, tz);
|
||||
expect(prev).not.toBeNull();
|
||||
expect(prev as number).toBeLessThanOrEqual(t);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
1
packages/foundation/timing/cron/tests/setup/polyfills.ts
Normal file
1
packages/foundation/timing/cron/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
251
packages/foundation/timing/cron/tests/vectors.test.ts
Normal file
251
packages/foundation/timing/cron/tests/vectors.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseCron } from "../src/index.js";
|
||||
|
||||
interface Vector {
|
||||
expr: string;
|
||||
after: number;
|
||||
tz: string;
|
||||
expected: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const utc = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number =>
|
||||
Date.UTC(y, mo, d, h, mi, s);
|
||||
|
||||
const vectors: Vector[] = [
|
||||
// Hourly variants
|
||||
{
|
||||
expr: "0 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 30),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 11, 0),
|
||||
label: "hourly @00",
|
||||
},
|
||||
{
|
||||
expr: "*/15 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 7),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 10, 15),
|
||||
label: "every 15 minutes",
|
||||
},
|
||||
{
|
||||
expr: "*/15 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 45),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 11, 0),
|
||||
label: "every 15 wrap",
|
||||
},
|
||||
{
|
||||
expr: "*/30 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 29),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 10, 30),
|
||||
label: "every 30 minutes",
|
||||
},
|
||||
|
||||
// Daily
|
||||
{
|
||||
expr: "0 9 * * *",
|
||||
after: utc(2026, 4, 11, 8, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 9, 0),
|
||||
label: "daily 9am",
|
||||
},
|
||||
{
|
||||
expr: "0 0 * * *",
|
||||
after: utc(2026, 4, 11, 12, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 12, 0, 0),
|
||||
label: "daily midnight",
|
||||
},
|
||||
{
|
||||
expr: "@daily",
|
||||
after: utc(2026, 4, 11, 12, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 12, 0, 0),
|
||||
label: "@daily macro",
|
||||
},
|
||||
{
|
||||
expr: "@hourly",
|
||||
after: utc(2026, 4, 11, 10, 30),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 11, 0),
|
||||
label: "@hourly macro",
|
||||
},
|
||||
|
||||
// Weekdays / weekends
|
||||
// 2026-05-09 is Saturday. Next 9am weekday = Mon 2026-05-11.
|
||||
{
|
||||
expr: "0 9 * * MON-FRI",
|
||||
after: utc(2026, 4, 9),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 9, 0),
|
||||
label: "9am weekdays",
|
||||
},
|
||||
{
|
||||
expr: "0 12 * * 0",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 17, 12, 0),
|
||||
label: "noon Sundays",
|
||||
},
|
||||
{
|
||||
expr: "0 9 * * 7",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 17, 9, 0),
|
||||
label: "9am Sundays via 7",
|
||||
},
|
||||
|
||||
// Monthly / yearly
|
||||
{
|
||||
expr: "0 0 1 * *",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 5, 1, 0, 0),
|
||||
label: "first of month",
|
||||
},
|
||||
{
|
||||
expr: "@monthly",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 5, 1, 0, 0),
|
||||
label: "@monthly macro",
|
||||
},
|
||||
{
|
||||
expr: "0 0 1 1 *",
|
||||
after: utc(2026, 0, 2),
|
||||
tz: "UTC",
|
||||
expected: utc(2027, 0, 1, 0, 0),
|
||||
label: "annual Jan 1",
|
||||
},
|
||||
{
|
||||
expr: "@yearly",
|
||||
after: utc(2026, 0, 2),
|
||||
tz: "UTC",
|
||||
expected: utc(2027, 0, 1, 0, 0),
|
||||
label: "@yearly macro",
|
||||
},
|
||||
|
||||
// Month aliases
|
||||
{
|
||||
expr: "0 0 1 JUL *",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 6, 1, 0, 0),
|
||||
label: "month alias JUL",
|
||||
},
|
||||
|
||||
// 4 specific minutes
|
||||
{
|
||||
expr: "0,15,30,45 9-17 * * MON-FRI",
|
||||
after: utc(2026, 4, 11, 9, 16),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 9, 30),
|
||||
label: "quarter-hour business hours",
|
||||
},
|
||||
{
|
||||
expr: "0,15,30,45 9-17 * * MON-FRI",
|
||||
after: utc(2026, 4, 11, 17, 46),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 12, 9, 0),
|
||||
label: "wrap to next business day",
|
||||
},
|
||||
|
||||
// Range/step variations
|
||||
{
|
||||
expr: "0 10-12/2 * * *",
|
||||
after: utc(2026, 4, 11, 9, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 10, 0),
|
||||
label: "step in range first",
|
||||
},
|
||||
{
|
||||
expr: "0 10-12/2 * * *",
|
||||
after: utc(2026, 4, 11, 11, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 12, 0),
|
||||
label: "step in range second",
|
||||
},
|
||||
|
||||
// Leap year
|
||||
{
|
||||
expr: "0 0 29 2 *",
|
||||
after: utc(2026, 0, 1),
|
||||
tz: "UTC",
|
||||
expected: utc(2028, 1, 29, 0, 0),
|
||||
label: "leap day next",
|
||||
},
|
||||
|
||||
// DST — America/New_York 2026-03-08 spring-forward
|
||||
{
|
||||
expr: "0 3 * * *",
|
||||
after: utc(2026, 2, 8, 6, 0),
|
||||
tz: "America/New_York",
|
||||
// 03:00 EDT on 2026-03-08 = 07:00 UTC
|
||||
expected: utc(2026, 2, 8, 7, 0),
|
||||
label: "NY 3am day-of-spring-forward",
|
||||
},
|
||||
|
||||
// Europe/London non-DST anchor
|
||||
// 2026-01-01 09:00 London = 09:00 UTC (GMT in January)
|
||||
{
|
||||
expr: "0 9 * * *",
|
||||
after: utc(2025, 11, 31, 23, 0),
|
||||
tz: "Europe/London",
|
||||
expected: utc(2026, 0, 1, 9, 0),
|
||||
label: "London winter 9am",
|
||||
},
|
||||
|
||||
// Australia/Sydney winter 12:00 local = 02:00 UTC (UTC+10).
|
||||
// after = 2026-05-31T00:00Z = 2026-05-31T10:00 Sydney, next noon
|
||||
// Sydney = 2026-05-31T12:00 local = 2026-05-31T02:00 UTC.
|
||||
{
|
||||
expr: "0 12 * * *",
|
||||
after: utc(2026, 4, 31, 0, 0),
|
||||
tz: "Australia/Sydney",
|
||||
expected: utc(2026, 4, 31, 2, 0),
|
||||
label: "Sydney winter noon",
|
||||
},
|
||||
|
||||
// Seconds + Quartz variants (need seconds: true)
|
||||
// Tested separately because parseCron options differ.
|
||||
];
|
||||
|
||||
describe("vectors — 5-field UTC + DST", () => {
|
||||
for (const v of vectors) {
|
||||
it(v.label, { timeout: 5000 }, () => {
|
||||
const expr = parseCron(v.expr);
|
||||
const next = expr.nextRun(v.after, v.tz);
|
||||
expect(next).toBe(v.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("vectors — 6-field seconds", () => {
|
||||
it("every 10 seconds", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("*/10 * * * * *", { seconds: true });
|
||||
expect(expr.nextRun(utc(2026, 4, 11, 10, 0, 5), "UTC")).toBe(
|
||||
utc(2026, 4, 11, 10, 0, 10)
|
||||
);
|
||||
});
|
||||
|
||||
it("seconds with hour wrap", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 0 * * * *", { seconds: true });
|
||||
expect(expr.nextRun(utc(2026, 4, 11, 10, 0, 30), "UTC")).toBe(
|
||||
utc(2026, 4, 11, 11, 0, 30)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vectors — 7-field Quartz year", () => {
|
||||
it("year-constrained next run", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 12 1 1 * 2030", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
expect(expr.nextRun(utc(2026, 0, 1), "UTC")).toBe(
|
||||
utc(2030, 0, 1, 12, 0, 0)
|
||||
);
|
||||
});
|
||||
});
|
||||
7
packages/foundation/timing/cron/tsconfig.lib.json
Normal file
7
packages/foundation/timing/cron/tsconfig.lib.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "./src", "outDir": "./dist", "lib": ["ES2022"], "types": ["node"] },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
17
packages/foundation/timing/cron/vite.config.ts
Normal file
17
packages/foundation/timing/cron/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/cron",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist", emptyOutDir: true, reportCompressedSize: true, sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["@suaveplan/error", "@suaveplan/types", "tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
17
packages/foundation/timing/cron/vitest.config.ts
Normal file
17
packages/foundation/timing/cron/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false, environment: "node", pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul", reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.schemas.ts", "src/**/*.types.ts", "src/**/index.ts", "tests/**"],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
92
packages/foundation/timing/datetime/README.md
Normal file
92
packages/foundation/timing/datetime/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/datetime - Timezone-aware date/time utilities with business-day arithmetic, recurrence rules, calendar math, and relative time formatting
|
||||
|
||||
## Summary
|
||||
|
||||
Timezone-aware date/time utilities covering the most common application-layer date operations that the native `Date` API leaves unaddressed. The package provides calendar math (`addDuration`, `subtractDuration`, `startOf`, `endOf`, `getCalendarMonth`, leap-year detection), business-day arithmetic with configurable holiday calendars and custom weekend definitions, an RRULE-subset recurrence engine (DAILY/WEEKLY/MONTHLY/YEARLY with BYDAY/BYMONTH/COUNT/UNTIL), and human-readable relative time formatting (`"3 hours ago"`, `"in 2 days"`). All stateful entry points accept an injectable `Clock` for deterministic testing. Use this package for scheduling UIs, calendar components, invoice date calculations, and any domain where "next business day" or "every other Monday" has real meaning. For low-level stopwatches, throttle/debounce, or vector clocks use `@suaveplan/timing`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_ - [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_ - [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`calendar`](src/calendar/calendar.md) | Core date arithmetic: `toDate`, `addDuration`, `subtractDuration`, `startOf`, `endOf`, `getCalendarMonth`, `daysInMonth`, `isLeapYear`, `isSameDay`, `isSameMonth` |
|
||||
| [`business-days`](src/business-days/business-days.md) | Working-day arithmetic with holiday and custom-weekend support |
|
||||
| [`recurrence`](src/recurrence/recurrence.md) | RRULE parse/format/expand engine (DAILY/WEEKLY/MONTHLY/YEARLY) |
|
||||
| [`relative`](src/relative/relative.md) | Human-readable relative time: `formatRelative`, `timeAgo`, `timeUntil`, `diffInUnits` |
|
||||
| [`errors`](src/errors/datetime-error.md) | `DatetimeError` + `DATETIME_CODES` registry |
|
||||
| [`otel`](src/otel/datetime-otel.md) | OpenTelemetry histogram and counter instrumentation |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { addDuration, getCalendarMonth, businessDaysBetween, expandRecurrence, parseRRule, formatRelative } from "@suaveplan/datetime";
|
||||
|
||||
// Calendar math
|
||||
const nextMonth = addDuration(new Date(), { months: 1 });
|
||||
const march2024 = getCalendarMonth(2024, 3);
|
||||
|
||||
// Business days
|
||||
const tradingDays = businessDaysBetween("2024-01-01", "2024-04-01");
|
||||
|
||||
// Recurrence
|
||||
const meetings = expandRecurrence(parseRRule("FREQ=WEEKLY;BYDAY=MO,WE;COUNT=8"));
|
||||
|
||||
// Relative time
|
||||
console.log(formatRelative(new Date(Date.now() - 3 * 3600_000))); // "3 hours ago"
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/datetime
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
125
packages/foundation/timing/datetime/package.json
Normal file
125
packages/foundation/timing/datetime/package.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"name": "@suaveplan/datetime",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Timezone-aware date/time utilities with business-day arithmetic, recurrence rules, calendar math, and relative time formatting",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./calendar": {
|
||||
"import": {
|
||||
"types": "./dist/calendar/calendar.d.ts",
|
||||
"default": "./dist/calendar/calendar.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/calendar/calendar.d.ts",
|
||||
"default": "./dist/calendar/calendar.js"
|
||||
}
|
||||
},
|
||||
"./business-days": {
|
||||
"import": {
|
||||
"types": "./dist/business-days/business-days.d.ts",
|
||||
"default": "./dist/business-days/business-days.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/business-days/business-days.d.ts",
|
||||
"default": "./dist/business-days/business-days.js"
|
||||
}
|
||||
},
|
||||
"./recurrence": {
|
||||
"import": {
|
||||
"types": "./dist/recurrence/recurrence.d.ts",
|
||||
"default": "./dist/recurrence/recurrence.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/recurrence/recurrence.d.ts",
|
||||
"default": "./dist/recurrence/recurrence.js"
|
||||
}
|
||||
},
|
||||
"./relative": {
|
||||
"import": {
|
||||
"types": "./dist/relative/relative.d.ts",
|
||||
"default": "./dist/relative/relative.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/relative/relative.d.ts",
|
||||
"default": "./dist/relative/relative.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/datetime-error.d.ts",
|
||||
"default": "./dist/errors/datetime-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/datetime-error.d.ts",
|
||||
"default": "./dist/errors/datetime-error.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/datetime-otel.d.ts",
|
||||
"default": "./dist/otel/datetime-otel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/datetime-otel.d.ts",
|
||||
"default": "./dist/otel/datetime-otel.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"bench": "bun run src/__benchmarks__/calendar.bench.ts",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# business-days
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `business-days` module provides a complete suite of working-day arithmetic functions for `@suaveplan/datetime`. It models "business day" as any day that is neither in the caller-supplied `weekendDays` set (default: Sunday and Saturday) nor in the caller-supplied `holidays` list. This allows the same API to serve different jurisdictions and corporate calendars without baking in any cultural assumptions.
|
||||
|
||||
All functions accept the polymorphic `DateInput` type and delegate to `toDate` for coercion, so callers can pass ISO strings, epoch milliseconds, or `Date` objects interchangeably.
|
||||
|
||||
## Features
|
||||
|
||||
- `isBusinessDay` — predicate for whether a given date is a working day
|
||||
- `nextBusinessDay` / `previousBusinessDay` — scan forward/backward for the nearest working day
|
||||
- `addBusinessDays` / `subtractBusinessDays` — advance or retreat by an exact count of working days
|
||||
- `businessDaysBetween` — count working days in a half-open interval `[start, end)`
|
||||
- Configurable `weekendDays` (any subset of 0–6)
|
||||
- Holiday exclusion via an `Date[]` array
|
||||
- Cooperative cancellation via `AbortSignal` (checked every 256 iterations in `businessDaysBetween`)
|
||||
- OTel counter via `recordBusinessDaysBetween`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { isBusinessDay, addBusinessDays } from "@suaveplan/datetime/business-days";
|
||||
|
||||
// Simple weekday check
|
||||
console.log(isBusinessDay("2024-03-18")); // true (Monday)
|
||||
console.log(isBusinessDay("2024-03-16")); // false (Saturday)
|
||||
|
||||
// Advance 5 business days
|
||||
const result = addBusinessDays("2024-03-18", 5);
|
||||
console.log(result.toDateString()); // Fri Mar 29 2024 (skips weekend)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
businessDaysBetween,
|
||||
nextBusinessDay,
|
||||
} from "@suaveplan/datetime/business-days";
|
||||
|
||||
const nyseHolidays = [
|
||||
new Date("2024-01-01"), // New Year's Day
|
||||
new Date("2024-07-04"), // Independence Day
|
||||
];
|
||||
|
||||
// How many NYSE trading days in Q1 2024?
|
||||
const q1 = businessDaysBetween("2024-01-01", "2024-04-01", {
|
||||
holidays: nyseHolidays,
|
||||
});
|
||||
console.log(q1); // ~62 trading days
|
||||
|
||||
// Cooperative cancellation
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
const days = businessDaysBetween("2020-01-01", "2030-01-01", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
console.log(days); // partial count, aborted after ~256 iterations
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isBusinessDay(date: DateInput, opts?: BusinessDayOptions): boolean`
|
||||
|
||||
Returns `true` when `date` is not a weekend day and not a holiday.
|
||||
|
||||
- `opts.weekendDays` — default `[0, 6]` (Sun, Sat)
|
||||
- `opts.holidays` — list of `Date` objects to exclude
|
||||
|
||||
### `nextBusinessDay(date: DateInput, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Returns the first business day strictly after `date`.
|
||||
|
||||
### `previousBusinessDay(date: DateInput, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Returns the first business day strictly before `date`.
|
||||
|
||||
### `addBusinessDays(date: DateInput, n: number, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Advance `date` by exactly `|n|` business days. Negative `n` moves backward.
|
||||
|
||||
### `subtractBusinessDays(date: DateInput, n: number, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Move `date` backward by exactly `n` business days. Delegates to `addBusinessDays(date, -n)`.
|
||||
|
||||
### `businessDaysBetween(start: DateInput, end: DateInput, opts?: BusinessDayOptions): number`
|
||||
|
||||
Count business days in `[start, end)`. Returns a negative number when `end < start`. Checks `opts.signal` every 256 iterations.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Holiday lookup uses a `Set<string>` keyed by `"YYYY-M-D"` (local time) built once per call. This is O(1) per day check and avoids allocating `Date` objects for the comparison.
|
||||
|
||||
All date arithmetic is performed at local-midnight epoch millis to prevent time-of-day accumulation errors across DST transitions. The `midnight()` helper normalises any input to 00:00:00.000 local time before iteration begins.
|
||||
|
||||
`addBusinessDays(d, 0)` returns the local midnight of `d` regardless of whether `d` itself is a business day, because zero steps means no movement.
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
addBusinessDays,
|
||||
businessDaysBetween,
|
||||
isBusinessDay,
|
||||
nextBusinessDay,
|
||||
previousBusinessDay,
|
||||
subtractBusinessDays,
|
||||
} from "./business-days.js";
|
||||
|
||||
describe("isBusinessDay", () => {
|
||||
it("Monday is a business day", { timeout: 5000 }, () => {
|
||||
expect(isBusinessDay(new Date(2024, 2, 18))).toBe(true); // Mon
|
||||
});
|
||||
|
||||
it("Saturday is not a business day", { timeout: 5000 }, () => {
|
||||
expect(isBusinessDay(new Date(2024, 2, 16))).toBe(false); // Sat
|
||||
});
|
||||
|
||||
it("Sunday is not a business day", { timeout: 5000 }, () => {
|
||||
expect(isBusinessDay(new Date(2024, 2, 17))).toBe(false); // Sun
|
||||
});
|
||||
|
||||
it("holidays are excluded", { timeout: 5000 }, () => {
|
||||
const holiday = new Date(2024, 2, 18); // Mon
|
||||
expect(isBusinessDay(holiday, { holidays: [holiday] })).toBe(false);
|
||||
});
|
||||
|
||||
it("custom weekendDays", { timeout: 5000 }, () => {
|
||||
// Only Friday off
|
||||
expect(isBusinessDay(new Date(2024, 2, 15), { weekendDays: [5] })).toBe(
|
||||
false
|
||||
); // Fri
|
||||
expect(isBusinessDay(new Date(2024, 2, 16), { weekendDays: [5] })).toBe(
|
||||
true
|
||||
); // Sat
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextBusinessDay", () => {
|
||||
it("Friday → Monday (skips weekend)", { timeout: 5000 }, () => {
|
||||
const fri = new Date(2024, 2, 15); // Fri
|
||||
const result = nextBusinessDay(fri);
|
||||
expect(result.getDay()).toBe(1); // Mon
|
||||
expect(result.getDate()).toBe(18);
|
||||
});
|
||||
|
||||
it("skips holidays", { timeout: 5000 }, () => {
|
||||
const thu = new Date(2024, 2, 14); // Thu
|
||||
const fri = new Date(2024, 2, 15); // Fri
|
||||
const result = nextBusinessDay(thu, { holidays: [fri] });
|
||||
expect(result.getDay()).toBe(1); // Mon
|
||||
});
|
||||
|
||||
it("Monday → Tuesday", { timeout: 5000 }, () => {
|
||||
const mon = new Date(2024, 2, 18);
|
||||
const result = nextBusinessDay(mon);
|
||||
expect(result.getDate()).toBe(19);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousBusinessDay", () => {
|
||||
it("Monday → Friday (skips weekend)", { timeout: 5000 }, () => {
|
||||
const mon = new Date(2024, 2, 18); // Mon
|
||||
const result = previousBusinessDay(mon);
|
||||
expect(result.getDay()).toBe(5); // Fri
|
||||
expect(result.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it("skips holidays", { timeout: 5000 }, () => {
|
||||
const tue = new Date(2024, 2, 19); // Tue
|
||||
const mon = new Date(2024, 2, 18); // Mon
|
||||
const result = previousBusinessDay(tue, { holidays: [mon] });
|
||||
expect(result.getDay()).toBe(5); // Fri
|
||||
});
|
||||
});
|
||||
|
||||
describe("addBusinessDays", () => {
|
||||
it("adds 5 business days from Monday", { timeout: 5000 }, () => {
|
||||
const mon = new Date(2024, 2, 18); // Mon Mar 18
|
||||
const result = addBusinessDays(mon, 5);
|
||||
// Mon+5 business = Fri Mar 22 → actually Mon, Tue, Wed, Thu, Fri = Mar 22
|
||||
expect(result.getDate()).toBe(25); // next Mon because Mon+5 = Mon
|
||||
// Let's verify: Mon→Tue→Wed→Thu→Fri = 5 days, result = Fri Mar 22
|
||||
// Actually counting: step 1=Tue, 2=Wed, 3=Thu, 4=Fri, 5=Mon(next week)
|
||||
// Mon + skip weekends: Tue(1), Wed(2), Thu(3), Fri(4), Mon(5) = Mar 25
|
||||
expect(result.getDay()).toBe(1); // Monday
|
||||
});
|
||||
|
||||
it("n=0 returns local midnight of same date", { timeout: 5000 }, () => {
|
||||
const d = new Date(2024, 2, 18, 14, 30, 0);
|
||||
const result = addBusinessDays(d, 0);
|
||||
expect(result.getDate()).toBe(18);
|
||||
expect(result.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("negative n moves backward", { timeout: 5000 }, () => {
|
||||
const wed = new Date(2024, 2, 20); // Wed
|
||||
const result = addBusinessDays(wed, -2);
|
||||
expect(result.getDay()).toBe(1); // Mon
|
||||
expect(result.getDate()).toBe(18);
|
||||
});
|
||||
|
||||
it("result is always a business day (property test)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.date({
|
||||
min: new Date("2020-01-01"),
|
||||
max: new Date("2025-12-31"),
|
||||
}),
|
||||
fc.integer({ min: 1, max: 30 }),
|
||||
(date, n) => {
|
||||
const result = addBusinessDays(date, n);
|
||||
expect(isBusinessDay(result)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("result is always a business day going backward (property test)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.date({
|
||||
min: new Date("2020-01-01"),
|
||||
max: new Date("2025-12-31"),
|
||||
}),
|
||||
fc.integer({ min: 1, max: 30 }),
|
||||
(date, n) => {
|
||||
const result = addBusinessDays(date, -n);
|
||||
expect(isBusinessDay(result)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtractBusinessDays", () => {
|
||||
it("delegates to addBusinessDays with -n", { timeout: 5000 }, () => {
|
||||
const wed = new Date(2024, 2, 20);
|
||||
expect(subtractBusinessDays(wed, 2).getTime()).toBe(
|
||||
addBusinessDays(wed, -2).getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("businessDaysBetween", () => {
|
||||
it("same date returns 0", { timeout: 5000 }, () => {
|
||||
const d = new Date(2024, 2, 18);
|
||||
expect(businessDaysBetween(d, d)).toBe(0);
|
||||
});
|
||||
|
||||
it("Mon to Fri = 4 business days (exclusive of end)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// [Mon, Fri) = Mon, Tue, Wed, Thu = 4 business days
|
||||
const mon = new Date(2024, 2, 18);
|
||||
const fri = new Date(2024, 2, 22);
|
||||
expect(businessDaysBetween(mon, fri)).toBe(4);
|
||||
});
|
||||
|
||||
it("Mon to Sat = 5 business days (Mon-Fri)", { timeout: 5000 }, () => {
|
||||
// [Mon, Sat) = Mon, Tue, Wed, Thu, Fri = 5 business days
|
||||
const mon = new Date(2024, 2, 18);
|
||||
const sat = new Date(2024, 2, 23);
|
||||
expect(businessDaysBetween(mon, sat)).toBe(5);
|
||||
});
|
||||
|
||||
it("Fri to Mon = 1 business day (exclusive of end)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// [Fri, Mon) = just Fri = 1 business day
|
||||
const fri = new Date(2024, 2, 15);
|
||||
const mon = new Date(2024, 2, 18);
|
||||
expect(businessDaysBetween(fri, mon)).toBe(1);
|
||||
});
|
||||
|
||||
it("end before start returns negative", { timeout: 5000 }, () => {
|
||||
// [Mon, Fri) = 4, reversed = -4
|
||||
const mon = new Date(2024, 2, 18);
|
||||
const fri = new Date(2024, 2, 22);
|
||||
expect(businessDaysBetween(fri, mon)).toBe(-4);
|
||||
});
|
||||
|
||||
it("excludes holidays", { timeout: 5000 }, () => {
|
||||
// [Mon, Sat) = 5, minus Wed holiday = 4
|
||||
const mon = new Date(2024, 2, 18);
|
||||
const sat = new Date(2024, 2, 23);
|
||||
const wed = new Date(2024, 2, 20);
|
||||
expect(businessDaysBetween(mon, sat, { holidays: [wed] })).toBe(4);
|
||||
});
|
||||
|
||||
it("respects abort signal", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
// Should not throw, just return partial or 0
|
||||
const start = new Date(2020, 0, 1);
|
||||
const end = new Date(2025, 0, 1);
|
||||
const result = businessDaysBetween(start, end, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(typeof result).toBe("number");
|
||||
});
|
||||
|
||||
it("handles custom weekend days", { timeout: 5000 }, () => {
|
||||
// Only Friday is weekend
|
||||
const mon = new Date(2024, 2, 18);
|
||||
const sat = new Date(2024, 2, 23);
|
||||
// Mon,Tue,Wed,Thu are business (Fri excluded), Sat counts
|
||||
const count = businessDaysBetween(mon, sat, { weekendDays: [5] });
|
||||
expect(count).toBe(4); // Mon, Tue, Wed, Thu (Fri excluded)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { toDate } from "../calendar/calendar.js";
|
||||
import { recordBusinessDaysBetween } from "../otel/datetime-otel.js";
|
||||
import type {
|
||||
BusinessDayOptions,
|
||||
DateInput,
|
||||
Weekday,
|
||||
} from "../types/datetime.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_WEEKEND: ReadonlyArray<Weekday> = [0, 6]; // Sun, Sat
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Strip time portion; return local-midnight epoch for `d`. */
|
||||
function midnight(d: Date): number {
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
/** Format a date as "YYYY-M-D" for cheap holiday set membership. */
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** Build a set of holiday keys from the options list. */
|
||||
function buildHolidaySet(holidays?: Date[]): Set<string> {
|
||||
const s = new Set<string>();
|
||||
for (const h of holidays ?? []) {
|
||||
s.add(dayKey(h));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns `true` when `date` is a working day (not a weekend, not a holiday).
|
||||
*
|
||||
* @param date - The date to test.
|
||||
* @param opts - Optional `weekendDays` (default Sun/Sat), `holidays`, `clock`.
|
||||
*/
|
||||
export function isBusinessDay(
|
||||
date: DateInput,
|
||||
opts?: BusinessDayOptions
|
||||
): boolean {
|
||||
const d = toDate(date);
|
||||
const weekendDays: ReadonlyArray<Weekday> =
|
||||
opts?.weekendDays ?? DEFAULT_WEEKEND;
|
||||
const holidays = buildHolidaySet(opts?.holidays);
|
||||
const dow = d.getDay() as Weekday;
|
||||
if ((weekendDays as number[]).includes(dow)) return false;
|
||||
if (holidays.has(dayKey(d))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next business day after `date` (exclusive of `date` itself).
|
||||
*
|
||||
* Iterates forward one day at a time until a business day is found.
|
||||
*/
|
||||
export function nextBusinessDay(
|
||||
date: DateInput,
|
||||
opts?: BusinessDayOptions
|
||||
): Date {
|
||||
const weekendDays: ReadonlyArray<Weekday> =
|
||||
opts?.weekendDays ?? DEFAULT_WEEKEND;
|
||||
const holidays = buildHolidaySet(opts?.holidays);
|
||||
let t = midnight(toDate(date)) + MS_IN_DAY;
|
||||
for (;;) {
|
||||
const candidate = new Date(t);
|
||||
const dow = candidate.getDay() as Weekday;
|
||||
if (
|
||||
!(weekendDays as number[]).includes(dow) &&
|
||||
!holidays.has(dayKey(candidate))
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
t += MS_IN_DAY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous business day before `date` (exclusive of `date` itself).
|
||||
*
|
||||
* Iterates backward one day at a time.
|
||||
*/
|
||||
export function previousBusinessDay(
|
||||
date: DateInput,
|
||||
opts?: BusinessDayOptions
|
||||
): Date {
|
||||
const weekendDays: ReadonlyArray<Weekday> =
|
||||
opts?.weekendDays ?? DEFAULT_WEEKEND;
|
||||
const holidays = buildHolidaySet(opts?.holidays);
|
||||
let t = midnight(toDate(date)) - MS_IN_DAY;
|
||||
for (;;) {
|
||||
const candidate = new Date(t);
|
||||
const dow = candidate.getDay() as Weekday;
|
||||
if (
|
||||
!(weekendDays as number[]).includes(dow) &&
|
||||
!holidays.has(dayKey(candidate))
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
t -= MS_IN_DAY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance `date` by exactly `n` business days, skipping weekends and holidays.
|
||||
*
|
||||
* Negative `n` moves backward.
|
||||
*/
|
||||
export function addBusinessDays(
|
||||
date: DateInput,
|
||||
n: number,
|
||||
opts?: BusinessDayOptions
|
||||
): Date {
|
||||
const weekendDays: ReadonlyArray<Weekday> =
|
||||
opts?.weekendDays ?? DEFAULT_WEEKEND;
|
||||
const holidays = buildHolidaySet(opts?.holidays);
|
||||
let t = midnight(toDate(date));
|
||||
const step = n >= 0 ? MS_IN_DAY : -MS_IN_DAY;
|
||||
let remaining = Math.abs(n);
|
||||
while (remaining > 0) {
|
||||
t += step;
|
||||
const candidate = new Date(t);
|
||||
const dow = candidate.getDay() as Weekday;
|
||||
if (
|
||||
!(weekendDays as number[]).includes(dow) &&
|
||||
!holidays.has(dayKey(candidate))
|
||||
) {
|
||||
remaining -= 1;
|
||||
}
|
||||
}
|
||||
return new Date(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move `date` backward by exactly `n` business days.
|
||||
*
|
||||
* Equivalent to `addBusinessDays(date, -n)`.
|
||||
*/
|
||||
export function subtractBusinessDays(
|
||||
date: DateInput,
|
||||
n: number,
|
||||
opts?: BusinessDayOptions
|
||||
): Date {
|
||||
return addBusinessDays(date, -n, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of business days between `start` (inclusive) and `end`
|
||||
* (exclusive). Returns a negative number when `end` is before `start`.
|
||||
*
|
||||
* Checks `opts.signal` every 256 iterations for cooperative cancellation.
|
||||
*/
|
||||
export function businessDaysBetween(
|
||||
start: DateInput,
|
||||
end: DateInput,
|
||||
opts?: BusinessDayOptions
|
||||
): number {
|
||||
const weekendDays: ReadonlyArray<Weekday> =
|
||||
opts?.weekendDays ?? DEFAULT_WEEKEND;
|
||||
const holidays = buildHolidaySet(opts?.holidays);
|
||||
const signal = opts?.signal;
|
||||
|
||||
let t = midnight(toDate(start));
|
||||
const endT = midnight(toDate(end));
|
||||
|
||||
if (t === endT) return 0;
|
||||
|
||||
const forward = t < endT;
|
||||
const step = forward ? MS_IN_DAY : -MS_IN_DAY;
|
||||
let count = 0;
|
||||
let iterations = 0;
|
||||
|
||||
while (forward ? t < endT : t > endT) {
|
||||
const candidate = new Date(t);
|
||||
const dow = candidate.getDay() as Weekday;
|
||||
if (
|
||||
!(weekendDays as number[]).includes(dow) &&
|
||||
!holidays.has(dayKey(candidate))
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
t += step;
|
||||
iterations += 1;
|
||||
if (iterations % 256 === 0 && signal?.aborted) break;
|
||||
}
|
||||
|
||||
recordBusinessDaysBetween(iterations);
|
||||
|
||||
return forward ? count : -count;
|
||||
}
|
||||
|
||||
// Re-export Clock type for consumers
|
||||
export type { Clock };
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
addBusinessDays,
|
||||
businessDaysBetween,
|
||||
type Clock,
|
||||
isBusinessDay,
|
||||
nextBusinessDay,
|
||||
previousBusinessDay,
|
||||
subtractBusinessDays,
|
||||
} from "./business-days";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { DateInput, DurationUnit } from "../types/datetime.types.js";
|
||||
import { daysInMonth, toDate } from "./calendar-core.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAYS_IN_WEEK = 7;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: BoundaryOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for `startOf` / `endOf`. */
|
||||
export interface BoundaryOptions {
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: startOf
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the start of the given `unit` for `date`.
|
||||
*
|
||||
* Supported units: `years`, `months`, `weeks`, `days`, `hours`, `minutes`,
|
||||
* `seconds`. `milliseconds` returns the date unchanged.
|
||||
*/
|
||||
export function startOf(
|
||||
date: DateInput,
|
||||
unit: DurationUnit,
|
||||
_opts?: BoundaryOptions
|
||||
): Date {
|
||||
const d = toDate(date);
|
||||
switch (unit) {
|
||||
case "years":
|
||||
return new Date(d.getFullYear(), 0, 1, 0, 0, 0, 0);
|
||||
case "months":
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
||||
case "weeks": {
|
||||
const dow = d.getDay(); // 0=Sun
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate() - dow,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
case "days":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
case "hours":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
d.getHours(),
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
case "minutes":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
d.getHours(),
|
||||
d.getMinutes(),
|
||||
0,
|
||||
0
|
||||
);
|
||||
case "seconds":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
d.getHours(),
|
||||
d.getMinutes(),
|
||||
d.getSeconds(),
|
||||
0
|
||||
);
|
||||
case "milliseconds":
|
||||
return new Date(d.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: endOf
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the end of the given `unit` for `date` (last millisecond).
|
||||
*
|
||||
* Supported units: `years`, `months`, `weeks`, `days`, `hours`, `minutes`,
|
||||
* `seconds`. `milliseconds` returns the date unchanged.
|
||||
*/
|
||||
export function endOf(
|
||||
date: DateInput,
|
||||
unit: DurationUnit,
|
||||
_opts?: BoundaryOptions
|
||||
): Date {
|
||||
const d = toDate(date);
|
||||
switch (unit) {
|
||||
case "years":
|
||||
return new Date(d.getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||
case "months": {
|
||||
const lastDay = daysInMonth(d.getFullYear(), d.getMonth() + 1);
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
lastDay,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999
|
||||
);
|
||||
}
|
||||
case "weeks": {
|
||||
const dow = d.getDay();
|
||||
const daysUntilSat = DAYS_IN_WEEK - 1 - dow;
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate() + daysUntilSat,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999
|
||||
);
|
||||
}
|
||||
case "days":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999
|
||||
);
|
||||
case "hours":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
d.getHours(),
|
||||
59,
|
||||
59,
|
||||
999
|
||||
);
|
||||
case "minutes":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
d.getHours(),
|
||||
d.getMinutes(),
|
||||
59,
|
||||
999
|
||||
);
|
||||
case "seconds":
|
||||
return new Date(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
d.getHours(),
|
||||
d.getMinutes(),
|
||||
d.getSeconds(),
|
||||
999
|
||||
);
|
||||
case "milliseconds":
|
||||
return new Date(d.getTime());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { DATETIME_CODES, DatetimeError } from "../errors/datetime-error.js";
|
||||
import type { DateInput, Timezone } from "../types/datetime.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: coercion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coerce a `DateInput` (Date | string | number) into a `Date`.
|
||||
*
|
||||
* @throws {DatetimeError} `DATETIME.PARSE.INVALID_INPUT` when `input` is a
|
||||
* string that cannot be parsed into a valid date.
|
||||
*/
|
||||
export function toDate(input: DateInput): Date {
|
||||
if (input instanceof Date) {
|
||||
if (Number.isNaN(input.getTime())) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input: String(input) } }
|
||||
);
|
||||
}
|
||||
return new Date(input.getTime());
|
||||
}
|
||||
if (typeof input === "number") {
|
||||
const d = new Date(input);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input: String(input) } }
|
||||
);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
// string
|
||||
const d = new Date(input);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input } }
|
||||
);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: brand helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cast a raw string to the `Timezone` brand.
|
||||
* No runtime validation is performed — use when you trust the input.
|
||||
*/
|
||||
export function asTimezone(tz: string): Timezone {
|
||||
return tz as Timezone;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: leap year / days in month
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns `true` if `year` is a leap year. */
|
||||
export function isLeapYear(year: number): boolean {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of days in the given month (1-indexed).
|
||||
*
|
||||
* @param year - Full calendar year (e.g. 2024)
|
||||
* @param month - 1-indexed month (1 = January, 12 = December)
|
||||
*/
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
// Using day 0 of next month gives last day of current month
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: same-day / same-month predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns `true` when `a` and `b` fall on the same calendar day. */
|
||||
export function isSameDay(a: DateInput, b: DateInput): boolean {
|
||||
const da = toDate(a);
|
||||
const db = toDate(b);
|
||||
return (
|
||||
da.getFullYear() === db.getFullYear() &&
|
||||
da.getMonth() === db.getMonth() &&
|
||||
da.getDate() === db.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns `true` when `a` and `b` fall in the same calendar month and year. */
|
||||
export function isSameMonth(a: DateInput, b: DateInput): boolean {
|
||||
const da = toDate(a);
|
||||
const db = toDate(b);
|
||||
return (
|
||||
da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth()
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
DateInput,
|
||||
Duration,
|
||||
DurationUnit,
|
||||
} from "../types/datetime.types.js";
|
||||
import { daysInMonth, toDate } from "./calendar-core.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS_IN_SECOND = 1000;
|
||||
const MS_IN_MINUTE = 60 * MS_IN_SECOND;
|
||||
const MS_IN_HOUR = 60 * MS_IN_MINUTE;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Clamp `day` to the last valid day of `year`/`month` (1-indexed). */
|
||||
function clampDay(year: number, month: number, day: number): number {
|
||||
const max = daysInMonth(year, month);
|
||||
return day > max ? max : day;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: duration arithmetic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a `Duration` to a date.
|
||||
*
|
||||
* Month arithmetic clamps to the last day of the resulting month so that
|
||||
* e.g. Jan 31 + 1 month → Feb 28 (or 29 on leap years).
|
||||
*/
|
||||
export function addDuration(date: DateInput, duration: Duration): Date {
|
||||
const d = toDate(date);
|
||||
|
||||
let year = d.getFullYear();
|
||||
let month = d.getMonth() + 1; // 1-indexed
|
||||
let day = d.getDate();
|
||||
let ms =
|
||||
d.getHours() * MS_IN_HOUR +
|
||||
d.getMinutes() * MS_IN_MINUTE +
|
||||
d.getSeconds() * MS_IN_SECOND +
|
||||
d.getMilliseconds();
|
||||
|
||||
year += duration.years ?? 0;
|
||||
month += duration.months ?? 0;
|
||||
|
||||
// Normalise month overflow/underflow
|
||||
while (month > 12) {
|
||||
month -= 12;
|
||||
year += 1;
|
||||
}
|
||||
while (month < 1) {
|
||||
month += 12;
|
||||
year -= 1;
|
||||
}
|
||||
|
||||
day = clampDay(year, month, day);
|
||||
|
||||
// Build base date from year/month/day, then add calendar days/weeks
|
||||
// using setDate so DST transitions are handled correctly.
|
||||
const base = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
base.setDate(
|
||||
base.getDate() + (duration.weeks ?? 0) * 7 + (duration.days ?? 0)
|
||||
);
|
||||
|
||||
ms += (duration.hours ?? 0) * MS_IN_HOUR;
|
||||
ms += (duration.minutes ?? 0) * MS_IN_MINUTE;
|
||||
ms += (duration.seconds ?? 0) * MS_IN_SECOND;
|
||||
ms += duration.milliseconds ?? 0;
|
||||
|
||||
return new Date(base.getTime() + ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract a `Duration` from a date.
|
||||
*
|
||||
* Equivalent to negating all duration fields and calling `addDuration`.
|
||||
*/
|
||||
export function subtractDuration(date: DateInput, duration: Duration): Date {
|
||||
const neg: Duration = {};
|
||||
for (const [key, v] of Object.entries(duration) as [
|
||||
DurationUnit,
|
||||
number,
|
||||
][]) {
|
||||
neg[key] = -v;
|
||||
}
|
||||
return addDuration(date, neg);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { CalendarMonth, Weekday } from "../types/datetime.types.js";
|
||||
import { daysInMonth } from "./calendar-core.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: CalendarMonthOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for `getCalendarMonth`. */
|
||||
export interface CalendarMonthOptions {
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: calendar month grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a full calendar month grid for the given `year` and 1-indexed `month`.
|
||||
*
|
||||
* Each `CalendarDay` includes the date, its day-of-week, whether it is a
|
||||
* weekend (Sat/Sun), and whether it is today (using `opts.clock` or wall-clock).
|
||||
*/
|
||||
export function getCalendarMonth(
|
||||
year: number,
|
||||
month: number,
|
||||
opts?: CalendarMonthOptions
|
||||
): CalendarMonth {
|
||||
const clock = opts?.clock ?? defaultClock;
|
||||
const now = new Date(clock());
|
||||
const todayStr = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
||||
|
||||
const count = daysInMonth(year, month);
|
||||
const days = [];
|
||||
for (let d = 1; d <= count; d++) {
|
||||
const date = new Date(year, month - 1, d, 0, 0, 0, 0);
|
||||
const dow = date.getDay() as Weekday;
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isToday =
|
||||
`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` ===
|
||||
todayStr;
|
||||
days.push({ date, dayOfWeek: dow, isWeekend, isToday });
|
||||
}
|
||||
|
||||
return { year, month, days };
|
||||
}
|
||||
112
packages/foundation/timing/datetime/src/calendar/calendar.md
Normal file
112
packages/foundation/timing/datetime/src/calendar/calendar.md
Normal 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.
|
||||
@@ -0,0 +1,396 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
addDuration,
|
||||
asTimezone,
|
||||
daysInMonth,
|
||||
endOf,
|
||||
getCalendarMonth,
|
||||
isLeapYear,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOf,
|
||||
subtractDuration,
|
||||
toDate,
|
||||
} from "./calendar.js";
|
||||
|
||||
describe("toDate", () => {
|
||||
it("accepts a Date object", { timeout: 5000 }, () => {
|
||||
const d = new Date("2024-03-15");
|
||||
expect(toDate(d).getTime()).toBe(d.getTime());
|
||||
});
|
||||
|
||||
it("accepts an ISO string", { timeout: 5000 }, () => {
|
||||
const d = toDate("2024-03-15T00:00:00Z");
|
||||
expect(d).toBeInstanceOf(Date);
|
||||
expect(Number.isNaN(d.getTime())).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts epoch millis", { timeout: 5000 }, () => {
|
||||
const ms = 1_710_460_800_000;
|
||||
expect(toDate(ms).getTime()).toBe(ms);
|
||||
});
|
||||
|
||||
it("throws for invalid string", { timeout: 5000 }, () => {
|
||||
let err: unknown;
|
||||
try {
|
||||
toDate("not-a-date");
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as { code?: string }).code).toBe(
|
||||
"DATETIME.PARSE.INVALID_INPUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for NaN date object", { timeout: 5000 }, () => {
|
||||
expect(() => toDate(new Date("invalid"))).toThrow();
|
||||
});
|
||||
|
||||
it("throws for NaN number", { timeout: 5000 }, () => {
|
||||
expect(() => toDate(Number.NaN)).toThrow();
|
||||
});
|
||||
|
||||
it("returns a copy not the same reference", { timeout: 5000 }, () => {
|
||||
const d = new Date("2024-01-01");
|
||||
const result = toDate(d);
|
||||
expect(result).not.toBe(d);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asTimezone", () => {
|
||||
it("brands the string", { timeout: 5000 }, () => {
|
||||
const tz = asTimezone("America/New_York");
|
||||
expect(tz).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLeapYear", () => {
|
||||
it("2024 is a leap year", { timeout: 5000 }, () => {
|
||||
expect(isLeapYear(2024)).toBe(true);
|
||||
});
|
||||
it("2023 is not a leap year", { timeout: 5000 }, () => {
|
||||
expect(isLeapYear(2023)).toBe(false);
|
||||
});
|
||||
it("1900 is not a leap year (century rule)", { timeout: 5000 }, () => {
|
||||
expect(isLeapYear(1900)).toBe(false);
|
||||
});
|
||||
it("2000 is a leap year (400 rule)", { timeout: 5000 }, () => {
|
||||
expect(isLeapYear(2000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysInMonth", () => {
|
||||
it("February 2024 has 29 days", { timeout: 5000 }, () => {
|
||||
expect(daysInMonth(2024, 2)).toBe(29);
|
||||
});
|
||||
it("February 2023 has 28 days", { timeout: 5000 }, () => {
|
||||
expect(daysInMonth(2023, 2)).toBe(28);
|
||||
});
|
||||
it("January has 31 days", { timeout: 5000 }, () => {
|
||||
expect(daysInMonth(2024, 1)).toBe(31);
|
||||
});
|
||||
it("April has 30 days", { timeout: 5000 }, () => {
|
||||
expect(daysInMonth(2024, 4)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameDay", () => {
|
||||
it("same date is same day", { timeout: 5000 }, () => {
|
||||
expect(isSameDay("2024-03-15", "2024-03-15")).toBe(true);
|
||||
});
|
||||
it("different date is not same day", { timeout: 5000 }, () => {
|
||||
expect(isSameDay("2024-03-15", "2024-03-16")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameMonth", () => {
|
||||
it("same month returns true", { timeout: 5000 }, () => {
|
||||
expect(isSameMonth("2024-03-01", "2024-03-31")).toBe(true);
|
||||
});
|
||||
it("different month returns false", { timeout: 5000 }, () => {
|
||||
expect(isSameMonth("2024-03-31", "2024-04-01")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDuration", () => {
|
||||
it("adds days", { timeout: 5000 }, () => {
|
||||
const result = addDuration("2024-01-01", { days: 5 });
|
||||
expect(result.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
it("adds months with clamping Jan 31 → Feb 29 (leap)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const jan31 = new Date(2024, 0, 31);
|
||||
const result = addDuration(jan31, { months: 1 });
|
||||
expect(result.getMonth()).toBe(1); // February
|
||||
expect(result.getDate()).toBe(29); // leap year
|
||||
});
|
||||
|
||||
it("adds months with clamping Jan 31 → Feb 28 (non-leap)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const jan31 = new Date(2023, 0, 31);
|
||||
const result = addDuration(jan31, { months: 1 });
|
||||
expect(result.getMonth()).toBe(1);
|
||||
expect(result.getDate()).toBe(28);
|
||||
});
|
||||
|
||||
it("adds years", { timeout: 5000 }, () => {
|
||||
const result = addDuration("2020-02-29", { years: 1 });
|
||||
expect(result.getFullYear()).toBe(2021);
|
||||
});
|
||||
|
||||
it("adds weeks", { timeout: 5000 }, () => {
|
||||
const result = addDuration("2024-01-01", { weeks: 2 });
|
||||
expect(result.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it("adds hours, minutes, seconds, milliseconds", { timeout: 5000 }, () => {
|
||||
const base = new Date(2024, 0, 1, 0, 0, 0, 0);
|
||||
const result = addDuration(base, {
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
seconds: 45,
|
||||
milliseconds: 500,
|
||||
});
|
||||
expect(result.getHours()).toBe(1);
|
||||
expect(result.getMinutes()).toBe(30);
|
||||
expect(result.getSeconds()).toBe(45);
|
||||
expect(result.getMilliseconds()).toBe(500);
|
||||
});
|
||||
|
||||
it("handles month overflow across year boundary", { timeout: 5000 }, () => {
|
||||
const result = addDuration("2024-11-01", { months: 3 });
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
expect(result.getMonth()).toBe(1); // February
|
||||
});
|
||||
|
||||
it("handles month underflow across year boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = addDuration("2024-02-01", { months: -3 });
|
||||
expect(result.getFullYear()).toBe(2023);
|
||||
expect(result.getMonth()).toBe(10); // November
|
||||
});
|
||||
|
||||
it("empty duration returns same date", { timeout: 5000 }, () => {
|
||||
const base = new Date(2024, 5, 15, 12, 0, 0, 0);
|
||||
const result = addDuration(base, {});
|
||||
expect(result.getTime()).toBe(base.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtractDuration", () => {
|
||||
it("subtracts days", { timeout: 5000 }, () => {
|
||||
const result = subtractDuration("2024-01-10", { days: 5 });
|
||||
expect(result.getDate()).toBe(5);
|
||||
});
|
||||
|
||||
it("subtracts months", { timeout: 5000 }, () => {
|
||||
const result = subtractDuration("2024-03-01", { months: 1 });
|
||||
expect(result.getMonth()).toBe(1); // February
|
||||
});
|
||||
|
||||
it("subtracts years", { timeout: 5000 }, () => {
|
||||
const result = subtractDuration("2024-06-15", { years: 2 });
|
||||
expect(result.getFullYear()).toBe(2022);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDuration / subtractDuration property test", () => {
|
||||
it("add then subtract returns original date (days, weeks)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.date({
|
||||
min: new Date("2000-01-01"),
|
||||
max: new Date("2030-12-31"),
|
||||
}),
|
||||
fc.record({
|
||||
days: fc.integer({ min: 0, max: 365 }),
|
||||
weeks: fc.integer({ min: 0, max: 52 }),
|
||||
}),
|
||||
(rawDate, duration) => {
|
||||
// Normalise to local midnight so the property test exercises
|
||||
// calendar-day arithmetic without DST time-drift artefacts.
|
||||
const date = new Date(
|
||||
rawDate.getFullYear(),
|
||||
rawDate.getMonth(),
|
||||
rawDate.getDate()
|
||||
);
|
||||
const added = addDuration(date, duration);
|
||||
const subtracted = subtractDuration(added, duration);
|
||||
expect(subtracted.getFullYear()).toBe(date.getFullYear());
|
||||
expect(subtracted.getMonth()).toBe(date.getMonth());
|
||||
expect(subtracted.getDate()).toBe(date.getDate());
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startOf", () => {
|
||||
it("startOf years", { timeout: 5000 }, () => {
|
||||
const result = startOf("2024-06-15T14:30:00", "years");
|
||||
expect(result.getMonth()).toBe(0);
|
||||
expect(result.getDate()).toBe(1);
|
||||
expect(result.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("startOf months", { timeout: 5000 }, () => {
|
||||
const result = startOf("2024-06-15", "months");
|
||||
expect(result.getDate()).toBe(1);
|
||||
expect(result.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("startOf weeks (Sunday)", { timeout: 5000 }, () => {
|
||||
// 2024-03-20 is a Wednesday (day 3); start of week should be Sunday 2024-03-17
|
||||
const result = startOf(new Date(2024, 2, 20), "weeks");
|
||||
expect(result.getDay()).toBe(0);
|
||||
expect(result.getDate()).toBe(17);
|
||||
});
|
||||
|
||||
it("startOf days", { timeout: 5000 }, () => {
|
||||
const result = startOf("2024-03-15T14:30:45.123", "days");
|
||||
expect(result.getHours()).toBe(0);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
expect(result.getSeconds()).toBe(0);
|
||||
expect(result.getMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("startOf hours", { timeout: 5000 }, () => {
|
||||
const result = startOf(new Date(2024, 2, 15, 14, 30, 45, 123), "hours");
|
||||
expect(result.getHours()).toBe(14);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
expect(result.getSeconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("startOf minutes", { timeout: 5000 }, () => {
|
||||
const result = startOf(
|
||||
new Date(2024, 2, 15, 14, 30, 45, 123),
|
||||
"minutes"
|
||||
);
|
||||
expect(result.getMinutes()).toBe(30);
|
||||
expect(result.getSeconds()).toBe(0);
|
||||
expect(result.getMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("startOf seconds", { timeout: 5000 }, () => {
|
||||
const result = startOf(
|
||||
new Date(2024, 2, 15, 14, 30, 45, 123),
|
||||
"seconds"
|
||||
);
|
||||
expect(result.getSeconds()).toBe(45);
|
||||
expect(result.getMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
it("startOf milliseconds returns same time", { timeout: 5000 }, () => {
|
||||
const d = new Date(2024, 2, 15, 14, 30, 45, 123);
|
||||
const result = startOf(d, "milliseconds");
|
||||
expect(result.getTime()).toBe(d.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("endOf", () => {
|
||||
it("endOf years", { timeout: 5000 }, () => {
|
||||
const result = endOf("2024-06-15", "years");
|
||||
expect(result.getMonth()).toBe(11);
|
||||
expect(result.getDate()).toBe(31);
|
||||
expect(result.getHours()).toBe(23);
|
||||
expect(result.getMinutes()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf months", { timeout: 5000 }, () => {
|
||||
const result = endOf("2024-02-01", "months");
|
||||
expect(result.getDate()).toBe(29); // leap year
|
||||
expect(result.getHours()).toBe(23);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf weeks (Saturday)", { timeout: 5000 }, () => {
|
||||
// 2024-03-20 is Wednesday; end of week should be Saturday 2024-03-23
|
||||
const result = endOf(new Date(2024, 2, 20), "weeks");
|
||||
expect(result.getDay()).toBe(6);
|
||||
expect(result.getDate()).toBe(23);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf days", { timeout: 5000 }, () => {
|
||||
const result = endOf("2024-03-15", "days");
|
||||
expect(result.getHours()).toBe(23);
|
||||
expect(result.getMinutes()).toBe(59);
|
||||
expect(result.getSeconds()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf hours", { timeout: 5000 }, () => {
|
||||
const result = endOf(new Date(2024, 2, 15, 14, 30, 45, 0), "hours");
|
||||
expect(result.getMinutes()).toBe(59);
|
||||
expect(result.getSeconds()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf minutes", { timeout: 5000 }, () => {
|
||||
const result = endOf(new Date(2024, 2, 15, 14, 30, 45, 0), "minutes");
|
||||
expect(result.getSeconds()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf seconds", { timeout: 5000 }, () => {
|
||||
const result = endOf(new Date(2024, 2, 15, 14, 30, 45, 0), "seconds");
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
|
||||
it("endOf milliseconds returns same time", { timeout: 5000 }, () => {
|
||||
const d = new Date(2024, 2, 15, 14, 30, 45, 500);
|
||||
const result = endOf(d, "milliseconds");
|
||||
expect(result.getTime()).toBe(d.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCalendarMonth", () => {
|
||||
it("returns correct number of days for January 2024", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cal = getCalendarMonth(2024, 1);
|
||||
expect(cal.year).toBe(2024);
|
||||
expect(cal.month).toBe(1);
|
||||
expect(cal.days.length).toBe(31);
|
||||
});
|
||||
|
||||
it("marks weekends correctly", { timeout: 5000 }, () => {
|
||||
const cal = getCalendarMonth(2024, 1);
|
||||
for (const day of cal.days) {
|
||||
const expected = day.dayOfWeek === 0 || day.dayOfWeek === 6;
|
||||
expect(day.isWeekend).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks today correctly with clock injection", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 2, 15).getTime(); // March 15 2024
|
||||
const cal = getCalendarMonth(2024, 3, { clock });
|
||||
const today = cal.days.filter((d) => d.isToday);
|
||||
expect(today.length).toBe(1);
|
||||
expect(today[0]?.date.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it("no day is today when clock is in different month", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = () => new Date(2024, 5, 1).getTime(); // June
|
||||
const cal = getCalendarMonth(2024, 3, { clock }); // March
|
||||
const today = cal.days.filter((d) => d.isToday);
|
||||
expect(today.length).toBe(0);
|
||||
});
|
||||
|
||||
it("February 2024 has 29 days (leap year)", { timeout: 5000 }, () => {
|
||||
const cal = getCalendarMonth(2024, 2);
|
||||
expect(cal.days.length).toBe(29);
|
||||
});
|
||||
});
|
||||
15
packages/foundation/timing/datetime/src/calendar/calendar.ts
Normal file
15
packages/foundation/timing/datetime/src/calendar/calendar.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Re-export barrel — keeps all existing import paths valid.
|
||||
|
||||
export type { BoundaryOptions } from "./calendar-boundary.js";
|
||||
export { endOf, startOf } from "./calendar-boundary.js";
|
||||
export {
|
||||
asTimezone,
|
||||
daysInMonth,
|
||||
isLeapYear,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
toDate,
|
||||
} from "./calendar-core.js";
|
||||
export { addDuration, subtractDuration } from "./calendar-duration.js";
|
||||
export type { CalendarMonthOptions } from "./calendar-grid.js";
|
||||
export { getCalendarMonth } from "./calendar-grid.js";
|
||||
15
packages/foundation/timing/datetime/src/calendar/index.ts
Normal file
15
packages/foundation/timing/datetime/src/calendar/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
addDuration,
|
||||
asTimezone,
|
||||
type BoundaryOptions,
|
||||
type CalendarMonthOptions,
|
||||
daysInMonth,
|
||||
endOf,
|
||||
getCalendarMonth,
|
||||
isLeapYear,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOf,
|
||||
subtractDuration,
|
||||
toDate,
|
||||
} from "./calendar";
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Spec-conformance tests for `@suaveplan/datetime`.
|
||||
*
|
||||
* Pins implicit scenarios named in `openspec/specs/datetime/spec.md` that
|
||||
* existing unit tests touch indirectly: DateInput acceptance, injectable
|
||||
* Clock determinism, business-day options + abort signal, calendar
|
||||
* arithmetic correctness, RRULE field parsing, error-code namespacing,
|
||||
* Zod schemas at the boundary, i18n-keyed relative output, OTel histogram
|
||||
* surface (duration + size for both hot paths), and `sideEffects: false`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
addBusinessDays,
|
||||
addDuration,
|
||||
asTimezone,
|
||||
businessDaysBetween,
|
||||
businessDaysBetweenDuration,
|
||||
businessDaysBetweenSize,
|
||||
businessDaysBetweenTotal,
|
||||
DATETIME_CODES,
|
||||
DatetimeError,
|
||||
daysInMonth,
|
||||
expandRecurrence,
|
||||
expandRecurrenceDuration,
|
||||
expandRecurrenceSize,
|
||||
formatRelative,
|
||||
formatRRule,
|
||||
isBusinessDay,
|
||||
isLeapYear,
|
||||
nextBusinessDay,
|
||||
nextOccurrence,
|
||||
parseRRule,
|
||||
previousBusinessDay,
|
||||
subtractBusinessDays,
|
||||
subtractDuration,
|
||||
timeAgo,
|
||||
timeUntil,
|
||||
toDate,
|
||||
} from "./index.js";
|
||||
import {
|
||||
CalendarDaySchema,
|
||||
CalendarMonthSchema,
|
||||
DateInputSchema,
|
||||
DurationSchema,
|
||||
DurationUnitSchema,
|
||||
FrequencySchema,
|
||||
RecurrenceRuleSchema,
|
||||
WeekdaySchema,
|
||||
} from "./types/datetime.schemas.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-01 — DateInput acceptance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-01 — DateInput acceptance", () => {
|
||||
it("every entry point accepts Date | ISO string | epoch number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const isoStr = "2025-01-15T00:00:00.000Z";
|
||||
const epoch = Date.parse(isoStr);
|
||||
const dateObj = new Date(epoch);
|
||||
for (const input of [dateObj, isoStr, epoch]) {
|
||||
expect(toDate(input)).toBeInstanceOf(Date);
|
||||
expect(typeof isBusinessDay(input)).toBe("boolean");
|
||||
expect(addDuration(input, { days: 1 })).toBeInstanceOf(Date);
|
||||
expect(typeof formatRelative(input)).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid inputs throw DatetimeError(PARSE.INVALID_INPUT)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const bad of [
|
||||
"not-a-date",
|
||||
Number.NaN,
|
||||
new Date(Number.NaN),
|
||||
] as const) {
|
||||
try {
|
||||
toDate(bad);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatetimeError);
|
||||
expect((err as DatetimeError).code).toBe(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-02 — Injectable clock determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-02 — Injectable clock", () => {
|
||||
it("formatRelative / timeAgo / timeUntil read time from injected clock", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixed = Date.parse("2025-06-01T12:00:00.000Z");
|
||||
const clock: Clock = () => fixed;
|
||||
const past = new Date(fixed - 60_000); // 60s ago
|
||||
const future = new Date(fixed + 60_000); // 60s ahead
|
||||
expect(formatRelative(past, { clock })).toBe("1 minute ago");
|
||||
expect(timeAgo(past, { clock })).toBe("1 minute ago");
|
||||
expect(timeUntil(future, { clock })).toBe("in 1 minute");
|
||||
});
|
||||
|
||||
it("expandRecurrence reads start time from injected clock", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixed = Date.parse("2025-01-01T00:00:00.000Z");
|
||||
const clock: Clock = () => fixed;
|
||||
const out = expandRecurrence({ freq: "daily", count: 3 }, { clock });
|
||||
expect(out).toHaveLength(3);
|
||||
// Generator yields strictly after the clock-supplied seed.
|
||||
expect(out[0]?.toISOString().slice(0, 10)).toBe("2025-01-02");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-03 — Business-day arithmetic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-03 — Business day arithmetic", () => {
|
||||
it("all six functions accept BusinessDayOptions with holidays & weekendDays", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const holidays = [new Date(2025, 0, 1)];
|
||||
const weekendDays = [5, 6] as const; // Fri, Sat
|
||||
const monday = new Date(2025, 0, 6);
|
||||
expect(isBusinessDay(monday, { holidays, weekendDays })).toBe(true);
|
||||
expect(
|
||||
nextBusinessDay(monday, { holidays, weekendDays })
|
||||
).toBeInstanceOf(Date);
|
||||
expect(
|
||||
previousBusinessDay(monday, { holidays, weekendDays })
|
||||
).toBeInstanceOf(Date);
|
||||
expect(
|
||||
addBusinessDays(monday, 3, { holidays, weekendDays })
|
||||
).toBeInstanceOf(Date);
|
||||
expect(
|
||||
subtractBusinessDays(monday, 3, { holidays, weekendDays })
|
||||
).toBeInstanceOf(Date);
|
||||
expect(
|
||||
businessDaysBetween(monday, new Date(2025, 0, 31), {
|
||||
holidays,
|
||||
weekendDays,
|
||||
})
|
||||
).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("businessDaysBetween honours pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
// Range large enough to exceed the 256-iteration check window.
|
||||
const start = new Date(2020, 0, 1);
|
||||
const end = new Date(2030, 0, 1);
|
||||
const got = businessDaysBetween(start, end, {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
// Aborts before counting most days; result well below the
|
||||
// ~2600 expected business days in the unbroken range.
|
||||
expect(got).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-04 — Calendar math correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-04 — Calendar math correctness", () => {
|
||||
it("Jan 31 + 1 month clamps to Feb (28 in 2025, 29 in 2024)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const got2025 = addDuration(new Date(2025, 0, 31), { months: 1 });
|
||||
expect(got2025.getMonth()).toBe(1);
|
||||
expect(got2025.getDate()).toBe(28);
|
||||
const got2024 = addDuration(new Date(2024, 0, 31), { months: 1 });
|
||||
expect(got2024.getMonth()).toBe(1);
|
||||
expect(got2024.getDate()).toBe(29);
|
||||
});
|
||||
|
||||
it("subtractDuration mirrors addDuration", { timeout: 5000 }, () => {
|
||||
const start = new Date(2025, 5, 15, 12, 0, 0, 0);
|
||||
const dur = { days: 3, hours: 4 };
|
||||
const fwd = addDuration(start, dur);
|
||||
const back = subtractDuration(fwd, dur);
|
||||
expect(back.getTime()).toBe(start.getTime());
|
||||
});
|
||||
|
||||
it("daysInMonth handles every month including leap February", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(daysInMonth(2024, 2)).toBe(29);
|
||||
expect(daysInMonth(2023, 2)).toBe(28);
|
||||
expect(daysInMonth(2025, 1)).toBe(31);
|
||||
expect(daysInMonth(2025, 4)).toBe(30);
|
||||
expect(isLeapYear(2024)).toBe(true);
|
||||
expect(isLeapYear(2023)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-05 — Recurrence rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-05 — Recurrence rules", () => {
|
||||
it("parseRRule reads FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = parseRRule(
|
||||
"FREQ=WEEKLY;INTERVAL=2;COUNT=4;UNTIL=20251231;BYDAY=MO,WE;BYMONTHDAY=1,15"
|
||||
);
|
||||
expect(rule.freq).toBe("weekly");
|
||||
expect(rule.interval).toBe(2);
|
||||
expect(rule.count).toBe(4);
|
||||
expect(rule.until).toBeInstanceOf(Date);
|
||||
expect(rule.byDay).toEqual([1, 3]);
|
||||
expect(rule.byMonthDay).toEqual([1, 15]);
|
||||
});
|
||||
|
||||
it("expandRecurrence without count/until throws INFINITE_EXPANSION", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
expandRecurrence({ freq: "daily" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatetimeError);
|
||||
expect((err as DatetimeError).code).toBe(
|
||||
DATETIME_CODES["RECURRENCE.INFINITE_EXPANSION"]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("nextOccurrence + formatRRule round-trip cleanly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=10");
|
||||
const next = nextOccurrence(rule, new Date(2025, 0, 1));
|
||||
expect(next).toBeInstanceOf(Date);
|
||||
const reformatted = formatRRule(rule);
|
||||
expect(reformatted).toContain("FREQ=DAILY");
|
||||
expect(reformatted).toContain("COUNT=10");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-06 — Structured errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-06 — Structured errors", () => {
|
||||
it("all DATETIME_CODES are namespaced under DATETIME.*", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(DATETIME_CODES)) {
|
||||
expect(code.startsWith("DATETIME.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("DATETIME_CODES registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DATETIME_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-07 — Zod schemas at the boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-07 — Zod schemas", () => {
|
||||
it("every public runtime type has a sibling Zod schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DateInputSchema.safeParse("2025-01-01").success).toBe(true);
|
||||
expect(DurationUnitSchema.safeParse("days").success).toBe(true);
|
||||
expect(DurationSchema.safeParse({ days: 1, hours: 2 }).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(WeekdaySchema.safeParse(3).success).toBe(true);
|
||||
expect(WeekdaySchema.safeParse(7).success).toBe(false);
|
||||
expect(FrequencySchema.safeParse("weekly").success).toBe(true);
|
||||
expect(
|
||||
CalendarDaySchema.safeParse({
|
||||
date: new Date(),
|
||||
dayOfWeek: 1,
|
||||
isWeekend: false,
|
||||
isToday: true,
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
CalendarMonthSchema.safeParse({
|
||||
year: 2025,
|
||||
month: 1,
|
||||
days: [],
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
RecurrenceRuleSchema.safeParse({
|
||||
freq: "daily",
|
||||
count: 5,
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-08 — i18n-ready output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-08 — i18n-ready output", () => {
|
||||
it("formatRelative output is the English pass-through default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixed = Date.parse("2025-06-01T12:00:00.000Z");
|
||||
const clock: Clock = () => fixed;
|
||||
// Cover the four label variants: just-now, past, future, plural.
|
||||
expect(formatRelative(new Date(fixed - 1000), { clock })).toBe(
|
||||
"just now"
|
||||
);
|
||||
expect(formatRelative(new Date(fixed - 5 * 60_000), { clock })).toBe(
|
||||
"5 minutes ago"
|
||||
);
|
||||
expect(formatRelative(new Date(fixed + 60_000), { clock })).toBe(
|
||||
"in 1 minute"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-09 — OTel instrumentation: histograms for both hot paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-09 — OTel instrumentation", () => {
|
||||
it("expandRecurrence + businessDaysBetween expose duration + size histograms", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const inst of [
|
||||
expandRecurrenceDuration,
|
||||
expandRecurrenceSize,
|
||||
businessDaysBetweenDuration,
|
||||
businessDaysBetweenSize,
|
||||
]) {
|
||||
expect(inst).toBeDefined();
|
||||
expect(typeof inst.record).toBe("function");
|
||||
}
|
||||
expect(typeof businessDaysBetweenTotal.add).toBe("function");
|
||||
});
|
||||
|
||||
it("histograms record without an SDK (no-op safe)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
expandRecurrence({ freq: "daily", count: 3 })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
businessDaysBetween(new Date(2025, 0, 1), new Date(2025, 0, 31))
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DT-10 — sideEffects: false
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DT-10 — sideEffects: false", () => {
|
||||
it("package.json declares sideEffects: false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("asTimezone brand helper is pure", { timeout: 5000 }, () => {
|
||||
expect(asTimezone("America/New_York")).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
# datetime-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DatetimeError` is the typed error class for the entire `@suaveplan/datetime` package. It extends the project-wide `SuaveplanError` base class with a narrow `DatetimeCode` type parameter, allowing callers to discriminate error origins with `instanceof` and narrow the `code` property to one of three categories: `PARSE.INVALID_INPUT`, `RECURRENCE.INFINITE_EXPANSION`, and `TIMEZONE.UNKNOWN`.
|
||||
|
||||
Keeping all error definitions in a single, versioned registry (`DATETIME_CODES`) means consumers can exhaustively handle failure modes without consulting documentation or source code.
|
||||
|
||||
## Features
|
||||
|
||||
- Inherits full `SuaveplanError` surface: `code`, `context`, `timestamp`, `traceId`, `cause`
|
||||
- Three error codes covering the library's failure surface
|
||||
- `defineCodes` ensures the registry is frozen and type-inferred at build time
|
||||
- Compatible with `walkCauses`, `toSpanEvent`, and the `@suaveplan/error` wire format
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DatetimeError, DATETIME_CODES } from "@suaveplan/datetime/errors";
|
||||
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input: "not-a-date" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DatetimeError, DATETIME_CODES } from "@suaveplan/datetime/errors";
|
||||
|
||||
function guardExpansion(hasTerminator: boolean): void {
|
||||
if (!hasTerminator) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["RECURRENCE.INFINITE_EXPANSION"],
|
||||
"datetime.recurrence.infinite_expansion",
|
||||
{
|
||||
context: { hint: "supply count or until" },
|
||||
traceId: "abc-123",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
guardExpansion(false);
|
||||
} catch (err) {
|
||||
if (err instanceof DatetimeError) {
|
||||
// code is narrowed to DatetimeCode
|
||||
console.error(err.code, err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DATETIME_CODES`
|
||||
|
||||
`Record<"PARSE.INVALID_INPUT" | "RECURRENCE.INFINITE_EXPANSION" | "TIMEZONE.UNKNOWN", string>`
|
||||
|
||||
Frozen registry of all datetime error codes. Values are fully-qualified strings of the form `DATETIME.<reason>`.
|
||||
|
||||
### `DatetimeCode`
|
||||
|
||||
`type DatetimeCode = (typeof DATETIME_CODES)[keyof typeof DATETIME_CODES]`
|
||||
|
||||
Union of all valid datetime error code strings.
|
||||
|
||||
### `class DatetimeError extends SuaveplanError<DatetimeCode>`
|
||||
|
||||
**Constructor:** `new DatetimeError(code: DatetimeCode, messageKey: string, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `code` — one of the values in `DATETIME_CODES`
|
||||
- `messageKey` — i18n key passed to `resolveMessage`
|
||||
- `options` — optional `{ cause, context, clock, traceId }`
|
||||
|
||||
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `cause`, `name`, `stack`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The three-code surface was chosen to be exhaustive yet minimal:
|
||||
|
||||
- Parsing errors cover any `DateInput` coercion failure
|
||||
- Recurrence expansion errors guard against infinite loops
|
||||
- Timezone errors are reserved for future IANA name validation
|
||||
|
||||
The `defineCodes` helper freezes the registry object, preventing accidental mutation and enabling tree-shaking of unused codes.
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DATETIME_CODES, DatetimeError } from "./datetime-error.js";
|
||||
|
||||
describe("DATETIME_CODES", () => {
|
||||
it("has PARSE.INVALID_INPUT code", { timeout: 5000 }, () => {
|
||||
expect(DATETIME_CODES["PARSE.INVALID_INPUT"]).toBe(
|
||||
"DATETIME.PARSE.INVALID_INPUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("has RECURRENCE.INFINITE_EXPANSION code", { timeout: 5000 }, () => {
|
||||
expect(DATETIME_CODES["RECURRENCE.INFINITE_EXPANSION"]).toBe(
|
||||
"DATETIME.RECURRENCE.INFINITE_EXPANSION"
|
||||
);
|
||||
});
|
||||
|
||||
it("has TIMEZONE.UNKNOWN code", { timeout: 5000 }, () => {
|
||||
expect(DATETIME_CODES["TIMEZONE.UNKNOWN"]).toBe(
|
||||
"DATETIME.TIMEZONE.UNKNOWN"
|
||||
);
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DATETIME_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatetimeError", () => {
|
||||
it("is instanceof Error and DatetimeError", { timeout: 5000 }, () => {
|
||||
const err = new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(DatetimeError);
|
||||
});
|
||||
|
||||
it("exposes correct code", { timeout: 5000 }, () => {
|
||||
const err = new DatetimeError(
|
||||
DATETIME_CODES["RECURRENCE.INFINITE_EXPANSION"],
|
||||
"datetime.recurrence.infinite_expansion"
|
||||
);
|
||||
expect(err.code).toBe("DATETIME.RECURRENCE.INFINITE_EXPANSION");
|
||||
});
|
||||
|
||||
it("exposes name as DatetimeError", { timeout: 5000 }, () => {
|
||||
const err = new DatetimeError(
|
||||
DATETIME_CODES["TIMEZONE.UNKNOWN"],
|
||||
"datetime.timezone.unknown"
|
||||
);
|
||||
expect(err.name).toBe("DatetimeError");
|
||||
});
|
||||
|
||||
it("accepts context", { timeout: 5000 }, () => {
|
||||
const err = new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input: "bad" } }
|
||||
);
|
||||
expect(err.context?.["input"]).toBe("bad");
|
||||
});
|
||||
|
||||
it("accepts cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("original");
|
||||
const err = new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("has a timestamp", { timeout: 5000 }, () => {
|
||||
const before = Date.now();
|
||||
const err = new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input"
|
||||
);
|
||||
const after = Date.now();
|
||||
expect(err.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(err.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error code registry for `@suaveplan/datetime`.
|
||||
*
|
||||
* Three top-level namespaces:
|
||||
* - `DATETIME.PARSE.*` — input parsing failures
|
||||
* - `DATETIME.RECURRENCE.*` — recurrence expansion failures
|
||||
* - `DATETIME.TIMEZONE.*` — timezone resolution failures
|
||||
*/
|
||||
export const DATETIME_CODES = defineCodes("DATETIME", [
|
||||
"PARSE.INVALID_INPUT",
|
||||
"RECURRENCE.INFINITE_EXPANSION",
|
||||
"TIMEZONE.UNKNOWN",
|
||||
] as const);
|
||||
|
||||
export type DatetimeCode = (typeof DATETIME_CODES)[keyof typeof DATETIME_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/datetime` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` with a strongly-typed `code` property drawn from
|
||||
* `DATETIME_CODES`, giving callers narrow `instanceof` discrimination.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* throw new DatetimeError(
|
||||
* DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
* "datetime.parse.invalid_input",
|
||||
* { context: { input: "not-a-date" } }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class DatetimeError extends SuaveplanError<DatetimeCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: DatetimeCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
5
packages/foundation/timing/datetime/src/errors/index.ts
Normal file
5
packages/foundation/timing/datetime/src/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DATETIME_CODES,
|
||||
type DatetimeCode,
|
||||
DatetimeError,
|
||||
} from "./datetime-error";
|
||||
73
packages/foundation/timing/datetime/src/index.ts
Normal file
73
packages/foundation/timing/datetime/src/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Types
|
||||
|
||||
// Business days
|
||||
export {
|
||||
addBusinessDays,
|
||||
businessDaysBetween,
|
||||
isBusinessDay,
|
||||
nextBusinessDay,
|
||||
previousBusinessDay,
|
||||
subtractBusinessDays,
|
||||
} from "./business-days/business-days.js";
|
||||
export type {
|
||||
BoundaryOptions,
|
||||
CalendarMonthOptions,
|
||||
} from "./calendar/calendar.js";
|
||||
// Calendar
|
||||
export {
|
||||
addDuration,
|
||||
asTimezone,
|
||||
daysInMonth,
|
||||
endOf,
|
||||
getCalendarMonth,
|
||||
isLeapYear,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOf,
|
||||
subtractDuration,
|
||||
toDate,
|
||||
} from "./calendar/calendar.js";
|
||||
export type { DatetimeCode } from "./errors/datetime-error.js";
|
||||
// Errors
|
||||
export { DATETIME_CODES, DatetimeError } from "./errors/datetime-error.js";
|
||||
// OTel
|
||||
export {
|
||||
businessDaysBetweenDuration,
|
||||
businessDaysBetweenSize,
|
||||
businessDaysBetweenTotal,
|
||||
expandRecurrenceDuration,
|
||||
expandRecurrenceSize,
|
||||
recordBusinessDaysBetween,
|
||||
recordExpandRecurrence,
|
||||
} from "./otel/datetime-otel.js";
|
||||
|
||||
// Recurrence
|
||||
export {
|
||||
expandRecurrence,
|
||||
formatRRule,
|
||||
nextOccurrence,
|
||||
parseRRule,
|
||||
} from "./recurrence/recurrence.js";
|
||||
|
||||
// Relative
|
||||
export {
|
||||
diffInUnits,
|
||||
formatRelative,
|
||||
timeAgo,
|
||||
timeUntil,
|
||||
} from "./relative/relative.js";
|
||||
export type {
|
||||
BusinessDayOptions,
|
||||
CalendarDay,
|
||||
CalendarMonth,
|
||||
DateInput,
|
||||
DayAbbreviation,
|
||||
Duration,
|
||||
DurationUnit,
|
||||
Frequency,
|
||||
RecurrenceOptions,
|
||||
RecurrenceRule,
|
||||
RelativeTimeOptions,
|
||||
Timezone,
|
||||
Weekday,
|
||||
} from "./types/datetime.types.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# datetime-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`datetime-otel` provides lightweight OpenTelemetry instrumentation for the `@suaveplan/datetime` package. It registers a histogram for `expandRecurrence` call durations and a counter for `businessDaysBetween` interval evaluations. Both instruments are initialised lazily via `metrics.getMeter()`, which is a no-op when no global `MeterProvider` is configured, ensuring zero overhead in environments without observability infrastructure.
|
||||
|
||||
The module exposes thin wrapper functions (`recordExpandRecurrence`, `recordBusinessDaysBetween`) so the hot-path modules do not need to import `@opentelemetry/api` directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `datetime.expand_recurrence.duration_ms` histogram (unit: `ms`)
|
||||
- `datetime.business_days_between.total` counter (unit: `{days}`)
|
||||
- Zero-overhead when no OTel SDK is configured
|
||||
- Named meter `@suaveplan/datetime` for easy filtering in dashboards
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordExpandRecurrence } from "@suaveplan/datetime/otel";
|
||||
|
||||
const start = Date.now();
|
||||
// ... run expandRecurrence ...
|
||||
recordExpandRecurrence(Date.now() - start);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { recordBusinessDaysBetween } from "@suaveplan/datetime/otel";
|
||||
|
||||
// Wire up an SDK meter provider before the first import of datetime modules
|
||||
const provider = new MeterProvider();
|
||||
// metrics.setGlobalMeterProvider(provider); // done at app startup
|
||||
|
||||
// Later, inside businessDaysBetween implementation:
|
||||
recordBusinessDaysBetween(365); // records 365 day-intervals evaluated
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `expandRecurrenceDuration`
|
||||
|
||||
`Histogram` — OpenTelemetry histogram for `expandRecurrence` durations.
|
||||
|
||||
### `businessDaysBetweenTotal`
|
||||
|
||||
`Counter` — OpenTelemetry counter for `businessDaysBetween` day-intervals.
|
||||
|
||||
### `recordExpandRecurrence(durationMs: number): void`
|
||||
|
||||
Records a single `expandRecurrence` call duration. Call immediately after the expansion completes.
|
||||
|
||||
- `durationMs` — wall-clock elapsed time in milliseconds
|
||||
|
||||
### `recordBusinessDaysBetween(count: number): void`
|
||||
|
||||
Increments the `businessDaysBetween` counter by the number of calendar days evaluated.
|
||||
|
||||
- `count` — number of calendar-day intervals walked during the call
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`metrics.getMeter()` is called once at module initialisation. The returned `Meter` instance is cached for the lifetime of the process. In environments without a registered `MeterProvider`, all record/add calls are no-ops at the OTel SDK level, incurring only a function-call overhead.
|
||||
|
||||
The histogram uses millisecond granularity, which is sufficient for typical recurrence expansions (tens to hundreds of milliseconds). For sub-millisecond benchmarks, use the `mitata` benchmark suite instead.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
businessDaysBetweenTotal,
|
||||
expandRecurrenceDuration,
|
||||
recordBusinessDaysBetween,
|
||||
recordExpandRecurrence,
|
||||
} from "./datetime-otel.js";
|
||||
|
||||
describe("datetime-otel", () => {
|
||||
it("exports expandRecurrenceDuration histogram", { timeout: 5000 }, () => {
|
||||
expect(expandRecurrenceDuration).toBeDefined();
|
||||
expect(typeof expandRecurrenceDuration.record).toBe("function");
|
||||
});
|
||||
|
||||
it("exports businessDaysBetweenTotal counter", { timeout: 5000 }, () => {
|
||||
expect(businessDaysBetweenTotal).toBeDefined();
|
||||
expect(typeof businessDaysBetweenTotal.add).toBe("function");
|
||||
});
|
||||
|
||||
it("recordExpandRecurrence does not throw", { timeout: 5000 }, () => {
|
||||
expect(() => recordExpandRecurrence(42)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordBusinessDaysBetween does not throw", { timeout: 5000 }, () => {
|
||||
expect(() => recordBusinessDaysBetween(100)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordExpandRecurrence with 0 does not throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordExpandRecurrence(0)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordBusinessDaysBetween with 0 does not throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordBusinessDaysBetween(0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/datetime");
|
||||
|
||||
/**
|
||||
* Histogram tracking the wall-clock duration of `expandRecurrence` calls in
|
||||
* milliseconds. Use this to detect expensive recurrence expansions in production.
|
||||
*/
|
||||
export const expandRecurrenceDuration = meter.createHistogram(
|
||||
"datetime.expand_recurrence.duration_ms",
|
||||
{
|
||||
description: "Duration of expandRecurrence() calls in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram tracking the size (number of dates emitted) by
|
||||
* `expandRecurrence`. Pairs with {@link expandRecurrenceDuration} to
|
||||
* correlate output size with latency.
|
||||
*/
|
||||
export const expandRecurrenceSize = meter.createHistogram(
|
||||
"datetime.expand_recurrence.size",
|
||||
{
|
||||
description: "Number of dates emitted by expandRecurrence()",
|
||||
unit: "{dates}",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram tracking the wall-clock duration of `businessDaysBetween` calls
|
||||
* in milliseconds.
|
||||
*/
|
||||
export const businessDaysBetweenDuration = meter.createHistogram(
|
||||
"datetime.business_days_between.duration_ms",
|
||||
{
|
||||
description: "Duration of businessDaysBetween() calls in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram tracking the calendar-day span evaluated by
|
||||
* `businessDaysBetween` calls.
|
||||
*/
|
||||
export const businessDaysBetweenSize = meter.createHistogram(
|
||||
"datetime.business_days_between.size",
|
||||
{
|
||||
description: "Calendar-day span evaluated by businessDaysBetween()",
|
||||
unit: "{days}",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Counter tracking the cumulative number of intervals evaluated by
|
||||
* `businessDaysBetween`. Use this to monitor workload distribution.
|
||||
*/
|
||||
export const businessDaysBetweenTotal = meter.createCounter(
|
||||
"datetime.business_days_between.total",
|
||||
{
|
||||
description:
|
||||
"Cumulative number of day-intervals evaluated by businessDaysBetween()",
|
||||
unit: "{days}",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Record a `businessDaysBetween` observation.
|
||||
*
|
||||
* @param count - Number of calendar days evaluated in the call.
|
||||
*/
|
||||
export function recordBusinessDaysBetween(count: number): void {
|
||||
businessDaysBetweenTotal.add(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an `expandRecurrence` duration observation.
|
||||
*
|
||||
* @param durationMs - Elapsed milliseconds for the expansion call.
|
||||
*/
|
||||
export function recordExpandRecurrence(durationMs: number): void {
|
||||
expandRecurrenceDuration.record(durationMs);
|
||||
}
|
||||
9
packages/foundation/timing/datetime/src/otel/index.ts
Normal file
9
packages/foundation/timing/datetime/src/otel/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
businessDaysBetweenDuration,
|
||||
businessDaysBetweenSize,
|
||||
businessDaysBetweenTotal,
|
||||
expandRecurrenceDuration,
|
||||
expandRecurrenceSize,
|
||||
recordBusinessDaysBetween,
|
||||
recordExpandRecurrence,
|
||||
} from "./datetime-otel";
|
||||
25
packages/foundation/timing/datetime/src/recurrence/index.ts
Normal file
25
packages/foundation/timing/datetime/src/recurrence/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
expandRecurrence,
|
||||
formatRRule,
|
||||
generateCandidates,
|
||||
nextOccurrence,
|
||||
parseRRule,
|
||||
} from "./recurrence";
|
||||
export {
|
||||
generateDaily,
|
||||
generateMonthly,
|
||||
generateWeekly,
|
||||
generateYearly,
|
||||
} from "./recurrence-generate";
|
||||
export {
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
DAY_INDEX,
|
||||
DAY_NAME,
|
||||
daysInMonth,
|
||||
FREQ_MAP,
|
||||
FREQ_RRULE,
|
||||
fromRRuleDate,
|
||||
toRRuleDate,
|
||||
} from "./recurrence-helpers";
|
||||
@@ -0,0 +1,95 @@
|
||||
# recurrence-generate
|
||||
|
||||
## Purpose
|
||||
|
||||
`recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for each recurrence frequency supported by `@suaveplan/datetime`. Each generator yields dates strictly after a given `from` anchor, and the `generateCandidates` dispatcher routes to the correct generator based on `RecurrenceRule.freq`.
|
||||
|
||||
This module is an internal implementation detail. Public callers should use `expandRecurrence` and `nextOccurrence` from `recurrence.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateDaily` — daily step with optional `BYDAY` weekday filter
|
||||
- `generateWeekly` — weekly step, either plain (same weekday) or with `BYDAY` multi-day selection
|
||||
- `generateMonthly` — monthly step supporting `BYMONTHDAY`, `BYDAY`, or plain monthly recurrence
|
||||
- `generateYearly` — yearly step supporting `BYMONTHDAY`, `BYDAY`, or plain yearly recurrence
|
||||
- `generateCandidates` — dispatcher that routes to the correct generator from `RecurrenceRule.freq`
|
||||
- All generators are infinite; termination is enforced by callers (`count` / `until` / `AbortSignal`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateCandidates } from "./recurrence-generate.js";
|
||||
|
||||
const rule = { freq: "daily" as const, count: 5 };
|
||||
const from = new Date(2024, 0, 1);
|
||||
|
||||
const gen = generateCandidates(rule, from);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
console.log(gen.next().value?.toDateString());
|
||||
}
|
||||
// Tue Jan 02 2024 … Sat Jan 06 2024
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generateCandidates } from "./recurrence-generate.js";
|
||||
|
||||
// Monthly with BYMONTHDAY — yields the 15th of each month
|
||||
const rule = {
|
||||
freq: "monthly" as const,
|
||||
byMonthDay: [15],
|
||||
};
|
||||
const from = new Date(2024, 0, 1);
|
||||
const gen = generateCandidates(rule, from);
|
||||
|
||||
const first = gen.next().value!;
|
||||
console.log(first.toDateString()); // Mon Jan 15 2024
|
||||
|
||||
const second = gen.next().value!;
|
||||
console.log(second.toDateString()); // Thu Feb 15 2024
|
||||
|
||||
// Weekly with BYDAY — yields Mon/Wed/Fri
|
||||
const weeklyRule = {
|
||||
freq: "weekly" as const,
|
||||
byDay: [1, 3, 5] as const,
|
||||
};
|
||||
const weeklyGen = generateCandidates(weeklyRule, new Date(2024, 2, 11));
|
||||
console.log(weeklyGen.next().value?.getDay()); // 3 (Wed Mar 13)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateCandidates(rule: RecurrenceRule, from: Date): Generator<Date>`
|
||||
|
||||
Unified dispatcher. Routes to the frequency-specific generator based on `rule.freq`. All yielded dates are strictly after `from`.
|
||||
|
||||
### `generateDaily(rule, from, interval): Generator<Date>`
|
||||
|
||||
Steps `interval` days at a time. If `rule.byDay` is set, only yields dates whose `getDay()` is in the list.
|
||||
|
||||
### `generateWeekly(rule, from, interval): Generator<Date>`
|
||||
|
||||
If `rule.byDay` is set: anchors to the Sunday of the current week, then emits the specified weekdays in sorted order each `interval * 7` days. Without `byDay`: yields the same weekday every `interval * 7` days.
|
||||
|
||||
### `generateMonthly(rule, from, interval): Generator<Date>`
|
||||
|
||||
Three modes:
|
||||
|
||||
1. `byMonthDay` — emits the specified day-of-month values (sorted) in each month, skipping months where a day exceeds `daysInMonth`.
|
||||
2. `byDay` — scans each month and emits all days matching the weekday list.
|
||||
3. Plain — yields the same day-of-month each `interval` months using `addMonths` clamping.
|
||||
|
||||
### `generateYearly(rule, from, interval): Generator<Date>`
|
||||
|
||||
Three modes:
|
||||
|
||||
1. `byMonthDay` — emits specified day-of-month values in each year.
|
||||
2. `byDay` — advances one day at a time from the year anchor, yielding matching weekdays.
|
||||
3. Plain — yields the same date each `interval` years using `addYears`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All generators are implemented as native ES `Generator` functions (`function*`) to enable lazy evaluation. They produce an infinite sequence; the caller (`expandRecurrence`, `nextOccurrence`) is responsible for applying `count`, `until`, and `AbortSignal` termination conditions.
|
||||
|
||||
The `generateWeekly` with `BYDAY` mode anchors to the start of the week (Sunday) on the first iteration, which means it may emit dates in the current week before advancing. Dates before or equal to `from` are filtered out with a `candidate > from` guard.
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { RecurrenceRule } from "../types/datetime.types.js";
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
daysInMonth,
|
||||
} from "./recurrence-helpers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: per-frequency generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate candidates for FREQ=DAILY, stepping `interval` days at a time. */
|
||||
export function* generateDaily(
|
||||
rule: RecurrenceRule,
|
||||
from: Date,
|
||||
interval: number
|
||||
): Generator<Date> {
|
||||
let cur = addDays(from, interval);
|
||||
for (;;) {
|
||||
if (
|
||||
!rule.byDay?.length ||
|
||||
(rule.byDay as number[]).includes(cur.getDay())
|
||||
) {
|
||||
yield cur;
|
||||
}
|
||||
cur = addDays(cur, interval);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate candidates for FREQ=WEEKLY. */
|
||||
export function* generateWeekly(
|
||||
rule: RecurrenceRule,
|
||||
from: Date,
|
||||
interval: number
|
||||
): Generator<Date> {
|
||||
if (rule.byDay && rule.byDay.length > 0) {
|
||||
let weekStart = new Date(
|
||||
from.getFullYear(),
|
||||
from.getMonth(),
|
||||
from.getDate() - from.getDay(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
for (;;) {
|
||||
const days = (rule.byDay as number[]).slice().sort((a, b) => a - b);
|
||||
for (const dow of days) {
|
||||
const candidate = addDays(weekStart, dow);
|
||||
if (candidate > from) yield candidate;
|
||||
}
|
||||
weekStart = addDays(weekStart, interval * 7);
|
||||
}
|
||||
} else {
|
||||
let cur = addDays(from, interval * 7);
|
||||
for (;;) {
|
||||
yield cur;
|
||||
cur = addDays(cur, interval * 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate candidates for FREQ=MONTHLY. */
|
||||
export function* generateMonthly(
|
||||
rule: RecurrenceRule,
|
||||
from: Date,
|
||||
interval: number
|
||||
): Generator<Date> {
|
||||
if (rule.byMonthDay && rule.byMonthDay.length > 0) {
|
||||
let year = from.getFullYear();
|
||||
let month = from.getMonth() + 1;
|
||||
for (;;) {
|
||||
const days = (rule.byMonthDay as number[])
|
||||
.slice()
|
||||
.sort((a, b) => a - b);
|
||||
for (const d of days) {
|
||||
const maxDay = daysInMonth(year, month);
|
||||
if (d > maxDay) continue;
|
||||
const candidate = new Date(year, month - 1, d, 0, 0, 0, 0);
|
||||
if (candidate > from) yield candidate;
|
||||
}
|
||||
month += interval;
|
||||
while (month > 12) {
|
||||
month -= 12;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
} else if (rule.byDay && rule.byDay.length > 0) {
|
||||
let year = from.getFullYear();
|
||||
let month = from.getMonth() + 1;
|
||||
for (;;) {
|
||||
const count = daysInMonth(year, month);
|
||||
for (let d = 1; d <= count; d++) {
|
||||
const candidate = new Date(year, month - 1, d, 0, 0, 0, 0);
|
||||
if (
|
||||
candidate > from &&
|
||||
(rule.byDay as number[]).includes(candidate.getDay())
|
||||
) {
|
||||
yield candidate;
|
||||
}
|
||||
}
|
||||
month += interval;
|
||||
while (month > 12) {
|
||||
month -= 12;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let cur = addMonths(from, interval);
|
||||
for (;;) {
|
||||
yield cur;
|
||||
cur = addMonths(cur, interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate candidates for FREQ=YEARLY. */
|
||||
export function* generateYearly(
|
||||
rule: RecurrenceRule,
|
||||
from: Date,
|
||||
interval: number
|
||||
): Generator<Date> {
|
||||
if (rule.byMonthDay && rule.byMonthDay.length > 0) {
|
||||
let cur = addYears(from, interval);
|
||||
for (;;) {
|
||||
const days = (rule.byMonthDay as number[])
|
||||
.slice()
|
||||
.sort((a, b) => a - b);
|
||||
for (const d of days) {
|
||||
const candidate = new Date(
|
||||
cur.getFullYear(),
|
||||
cur.getMonth(),
|
||||
d,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
yield candidate;
|
||||
}
|
||||
cur = addYears(cur, interval);
|
||||
}
|
||||
} else if (rule.byDay && rule.byDay.length > 0) {
|
||||
let cur = addYears(from, interval);
|
||||
for (;;) {
|
||||
if ((rule.byDay as number[]).includes(cur.getDay())) yield cur;
|
||||
cur = addDays(cur, 1);
|
||||
}
|
||||
} else {
|
||||
let cur = addYears(from, interval);
|
||||
for (;;) {
|
||||
yield cur;
|
||||
cur = addYears(cur, interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: unified candidate generator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate candidate dates for a rule starting strictly after `from`.
|
||||
*
|
||||
* For FREQ=DAILY/WEEKLY: step the base date and optionally filter by BYDAY.
|
||||
* For FREQ=MONTHLY + BYMONTHDAY: emit the specified day-of-month in each month.
|
||||
* For FREQ=MONTHLY + BYDAY: emit the specified weekdays found in each month.
|
||||
* For FREQ=YEARLY: step by year, apply optional filters.
|
||||
*/
|
||||
export function* generateCandidates(
|
||||
rule: RecurrenceRule,
|
||||
from: Date
|
||||
): Generator<Date> {
|
||||
const interval = rule.interval ?? 1;
|
||||
if (rule.freq === "daily") {
|
||||
yield* generateDaily(rule, from, interval);
|
||||
} else if (rule.freq === "weekly") {
|
||||
yield* generateWeekly(rule, from, interval);
|
||||
} else if (rule.freq === "monthly") {
|
||||
yield* generateMonthly(rule, from, interval);
|
||||
} else {
|
||||
yield* generateYearly(rule, from, interval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# recurrence-helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
`recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurrence sub-modules of `@suaveplan/datetime`. It contains the frequency/day-name mappings used by the RRULE parser and formatter, and the `addDays`, `addMonths`, `addYears`, `daysInMonth`, `toRRuleDate`, and `fromRRuleDate` helpers used by the candidate generators.
|
||||
|
||||
This module is an internal implementation detail. All public API is exposed through `recurrence.ts`. Direct imports are supported but not part of the documented public surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `FREQ_MAP` — maps RRULE `FREQ` strings to `Frequency` values
|
||||
- `FREQ_RRULE` — maps `Frequency` values back to RRULE strings
|
||||
- `DAY_NAME` / `DAY_INDEX` — bidirectional weekday ↔ abbreviation maps
|
||||
- `toRRuleDate` — format a `Date` as `YYYYMMDD`
|
||||
- `fromRRuleDate` — parse `YYYYMMDD` (or `YYYYMMDDTHHmmssZ`) into a `Date`
|
||||
- `daysInMonth` — days in a 1-indexed month (local copy for recurrence module independence)
|
||||
- `addDays` / `addMonths` / `addYears` — safe date arithmetic with month-end clamping
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toRRuleDate, fromRRuleDate, addDays } from "./recurrence-helpers.js";
|
||||
|
||||
const d = new Date(2024, 0, 31);
|
||||
console.log(toRRuleDate(d)); // "20240131"
|
||||
|
||||
const parsed = fromRRuleDate("20241231");
|
||||
console.log(parsed.getMonth()); // 11 (December)
|
||||
|
||||
const next = addDays(d, 7);
|
||||
console.log(next.getDate()); // 7 (Feb 7 2024)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { addMonths, addYears, daysInMonth } from "./recurrence-helpers.js";
|
||||
|
||||
// Month-end clamping: Jan 31 + 1 month = Feb 29 (leap year 2024)
|
||||
const jan31 = new Date(2024, 0, 31);
|
||||
const feb = addMonths(jan31, 1);
|
||||
console.log(feb.getDate()); // 29
|
||||
|
||||
// Year addition uses addMonths internally
|
||||
const twoYearsLater = addYears(jan31, 2);
|
||||
console.log(twoYearsLater.getFullYear()); // 2026
|
||||
|
||||
// Days in February 2024 (leap year)
|
||||
console.log(daysInMonth(2024, 2)); // 29
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FREQ_MAP: Record<string, Frequency>`
|
||||
|
||||
Maps uppercase RRULE frequency strings (`"DAILY"`, `"WEEKLY"`, `"MONTHLY"`, `"YEARLY"`) to their `Frequency` type values.
|
||||
|
||||
### `FREQ_RRULE: Record<Frequency, string>`
|
||||
|
||||
Inverse of `FREQ_MAP`. Maps `Frequency` type values back to uppercase RRULE strings.
|
||||
|
||||
### `DAY_NAME: Record<Weekday, string>`
|
||||
|
||||
Maps weekday indices (0–6) to two-letter RRULE abbreviations (`"SU"` … `"SA"`).
|
||||
|
||||
### `DAY_INDEX: Record<string, Weekday>`
|
||||
|
||||
Maps RRULE day abbreviations to weekday indices.
|
||||
|
||||
### `toRRuleDate(d: Date): string`
|
||||
|
||||
Format `d` as `YYYYMMDD` with zero-padded year, month, and day.
|
||||
|
||||
### `fromRRuleDate(s: string): Date`
|
||||
|
||||
Parse `s` (either `YYYYMMDD` or `YYYYMMDDTHHmmssZ`) into a local-midnight `Date`. The time and timezone suffix are stripped.
|
||||
|
||||
### `daysInMonth(year: number, month: number): number`
|
||||
|
||||
Returns the number of days in the given 1-indexed month. Mirrors the implementation in `calendar-core` to keep this module self-contained.
|
||||
|
||||
### `addDays(d: Date, n: number): Date`
|
||||
|
||||
Add `n` days to `d` using millisecond arithmetic.
|
||||
|
||||
### `addMonths(d: Date, n: number): Date`
|
||||
|
||||
Add `n` months with day clamping to the last valid day of the resulting month.
|
||||
|
||||
### `addYears(d: Date, n: number): Date`
|
||||
|
||||
Add `n` years by delegating to `addMonths(d, n * 12)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`addMonths` is the source of truth for month-end clamping in the recurrence engine. It handles month overflow (month > 12 rolls the year forward) and clamps the day to `daysInMonth(year, month)` using `Math.min`, ensuring that e.g. Jan 31 + 1 month → Feb 28/29 rather than Mar 2/3.
|
||||
|
||||
`daysInMonth` is intentionally duplicated from `calendar-core` rather than imported, to keep `recurrence-helpers` self-contained and free of cross-category imports.
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Frequency, Weekday } from "../types/datetime.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FREQ_MAP: Record<string, Frequency> = {
|
||||
DAILY: "daily",
|
||||
WEEKLY: "weekly",
|
||||
MONTHLY: "monthly",
|
||||
YEARLY: "yearly",
|
||||
};
|
||||
|
||||
export const FREQ_RRULE: Record<Frequency, string> = {
|
||||
daily: "DAILY",
|
||||
weekly: "WEEKLY",
|
||||
monthly: "MONTHLY",
|
||||
yearly: "YEARLY",
|
||||
};
|
||||
|
||||
export const DAY_NAME: Record<Weekday, string> = {
|
||||
0: "SU",
|
||||
1: "MO",
|
||||
2: "TU",
|
||||
3: "WE",
|
||||
4: "TH",
|
||||
5: "FR",
|
||||
6: "SA",
|
||||
};
|
||||
|
||||
export const DAY_INDEX: Record<string, Weekday> = {
|
||||
SU: 0,
|
||||
MO: 1,
|
||||
TU: 2,
|
||||
WE: 3,
|
||||
TH: 4,
|
||||
FR: 5,
|
||||
SA: 6,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date arithmetic helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Format a Date as `YYYYMMDD` (used inside RRULE UNTIL). */
|
||||
export function toRRuleDate(d: Date): string {
|
||||
const y = String(d.getFullYear()).padStart(4, "0");
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}${m}${day}`;
|
||||
}
|
||||
|
||||
/** Parse a `YYYYMMDD` (or `YYYYMMDDTHHmmssZ`) string into a Date. */
|
||||
export function fromRRuleDate(s: string): Date {
|
||||
// Support both YYYYMMDD and YYYYMMDDTHHmmssZ
|
||||
const clean = s.replace("T", "").replace("Z", "");
|
||||
const y = Number(clean.slice(0, 4));
|
||||
const mo = Number(clean.slice(4, 6)) - 1;
|
||||
const d = Number(clean.slice(6, 8));
|
||||
return new Date(y, mo, d, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** Days in a given month (1-indexed). */
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
/** Add months with day clamping (n must be positive). */
|
||||
export function addMonths(d: Date, n: number): Date {
|
||||
let year = d.getFullYear();
|
||||
let month = d.getMonth() + 1 + n; // 1-indexed
|
||||
while (month > 12) {
|
||||
month -= 12;
|
||||
year += 1;
|
||||
}
|
||||
const maxDay = daysInMonth(year, month);
|
||||
const day = Math.min(d.getDate(), maxDay);
|
||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** Add years. */
|
||||
export function addYears(d: Date, n: number): Date {
|
||||
return addMonths(d, n * 12);
|
||||
}
|
||||
|
||||
/** Add days. */
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
return new Date(d.getTime() + n * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { expandRecurrence, parseRRule } from "./recurrence.js";
|
||||
|
||||
describe("expandRecurrence", () => {
|
||||
it("throws INFINITE_EXPANSION when no count or until", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = parseRRule("FREQ=DAILY");
|
||||
let err: unknown;
|
||||
try {
|
||||
expandRecurrence(rule);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect((err as { code?: string }).code).toBe(
|
||||
"DATETIME.RECURRENCE.INFINITE_EXPANSION"
|
||||
);
|
||||
});
|
||||
|
||||
it("expands COUNT=5 daily", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=5");
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(5);
|
||||
expect(dates[0]?.getDate()).toBe(2);
|
||||
expect(dates[4]?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
it("expands UNTIL bound", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=DAILY;UNTIL=20240105");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(4); // Jan 2,3,4,5
|
||||
});
|
||||
|
||||
it("expands daily with BYDAY filter (skips non-matching weekdays)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// FREQ=DAILY;BYDAY=MO generates only Mondays, skipping Tue–Sun
|
||||
const clock = () => new Date(2024, 2, 11).getTime(); // Mon Mar 11
|
||||
const rule = parseRRule("FREQ=DAILY;BYDAY=MO;COUNT=2");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(2);
|
||||
for (const d of dates) {
|
||||
expect(d.getDay()).toBe(1); // Monday
|
||||
}
|
||||
// Verify next Monday is Mar 18 (7 days later, not 1 day)
|
||||
expect(dates[0]?.getDate()).toBe(18);
|
||||
});
|
||||
|
||||
it("expands weekly BYDAY", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 2, 11).getTime(); // Mon
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(6);
|
||||
for (const d of dates) {
|
||||
expect([1, 3, 5]).toContain(d.getDay());
|
||||
}
|
||||
});
|
||||
|
||||
it("expands monthly BYMONTHDAY", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(3);
|
||||
for (const d of dates) {
|
||||
expect(d.getDate()).toBe(15);
|
||||
}
|
||||
});
|
||||
|
||||
it("expands monthly BYMONTHDAY multiple days (exercises sort)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=10,20;COUNT=4");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(4);
|
||||
// Should be Jan 10, Jan 20, Feb 10, Feb 20
|
||||
expect(dates[0]?.getDate()).toBe(10);
|
||||
expect(dates[1]?.getDate()).toBe(20);
|
||||
});
|
||||
|
||||
it("expands monthly BYMONTHDAY crossing year boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Start in December, interval=1, crosses to January next year
|
||||
const clock = () => new Date(2024, 11, 1).getTime(); // Dec 1 2024
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(3);
|
||||
// Dec 15 2024, Jan 15 2025, Feb 15 2025
|
||||
expect(dates[0]?.getMonth()).toBe(11); // December
|
||||
expect(dates[1]?.getFullYear()).toBe(2025);
|
||||
expect(dates[1]?.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
it("expands monthly BYDAY crossing year boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Start in December, get Mondays in each month for 2 months (crosses to Jan 2025)
|
||||
const clock = () => new Date(2024, 11, 30).getTime(); // Dec 30 2024
|
||||
const rule = {
|
||||
freq: "monthly" as const,
|
||||
byDay: [1 as const],
|
||||
count: 4,
|
||||
interval: 1,
|
||||
};
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(4);
|
||||
for (const d of dates) {
|
||||
expect(d.getDay()).toBe(1); // Monday
|
||||
}
|
||||
// Month rolls from Dec to Jan — should see 2025
|
||||
const years = new Set(dates.map((d) => d.getFullYear()));
|
||||
expect(years.size).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("respects abort signal", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=1000");
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const dates = expandRecurrence(rule, {
|
||||
clock,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Should return empty or partial since aborted immediately
|
||||
expect(Array.isArray(dates)).toBe(true);
|
||||
});
|
||||
|
||||
it("checks signal at 64-occurrence boundary", { timeout: 5000 }, () => {
|
||||
// Abort after 64 occurrences by using a counter inside the signal check
|
||||
const controller = new AbortController();
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=200");
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
// Abort signal immediately to confirm expansion terminates before 200
|
||||
controller.abort();
|
||||
const dates = expandRecurrence(rule, {
|
||||
clock,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Since signal is already aborted, the loop breaks on first check
|
||||
expect(dates.length).toBeLessThanOrEqual(200);
|
||||
// Should be 0 since we abort before the loop body
|
||||
expect(dates.length).toBe(0);
|
||||
});
|
||||
|
||||
it("expands yearly COUNT=3", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=YEARLY;COUNT=3");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(3);
|
||||
expect(dates[0]?.getFullYear()).toBe(2025);
|
||||
expect(dates[1]?.getFullYear()).toBe(2026);
|
||||
expect(dates[2]?.getFullYear()).toBe(2027);
|
||||
});
|
||||
|
||||
it("expands yearly BYMONTHDAY", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=YEARLY;BYMONTHDAY=1;COUNT=2");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(2);
|
||||
expect(dates[0]?.getFullYear()).toBe(2025);
|
||||
expect(dates[0]?.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it("expands yearly BYDAY (first matching weekday in each year)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Expand 2 years forward, finding Mondays
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = { freq: "yearly" as const, byDay: [1 as const], count: 2 };
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(2);
|
||||
for (const d of dates) {
|
||||
expect(d.getDay()).toBe(1); // Monday
|
||||
}
|
||||
});
|
||||
|
||||
it("expands monthly BYDAY (weekdays in each month)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
// Emit all Mondays in each month, get first 4
|
||||
const rule = {
|
||||
freq: "monthly" as const,
|
||||
byDay: [1 as const],
|
||||
count: 4,
|
||||
};
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(4);
|
||||
for (const d of dates) {
|
||||
expect(d.getDay()).toBe(1); // Monday
|
||||
}
|
||||
});
|
||||
|
||||
it("expands yearly BYMONTHDAY (multiple day-of-months)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=YEARLY;BYMONTHDAY=1,15;COUNT=4");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(4);
|
||||
// 2025-01-01, 2025-01-15, 2026-01-01, 2026-01-15 (roughly)
|
||||
for (const d of dates) {
|
||||
expect([1, 15]).toContain(d.getDate());
|
||||
}
|
||||
});
|
||||
|
||||
it("expands weekly plain (no BYDAY)", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2024, 2, 11).getTime(); // Mon
|
||||
const rule = parseRRule("FREQ=WEEKLY;COUNT=4");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(4);
|
||||
for (const d of dates) {
|
||||
expect(d.getDay()).toBe(1); // Always Monday (same dow as start)
|
||||
}
|
||||
});
|
||||
|
||||
it("expands monthly plain (no BYMONTHDAY or BYDAY)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = () => new Date(2024, 0, 15).getTime();
|
||||
const rule = parseRRule("FREQ=MONTHLY;COUNT=3");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(3);
|
||||
for (const d of dates) {
|
||||
expect(d.getDate()).toBe(15);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses wall clock when no clock provided", { timeout: 5000 }, () => {
|
||||
// No clock option — uses Date.now()
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=3");
|
||||
const dates = expandRecurrence(rule);
|
||||
expect(dates.length).toBe(3);
|
||||
});
|
||||
|
||||
it("monthly BYMONTHDAY=31 skips months with fewer days", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Feb doesn't have day 31, so should be skipped
|
||||
const clock = () => new Date(2024, 0, 1).getTime(); // Jan 2024
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
// Jan 31, Mar 31, May 31 (Feb and Apr skipped)
|
||||
expect(dates.length).toBe(3);
|
||||
expect(dates[0]?.getDate()).toBe(31);
|
||||
// None should be in February or April
|
||||
const months = dates.map((d) => d.getMonth());
|
||||
expect(months).not.toContain(1); // February
|
||||
});
|
||||
|
||||
it("respects signal abort at 64-occurrence boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const controller = new AbortController();
|
||||
const clock = () => new Date(2024, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=200");
|
||||
// Abort after 65 occurrences — the check at `generated % 64 === 0` fires at 64
|
||||
const count = 0;
|
||||
const signal = controller.signal;
|
||||
const results = expandRecurrence(rule, {
|
||||
clock,
|
||||
signal,
|
||||
});
|
||||
// With signal NOT aborted, get full 200
|
||||
expect(results.length).toBe(200);
|
||||
|
||||
// Now test with pre-aborted signal at boundary
|
||||
const ctrl2 = new AbortController();
|
||||
// Abort immediately; the check at line 411 fires before first candidate
|
||||
ctrl2.abort();
|
||||
const results2 = expandRecurrence(parseRRule("FREQ=DAILY;COUNT=200"), {
|
||||
clock,
|
||||
signal: ctrl2.signal,
|
||||
});
|
||||
expect(results2.length).toBe(0);
|
||||
void count;
|
||||
});
|
||||
|
||||
it("expandRecurrence with UNTIL bound (yearly)", { timeout: 5000 }, () => {
|
||||
const clock = () => new Date(2023, 0, 1).getTime();
|
||||
const rule = parseRRule("FREQ=YEARLY;UNTIL=20250101");
|
||||
const dates = expandRecurrence(rule, { clock });
|
||||
expect(dates.length).toBe(2); // 2024 and 2025
|
||||
});
|
||||
});
|
||||
102
packages/foundation/timing/datetime/src/recurrence/recurrence.md
Normal file
102
packages/foundation/timing/datetime/src/recurrence/recurrence.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# recurrence
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `recurrence` module implements a simplified iCalendar-compatible recurrence rule engine for `@suaveplan/datetime`. It provides parsing, formatting, and expansion of RRULE-style recurrence rules covering the most common scheduling patterns: daily, weekly, monthly, and yearly recurrences with interval, count, until, by-day-of-week, and by-day-of-month filters.
|
||||
|
||||
The design prioritises correctness and safety over exhaustiveness: it deliberately excludes the more exotic RFC 5545 features (BYSETPOS, BYHOUR, EXDATE, etc.) in order to maintain a clear, auditable code path that cannot produce runaway expansions.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseRRule` — parse RRULE string into a typed `RecurrenceRule`
|
||||
- `formatRRule` — serialise `RecurrenceRule` back to RRULE string (round-trippable)
|
||||
- `nextOccurrence` — compute the next single occurrence after a given date
|
||||
- `expandRecurrence` — expand all occurrences with `count` or `until` bound (required)
|
||||
- Cooperative cancellation via `AbortSignal` (checked every 64 occurrences)
|
||||
- OTel histogram integration via `recordExpandRecurrence`
|
||||
- `clock` injection for deterministic tests
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRRule, expandRecurrence } from "@suaveplan/datetime/recurrence";
|
||||
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6");
|
||||
const dates = expandRecurrence(rule);
|
||||
console.log(dates.length); // 6
|
||||
console.log(dates[0].toDateString()); // next Monday/Wednesday/Friday
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRRule,
|
||||
formatRRule,
|
||||
nextOccurrence,
|
||||
expandRecurrence,
|
||||
} from "@suaveplan/datetime/recurrence";
|
||||
|
||||
// Round-trip an RRULE string
|
||||
const raw = "FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3";
|
||||
const rule = parseRRule(raw);
|
||||
const formatted = formatRRule(rule);
|
||||
console.log(formatted === raw); // true
|
||||
|
||||
// Deterministic expansion with a fake clock and abort signal
|
||||
const clock = () => new Date("2024-01-01T00:00:00Z").getTime();
|
||||
const controller = new AbortController();
|
||||
const dates = expandRecurrence(
|
||||
parseRRule("FREQ=DAILY;COUNT=365"),
|
||||
{ clock, signal: controller.signal }
|
||||
);
|
||||
console.log(dates.length); // 365
|
||||
|
||||
// Next weekly occurrence on Mondays
|
||||
const next = nextOccurrence(
|
||||
parseRRule("FREQ=WEEKLY;BYDAY=MO"),
|
||||
new Date("2024-03-13") // Wednesday
|
||||
);
|
||||
console.log(next?.toDateString()); // Mon Mar 18 2024
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRRule(input: string): RecurrenceRule`
|
||||
|
||||
Parse a semicolon-delimited RRULE string. Supported fields:
|
||||
|
||||
- `FREQ` (required): `DAILY | WEEKLY | MONTHLY | YEARLY`
|
||||
- `INTERVAL`: positive integer, defaults to 1
|
||||
- `COUNT`: positive integer
|
||||
- `UNTIL`: `YYYYMMDD` or `YYYYMMDDTHHmmssZ`
|
||||
- `BYDAY`: comma-separated `SU|MO|TU|WE|TH|FR|SA`
|
||||
- `BYMONTHDAY`: comma-separated integers 1–31
|
||||
|
||||
Throws `DatetimeError("DATETIME.PARSE.INVALID_INPUT")` when `FREQ` is missing or unrecognised.
|
||||
|
||||
### `formatRRule(rule: RecurrenceRule): string`
|
||||
|
||||
Serialise a `RecurrenceRule` to an RRULE string. Output is round-trippable through `parseRRule`.
|
||||
|
||||
### `nextOccurrence(rule: RecurrenceRule, from: DateInput): Date | null`
|
||||
|
||||
Returns the first occurrence strictly after `from` that matches all filters. Returns `null` when the rule terminates (`until` exceeded or `count` exhausted) before generating a match.
|
||||
|
||||
### `expandRecurrence(rule: RecurrenceRule, opts?: RecurrenceOptions): Date[]`
|
||||
|
||||
Expand all occurrences into a `Date[]`. Requires `rule.count` or `rule.until`; throws `DatetimeError("DATETIME.RECURRENCE.INFINITE_EXPANSION")` when neither is set.
|
||||
|
||||
- `opts.clock` — injectable `Clock` to set the expansion start point (default: `Date.now`)
|
||||
- `opts.signal` — `AbortSignal` checked every 64 occurrences
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The expansion loop uses an internal safety limit of `count * 10 + 10000` to guard against degenerate filter combinations (e.g. `FREQ=MONTHLY;BYMONTHDAY=31;COUNT=100` in months shorter than 31 days). In practice this limit is never reached for well-formed rules.
|
||||
|
||||
Month arithmetic uses the same clamping strategy as `calendar.addDuration`, so `FREQ=MONTHLY;BYMONTHDAY=31` in February produces no occurrence for that month rather than overflowing into March.
|
||||
|
||||
`nextOccurrence` does not require `count` or `until` since it only generates a single result, but it applies a 3,650-iteration safety cap (~10 years of daily occurrences) before returning `null`.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { nextOccurrence, parseRRule } from "./recurrence.js";
|
||||
|
||||
describe("nextOccurrence", () => {
|
||||
it("returns next daily occurrence", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=10");
|
||||
const from = new Date(2024, 0, 1);
|
||||
const next = nextOccurrence(rule, from);
|
||||
expect(next?.getDate()).toBe(2);
|
||||
});
|
||||
|
||||
it("returns next weekly occurrence on correct day", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// From Wednesday, next Monday
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO");
|
||||
const from = new Date(2024, 2, 13); // Wed
|
||||
const next = nextOccurrence(rule, from);
|
||||
expect(next?.getDay()).toBe(1); // Monday
|
||||
expect(next?.getDate()).toBe(18);
|
||||
});
|
||||
|
||||
it("returns null when until exceeded before match", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO;UNTIL=20240315");
|
||||
const from = new Date(2024, 2, 14); // Thu - next Mon would be Mar 18, after until
|
||||
const next = nextOccurrence(rule, from);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it("returns next monthly occurrence", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3");
|
||||
const from = new Date(2024, 0, 15);
|
||||
const next = nextOccurrence(rule, from);
|
||||
expect(next?.getDate()).toBe(15);
|
||||
expect(next?.getMonth()).toBe(1); // February
|
||||
});
|
||||
|
||||
it("returns next yearly occurrence", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=YEARLY;COUNT=3");
|
||||
const from = new Date(2024, 0, 1);
|
||||
const next = nextOccurrence(rule, from);
|
||||
expect(next?.getFullYear()).toBe(2025);
|
||||
});
|
||||
|
||||
it("returns null when no match found within safety limit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// BYMONTHDAY=31 on a MONTHLY rule — most months won't match
|
||||
// but eventually one will (any month with 31 days)
|
||||
// Let's test count exhaustion scenario
|
||||
const _rule = parseRRule("FREQ=DAILY;COUNT=1");
|
||||
// from = already at the only count (manually set an expired until)
|
||||
const until = new Date(2024, 0, 1);
|
||||
const ruleWithUntil = { freq: "daily" as const, until };
|
||||
const from = new Date(2024, 0, 2);
|
||||
const next = nextOccurrence(ruleWithUntil, from);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it("BYDAY=[] filter matches any weekday", { timeout: 5000 }, () => {
|
||||
const rule = { freq: "weekly" as const, count: 3 };
|
||||
const from = new Date(2024, 2, 13); // Wed
|
||||
const next = nextOccurrence(rule, from);
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { formatRRule, parseRRule } from "./recurrence.js";
|
||||
|
||||
describe("parseRRule", () => {
|
||||
it("parses FREQ=DAILY", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY");
|
||||
expect(rule.freq).toBe("daily");
|
||||
});
|
||||
|
||||
it("parses FREQ=WEEKLY", { timeout: 5000 }, () => {
|
||||
expect(parseRRule("FREQ=WEEKLY").freq).toBe("weekly");
|
||||
});
|
||||
|
||||
it("parses FREQ=MONTHLY", { timeout: 5000 }, () => {
|
||||
expect(parseRRule("FREQ=MONTHLY").freq).toBe("monthly");
|
||||
});
|
||||
|
||||
it("parses FREQ=YEARLY", { timeout: 5000 }, () => {
|
||||
expect(parseRRule("FREQ=YEARLY").freq).toBe("yearly");
|
||||
});
|
||||
|
||||
it("parses INTERVAL", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;INTERVAL=2");
|
||||
expect(rule.interval).toBe(2);
|
||||
});
|
||||
|
||||
it("parses COUNT", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=5");
|
||||
expect(rule.count).toBe(5);
|
||||
});
|
||||
|
||||
it("parses UNTIL (YYYYMMDD)", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;UNTIL=20241231");
|
||||
expect(rule.until?.getFullYear()).toBe(2024);
|
||||
expect(rule.until?.getMonth()).toBe(11);
|
||||
expect(rule.until?.getDate()).toBe(31);
|
||||
});
|
||||
|
||||
it("parses UNTIL with time suffix", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;UNTIL=20241231T000000Z");
|
||||
expect(rule.until?.getFullYear()).toBe(2024);
|
||||
});
|
||||
|
||||
it("parses BYDAY", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
||||
expect(rule.byDay).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it("parses BYMONTHDAY", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=15");
|
||||
expect(rule.byMonthDay).toEqual([15]);
|
||||
});
|
||||
|
||||
it("ignores parts without '=' sign", { timeout: 5000 }, () => {
|
||||
// Covers the `if (eq === -1) continue` branch in the parser loop
|
||||
const rule = parseRRule("FREQ=DAILY;BADPART;COUNT=3");
|
||||
expect(rule.freq).toBe("daily");
|
||||
expect(rule.count).toBe(3);
|
||||
});
|
||||
|
||||
it("throws for missing FREQ", { timeout: 5000 }, () => {
|
||||
let err: unknown;
|
||||
try {
|
||||
parseRRule("INTERVAL=2");
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect((err as { code?: string }).code).toBe(
|
||||
"DATETIME.PARSE.INVALID_INPUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for unknown FREQ", { timeout: 5000 }, () => {
|
||||
let err: unknown;
|
||||
try {
|
||||
parseRRule("FREQ=HOURLY");
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect((err as { code?: string }).code).toBe(
|
||||
"DATETIME.PARSE.INVALID_INPUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles all day abbreviations", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH,FR,SA");
|
||||
expect(rule.byDay).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRRule", () => {
|
||||
it("formats basic rule", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=5");
|
||||
expect(formatRRule(rule)).toBe("FREQ=DAILY;COUNT=5");
|
||||
});
|
||||
|
||||
it("formats UNTIL", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;UNTIL=20241231");
|
||||
const formatted = formatRRule(rule);
|
||||
expect(formatted).toContain("UNTIL=20241231");
|
||||
});
|
||||
|
||||
it("formats BYDAY", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6");
|
||||
const formatted = formatRRule(rule);
|
||||
expect(formatted).toContain("BYDAY=MO,WE,FR");
|
||||
expect(formatted).toContain("FR");
|
||||
});
|
||||
|
||||
it("formats all day abbreviations round-trip", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule(
|
||||
"FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH,FR,SA;COUNT=1"
|
||||
);
|
||||
const formatted = formatRRule(rule);
|
||||
expect(formatted).toContain("SU");
|
||||
expect(formatted).toContain("MO");
|
||||
expect(formatted).toContain("TU");
|
||||
expect(formatted).toContain("WE");
|
||||
expect(formatted).toContain("TH");
|
||||
expect(formatted).toContain("FR");
|
||||
expect(formatted).toContain("SA");
|
||||
});
|
||||
|
||||
it("formats BYMONTHDAY", { timeout: 5000 }, () => {
|
||||
const rule = parseRRule("FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3");
|
||||
expect(formatRRule(rule)).toContain("BYMONTHDAY=15");
|
||||
});
|
||||
|
||||
it("formats INTERVAL", { timeout: 5000 }, () => {
|
||||
// Ensures the `if (rule.interval !== undefined)` branch is taken
|
||||
const rule = parseRRule("FREQ=WEEKLY;INTERVAL=2;COUNT=4");
|
||||
const formatted = formatRRule(rule);
|
||||
expect(formatted).toContain("INTERVAL=2");
|
||||
});
|
||||
|
||||
it("parseRRule → formatRRule → parseRRule identity (property test)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const freqs = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"] as const;
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...freqs),
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
(freq, count) => {
|
||||
const input = `FREQ=${freq};COUNT=${count}`;
|
||||
const parsed = parseRRule(input);
|
||||
const formatted = formatRRule(parsed);
|
||||
const reparsed = parseRRule(formatted);
|
||||
expect(reparsed.freq).toBe(parsed.freq);
|
||||
expect(reparsed.count).toBe(parsed.count);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
172
packages/foundation/timing/datetime/src/recurrence/recurrence.ts
Normal file
172
packages/foundation/timing/datetime/src/recurrence/recurrence.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { toDate } from "../calendar/calendar.js";
|
||||
import { DATETIME_CODES, DatetimeError } from "../errors/datetime-error.js";
|
||||
import { recordExpandRecurrence } from "../otel/datetime-otel.js";
|
||||
import type {
|
||||
DateInput,
|
||||
RecurrenceOptions,
|
||||
RecurrenceRule,
|
||||
Weekday,
|
||||
} from "../types/datetime.types.js";
|
||||
import { generateCandidates } from "./recurrence-generate.js";
|
||||
import {
|
||||
DAY_INDEX,
|
||||
DAY_NAME,
|
||||
FREQ_MAP,
|
||||
FREQ_RRULE,
|
||||
fromRRuleDate,
|
||||
toRRuleDate,
|
||||
} from "./recurrence-helpers.js";
|
||||
|
||||
// Re-export sub-module symbols so all existing import paths keep working.
|
||||
export { generateCandidates } from "./recurrence-generate.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: parse / format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a simplified RRULE string into a `RecurrenceRule`.
|
||||
*
|
||||
* Supported fields: `FREQ`, `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY`, `BYMONTHDAY`.
|
||||
*
|
||||
* @example `"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;COUNT=10"`
|
||||
*/
|
||||
export function parseRRule(input: string): RecurrenceRule {
|
||||
const parts = input.trim().split(";");
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
map[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1);
|
||||
}
|
||||
|
||||
const freqRaw = map["FREQ"];
|
||||
if (freqRaw === undefined) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input, reason: "FREQ is required" } }
|
||||
);
|
||||
}
|
||||
const freq = FREQ_MAP[freqRaw.toUpperCase()];
|
||||
if (freq === undefined) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input, reason: `unknown FREQ: ${freqRaw}` } }
|
||||
);
|
||||
}
|
||||
|
||||
const rule: RecurrenceRule = { freq };
|
||||
|
||||
if (map["INTERVAL"] !== undefined) {
|
||||
rule.interval = Number.parseInt(map["INTERVAL"], 10);
|
||||
}
|
||||
if (map["COUNT"] !== undefined) {
|
||||
rule.count = Number.parseInt(map["COUNT"], 10);
|
||||
}
|
||||
if (map["UNTIL"] !== undefined) {
|
||||
rule.until = fromRRuleDate(map["UNTIL"]);
|
||||
}
|
||||
if (map["BYDAY"] !== undefined) {
|
||||
rule.byDay = map["BYDAY"]
|
||||
.split(",")
|
||||
.map((s) => {
|
||||
const key = s.trim().toUpperCase().slice(-2);
|
||||
return DAY_INDEX[key] as Weekday;
|
||||
})
|
||||
.filter((d) => d !== undefined);
|
||||
}
|
||||
if (map["BYMONTHDAY"] !== undefined) {
|
||||
rule.byMonthDay = map["BYMONTHDAY"]
|
||||
.split(",")
|
||||
.map((s) => Number.parseInt(s.trim(), 10));
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a `RecurrenceRule` back into an RRULE string.
|
||||
*
|
||||
* The output can be round-tripped through `parseRRule`.
|
||||
*/
|
||||
export function formatRRule(rule: RecurrenceRule): string {
|
||||
const parts: string[] = [`FREQ=${FREQ_RRULE[rule.freq]}`];
|
||||
if (rule.interval !== undefined) parts.push(`INTERVAL=${rule.interval}`);
|
||||
if (rule.count !== undefined) parts.push(`COUNT=${rule.count}`);
|
||||
if (rule.until !== undefined)
|
||||
parts.push(`UNTIL=${toRRuleDate(rule.until)}`);
|
||||
if (rule.byDay !== undefined && rule.byDay.length > 0) {
|
||||
parts.push(`BYDAY=${rule.byDay.map((d) => DAY_NAME[d]).join(",")}`);
|
||||
}
|
||||
if (rule.byMonthDay !== undefined && rule.byMonthDay.length > 0) {
|
||||
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`);
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: next occurrence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the next occurrence of `rule` strictly after `from`, or `null` when
|
||||
* the rule terminates before generating another occurrence.
|
||||
*/
|
||||
export function nextOccurrence(
|
||||
rule: RecurrenceRule,
|
||||
from: DateInput
|
||||
): Date | null {
|
||||
const fromDate = toDate(from);
|
||||
const candidate = generateCandidates(rule, fromDate).next().value as Date;
|
||||
if (rule.until !== undefined && candidate > rule.until) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: full expansion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expand all occurrences of `rule`.
|
||||
*
|
||||
* Requires exactly one of `rule.count` or `rule.until`; throws
|
||||
* `DatetimeError(DATETIME.RECURRENCE.INFINITE_EXPANSION)` when neither is set.
|
||||
*
|
||||
* Checks `opts.signal` every iteration for cooperative cancellation.
|
||||
*/
|
||||
export function expandRecurrence(
|
||||
rule: RecurrenceRule,
|
||||
opts?: RecurrenceOptions
|
||||
): Date[] {
|
||||
if (rule.count === undefined && rule.until === undefined) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["RECURRENCE.INFINITE_EXPANSION"],
|
||||
"datetime.recurrence.infinite_expansion",
|
||||
{ context: { rule: formatRRule(rule) } }
|
||||
);
|
||||
}
|
||||
|
||||
const signal = opts?.signal;
|
||||
const results: Date[] = [];
|
||||
const start = new Date((opts?.clock ?? (() => Date.now()))());
|
||||
|
||||
const t0 = Date.now();
|
||||
let generated = 0;
|
||||
|
||||
for (const candidate of generateCandidates(rule, start)) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
if (rule.until !== undefined && candidate > rule.until) break;
|
||||
|
||||
results.push(candidate);
|
||||
generated += 1;
|
||||
|
||||
if (rule.count !== undefined && generated >= rule.count) break;
|
||||
}
|
||||
|
||||
recordExpandRecurrence(Date.now() - t0);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { diffInUnits, formatRelative, timeAgo, timeUntil } from "./relative";
|
||||
84
packages/foundation/timing/datetime/src/relative/relative.md
Normal file
84
packages/foundation/timing/datetime/src/relative/relative.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# relative
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `relative` module provides human-readable relative-time formatting for `@suaveplan/datetime`. It converts the distance between two moments into concise English phrases like `"3 days ago"`, `"in 2 hours"`, or `"just now"`. The `clock` injection point makes all functions deterministic in tests, while the unified `formatRelative` entry point subsumes the common `timeAgo` / `timeUntil` aliases.
|
||||
|
||||
The module also exposes `diffInUnits`, a lower-level utility that returns the signed numeric difference between two `DateInput` values in any `DurationUnit`. This is useful when callers need the raw integer delta rather than a formatted string.
|
||||
|
||||
## Features
|
||||
|
||||
- `diffInUnits` — signed numeric difference in any `DurationUnit`
|
||||
- `formatRelative` — threshold-based English string (past and future)
|
||||
- `timeAgo` — convenience alias for past-relative formatting
|
||||
- `timeUntil` — convenience alias for future-relative formatting
|
||||
- Pluggable `Clock` for deterministic rendering in tests
|
||||
- `"just now"` threshold for differences under 45 seconds
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { timeAgo, timeUntil } from "@suaveplan/datetime/relative";
|
||||
|
||||
// Using wall clock
|
||||
console.log(timeAgo(new Date(Date.now() - 3 * 60_000))); // "3 minutes ago"
|
||||
console.log(timeUntil(new Date(Date.now() + 2 * 3600_000))); // "in 2 hours"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { diffInUnits, formatRelative } from "@suaveplan/datetime/relative";
|
||||
|
||||
// Deterministic with a fake clock
|
||||
const clock = () => new Date("2024-06-01T12:00:00Z").getTime();
|
||||
|
||||
const pastDate = "2024-05-28T12:00:00Z";
|
||||
console.log(formatRelative(pastDate, { clock })); // "4 days ago"
|
||||
console.log(diffInUnits(pastDate, "2024-06-01T12:00:00Z", "days")); // 4
|
||||
|
||||
// Sub-unit precision
|
||||
const oneHourAgo = new Date(clock() - 3723_000); // 1h 2m 3s ago
|
||||
console.log(formatRelative(oneHourAgo, { clock })); // "1 hour ago"
|
||||
console.log(diffInUnits(oneHourAgo, new Date(clock()), "seconds")); // 3723
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `diffInUnits(a: DateInput, b: DateInput, unit: DurationUnit): number`
|
||||
|
||||
Returns `floor((b − a) / unitMs)` truncated toward zero. Positive when `b > a`.
|
||||
|
||||
- `a` — start date (any `DateInput`)
|
||||
- `b` — end date (any `DateInput`)
|
||||
- `unit` — one of the `DurationUnit` values
|
||||
|
||||
### `formatRelative(date: DateInput, opts?: RelativeTimeOptions): string`
|
||||
|
||||
Format the difference between `date` and `opts.clock()` (default: `Date.now()`).
|
||||
|
||||
- Distances under 45 seconds → `"just now"`
|
||||
- Distances under 45 minutes → `"N seconds ago"` / `"in N seconds"`
|
||||
- Distances under 22 hours → `"N minutes ago"` / `"in N minutes"`
|
||||
- Distances under 26 days → `"N hours ago"` / `"in N hours"`
|
||||
- Distances under 11 months → `"N days ago"` / `"in N days"`
|
||||
- Otherwise → `"N months ago"` / `"in N months"` or `"N years ago"` / `"in N years"`
|
||||
|
||||
### `timeAgo(date: DateInput, opts?: RelativeTimeOptions): string`
|
||||
|
||||
Alias for `formatRelative`. Intended for past dates, but works symmetrically.
|
||||
|
||||
### `timeUntil(date: DateInput, opts?: RelativeTimeOptions): string`
|
||||
|
||||
Alias for `formatRelative`. Intended for future dates, but works symmetrically.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Average month (30.4375 days) and average year (365.25 days) constants are used for the coarse `months` / `years` thresholds. These are approximations intentionally — precise calendar arithmetic for display strings would add complexity without improving user experience. Use `diffInUnits("months")` together with calendar arithmetic when you need exact month counts.
|
||||
|
||||
The threshold table mirrors the conventions used by popular libraries like `date-fns` and `moment.js` so that migrating code produces the same user-visible strings.
|
||||
|
||||
The `precision` field in `RelativeTimeOptions` is reserved for future use to cap the label at a specific unit (e.g. only show up to "days", never "hours"). Currently it is accepted but not applied in the implementation.
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { diffInUnits, formatRelative, timeAgo, timeUntil } from "./relative.js";
|
||||
|
||||
const clock = (isoDate: string) => () => new Date(isoDate).getTime();
|
||||
|
||||
describe("diffInUnits", () => {
|
||||
it("milliseconds", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
diffInUnits(
|
||||
"2024-01-01T00:00:00.000Z",
|
||||
"2024-01-01T00:00:01.000Z",
|
||||
"milliseconds"
|
||||
)
|
||||
).toBe(1000);
|
||||
});
|
||||
|
||||
it("seconds", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
diffInUnits(
|
||||
"2024-01-01T00:00:00Z",
|
||||
"2024-01-01T00:01:00Z",
|
||||
"seconds"
|
||||
)
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
it("minutes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
diffInUnits(
|
||||
"2024-01-01T00:00:00Z",
|
||||
"2024-01-01T01:00:00Z",
|
||||
"minutes"
|
||||
)
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
it("hours", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
diffInUnits("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "hours")
|
||||
).toBe(24);
|
||||
});
|
||||
|
||||
it("days", { timeout: 5000 }, () => {
|
||||
expect(diffInUnits("2024-01-01Z", "2024-01-08Z", "days")).toBe(7);
|
||||
});
|
||||
|
||||
it("weeks", { timeout: 5000 }, () => {
|
||||
expect(diffInUnits("2024-01-01Z", "2024-01-15Z", "weeks")).toBe(2);
|
||||
});
|
||||
|
||||
it("months (approximate)", { timeout: 5000 }, () => {
|
||||
// 365 days ≈ 12 months
|
||||
const diff = diffInUnits("2024-01-01Z", "2025-01-01Z", "months");
|
||||
expect(diff).toBeGreaterThanOrEqual(11);
|
||||
expect(diff).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it("years (approximate)", { timeout: 5000 }, () => {
|
||||
const diff = diffInUnits("2024-01-01Z", "2026-01-01Z", "years");
|
||||
// 2 years is approximately 730 days / 365.25 = 1.998... truncated = 1
|
||||
// but actual ms is 2 * 365.25 * 86400000 which is exactly 2 years
|
||||
expect(diff).toBeGreaterThanOrEqual(1);
|
||||
expect(diff).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("negative when b < a", { timeout: 5000 }, () => {
|
||||
expect(diffInUnits("2024-01-08Z", "2024-01-01Z", "days")).toBe(-7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelative", () => {
|
||||
it("'just now' for < 45s difference", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 30_000); // 30s ago
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("just now");
|
||||
});
|
||||
|
||||
it("future 'just now' for < 45s", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 30_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("just now");
|
||||
});
|
||||
|
||||
it("'N seconds ago'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 120_000); // 2 min ago
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("2 minutes ago");
|
||||
});
|
||||
|
||||
it("'in N seconds'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 90_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("in 2 minutes");
|
||||
});
|
||||
|
||||
it("'N hours ago'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 3 * 3_600_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("3 hours ago");
|
||||
});
|
||||
|
||||
it("'in N hours'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 5 * 3_600_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("in 5 hours");
|
||||
});
|
||||
|
||||
it("'N days ago'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 4 * 86_400_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("4 days ago");
|
||||
});
|
||||
|
||||
it("'in N days'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 10 * 86_400_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("in 10 days");
|
||||
});
|
||||
|
||||
it("'N months ago'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 60 * 86_400_000); // ~2 months
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("2 months ago");
|
||||
});
|
||||
|
||||
it("'in N months'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 90 * 86_400_000); // ~3 months
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("in 3 months");
|
||||
});
|
||||
|
||||
it("'N years ago'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 400 * 86_400_000); // > 11 months
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("1 year ago");
|
||||
});
|
||||
|
||||
it("'in N years'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 800 * 86_400_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("in 2 years");
|
||||
});
|
||||
|
||||
it("singular 'minute'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 60_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("1 minute ago");
|
||||
});
|
||||
|
||||
it("singular 'hour'", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 3_600_000);
|
||||
expect(formatRelative(d, { clock: clock(now) })).toBe("1 hour ago");
|
||||
});
|
||||
|
||||
it("uses wall clock when no clock provided", { timeout: 5000 }, () => {
|
||||
const d = new Date(Date.now() - 5000);
|
||||
const result = formatRelative(d);
|
||||
expect(result).toBe("just now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeAgo", () => {
|
||||
it("delegates to formatRelative", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() - 4 * 86_400_000);
|
||||
expect(timeAgo(d, { clock: clock(now) })).toBe(
|
||||
formatRelative(d, { clock: clock(now) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeUntil", () => {
|
||||
it("delegates to formatRelative", { timeout: 5000 }, () => {
|
||||
const now = "2024-06-01T12:00:00.000Z";
|
||||
const d = new Date(new Date(now).getTime() + 4 * 86_400_000);
|
||||
expect(timeUntil(d, { clock: clock(now) })).toBe(
|
||||
formatRelative(d, { clock: clock(now) })
|
||||
);
|
||||
});
|
||||
});
|
||||
146
packages/foundation/timing/datetime/src/relative/relative.ts
Normal file
146
packages/foundation/timing/datetime/src/relative/relative.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { toDate } from "../calendar/calendar.js";
|
||||
import type {
|
||||
DateInput,
|
||||
DurationUnit,
|
||||
RelativeTimeOptions,
|
||||
} from "../types/datetime.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS = 1;
|
||||
const SECOND = 1000 * MS;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30.4375 * DAY; // average month in ms
|
||||
const YEAR = 365.25 * DAY; // average year in ms
|
||||
|
||||
function nowMs(opts?: RelativeTimeOptions): number {
|
||||
return opts?.clock !== undefined ? opts.clock() : Date.now();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: diffInUnits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the signed difference between `a` and `b` in the given `unit`.
|
||||
*
|
||||
* Result is truncated toward zero (not rounded). Positive means `b > a`.
|
||||
*/
|
||||
export function diffInUnits(
|
||||
a: DateInput,
|
||||
b: DateInput,
|
||||
unit: DurationUnit
|
||||
): number {
|
||||
const da = toDate(a).getTime();
|
||||
const db = toDate(b).getTime();
|
||||
const diffMs = db - da;
|
||||
|
||||
switch (unit) {
|
||||
case "milliseconds":
|
||||
return diffMs;
|
||||
case "seconds":
|
||||
return Math.trunc(diffMs / SECOND);
|
||||
case "minutes":
|
||||
return Math.trunc(diffMs / MINUTE);
|
||||
case "hours":
|
||||
return Math.trunc(diffMs / HOUR);
|
||||
case "days":
|
||||
return Math.trunc(diffMs / DAY);
|
||||
case "weeks":
|
||||
return Math.trunc(diffMs / WEEK);
|
||||
case "months":
|
||||
return Math.trunc(diffMs / MONTH);
|
||||
case "years":
|
||||
return Math.trunc(diffMs / YEAR);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: threshold-based label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Thresholds in ascending order. The last entry captures all remaining values. */
|
||||
const STEPS: Array<{
|
||||
maxMs: number;
|
||||
singular: string;
|
||||
plural: string;
|
||||
divisor: number;
|
||||
}> = [
|
||||
{
|
||||
maxMs: 45 * SECOND,
|
||||
singular: "second",
|
||||
plural: "seconds",
|
||||
divisor: SECOND,
|
||||
},
|
||||
{
|
||||
maxMs: 45 * MINUTE,
|
||||
singular: "minute",
|
||||
plural: "minutes",
|
||||
divisor: MINUTE,
|
||||
},
|
||||
{ maxMs: 22 * HOUR, singular: "hour", plural: "hours", divisor: HOUR },
|
||||
{ maxMs: 26 * DAY, singular: "day", plural: "days", divisor: DAY },
|
||||
{ maxMs: 11 * MONTH, singular: "month", plural: "months", divisor: MONTH },
|
||||
];
|
||||
|
||||
function buildRelativeLabel(diffMs: number, _precision?: DurationUnit): string {
|
||||
const abs = Math.abs(diffMs);
|
||||
const past = diffMs < 0;
|
||||
|
||||
if (abs < 45 * SECOND) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
for (const { maxMs, singular, plural, divisor } of STEPS) {
|
||||
if (abs < maxMs) {
|
||||
const count = Math.round(abs / divisor);
|
||||
const unit = count === 1 ? singular : plural;
|
||||
return past ? `${count} ${unit} ago` : `in ${count} ${unit}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Anything beyond 11 months → years
|
||||
const count = Math.round(abs / YEAR);
|
||||
const unit = count === 1 ? "year" : "years";
|
||||
return past ? `${count} ${unit} ago` : `in ${count} ${unit}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: formatRelative
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format the difference between `date` and the current time (from `opts.clock`)
|
||||
* as a human-readable relative string such as `"3 days ago"` or `"in 2 hours"`.
|
||||
*/
|
||||
export function formatRelative(
|
||||
date: DateInput,
|
||||
opts?: RelativeTimeOptions
|
||||
): string {
|
||||
const now = nowMs(opts);
|
||||
const then = toDate(date).getTime();
|
||||
return buildRelativeLabel(then - now, opts?.precision);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience alias: format `date` as a past relative string.
|
||||
*
|
||||
* Equivalent to `formatRelative` when `date` is in the past.
|
||||
*/
|
||||
export function timeAgo(date: DateInput, opts?: RelativeTimeOptions): string {
|
||||
return formatRelative(date, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience alias: format `date` as a future relative string.
|
||||
*
|
||||
* Equivalent to `formatRelative` when `date` is in the future.
|
||||
*/
|
||||
export function timeUntil(date: DateInput, opts?: RelativeTimeOptions): string {
|
||||
return formatRelative(date, opts);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Zod schema for `DateInput` — accepts Date, ISO string, or epoch millis. */
|
||||
export const DateInputSchema = z.union([z.date(), z.string(), z.number()]);
|
||||
|
||||
/** Zod schema for `DurationUnit`. */
|
||||
export const DurationUnitSchema = z.enum([
|
||||
"years",
|
||||
"months",
|
||||
"weeks",
|
||||
"days",
|
||||
"hours",
|
||||
"minutes",
|
||||
"seconds",
|
||||
"milliseconds",
|
||||
]);
|
||||
|
||||
/** Zod schema for `Duration`. */
|
||||
export const DurationSchema = z.object({
|
||||
years: z.number().optional(),
|
||||
months: z.number().optional(),
|
||||
weeks: z.number().optional(),
|
||||
days: z.number().optional(),
|
||||
hours: z.number().optional(),
|
||||
minutes: z.number().optional(),
|
||||
seconds: z.number().optional(),
|
||||
milliseconds: z.number().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `Weekday` (0–6). */
|
||||
export const WeekdaySchema = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal(2),
|
||||
z.literal(3),
|
||||
z.literal(4),
|
||||
z.literal(5),
|
||||
z.literal(6),
|
||||
]);
|
||||
|
||||
/** Zod schema for `Frequency`. */
|
||||
export const FrequencySchema = z.enum(["daily", "weekly", "monthly", "yearly"]);
|
||||
|
||||
/** Zod schema for `CalendarDay`. */
|
||||
export const CalendarDaySchema = z.object({
|
||||
date: z.date(),
|
||||
dayOfWeek: WeekdaySchema,
|
||||
isWeekend: z.boolean(),
|
||||
isToday: z.boolean(),
|
||||
});
|
||||
|
||||
/** Zod schema for `CalendarMonth`. */
|
||||
export const CalendarMonthSchema = z.object({
|
||||
year: z.number().int(),
|
||||
month: z.number().int().min(1).max(12),
|
||||
days: z.array(CalendarDaySchema),
|
||||
});
|
||||
|
||||
/** Zod schema for `RecurrenceRule`. */
|
||||
export const RecurrenceRuleSchema = z.object({
|
||||
freq: FrequencySchema,
|
||||
interval: z.number().int().positive().optional(),
|
||||
count: z.number().int().positive().optional(),
|
||||
until: z.date().optional(),
|
||||
byDay: z.array(WeekdaySchema).optional(),
|
||||
byMonthDay: z.array(z.number().int().min(1).max(31)).optional(),
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
# datetime.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the `@suaveplan/datetime` package. This module is the single source of truth for the shapes that flow through business-day arithmetic, calendar math, recurrence expansion, and relative-time formatting. Co-locating these definitions in one flat file keeps the feature modules focused on behaviour and keeps cross-module contracts obvious at a glance.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime cost — all exports are pure TypeScript types
|
||||
- Branded `Timezone` string for IANA names that prevents accidental raw-string injection
|
||||
- `DurationUnit` union aligned with the full `years | months | weeks | days | hours | minutes | seconds | milliseconds` vocabulary used across the library
|
||||
- `BusinessDayOptions` and `RelativeTimeOptions` carry a pluggable `Clock` from `@suaveplan/types` so every consumer can be driven by a deterministic fake in tests
|
||||
- `RecurrenceRule` subset aligned with the iCalendar/RRULE vocabulary (`freq`, `interval`, `count`, `until`, `byDay`, `byMonthDay`) without the full RFC 5545 complexity
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Duration, Frequency, Weekday } from "@suaveplan/datetime/types/datetime.types";
|
||||
|
||||
const oneWeek: Duration = { days: 7 };
|
||||
const monday: Weekday = 1;
|
||||
const rule: Frequency = "weekly";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BusinessDayOptions,
|
||||
RecurrenceRule,
|
||||
Timezone,
|
||||
} from "@suaveplan/datetime/types/datetime.types";
|
||||
|
||||
const businessOptions: BusinessDayOptions = {
|
||||
holidays: [new Date("2026-01-01")],
|
||||
weekendDays: [0, 6],
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
};
|
||||
|
||||
const weeklyStandup: RecurrenceRule = {
|
||||
freq: "weekly",
|
||||
interval: 1,
|
||||
byDay: [1, 3, 5],
|
||||
count: 52,
|
||||
};
|
||||
|
||||
const nycTz = "America/New_York" as Timezone;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DateInput` — union of `Date | string | number`; accepted by parser helpers.
|
||||
- `DurationUnit` — named calendar/time units.
|
||||
- `Duration` — partial record of `DurationUnit → number`.
|
||||
- `Weekday` — numeric index `0..6` matching `Date.prototype.getDay()`.
|
||||
- `DayAbbreviation` — three-letter English abbreviations.
|
||||
- `Frequency` — `"daily" | "weekly" | "monthly" | "yearly"` recurrence cadence.
|
||||
- `Timezone` — branded IANA-name string.
|
||||
- `CalendarDay` / `CalendarMonth` — grid shapes returned by `getCalendarMonth`.
|
||||
- `BusinessDayOptions` — options bundle for business-day arithmetic with optional holidays, custom weekend days, clock injection, and `AbortSignal`.
|
||||
- `RelativeTimeOptions` — locale, clock, and precision knobs for `formatRelativeTime`.
|
||||
- `RecurrenceRule` — RRULE-like shape consumed by `expandRecurrence`.
|
||||
- `RecurrenceOptions` — signal + clock bundle for the expansion loop.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces deliberately avoid class-based modelling so they can round-trip through JSON and be used as wire-format types by downstream services. The `Timezone` brand is enforced only at compile time; runtime validation lives in `asTimezone()` in the calendar module, which checks against `Intl.supportedValuesOf('timeZone')`. Consumers should never cast a raw `string` to `Timezone` — always construct via the validator.
|
||||
|
||||
The `Clock` and `Rng` injections come from `@suaveplan/types` (the types-only foundation package) rather than being redefined here, which prevents structural drift between `datetime`, `timing`, `retry`, and every other package that takes a clock.
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
/** Any value that can be coerced into a `Date`. */
|
||||
export type DateInput = Date | string | number;
|
||||
|
||||
/** Time unit names used throughout the library. */
|
||||
export type DurationUnit =
|
||||
| "years"
|
||||
| "months"
|
||||
| "weeks"
|
||||
| "days"
|
||||
| "hours"
|
||||
| "minutes"
|
||||
| "seconds"
|
||||
| "milliseconds";
|
||||
|
||||
/** A bag of duration components; all fields optional, all non-negative by convention. */
|
||||
export type Duration = Partial<Record<DurationUnit, number>>;
|
||||
|
||||
/** Day-of-week numeric index: 0 = Sunday … 6 = Saturday (matches `Date.prototype.getDay()`). */
|
||||
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/** Short day abbreviation. */
|
||||
export type DayAbbreviation =
|
||||
| "Sun"
|
||||
| "Mon"
|
||||
| "Tue"
|
||||
| "Wed"
|
||||
| "Thu"
|
||||
| "Fri"
|
||||
| "Sat";
|
||||
|
||||
/** Recurrence frequency. */
|
||||
export type Frequency = "daily" | "weekly" | "monthly" | "yearly";
|
||||
|
||||
/**
|
||||
* Branded string representing an IANA timezone name (e.g. `"America/New_York"`).
|
||||
* Use `asTimezone()` from the calendar module to construct safely.
|
||||
*/
|
||||
export type Timezone = string & { readonly __brand: "Timezone" };
|
||||
|
||||
/** A single day entry inside a calendar grid. */
|
||||
export interface CalendarDay {
|
||||
date: Date;
|
||||
dayOfWeek: Weekday;
|
||||
isWeekend: boolean;
|
||||
isToday: boolean;
|
||||
}
|
||||
|
||||
/** A full calendar month grid. */
|
||||
export interface CalendarMonth {
|
||||
year: number;
|
||||
month: number;
|
||||
days: CalendarDay[];
|
||||
}
|
||||
|
||||
/** Options shared by business-day functions. */
|
||||
export interface BusinessDayOptions {
|
||||
holidays?: Date[];
|
||||
weekendDays?: Weekday[];
|
||||
clock?: Clock;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Options for relative-time formatting functions. */
|
||||
export interface RelativeTimeOptions {
|
||||
locale?: string;
|
||||
clock?: Clock;
|
||||
precision?: DurationUnit;
|
||||
}
|
||||
|
||||
/** An iCalendar-like recurrence rule. */
|
||||
export interface RecurrenceRule {
|
||||
freq: Frequency;
|
||||
interval?: number;
|
||||
count?: number;
|
||||
until?: Date;
|
||||
byDay?: Weekday[];
|
||||
byMonthDay?: number[];
|
||||
}
|
||||
|
||||
/** Options for recurrence expansion. */
|
||||
export interface RecurrenceOptions {
|
||||
signal?: AbortSignal;
|
||||
clock?: Clock;
|
||||
}
|
||||
25
packages/foundation/timing/datetime/src/types/index.ts
Normal file
25
packages/foundation/timing/datetime/src/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
CalendarDaySchema,
|
||||
CalendarMonthSchema,
|
||||
DateInputSchema,
|
||||
DurationSchema,
|
||||
DurationUnitSchema,
|
||||
FrequencySchema,
|
||||
RecurrenceRuleSchema,
|
||||
WeekdaySchema,
|
||||
} from "./datetime.schemas";
|
||||
export type {
|
||||
BusinessDayOptions,
|
||||
CalendarDay,
|
||||
CalendarMonth,
|
||||
DateInput,
|
||||
DayAbbreviation,
|
||||
Duration,
|
||||
DurationUnit,
|
||||
Frequency,
|
||||
RecurrenceOptions,
|
||||
RecurrenceRule,
|
||||
RelativeTimeOptions,
|
||||
Timezone,
|
||||
Weekday,
|
||||
} from "./datetime.types";
|
||||
40
packages/foundation/timing/datetime/tests/dep-graph.test.ts
Normal file
40
packages/foundation/timing/datetime/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
// Ban Date.now() at module scope (bare call outside a function body)
|
||||
/^(?!.*(?:function|=>|\bconst\b|\blet\b|\breturn\b|\bexport\b.*\bfunction\b)).*\bDate\.now\(\)/m,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Only check the first 5 patterns (node/browser built-ins)
|
||||
// The Date.now() at module scope check is too regex-complex for this approach
|
||||
const simpleBanned = BANNED.slice(0, 7);
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of simpleBanned) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user