# Capability Spec: i18n-formatter-date (i18n-formatter-date) **Package**: `@suaveplan/i18n-formatter-date` **Status**: ACTIVE > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## Requirements ### Requirement: DateFormatter class wrapping `Intl.DateTimeFormat` The package SHALL export a `DateFormatter` class that wraps `Intl.DateTimeFormat` with construction-time Zod validation of options and `format(date: Date, locale: string): string` and `formatRange(start: Date, end: Date, locale: string): string` methods. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`. #### Scenario: format with default options - **GIVEN** `const f = new DateFormatter()` - **WHEN** `f.format(new Date(2026, 0, 1), "en-GB")` is called - **THEN** the result contains `"2026"` (locale-correct rendering) #### Scenario: invalid options throw INVALID_FORMATTER_OPTIONS - **WHEN** `new DateFormatter({ preset: "invalid" as never })` is called - **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"` **Verified by:** `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"DateFormatter formats with default options"` AND `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"DateFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"` ### Requirement: `formatDate` standalone helper with preset resolution The package SHALL export a `formatDate(date, locale, options?)` standalone helper. When `options.preset` is supplied, it SHALL be resolved against the 8-entry `PRESET_MAP` (`short`, `medium`, `long`, `full`, `time-short`, `time-long`, `date-short`, `date-long`). Raw `Intl.DateTimeFormatOptions` (`weekday`, `year`, `month`, `day`, etc.) supplied alongside `preset` SHALL override the preset's defaults for those fields. #### Scenario: preset short renders short style - **WHEN** `formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" })` is called - **THEN** the result is a short-style date+time string (e.g. `"01/01/2026, 00:00"` on en-GB) #### Scenario: raw options override preset - **WHEN** `formatDate(d, "en-GB", { preset: "short", year: undefined })` is called - **THEN** the result drops the year field while retaining the preset's other defaults #### Scenario: standalone twins class output - **WHEN** both `formatDate(d, l, opts)` and `new DateFormatter(opts).format(d, l)` are called for the same `(d, l, opts)` - **THEN** the results are identical **Verified by:** `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"formatDate preset short renders short style"` AND `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"formatDate raw options override preset"` AND `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"formatDate output twins DateFormatter.format output"` ### Requirement: `formatDateRange` standalone helper The package SHALL export a `formatDateRange(start, end, locale, options?)` standalone helper that uses `Intl.DateTimeFormat.formatRange` for cross-day ranges and falls back to two-segment rendering when `formatRange` is unavailable on older runtimes. #### Scenario: range across two days - **WHEN** `formatDateRange(new Date(2026, 0, 1), new Date(2026, 0, 5), "en-GB")` is called - **THEN** the result contains both day numbers `"1"` and `"5"` (locale-correct rendering) **Verified by:** `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"formatDateRange renders cross-day range"` ### Requirement: `Intl.DateTimeFormat` `RangeError` maps to `UNSUPPORTED_LOCALE` The package SHALL catch every `RangeError` thrown by the `Intl.DateTimeFormat` constructor and re-throw it as `I18nError(I18N.UNSUPPORTED_LOCALE)` with the original error attached as `cause` and the locale string in `context`. #### Scenario: bogus locale throws UNSUPPORTED_LOCALE - **WHEN** `formatDate(new Date(), "xx-bogus-tag-!!!")` is called - **THEN** the call throws `I18nError` with code `"I18N.UNSUPPORTED_LOCALE"` AND the error's `cause` is the original `RangeError` **Verified by:** `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"` ### Requirement: Per-(locale, options-hash) cache The package SHALL maintain an internal `Map` keyed by `${locale}|${JSON.stringify(options)}`. Repeated calls with identical `(locale, options)` SHALL reuse the same `Intl.DateTimeFormat` instance. The package SHALL export a `clearDateFormatterCache()` test-only helper. #### Scenario: identical options reuse the same Intl.DateTimeFormat - **GIVEN** the cache is empty - **WHEN** `formatDate(d, "en-GB", { preset: "short" })` is called twice in sequence - **THEN** only one `Intl.DateTimeFormat` constructor invocation occurs #### Scenario: distinct preset creates distinct cache entries - **WHEN** `formatDate(d, "en-GB", { preset: "short" })` and `formatDate(d, "en-GB", { preset: "long" })` are called - **THEN** two distinct `Intl.DateTimeFormat` instances are created and cached **Verified by:** `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"identical options reuse cached Intl.DateTimeFormat"` AND `packages/i18n/i18n-formatter-date/src/formatter.test.ts::"distinct preset creates distinct cache entries"` ### Requirement: OTel histogram `i18n.format.date.duration_ms` The package SHALL emit an OTel histogram named exactly `i18n.format.date.duration_ms` (unit `ms`) on every successful `formatDate` or `formatDateRange` call, tagged with `locale` and `preset` (or `"custom"` when no preset is supplied). #### Scenario: histogram emitted on every call - **GIVEN** an OTel test exporter capturing histogram observations - **WHEN** `formatDate(d, "en-GB", { preset: "short" })` is called - **THEN** exactly one observation appears under `i18n.format.date.duration_ms` with `locale: "en-GB"` and `preset: "short"` attributes **Verified by:** `packages/i18n/i18n-formatter-date/src/observability.test.ts::"emits i18n.format.date.duration_ms histogram on every call"` ### Requirement: OTel cache-hit counter `i18n.format.cache_hit` The package SHALL emit an OTel counter `i18n.format.cache_hit` tagged with `formatter: "date"` on every cache hit. The counter SHALL NOT increment on the first (cache-miss) call. #### Scenario: cache-hit counter increments only on second call - **GIVEN** an OTel test exporter capturing counter observations and an empty cache - **WHEN** `formatDate(d, "en-GB", { preset: "short" })` is called twice in sequence - **THEN** the counter `i18n.format.cache_hit{formatter="date"}` shows exactly one increment **Verified by:** `packages/i18n/i18n-formatter-date/src/observability.test.ts::"cache-hit counter increments only on second call with same options"` ### Requirement: `formatTime` standalone helper The package SHALL export a `formatTime(date, locale, long?)` standalone helper that pre-binds `formatDate` with the `time-short` (or `time-long` when `long: true`) preset. #### Scenario: short time rendering - **WHEN** `formatTime(new Date(2026, 0, 1, 14, 30), "en-GB", false)` is called - **THEN** the result equals a short-style time (e.g. `"14:30"`) and does NOT include seconds #### Scenario: long time rendering includes seconds - **WHEN** `formatTime(new Date(2026, 0, 1, 14, 30, 45), "en-GB", true)` is called - **THEN** the result includes seconds (e.g. `"14:30:45"`) **Verified by:** `packages/i18n/i18n-formatter-date/src/helpers.test.ts::"formatTime short does not include seconds"` AND `packages/i18n/i18n-formatter-date/src/helpers.test.ts::"formatTime long includes seconds"` ### Requirement: `formatDateOnly` standalone helper The package SHALL export a `formatDateOnly(date, locale, long?)` standalone helper that pre-binds `formatDate` with the `date-short` (or `date-long` when `long: true`) preset. #### Scenario: short date-only rendering - **WHEN** `formatDateOnly(new Date(2026, 0, 1), "en-GB", false)` is called - **THEN** the result is a numeric short-date (e.g. `"01/01/2026"`) without time **Verified by:** `packages/i18n/i18n-formatter-date/src/helpers.test.ts::"formatDateOnly short renders numeric date"` ### Requirement: `getDayNames` and `getMonthNames` helpers The package SHALL export `getDayNames(locale, style?)` returning 7 day names starting from the locale-defined first day of the week (consults `Intl.Locale.getWeekInfo()` when available; falls back to Monday-start) and `getMonthNames(locale, style?)` returning 12 month names. Both accept `style: "long" | "short" | "narrow"` (default `"long"`). #### Scenario: getDayNames returns 7 strings - **WHEN** `getDayNames("en-GB", "long")` is called - **THEN** the result has length `7` AND every entry is a non-empty string #### Scenario: getMonthNames returns 12 strings - **WHEN** `getMonthNames("en-GB", "long")` is called - **THEN** the result has length `12` AND every entry is a non-empty string #### Scenario: getDayNames falls back to Monday-start when getWeekInfo() unavailable - **GIVEN** `Intl.Locale.prototype.getWeekInfo` is mocked to throw - **WHEN** `getDayNames("en-GB", "long")` is called - **THEN** the first entry is `"Monday"` (locale-correct Monday rendering) **Verified by:** `packages/i18n/i18n-formatter-date/src/helpers.test.ts::"getDayNames returns 7 strings"` AND `packages/i18n/i18n-formatter-date/src/helpers.test.ts::"getMonthNames returns 12 strings"` AND `packages/i18n/i18n-formatter-date/src/helpers.test.ts::"getDayNames falls back to Monday-start when getWeekInfo unavailable"` ### Requirement: Subpath exports The package SHALL declare subpath exports in `package.json`: - `@suaveplan/i18n-formatter-date` — root barrel - `@suaveplan/i18n-formatter-date/formatter` — `DateFormatter` class + `formatDate` + `formatDateRange` + types + schema only - `@suaveplan/i18n-formatter-date/helpers` — `formatTime`, `formatDateOnly`, `getDayNames`, `getMonthNames` only #### Scenario: every documented subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/i18n/i18n-formatter-date/src/index.test.ts::"every documented subpath export resolves"` ### Requirement: Side-effect-free package The package SHALL declare `"sideEffects": false` in `package.json`. #### Scenario: package.json declares sideEffects false - **WHEN** `package.json` is parsed - **THEN** the `sideEffects` field equals `false` **Verified by:** `packages/i18n/i18n-formatter-date/src/index.test.ts::"package.json declares sideEffects false"`