Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
# @suaveplan/i18n-formatter-date - Intl.DateTimeFormat wrapper for the @suaveplan/i18n family — 8-preset table (short/medium/long/full/time-short/time-long/date-short/date-long), formatDate / formatTime / formatDateOnly / formatDateRange / getDayNames / getMonthNames. Per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Universal leaf package.
## Summary
Wrapper around `Intl.DateTimeFormat` for the `@suaveplan/i18n` family
— 8-preset table (short/medium/long/full × date+time/time-only/date-only),
range rendering, day/month name lists, and a per-(locale,
options-hash) cache.
<!-- begin:auto-docs -->
## Documentation tree
- [day-month-names](./src/day-month-names/day-month-names.md) — `getDayNames(locale, style?)` and `getMonthNames(locale, style?)` return the locale-correct list of weekday or month na…
- [day-month-names](./src/day-month-names/day-month-names.md) — `getDayNames(locale, style?)` and `getMonthNames(locale, style?)` return the locale-correct list of weekday or month na…
- [formatter](./src/formatter/formatter.md) — `DateFormatter` and the `formatDate` / `formatDateRange` standalone helpers expose `Intl.DateTimeFormat` for the `@suav…
- [formatter](./src/formatter/formatter.md) — `DateFormatter` and the `formatDate` / `formatDateRange` standalone helpers expose `Intl.DateTimeFormat` for the `@suav…
- [helpers](./src/helpers/helpers.md) — `formatTime` and `formatDateOnly` pre-bind `formatDate` with the appropriate preset for time-only or date-only renderin…
- [helpers](./src/helpers/helpers.md) — `formatTime` and `formatDateOnly` pre-bind `formatDate` with the appropriate preset for time-only or date-only renderin…
- [observability](./src/observability/observability.md) — The Date formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests can…
- [observability](./src/observability/observability.md) — The Date formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests can…
- [presets](./src/presets/presets.md) — `PRESET_MAP` is the canonical 8-entry table that maps each `DateFormatPreset` discriminant to a concrete `Intl.DateTime…
- [presets](./src/presets/presets.md) — `PRESET_MAP` is the canonical 8-entry table that maps each `DateFormatPreset` discriminant to a concrete `Intl.DateTime…
<!-- end:auto-docs -->
## Why this exists
The 14-package i18n modular split (cross-cutting design
`2026-05-05-i18n-modular-split.md`) lifts each `Intl.*` wrapper out
of the monolith into its own sibling package. Lifting the date
helper out of `@suaveplan/i18n` lets consumers that only need
date/time rendering (most UI consumers) pull a single small package
instead of the entire i18n core.
The package adds the canonical Wave-3 formatter shape on top of the
monolith's standalone helpers: Zod-validated options, a per-(locale,
options-hash) cache, OTel histogram + cache-hit counter, and the
`DateFormatter` class wrapper for symmetry with `NumberFormatter` /
`RelativeTimeFormatter` / `ListFormatter` / `DisplayNameFormatter`.
## Install
```bash
bun add @suaveplan/i18n-formatter-date
```
## Quick Start
```ts
import {
DateFormatter,
formatDate,
formatDateOnly,
formatDateRange,
formatTime,
getDayNames,
getMonthNames,
} from "@suaveplan/i18n-formatter-date";
const now = new Date(2026, 0, 1, 14, 30, 45);
formatDate(now, "en-GB", { preset: "short" }); // "01/01/2026, 14:30"
formatTime(now, "en-GB", true); // "14:30:45"
formatDateOnly(now, "en-GB", true); // "1 January 2026"
formatDateRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB",
{ day: "numeric", month: "long" }
); // "1 – 5 January"
getDayNames("en-GB", "short");
// ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const f = new DateFormatter({ preset: "long", timeZone: "Europe/London" });
f.format(now, "en-GB");
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-date` | Root barrel — class + standalones + helpers + presets + day/month names |
| `@suaveplan/i18n-formatter-date/formatter` | `DateFormatter`, `formatDate`, `formatDateRange`, `toBcp47`, types, schema |
| `@suaveplan/i18n-formatter-date/helpers` | `formatTime`, `formatDateOnly` |
| `@suaveplan/i18n-formatter-date/presets` | `PRESET_MAP`, `DATE_FORMAT_PRESETS`, `resolvePreset` |
| `@suaveplan/i18n-formatter-date/day-month-names` | `getDayNames`, `getMonthNames` |
## Modules Documentation
- [formatter.md](src/formatter.md) — `DateFormatter`, `formatDate`, `formatDateRange`, `toBcp47`, `clearDateFormatterCache`
- [helpers.md](src/helpers.md) — `formatTime`, `formatDateOnly`
- [presets.md](src/presets.md) — 8-preset table + `resolvePreset`
- [day-month-names.md](src/day-month-names.md) — `getDayNames`, `getMonthNames`
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `DateFormatter` | class | `formatter.ts` |
| `formatDate` | function | `formatter.ts` |
| `formatDateRange` | function | `formatter.ts` |
| `toBcp47` | function | `formatter.ts` |
| `clearDateFormatterCache` | function | `formatter.ts` |
| `DateFormatterOptionsSchema` | Zod schema | `formatter.schemas.ts` |
| `DateFormatPreset` | type | `formatter.types.ts` |
| `DateFormatOptions` | type | `formatter.types.ts` |
| `ExtendedDateOptions` | type | `formatter.types.ts` |
| `DateFormatterCacheKey` | type | `formatter.types.ts` |
| `formatTime` | function | `helpers.ts` |
| `formatDateOnly` | function | `helpers.ts` |
| `PRESET_MAP` | const | `presets.ts` |
| `DATE_FORMAT_PRESETS` | const | `presets.ts` |
| `resolvePreset` | function | `presets.ts` |
| `PresetTable` | type | `presets.types.ts` |
| `getDayNames` | function | `day-month-names.ts` |
| `getMonthNames` | function | `day-month-names.ts` |
| `recordDateFormat` | function | `observability.ts` |
| `recordDateCacheHit` | function | `observability.ts` |
| `getDateMetricsState` | function | `observability.ts` |
| `resetDateMetricsState` | function | `observability.ts` |
## Versioning
Part of the 14-package i18n modular-split rollout. Stays at `0.1.0`
until the slim core (Wave 6) cuts the family `1.0.0` release.

View File

@@ -0,0 +1,88 @@
{
"name": "@suaveplan/i18n-formatter-date",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Intl.DateTimeFormat wrapper for the @suaveplan/i18n family — 8-preset table (short/medium/long/full/time-short/time-long/date-short/date-long), formatDate / formatTime / formatDateOnly / formatDateRange / getDayNames / getMonthNames. Per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Universal leaf package.",
"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"
}
},
"./formatter": {
"import": {
"types": "./dist/formatter/index.d.ts",
"default": "./dist/formatter/index.js"
}
},
"./helpers": {
"import": {
"types": "./dist/helpers/index.d.ts",
"default": "./dist/helpers/index.js"
}
},
"./presets": {
"import": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
}
},
"./day-month-names": {
"import": {
"types": "./dist/day-month-names/index.d.ts",
"default": "./dist/day-month-names/index.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/i18n-shared": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "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"
]
}

View File

@@ -0,0 +1,112 @@
# Day / Month names
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`getDayNames(locale, style?)` and `getMonthNames(locale, style?)`
return the locale-correct list of weekday or month names. They are
the canonical primitives for day-picker / calendar / date-picker UI
components — every locale wants a different starting weekday and a
different rendering style.
`getDayNames` consults `Intl.Locale.getWeekInfo()` (ECMA-402 §10.6) to
discover the locale-defined first day of the week (1 = Monday … 7 =
Sunday). When the host runtime lacks `getWeekInfo` (older Node, older
browsers), the function falls back to Monday-start.
## Features
- `getDayNames(locale, style?)` returns 7 strings starting from the locale's first weekday
- `getMonthNames(locale, style?)` returns 12 strings
- Both accept `"long"` (default), `"short"`, or `"narrow"` style
- BCP-47 normalisation through `LOCALE_BCP47_MAP`
- `getDayNames` falls back to Monday-start when `getWeekInfo` is unavailable or throws
- Pure functions — no shared state, no cache
## Usage: Basic Example
```ts
import {
getDayNames,
getMonthNames,
} from "@suaveplan/i18n-formatter-date";
getDayNames("en-GB");
// ["Monday", "Tuesday", ..., "Sunday"]
getDayNames("en-US", "short");
// ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
getMonthNames("en-GB");
// ["January", "February", ..., "December"]
getMonthNames("fr-FR", "narrow");
// ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]
```
## Usage: Advanced Example
```ts
import {
getDayNames,
getMonthNames,
} from "@suaveplan/i18n-formatter-date";
// Build a date-picker header that respects the user's locale.
function renderCalendarHeader(locale: string) {
const days = getDayNames(locale, "narrow");
const months = getMonthNames(locale, "long");
return { days, months };
}
const header = renderCalendarHeader("ja-JP");
// header.days[0] is the locale-correct first weekday name
// header.months is the 12 long Japanese month names
// Stub Intl.Locale.getWeekInfo to test the Monday-start fallback.
const Original = Intl.Locale;
Intl.Locale = function () {
return { getWeekInfo: undefined } as unknown as Intl.Locale;
} as unknown as typeof Intl.Locale;
try {
const days = getDayNames("en-US"); // falls back to Monday-start
console.log(days[0]); // "Monday"
} finally {
Intl.Locale = Original;
}
```
## API Reference
### `getDayNames(locale, style?): string[]`
- `locale` — BCP-47 locale tag.
- `style` — `"long"` (default), `"short"`, or `"narrow"`.
- Returns 7 strings starting from the locale-defined first weekday.
- Anchors on Monday 2024-01-01 then offsets by `(firstDay - 1)` so
index 0 is the locale's first weekday.
### `getMonthNames(locale, style?): string[]`
- `locale` — BCP-47 locale tag.
- `style` — `"long"` (default), `"short"`, or `"narrow"`.
- Returns 12 strings, one per month.
## Implementation Notes
- The `getWeekInfo()` probe is wrapped in `try/catch` because (a)
older runtimes don't expose the method at all (the access throws
`TypeError`), and (b) some older proposals exposed `weekInfo` as a
property — neither path is hit on current Node/Bun/browser, but the
fallback keeps the function safe across the entire range.
- The constants are computed by iterating dates through
`Intl.DateTimeFormat`, not by hard-coding day/month strings —
that's what makes the helpers correctly return localised names for
every BCP-47 tag without a translation table.
- These helpers do NOT participate in the formatter cache because
they construct ad-hoc `Intl.DateTimeFormat` instances per call.
The cost is negligible (single-digit microseconds) and avoids
mixing weekday-style cache entries with the main formatter cache.

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getDayNames, getMonthNames } from "./day-month-names.js";
describe("getDayNames", () => {
it("returns 7 day names", { timeout: 5000 }, () => {
const days = getDayNames("en");
expect(days).toHaveLength(7);
expect(days.every((d) => typeof d === "string")).toBe(true);
});
it("returns short day names", { timeout: 5000 }, () => {
const days = getDayNames("en", "short");
expect(days).toHaveLength(7);
});
it("returns narrow day names", { timeout: 5000 }, () => {
const days = getDayNames("fr", "narrow");
expect(days).toHaveLength(7);
});
it("getDayNames returns 7 strings", { timeout: 5000 }, () => {
const days = getDayNames("en-GB", "long");
expect(days).toHaveLength(7);
expect(days.every((d) => d.length > 0)).toBe(true);
});
});
describe("getDayNames runtime fallbacks", () => {
it("falls back to Monday-start when getWeekInfo is absent", {
timeout: 5000,
}, () => {
const Locale = Intl.Locale;
const Stub = function (this: unknown, _tag: string): object {
return { getWeekInfo: undefined };
} as unknown as typeof Intl.Locale;
(Intl as unknown as { Locale: typeof Intl.Locale }).Locale = Stub;
try {
const days = getDayNames("en-US", "short");
expect(days[0]).toBe("Mon");
} finally {
(Intl as unknown as { Locale: typeof Intl.Locale }).Locale = Locale;
}
});
it("falls back to Monday-start when Intl.Locale throws", {
timeout: 5000,
}, () => {
const Locale = Intl.Locale;
const Throws = (() => {
throw new RangeError("synthetic");
}) as unknown as typeof Intl.Locale;
(Intl as unknown as { Locale: typeof Intl.Locale }).Locale = Throws;
try {
const days = getDayNames("en-US", "short");
expect(days[0]).toBe("Mon");
} finally {
(Intl as unknown as { Locale: typeof Intl.Locale }).Locale = Locale;
}
});
it("getDayNames falls back to Monday-start when getWeekInfo unavailable", {
timeout: 5000,
}, () => {
const Locale = Intl.Locale;
const Stub = function (this: unknown, _tag: string): object {
return { getWeekInfo: undefined };
} as unknown as typeof Intl.Locale;
(Intl as unknown as { Locale: typeof Intl.Locale }).Locale = Stub;
try {
const days = getDayNames("en-GB", "long");
expect(days[0]).toBe("Monday");
} finally {
(Intl as unknown as { Locale: typeof Intl.Locale }).Locale = Locale;
}
});
});
describe("getMonthNames", () => {
it("returns 12 month names", { timeout: 5000 }, () => {
const months = getMonthNames("en");
expect(months).toHaveLength(12);
});
it("returns short month names", { timeout: 5000 }, () => {
const months = getMonthNames("fr", "short");
expect(months).toHaveLength(12);
});
it("returns narrow month names", { timeout: 5000 }, () => {
const months = getMonthNames("de", "narrow");
expect(months).toHaveLength(12);
});
it("getMonthNames returns 12 strings", { timeout: 5000 }, () => {
const months = getMonthNames("en-GB", "long");
expect(months).toHaveLength(12);
expect(months.every((m) => m.length > 0)).toBe(true);
});
});

View File

@@ -0,0 +1,59 @@
import { toBcp47 } from "../formatter/formatter.js";
/**
* Get localised day names starting from the locale-defined first day
* of the week. Consults `Intl.Locale.getWeekInfo()` (ECMA-402 / Stage
* 4); when the runtime lacks `weekInfo`, falls back to Monday-start.
* Returns 7 strings in the requested style.
*
* `firstDay` is encoded as 1 = Monday … 7 = Sunday per ECMA-402.
*
* Sourced from monolith L126–L160.
*/
export function getDayNames(
locale: string,
style: "long" | "short" | "narrow" = "long"
): string[] {
const bcp47 = toBcp47(locale);
let firstDay = 1;
try {
const probe = new Intl.Locale(bcp47) as unknown as {
getWeekInfo: () => { firstDay: number };
};
firstDay = probe.getWeekInfo().firstDay;
} catch {
// Older runtimes: stick with Monday-start.
}
// Anchor on Monday 2024-01-01 then offset by (firstDay - 1) so day[0]
// is the locale-correct first weekday.
const base = new Date(2024, 0, 1);
base.setDate(base.getDate() + (firstDay - 1));
const days: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(base);
d.setDate(base.getDate() + i);
days.push(new Intl.DateTimeFormat(bcp47, { weekday: style }).format(d));
}
return days;
}
/**
* Get localised month names for all 12 months. Returns 12 strings in
* the requested style. Sourced from monolith L166–L180.
*/
export function getMonthNames(
locale: string,
style: "long" | "short" | "narrow" = "long"
): string[] {
const bcp47 = toBcp47(locale);
const months: string[] = [];
for (let i = 0; i < 12; i++) {
months.push(
new Intl.DateTimeFormat(bcp47, { month: style }).format(
new Date(2024, i, 1)
)
);
}
return months;
}

View File

@@ -0,0 +1,4 @@
export {
getDayNames,
getMonthNames,
} from "./day-month-names.js";

View File

@@ -0,0 +1,70 @@
import z from "zod";
const DateTimeFieldStyle = z.enum([
"numeric",
"2-digit",
"long",
"short",
"narrow",
]);
/**
* Zod schema for `ExtendedDateOptions`. Validates `preset` against the
* 8-entry table; validates the subset of `Intl.DateTimeFormatOptions`
* field-style enums (`weekday`, `era`, `year`, `month`, `day`, `hour`,
* `minute`, `second`, `timeZoneName`); accepts arbitrary string-typed
* `timeZone` and `calendar` / `numberingSystem` values.
*
* Unrecognised top-level keys are passed through (Zod `passthrough`)
* so future ECMA-402 fields work without schema bumps.
*/
export const DateFormatterOptionsSchema = z
.object({
preset: z
.enum([
"short",
"medium",
"long",
"full",
"time-short",
"time-long",
"date-short",
"date-long",
])
.optional(),
locale: z.string().optional(),
weekday: z.enum(["long", "short", "narrow"]).optional(),
era: z.enum(["long", "short", "narrow"]).optional(),
year: z.enum(["numeric", "2-digit"]).optional(),
month: DateTimeFieldStyle.optional(),
day: z.enum(["numeric", "2-digit"]).optional(),
hour: z.enum(["numeric", "2-digit"]).optional(),
minute: z.enum(["numeric", "2-digit"]).optional(),
second: z.enum(["numeric", "2-digit"]).optional(),
timeZoneName: z
.enum([
"long",
"short",
"shortOffset",
"longOffset",
"shortGeneric",
"longGeneric",
])
.optional(),
timeZone: z.string().optional(),
hour12: z.boolean().optional(),
hourCycle: z.enum(["h11", "h12", "h23", "h24"]).optional(),
calendar: z.string().optional(),
numberingSystem: z.string().optional(),
formatMatcher: z.enum(["basic", "best fit"]).optional(),
localeMatcher: z.enum(["lookup", "best fit"]).optional(),
dateStyle: z.enum(["full", "long", "medium", "short"]).optional(),
timeStyle: z.enum(["full", "long", "medium", "short"]).optional(),
dayPeriod: z.enum(["narrow", "short", "long"]).optional(),
fractionalSecondDigits: z.number().int().min(1).max(3).optional(),
})
.passthrough();
export type DateFormatterOptionsInput = z.input<
typeof DateFormatterOptionsSchema
>;

View File

@@ -0,0 +1,144 @@
/**
* Spec-conformance pins for DateFormatter — Zod rejection paths,
* Intl.RangeError mapping, OTel emission, and cache reuse semantics.
*/
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
clearDateFormatterCache,
DateFormatter,
formatDate,
} from "./formatter/formatter.js";
import type {
DateFormatPreset,
ExtendedDateOptions,
} from "./formatter.types.js";
import {
getDateMetricsState,
resetDateMetricsState,
} from "./observability/observability.js";
describe("DateFormatter spec-conformance", () => {
it("rejects non-object options shape (no path → field='options')", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error wrong type for safeParse
formatDate(new Date(), "en-GB", "not-an-object");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "options",
});
}
});
it("rejects unknown preset with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new DateFormatter({ preset: "magic" as never });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "preset",
});
}
});
it("rejects unknown weekday style with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
formatDate(new Date(), "en-GB", {
weekday: "magic" as never,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "weekday",
});
}
});
it("maps Intl RangeError to UNSUPPORTED_LOCALE", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
try {
formatDate(new Date(), "en US");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
}
});
it("reuses cached Intl.DateTimeFormat on identical key", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
const state = getDateMetricsState();
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("date");
});
it("does not reuse cache across distinct presets", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "long" });
const state = getDateMetricsState();
expect(state.cacheHitSamples).toHaveLength(0);
});
it("records a duration_ms sample per call with locale + preset", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
const state = getDateMetricsState();
expect(state.durationSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
preset: "short",
});
});
it("records 'custom' preset tag when preset omitted", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { year: "numeric" });
const state = getDateMetricsState();
expect(state.durationSamples[0]?.attributes.preset).toBe("custom");
});
it("preset field of ExtendedDateOptions matches DateFormatPreset", {
timeout: 5000,
}, () => {
expectTypeOf<ExtendedDateOptions["preset"]>().toEqualTypeOf<
DateFormatPreset | undefined
>();
});
});

View File

@@ -0,0 +1,25 @@
/** Date formatter type definitions. */
/** Predefined date/time format presets — 8 entries. */
export type DateFormatPreset =
| "short"
| "medium"
| "long"
| "full"
| "time-short"
| "time-long"
| "date-short"
| "date-long";
/** Options for date formatting — extends Intl.DateTimeFormatOptions. */
export interface DateFormatOptions extends Intl.DateTimeFormatOptions {
locale?: string;
}
/** Extended date options with optional preset. */
export interface ExtendedDateOptions extends DateFormatOptions {
preset?: DateFormatPreset;
}
/** Cache key shape: `${locale}|${jsonOptions}`. */
export type DateFormatterCacheKey = string;

View File

@@ -0,0 +1,133 @@
# DateFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DateFormatter` and the `formatDate` / `formatDateRange` standalone
helpers expose `Intl.DateTimeFormat` for the `@suaveplan/i18n` family
at the canonical Wave-3 formatter surface. Consumers format `Date`
instances against an 8-entry preset table or raw
`Intl.DateTimeFormatOptions`, with locale-correct rendering across
every BCP-47 tag.
This module is the broadest formatter in the family — it ships the
class wrapper, the standalone formatters, the preset/raw merge logic,
the BCP-47 normalisation map (via `@suaveplan/i18n-shared`), and the
per-(locale, options-hash) cache.
## Features
- `DateFormatter` class with stored options + `format(date, locale)` and `formatRange(start, end, locale)` methods
- `formatDate(date, locale, options?)` standalone helper
- `formatDateRange(start, end, locale, options?)` standalone helper
- 8-preset table: `short`, `medium`, `long`, `full`, `time-short`, `time-long`, `date-short`, `date-long`
- Raw `Intl.DateTimeFormatOptions` accepted alongside or instead of `preset`
- Construction-time Zod validation with `INVALID_FORMATTER_OPTIONS` mapping
- `Intl.DateTimeFormat` `RangeError` mapped to `UNSUPPORTED_LOCALE` with original error attached as `cause`
- Per-(locale, options-hash) cache — repeated calls with identical key reuse the same `Intl.DateTimeFormat`
- BCP-47 normalisation through `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`
- `clearDateFormatterCache()` test-only helper
## Usage: Basic Example
```ts
import {
formatDate,
formatDateRange,
} from "@suaveplan/i18n-formatter-date";
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
// e.g. "01/01/2026, 00:00"
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "long" });
// e.g. "1 January 2026 at 00:00:00"
formatDateRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB",
{ day: "numeric", month: "long" }
); // e.g. "1 – 5 January"
```
## Usage: Advanced Example
```ts
import {
DateFormatter,
type ExtendedDateOptions,
} from "@suaveplan/i18n-formatter-date";
const opts: ExtendedDateOptions = {
preset: "short",
// Override the preset's month field — raw options take precedence.
month: "long",
timeZone: "Europe/London",
};
const f = new DateFormatter(opts);
f.format(new Date(2026, 0, 1), "en-GB"); // "1 January 2026, 00:00"
f.format(new Date(2026, 0, 1), "fr-FR"); // "1 janvier 2026 à 00:00"
// Range rendering uses the same configured options.
f.formatRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB"
);
```
## API Reference
### `formatDate(date, locale, options?): string`
Standalone helper. Validates options via Zod, normalises the locale
through `LOCALE_BCP47_MAP`, resolves `options.preset` against
`PRESET_MAP` (with raw fields overriding preset defaults), looks up
the cached `Intl.DateTimeFormat`, and returns `formatter.format(date)`.
Throws `I18nError(INVALID_FORMATTER_OPTIONS)` on Zod failure; throws
`I18nError(UNSUPPORTED_LOCALE)` on `Intl.DateTimeFormat` `RangeError`
with the original error as `cause`.
### `formatDateRange(start, end, locale, options?): string`
Standalone helper for range rendering. Uses
`Intl.DateTimeFormat.formatRange` under the hood; raw
`Intl.DateTimeFormatOptions` are passed straight through (no preset
resolution).
### `class DateFormatter`
- `constructor(options?: ExtendedDateOptions)` — Zod-validates options at construction
- `format(date: Date, locale: string): string` — same semantics as standalone
- `formatRange(start: Date, end: Date, locale: string): string` — uses the configured options' resolved Intl options for range rendering
### `toBcp47(locale: string): string`
Look up the locale in `LOCALE_BCP47_MAP` (direct first, then via
`toLowerCase` fallback). Returns the input locale unchanged when no
mapping exists. Exported for downstream callers (e.g. day/month name
helpers).
### `clearDateFormatterCache(): void`
Test-only helper that empties the per-(locale, options-hash) cache.
## Implementation Notes
- The cache key is `${locale}|${JSON.stringify(options)}` — JSON
serialisation is stable for objects with no symbol/function keys
and no NaN/Infinity values, which `ExtendedDateOptions` does not
contain.
- `presetTag(options)` returns `options.preset ?? "custom"` so the
OTel histogram tag is always present.
- The `formatRange` method on `Intl.DateTimeFormat` ships in Node 18+
and every current browser. The package does not provide a
fallback — a runtime missing `formatRange` is below the family's
documented support floor.
- `validateOptions(undefined)` is a no-op so consumers can call
`formatDate(d, l)` with no options object.

View File

@@ -0,0 +1,245 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { resetDateMetricsState } from "../observability/observability.js";
import {
clearDateFormatterCache,
DateFormatter,
formatDate,
formatDateRange,
} from "./formatter.js";
const testDate = new Date("2024-06-15T14:30:00.000Z");
describe("formatDate", () => {
it("formats with a preset", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDate(testDate, "en", { preset: "date-short" });
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("formats with raw Intl options", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDate(testDate, "en", { year: "numeric" });
expect(r).toContain("2024");
});
it("formats without options", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDate(testDate, "en");
expect(typeof r).toBe("string");
});
it("handles BCP-47 passthrough locale", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDate(testDate, "en-US", { preset: "date-short" });
expect(typeof r).toBe("string");
});
it("passes through unknown locale codes unchanged", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
const r = formatDate(testDate, "en-AU", { year: "numeric" });
expect(r).toContain("2024");
});
it("normalises uppercase locale via toLowerCase fallback", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
const r = formatDate(testDate, "EN", { year: "numeric" });
expect(r).toContain("2024");
});
it("formats with all presets without throwing", { timeout: 5000 }, () => {
clearDateFormatterCache();
const presets = [
"short",
"medium",
"long",
"full",
"time-short",
"time-long",
"date-short",
"date-long",
] as const;
for (const preset of presets) {
const r = formatDate(testDate, "en", { preset });
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
}
});
it("formatDate preset short renders short style", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDate(new Date(2026, 0, 1), "en-GB", {
preset: "short",
});
expect(r).toContain("2026");
});
it("formatDate raw options override preset", { timeout: 5000 }, () => {
clearDateFormatterCache();
// preset: "short" includes year + month + day + hour + minute.
// Override hour/minute to undefined via rest semantics still
// keeps the year. Use day-only override instead: take preset
// "short" but force month: "long" — should produce a long month.
const out = formatDate(new Date(2026, 0, 15), "en-GB", {
preset: "short",
month: "long",
});
expect(out).toContain("January");
});
it("formatDate output twins DateFormatter.format output", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
const opts = { preset: "short" as const };
const standalone = formatDate(testDate, "en-GB", opts);
const klass = new DateFormatter(opts).format(testDate, "en-GB");
expect(standalone).toBe(klass);
});
});
describe("DateFormatter class", () => {
it("DateFormatter formats with default options", { timeout: 5000 }, () => {
clearDateFormatterCache();
const f = new DateFormatter();
const out = f.format(new Date(2026, 0, 1), "en-GB");
expect(out).toContain("2026");
});
it("DateFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new DateFormatter({ preset: "invalid" as never });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
"I18N.INVALID_FORMATTER_OPTIONS"
);
}
});
it("DateFormatter.formatRange produces a string", { timeout: 5000 }, () => {
clearDateFormatterCache();
const f = new DateFormatter({ preset: "date-short" });
const out = f.formatRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB"
);
expect(typeof out).toBe("string");
expect(out.length).toBeGreaterThan(0);
});
it("DateFormatter constructed with no options uses defaults", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
const f = new DateFormatter();
const out = f.formatRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB"
);
expect(typeof out).toBe("string");
});
});
describe("formatDateRange", () => {
it("formats a date range", { timeout: 5000 }, () => {
clearDateFormatterCache();
const start = new Date(2026, 0, 1);
const end = new Date(2026, 0, 5);
const r = formatDateRange(start, end, "en-GB");
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("formatDateRange renders cross-day range", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDateRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB",
{ day: "numeric", month: "numeric" }
);
expect(r).toContain("1");
expect(r).toContain("5");
});
it("formatDateRange supports raw options pass-through", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
const r = formatDateRange(
new Date(2026, 5, 1),
new Date(2026, 5, 30),
"en",
{ month: "long", day: "numeric" }
);
expect(typeof r).toBe("string");
});
});
describe("Intl.DateTimeFormat RangeError mapping", () => {
it("unsupported locale throws UNSUPPORTED_LOCALE with cause", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
try {
formatDate(new Date(), "xx-bogus-tag-!!!");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.UNSUPPORTED_LOCALE");
expect((err as I18nError).cause).toBeInstanceOf(RangeError);
}
});
});
describe("Per-(locale, options-hash) cache", () => {
it("identical options reuse cached Intl.DateTimeFormat", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
const a = formatDate(new Date(2026, 0, 1), "en-GB", {
preset: "short",
});
const b = formatDate(new Date(2026, 0, 1), "en-GB", {
preset: "short",
});
expect(a).toBe(b);
});
it("distinct preset creates distinct cache entries", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
const short = formatDate(new Date(2026, 0, 1), "en-GB", {
preset: "short",
});
const long = formatDate(new Date(2026, 0, 1), "en-GB", {
preset: "long",
});
expect(short).not.toBe(long);
});
it("clearDateFormatterCache drops cached entries", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
clearDateFormatterCache();
const r = formatDate(new Date(2026, 0, 1), "en-GB", {
preset: "short",
});
expect(typeof r).toBe("string");
});
});

View File

@@ -0,0 +1,157 @@
import {
I18N_CODES,
I18nError,
LOCALE_BCP47_MAP,
} from "@suaveplan/i18n-shared";
import { DateFormatterOptionsSchema } from "../formatter.schemas.js";
import type {
DateFormatterCacheKey,
ExtendedDateOptions,
} from "../formatter.types.js";
import {
recordDateCacheHit,
recordDateFormat,
} from "../observability/observability.js";
import { resolvePreset } from "../presets/presets.js";
const cache = new Map<DateFormatterCacheKey, Intl.DateTimeFormat>();
/**
* Map a short locale tag through `LOCALE_BCP47_MAP` to its canonical
* BCP-47 form. Falls back to the input locale unchanged when no
* mapping exists. Sourced from monolith L43–L49.
*/
export function toBcp47(locale: string): string {
const direct = LOCALE_BCP47_MAP[locale];
if (direct !== undefined) return direct;
const lower = LOCALE_BCP47_MAP[locale.toLowerCase()];
if (lower !== undefined) return lower;
return locale;
}
function buildKey(
locale: string,
options: ExtendedDateOptions | undefined
): DateFormatterCacheKey {
return `${locale}|${JSON.stringify(options ?? {})}`;
}
function validateOptions(options: ExtendedDateOptions | undefined): void {
if (options === undefined) return;
const parsed = DateFormatterOptionsSchema.safeParse(options);
if (parsed.success) return;
const field = String(parsed.error.issues[0]?.path[0] ?? "options");
throw new I18nError(
I18N_CODES.INVALID_FORMATTER_OPTIONS,
`DateFormatter: invalid options (field: ${field})`,
{ context: { field, issues: parsed.error.issues } }
);
}
function buildIntlFormat(
locale: string,
intlOptions: Intl.DateTimeFormatOptions | undefined
): Intl.DateTimeFormat {
try {
return new Intl.DateTimeFormat(locale, intlOptions);
} catch (cause) {
throw new I18nError(
I18N_CODES.UNSUPPORTED_LOCALE,
`DateFormatter: unsupported locale '${locale}'`,
{ cause, context: { locale } }
);
}
}
function getOrCreate(
locale: string,
options: ExtendedDateOptions | undefined,
intlOptions: Intl.DateTimeFormatOptions | undefined
): { formatter: Intl.DateTimeFormat; cached: boolean } {
const key = buildKey(locale, options);
const existing = cache.get(key);
if (existing) return { formatter: existing, cached: true };
const formatter = buildIntlFormat(locale, intlOptions);
cache.set(key, formatter);
return { formatter, cached: false };
}
function presetTag(options: ExtendedDateOptions | undefined): string {
return options?.preset ?? "custom";
}
/**
* Format a `Date` according to locale conventions. Accepts a preset
* name or raw `Intl.DateTimeFormatOptions`. Sourced from monolith
* L64–L73 with cache + OTel emission added.
*/
export function formatDate(
date: Date,
locale: string,
options?: ExtendedDateOptions
): string {
validateOptions(options);
const t0 = performance.now();
const bcp47 = toBcp47(locale);
const intlOptions = resolvePreset(options);
const { formatter, cached: hit } = getOrCreate(bcp47, options, intlOptions);
const out = formatter.format(date);
if (hit) recordDateCacheHit();
recordDateFormat(bcp47, presetTag(options), performance.now() - t0);
return out;
}
/**
* Format a date range between two `Date` objects. Uses
* `Intl.DateTimeFormat.formatRange`. Sourced from monolith L104–L116.
*/
export function formatDateRange(
start: Date,
end: Date,
locale: string,
options?: Intl.DateTimeFormatOptions
): string {
const t0 = performance.now();
const bcp47 = toBcp47(locale);
// Wrap as ExtendedDateOptions for cache-key construction; raw
// Intl options are passed through directly to Intl.DateTimeFormat.
const ext: ExtendedDateOptions | undefined = options
? (options as ExtendedDateOptions)
: undefined;
const { formatter, cached: hit } = getOrCreate(bcp47, ext, options);
const out = (
formatter as Intl.DateTimeFormat & {
formatRange: (s: Date, e: Date) => string;
}
).formatRange(start, end);
if (hit) recordDateCacheHit();
recordDateFormat(bcp47, presetTag(ext), performance.now() - t0);
return out;
}
/**
* Class wrapper for `formatDate` and `formatDateRange` with
* construction-time option validation and stored defaults.
*/
export class DateFormatter {
private readonly options: ExtendedDateOptions | undefined;
public constructor(options?: ExtendedDateOptions) {
validateOptions(options);
this.options = options;
}
public format(date: Date, locale: string): string {
return formatDate(date, locale, this.options);
}
public formatRange(start: Date, end: Date, locale: string): string {
const intlOptions = resolvePreset(this.options);
return formatDateRange(start, end, locale, intlOptions);
}
}
/** Test-only: clear the per-(locale, options-hash) cache. */
export function clearDateFormatterCache(): void {
cache.clear();
}

View File

@@ -0,0 +1,7 @@
export {
clearDateFormatterCache,
DateFormatter,
formatDate,
formatDateRange,
toBcp47,
} from "./formatter.js";

View File

@@ -0,0 +1,91 @@
# Date helpers — `formatTime`, `formatDateOnly`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`formatTime` and `formatDateOnly` pre-bind `formatDate` with the
appropriate preset for time-only or date-only rendering. They exist
because the most common UI patterns ("show just the time", "show just
the date") shouldn't require the caller to remember the preset
discriminant string.
## Features
- `formatTime(date, locale, long?)` — pre-binds `time-short` (`long === false`) or `time-long` (`long === true`)
- `formatDateOnly(date, locale, long?)` — pre-binds `date-short` (`long === false`) or `date-long` (`long === true`)
- Both default `long` to `false`
- Both go through the cached `formatDate` path — every advantage of the per-(locale, options-hash) cache and OTel emission applies
## Usage: Basic Example
```ts
import {
formatDateOnly,
formatTime,
} from "@suaveplan/i18n-formatter-date";
const now = new Date(2026, 0, 1, 14, 30, 45);
formatTime(now, "en-GB"); // "14:30"
formatTime(now, "en-GB", true); // "14:30:45"
formatDateOnly(now, "en-GB"); // "01/01/2026"
formatDateOnly(now, "en-GB", true); // "1 January 2026"
```
## Usage: Advanced Example
```ts
import {
formatDateOnly,
formatTime,
} from "@suaveplan/i18n-formatter-date";
// Render a chat message timestamp — short form by default,
// long form on hover/expand.
function renderTimestamp(d: Date, locale: string, expanded: boolean) {
const date = formatDateOnly(d, locale, expanded);
const time = formatTime(d, locale, expanded);
return expanded ? `${date} at ${time}` : `${date} ${time}`;
}
renderTimestamp(new Date(2026, 5, 15, 9, 5), "en-GB", false);
// "15/06/2026 09:05"
renderTimestamp(new Date(2026, 5, 15, 9, 5, 30), "en-GB", true);
// "15 June 2026 at 09:05:30"
```
## API Reference
### `formatTime(date, locale, long?): string`
Pre-binds `formatDate` with `preset: long ? "time-long" : "time-short"`.
- `date` — a `Date` instance.
- `locale` — BCP-47 locale tag.
- `long` — when `true`, includes seconds.
Throws every error class `formatDate` throws.
### `formatDateOnly(date, locale, long?): string`
Pre-binds `formatDate` with `preset: long ? "date-long" : "date-short"`.
- `date` — a `Date` instance.
- `locale` — BCP-47 locale tag.
- `long` — when `true`, uses the long month name (e.g. "January"
instead of "01").
Throws every error class `formatDate` throws.
## Implementation Notes
- Both helpers are one-line wrappers around `formatDate`. The
per-(locale, options-hash) cache shared with `formatDate` and
`formatDateRange` covers them automatically.
- Default of `long = false` matches the monolith behaviour to
minimise behavioural drift during the modular split.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { clearDateFormatterCache } from "../formatter/formatter.js";
import { formatDateOnly, formatTime } from "./helpers.js";
describe("formatTime", () => {
it("formats time (short)", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatTime(new Date(2026, 0, 1, 14, 30), "en-GB");
expect(typeof r).toBe("string");
});
it("formats time (long)", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatTime(new Date(2026, 0, 1, 14, 30, 45), "en-GB", true);
expect(typeof r).toBe("string");
});
it("formatTime short does not include seconds", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatTime(new Date(2026, 0, 1, 14, 30, 45), "en-GB", false);
// Short does not request seconds; rendering should not include
// a colon-separated second pair like ":45".
expect(r).not.toMatch(/:45/);
});
it("formatTime long includes seconds", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatTime(new Date(2026, 0, 1, 14, 30, 45), "en-GB", true);
expect(r).toMatch(/45/);
});
});
describe("formatDateOnly", () => {
it("formats date-short", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDateOnly(new Date(2026, 0, 1), "en-GB");
expect(typeof r).toBe("string");
});
it("formats date-long", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDateOnly(new Date(2026, 0, 1), "en-GB", true);
expect(typeof r).toBe("string");
expect(r).toContain("January");
});
it("formatDateOnly short renders numeric date", { timeout: 5000 }, () => {
clearDateFormatterCache();
const r = formatDateOnly(new Date(2026, 0, 1), "en-GB", false);
expect(r).toContain("2026");
});
});

View File

@@ -0,0 +1,25 @@
import { formatDate } from "../formatter/formatter.js";
/**
* Format the time portion of a `Date`. Pass `long = true` for seconds
* included. Sourced from monolith L79–L83.
*/
export function formatTime(date: Date, locale: string, long = false): string {
return formatDate(date, locale, {
preset: long ? "time-long" : "time-short",
});
}
/**
* Format just the date portion (no time). Pass `long = true` for full
* month name. Sourced from monolith L89–L97.
*/
export function formatDateOnly(
date: Date,
locale: string,
long = false
): string {
return formatDate(date, locale, {
preset: long ? "date-long" : "date-short",
});
}

View File

@@ -0,0 +1,4 @@
export {
formatDateOnly,
formatTime,
} from "./helpers.js";

View File

@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
describe("@suaveplan/i18n-formatter-date root barrel", () => {
it("re-exports DateFormatter", { timeout: 5000 }, () => {
expect(root.DateFormatter).toBeDefined();
});
it("re-exports formatDate / formatDateRange", { timeout: 5000 }, () => {
expect(typeof root.formatDate).toBe("function");
expect(typeof root.formatDateRange).toBe("function");
});
it("re-exports formatTime / formatDateOnly", { timeout: 5000 }, () => {
expect(typeof root.formatTime).toBe("function");
expect(typeof root.formatDateOnly).toBe("function");
});
it("re-exports getDayNames / getMonthNames", { timeout: 5000 }, () => {
expect(typeof root.getDayNames).toBe("function");
expect(typeof root.getMonthNames).toBe("function");
});
it("re-exports presets surface", { timeout: 5000 }, () => {
expect(root.PRESET_MAP).toBeDefined();
expect(root.DATE_FORMAT_PRESETS).toBeDefined();
expect(typeof root.resolvePreset).toBe("function");
});
it("re-exports schema + cache + toBcp47", { timeout: 5000 }, () => {
expect(root.DateFormatterOptionsSchema).toBeDefined();
expect(typeof root.clearDateFormatterCache).toBe("function");
expect(typeof root.toBcp47).toBe("function");
});
it("re-exports observability surface", { timeout: 5000 }, () => {
expect(typeof root.recordDateFormat).toBe("function");
expect(typeof root.recordDateCacheHit).toBe("function");
expect(typeof root.getDateMetricsState).toBe("function");
expect(typeof root.resetDateMetricsState).toBe("function");
});
it("every documented public export resolves", { timeout: 5000 }, () => {
const expected = [
"DateFormatter",
"formatDate",
"formatDateRange",
"formatTime",
"formatDateOnly",
"getDayNames",
"getMonthNames",
"PRESET_MAP",
"DATE_FORMAT_PRESETS",
"resolvePreset",
"DateFormatterOptionsSchema",
"clearDateFormatterCache",
"toBcp47",
];
const exported = Object.keys(root);
for (const name of expected) {
expect(exported).toContain(name);
}
});
});
describe("@suaveplan/i18n-formatter-date subpath imports", () => {
it("every documented subpath export resolves", {
timeout: 5000,
}, async () => {
const formatter = await import("./formatter/formatter.js");
expect(formatter.DateFormatter).toBeDefined();
const helpers = await import("./helpers/helpers.js");
expect(typeof helpers.formatTime).toBe("function");
const presets = await import("./presets/presets.js");
expect(presets.PRESET_MAP).toBeDefined();
const dmn = await import("./day-month-names/day-month-names.js");
expect(typeof dmn.getDayNames).toBe("function");
});
});
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
describe("package.json invariants", () => {
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("package.json declares the five documented subpath exports", {
timeout: 5000,
}, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as { exports?: Record<string, unknown> };
const exports = pkg.exports ?? {};
const required = [
".",
"./formatter",
"./helpers",
"./presets",
"./day-month-names",
];
for (const subpath of required) {
expect(exports).toHaveProperty(subpath);
}
});
});

View File

@@ -0,0 +1,33 @@
// Root barrel for @suaveplan/i18n-formatter-date. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export { getDayNames, getMonthNames } from "./day-month-names/index.js";
export {
clearDateFormatterCache,
DateFormatter,
formatDate,
formatDateRange,
toBcp47,
} from "./formatter/index.js";
export { DateFormatterOptionsSchema } from "./formatter.schemas.js";
export type {
DateFormatOptions,
DateFormatPreset,
DateFormatterCacheKey,
ExtendedDateOptions,
} from "./formatter.types.js";
export { formatDateOnly, formatTime } from "./helpers/index.js";
export {
type DateMetricSample,
type DateMetricsState,
getDateMetricsState,
recordDateCacheHit,
recordDateFormat,
resetDateMetricsState,
} from "./observability/index.js";
export {
DATE_FORMAT_PRESETS,
PRESET_MAP,
resolvePreset,
} from "./presets/index.js";
export type { PresetTable } from "./presets.types.js";

View File

@@ -0,0 +1,10 @@
export type {
DateMetricSample,
DateMetricsState,
} from "./observability.js";
export {
getDateMetricsState,
recordDateCacheHit,
recordDateFormat,
resetDateMetricsState,
} from "./observability.js";

View File

@@ -0,0 +1,98 @@
# Date observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Date formatter exports two OpenTelemetry instruments and a small
in-memory state ring so spec-conformance tests can assert exact
attribute payloads without standing up a full SDK exporter. The
instrument names match the canonical Wave-3 formatter contract —
every sibling formatter (number, relative-time, list, display-name,
currency, collator, segmenter) emits the same two metric names so
dashboards and alerts compose cleanly across the family.
## Features
- Histogram `i18n.format.date.duration_ms` (unit `ms`) — per-format wall-clock time
- Counter `i18n.format.cache_hit{formatter="date"}` — increments on cache reuse only
- In-memory state ring (`getDateMetricsState()`) for spec-conformance
- `resetDateMetricsState()` test-only helper to clear the ring
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [/* reader */] });
metrics.setGlobalMeterProvider(provider);
import { formatDate } from "@suaveplan/i18n-formatter-date";
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
// emits via your reader
```
## Usage: Advanced Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearDateFormatterCache,
formatDate,
} from "@suaveplan/i18n-formatter-date";
import {
getDateMetricsState,
resetDateMetricsState,
} from "@suaveplan/i18n-formatter-date";
it("emits histogram + cache-hit counter", () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
const state = getDateMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
preset: "short",
});
});
```
## API Reference
### `recordDateFormat(locale, preset, durationMs): void`
Records the duration histogram sample. Tags: `locale`, `preset`.
Called internally by `formatDate` / `formatDateRange`.
### `recordDateCacheHit(): void`
Increments the cache-hit counter with the constant tag
`{ formatter: "date" }`.
### `getDateMetricsState(): DateMetricsState`
Returns the in-memory ring of `durationSamples` and
`cacheHitSamples`. Used by spec-conformance tests.
### `resetDateMetricsState(): void`
Clears both ring buffers. Test-only helper.
## Implementation Notes
- The OTel meter is named `suaveplan.i18n.formatter.date`. Every
formatter in the family has its own meter so OTel resource
attributes can be tied per-package if a consumer chooses.
- When the caller omits `preset`, the histogram tag is `"custom"` so
every sample carries a non-null preset attribute.
- Sample dashboard query (PromQL/Grafana over OTLP):
`histogram_quantile(0.99, sum(rate(i18n_format_date_duration_ms_bucket[5m])) by (le, locale, preset))`
- The in-memory ring is intentionally unbounded — tests are short
and call `resetDateMetricsState()` between cases. A long-running
test loop should call reset to avoid memory growth.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearDateFormatterCache,
formatDate,
formatDateRange,
} from "../formatter/formatter.js";
import { getDateMetricsState, resetDateMetricsState } from "./observability.js";
describe("Date observability", () => {
it("emits i18n.format.date.duration_ms histogram on every call", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
const state = getDateMetricsState();
expect(state.durationSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
preset: "short",
});
});
it("cache-hit counter increments only on second call with same options", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
expect(getDateMetricsState().cacheHitSamples).toHaveLength(0);
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
const state = getDateMetricsState();
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("date");
});
it("formatDateRange also records histogram + cache-hit", {
timeout: 5000,
}, () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDateRange(new Date(2026, 0, 1), new Date(2026, 0, 5), "en-GB", {
day: "numeric",
month: "numeric",
});
formatDateRange(new Date(2026, 0, 1), new Date(2026, 0, 5), "en-GB", {
day: "numeric",
month: "numeric",
});
const state = getDateMetricsState();
expect(state.durationSamples.length).toBeGreaterThanOrEqual(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});

View File

@@ -0,0 +1,78 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for the Date formatter.
*
* Histogram records per-format wall-clock duration; the counter
* increments on every cache reuse so consumers can observe cache
* effectiveness.
*
* The module retains a small in-memory state ring so spec-conformance
* tests can assert exact attribute payloads without standing up a
* full SDK exporter.
*/
export interface DateMetricSample {
readonly attributes: Record<string, string | number>;
}
export interface DateMetricsState {
readonly durationSamples: DateMetricSample[];
readonly cacheHitSamples: DateMetricSample[];
}
let cached:
| {
readonly durationMs: Histogram;
readonly cacheHit: Counter;
}
| undefined;
const state: DateMetricsState = {
durationSamples: [],
cacheHitSamples: [],
};
function ensureInstruments() {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n.formatter.date");
cached = {
durationMs: meter.createHistogram("i18n.format.date.duration_ms", {
description:
"Duration in ms of DateFormatter.format / formatDateRange; labels: locale, preset.",
unit: "ms",
}),
cacheHit: meter.createCounter("i18n.format.cache_hit", {
description:
"Format-time cache reuse counter; labels: formatter (date).",
}),
};
return cached;
}
export function recordDateFormat(
locale: string,
preset: string,
durationMs: number
): void {
const ins = ensureInstruments();
const attributes = { locale, preset };
ins.durationMs.record(durationMs, attributes);
state.durationSamples.push({ attributes });
}
export function recordDateCacheHit(): void {
const ins = ensureInstruments();
const attributes = { formatter: "date" };
ins.cacheHit.add(1, attributes);
state.cacheHitSamples.push({ attributes });
}
export function getDateMetricsState(): DateMetricsState {
return state;
}
export function resetDateMetricsState(): void {
state.durationSamples.length = 0;
state.cacheHitSamples.length = 0;
}

View File

@@ -0,0 +1,10 @@
/** Type definitions for the 8-preset date/time format table. */
import type { DateFormatPreset } from "./formatter.types.js";
/**
* Resolution table mapping each `DateFormatPreset` to a concrete
* `Intl.DateTimeFormatOptions` payload.
*/
export type PresetTable = Readonly<
Record<DateFormatPreset, Intl.DateTimeFormatOptions>
>;

View File

@@ -0,0 +1,5 @@
export {
DATE_FORMAT_PRESETS,
PRESET_MAP,
resolvePreset,
} from "./presets.js";

View File

@@ -0,0 +1,100 @@
# Date format presets
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PRESET_MAP` is the canonical 8-entry table that maps each
`DateFormatPreset` discriminant to a concrete
`Intl.DateTimeFormatOptions` payload. `resolvePreset(options)` is the
merge function used inside `formatDate` to combine a preset's
defaults with raw caller-supplied options (raw fields override).
The presets exist so consumers can render dates with ergonomic verbs
(`{ preset: "short" }`) instead of having to memorise the eight or so
field-style enums `Intl.DateTimeFormat` accepts.
## Features
- 8 frozen presets covering every common UI case (date+time / time-only / date-only, plus `full` for legal-style timestamps)
- `DATE_FORMAT_PRESETS` array of all preset names — useful for building UI selects
- `resolvePreset(options?)` merges preset defaults with raw fields; raw fields take precedence
- Pure functions, side-effect-free
## Usage: Basic Example
```ts
import {
DATE_FORMAT_PRESETS,
PRESET_MAP,
resolvePreset,
} from "@suaveplan/i18n-formatter-date";
// Inspect a preset directly.
PRESET_MAP.short;
// { year: "numeric", month: "numeric", day: "numeric",
// hour: "numeric", minute: "numeric" }
// Build a select.
DATE_FORMAT_PRESETS.map((p) => ({ value: p, label: p }));
// Resolve a merged options object.
resolvePreset({ preset: "short", month: "long" });
// { year: "numeric", month: "long", day: "numeric",
// hour: "numeric", minute: "numeric" }
```
## Usage: Advanced Example
```ts
import { resolvePreset } from "@suaveplan/i18n-formatter-date";
// Power a custom Intl wrapper that needs the resolved Intl options
// without going through formatDate.
function customDate(d: Date, locale: string, preset: "short" | "long") {
const opts = resolvePreset({ preset });
return new Intl.DateTimeFormat(locale, opts).format(d);
}
// Override the default fields of a preset.
const fullWithoutWeekday = resolvePreset({
preset: "full",
weekday: undefined, // raw fields override; setting to undefined
// keeps the field in the object but
// Intl.DateTimeFormat ignores `undefined`.
});
```
## API Reference
### `PRESET_MAP: Readonly<Record<DateFormatPreset, Intl.DateTimeFormatOptions>>`
The 8-entry table. Keys: `"short"`, `"medium"`, `"long"`, `"full"`,
`"time-short"`, `"time-long"`, `"date-short"`, `"date-long"`.
### `DATE_FORMAT_PRESETS: readonly DateFormatPreset[]`
Frozen array of every preset name. Useful for select-box population.
### `resolvePreset(options?): Intl.DateTimeFormatOptions | undefined`
- Returns `undefined` when `options` is `undefined`.
- Returns `rest` (with `preset` and `locale` stripped) when no
`preset` is supplied.
- Returns `{ ...PRESET_MAP[preset], ...rest }` when `preset` is
supplied — raw fields override preset defaults.
## Implementation Notes
- `PRESET_MAP` and `DATE_FORMAT_PRESETS` are sourced verbatim from
the monolith `formatting/date/date.ts` L4–L41 (per the source
mapping table in the change folder's proposal).
- `resolvePreset` mirrors the monolith's L51–L58. The function is
small, pure, and exported so consumers can build their own Intl
wrappers without reinventing the merge.
- The `locale` field is stripped because `Intl.DateTimeFormat` does
not accept `locale` inside the options object — it must be passed
as the first constructor argument. Stripping here keeps the
resolved options object passable to `Intl.DateTimeFormat` directly.

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DATE_FORMAT_PRESETS, PRESET_MAP, resolvePreset } from "./presets.js";
describe("PRESET_MAP", () => {
it("contains all 8 entries", { timeout: 5000 }, () => {
const keys = Object.keys(PRESET_MAP).sort();
expect(keys).toEqual(
[
"date-long",
"date-short",
"full",
"long",
"medium",
"short",
"time-long",
"time-short",
].sort()
);
});
it("short includes year, month, day, hour, minute", {
timeout: 5000,
}, () => {
expect(PRESET_MAP.short).toMatchObject({
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
});
it("full includes weekday + timeZoneName long", { timeout: 5000 }, () => {
expect(PRESET_MAP.full).toMatchObject({
weekday: "long",
timeZoneName: "long",
});
});
it("date-short omits time fields", { timeout: 5000 }, () => {
const keys = Object.keys(PRESET_MAP["date-short"]);
expect(keys).not.toContain("hour");
expect(keys).not.toContain("minute");
});
});
describe("DATE_FORMAT_PRESETS", () => {
it("lists all 8 preset names", { timeout: 5000 }, () => {
expect(DATE_FORMAT_PRESETS).toHaveLength(8);
});
});
describe("resolvePreset", () => {
it("returns undefined for undefined options", { timeout: 5000 }, () => {
expect(resolvePreset()).toBeUndefined();
});
it("returns rest options when no preset present", { timeout: 5000 }, () => {
const r = resolvePreset({ year: "numeric" });
expect(r).toMatchObject({ year: "numeric" });
});
it("merges preset map with rest options", { timeout: 5000 }, () => {
const r = resolvePreset({ preset: "short", month: "long" });
expect(r).toMatchObject({
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
});
it("strips locale from rest options", { timeout: 5000 }, () => {
const r = resolvePreset({ preset: "short", locale: "en-GB" });
expect(r).not.toHaveProperty("locale");
});
});

View File

@@ -0,0 +1,83 @@
import type {
DateFormatPreset,
ExtendedDateOptions,
} from "../formatter.types.js";
import type { PresetTable } from "../presets.types.js";
/**
* Eight-entry preset table sourced verbatim from the monolith
* `formatting/date/date.ts` L4–L41. Order:
*
* - `short` — date + time, numeric/numeric/numeric, hour:minute
* - `medium` — date + time, numeric/short/numeric, hour:minute
* - `long` — date + time, numeric/long/numeric, hour:minute:second
* - `full` — weekday + date + time + timezone (full long form)
* - `time-short` — hour:minute only
* - `time-long` — hour:minute:second only
* - `date-short` — numeric/numeric/numeric date only
* - `date-long` — numeric/long/numeric date only
*/
export const PRESET_MAP: PresetTable = {
short: {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
},
medium: {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
},
long: {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
},
full: {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "long",
},
"time-short": { hour: "numeric", minute: "numeric" },
"time-long": { hour: "numeric", minute: "numeric", second: "numeric" },
"date-short": { year: "numeric", month: "numeric", day: "numeric" },
"date-long": { year: "numeric", month: "long", day: "numeric" },
};
/**
* Merge a preset (if supplied) with raw `Intl.DateTimeFormatOptions`.
* Raw options override preset defaults for matching fields. Sourced
* from monolith L51–L58.
*/
export function resolvePreset(
options?: ExtendedDateOptions
): Intl.DateTimeFormatOptions | undefined {
if (!options) return undefined;
const { preset, locale: _locale, ...rest } = options;
if (!preset) return rest;
return { ...PRESET_MAP[preset], ...rest };
}
/** All eight preset names, exported for callers building UI selects. */
export const DATE_FORMAT_PRESETS: readonly DateFormatPreset[] = [
"short",
"medium",
"long",
"full",
"time-short",
"time-long",
"date-short",
"date-long",
];

View File

@@ -0,0 +1,12 @@
{
"$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"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-formatter-date",
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/i18n-shared",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});