Initial Commit
This commit is contained in:
128
packages/localization/i18n/i18n-formatter-date/README.md
Normal file
128
packages/localization/i18n/i18n-formatter-date/README.md
Normal 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.
|
||||
|
||||
88
packages/localization/i18n/i18n-formatter-date/package.json
Normal file
88
packages/localization/i18n/i18n-formatter-date/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
getDayNames,
|
||||
getMonthNames,
|
||||
} from "./day-month-names.js";
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
clearDateFormatterCache,
|
||||
DateFormatter,
|
||||
formatDate,
|
||||
formatDateRange,
|
||||
toBcp47,
|
||||
} from "./formatter.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
formatDateOnly,
|
||||
formatTime,
|
||||
} from "./helpers.js";
|
||||
111
packages/localization/i18n/i18n-formatter-date/src/index.test.ts
Normal file
111
packages/localization/i18n/i18n-formatter-date/src/index.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
33
packages/localization/i18n/i18n-formatter-date/src/index.ts
Normal file
33
packages/localization/i18n/i18n-formatter-date/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
DateMetricSample,
|
||||
DateMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getDateMetricsState,
|
||||
recordDateCacheHit,
|
||||
recordDateFormat,
|
||||
resetDateMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DATE_FORMAT_PRESETS,
|
||||
PRESET_MAP,
|
||||
resolvePreset,
|
||||
} from "./presets.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user