# `@suaveplan/i18n-formatter-date` — `Intl.DateTimeFormat` wrapper **Package**: `@suaveplan/i18n-formatter-date` (NEW) **Status**: PROPOSED **Type**: feature (new universal-tier package; verbatim port of `formatting/date/` plus class wrapper + Zod schema + observability uplift) **Date**: 2026-05-05 **Wave**: 3 (depends on Wave 1 foundation; required by Wave 6 core slim) **Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §5b FormatBridge) **Path**: `packages/i18n/i18n-formatter-date/` **Tier**: universal ## Why The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package. `@suaveplan/i18n-formatter-date` ports the existing `formatting/date/` module — the broadest of the formatters today — and brings it up to the canonical Wave-3 shape (Zod-validated options, OTel histogram + cache-hit counter, class wrapper for symmetry, sibling test files). Today `formatting/date/` ships a rich surface (180 LoC): - `formatDate(date, locale, options?)` standalone with preset-or-options resolution (`packages/i18n/i18n/src/formatting/date/date.ts` L64–L73) - `formatTime(date, locale, long?)` standalone (`date.ts` L79–L83) — short / long preset - `formatDateOnly(date, locale, long?)` standalone (`date.ts` L89–L97) - `formatDateRange(start, end, locale, options?)` standalone using `formatRange` (`date.ts` L104–L116) - `getDayNames(locale, style?)` ECMA-402-§10.6-`getWeekInfo()`-aware day-name list (`date.ts` L126–L160) - `getMonthNames(locale, style?)` (`date.ts` L166–L180) - 8-preset table (`PRESET_MAP` at L4–L41): `short`, `medium`, `long`, `full`, `time-short`, `time-long`, `date-short`, `date-long` - BCP-47 normalisation via `LOCALE_BCP47_MAP` from shared constants (`date.ts` L43–L49) - Type definitions (`date.types.ts` L1–L24): `DateFormatPreset`, `DateFormatOptions`, `ExtendedDateOptions` This package introduces no new functionality beyond what the monolith ships — the changes are: (a) wrapping the standalones in a `DateFormatter` class for symmetry with `NumberFormatter`/`RelativeTimeFormatter`/`ListFormatter`, (b) adding Zod-validated options, (c) adding the canonical OTel histogram + cache-hit counter (the monolith's date formatter currently does not emit either), and (d) introducing a per-(locale, options-hash) cache (the monolith currently constructs a fresh `Intl.DateTimeFormat` per call). ## What changes ### New package: `@suaveplan/i18n-formatter-date` - ADDED: `packages/i18n/i18n-formatter-date/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`). - ADDED: `src/formatter.ts` — `DateFormatter` class (NEW); `formatDate` standalone (port of monolith L64–L73) extended with cache and OTel emission; `formatDateRange` standalone (port of L104–L116); `clearDateFormatterCache` test-only helper. `PRESET_MAP` and `resolvePreset` helper (port of L4–L58). `toBcp47` helper (port of L43–L49). - ADDED: `src/formatter.types.ts` — `DateFormatPreset`, `DateFormatOptions`, `ExtendedDateOptions`, `DateFormatterCacheKey`. Sourced from monolith `date.types.ts` L1–L24 plus new types for the class surface. - ADDED: `src/formatter.schemas.ts` — `DateFormatterOptionsSchema` Zod schema. NEW (the monolith does not currently Zod-validate options). - ADDED: `src/helpers.ts` — `formatTime(date, locale, long?)` (port of monolith L79–L83); `formatDateOnly(date, locale, long?)` (port of L89–L97); `getDayNames(locale, style?)` (port of L126–L160); `getMonthNames(locale, style?)` (port of L166–L180). - ADDED: `src/observability.ts` — local OTel histogram `i18n.format.date.duration_ms` + counter `i18n.format.cache_hit{formatter="date"}`. - ADDED: `src/formatter.test.ts` — port of monolith `date.test.ts` (185 lines) plus new tests for `DateFormatter` class, Zod-validation, cache reuse. - ADDED: `src/formatter.spec-conformance.test.ts` — every requirement scenario in this delta. - ADDED: `src/helpers.test.ts` — covers `formatTime`, `formatDateOnly`, `getDayNames`, `getMonthNames` (including `getWeekInfo()`-fallback for older runtimes). - ADDED: `src/index.ts` — explicit named re-exports. - ADDED: `src/formatter.md`, `src/helpers.md`, `src/observability.md` — sibling docs. ### Subpath exports - `.` — root barrel - `./formatter` — `DateFormatter` class + `formatDate` + `formatDateRange` + types + schema only - `./helpers` — `formatTime`, `formatDateOnly`, `getDayNames`, `getMonthNames` only ### Dependencies - `@suaveplan/i18n-shared workspace:^` — `I18N_CODES`, `I18nError`, `LOCALE_BCP47_MAP` - `@suaveplan/types workspace:^` - `@suaveplan/error workspace:^` - `@opentelemetry/api catalog:observability` - `tslib catalog:build` - `zod catalog:forms` - **No** dep on any other formatter package, on `@suaveplan/i18n` core, or on any backend package. ## Out of scope - **Removing `formatting/date/` from `packages/i18n/i18n/src/`** — happens in Wave 6. - **`formatToParts` for date output** — out of scope for Wave 3; can be added as a follow-up if downstream consumers (e.g. `react-data-table` date columns) need part-by-part rendering. - **`Temporal` API support** — out of scope; `Intl.DateTimeFormat` accepts `Date | number` today and the monolith's surface matches that. Temporal-aware overloads are a follow-up once Temporal stabilises across runtimes. - **Custom calendar systems beyond what `Intl.DateTimeFormat` supports** — out of scope. ## Source mapping | Requirement | Source file | Source lines | Notes | |---|---|---|---| | `formatDate` standalone with preset-or-options resolution | `packages/i18n/i18n/src/formatting/date/date.ts` | L64–L73 | Verbatim port; extended with cache + OTel emission | | `PRESET_MAP` (8 presets: short/medium/long/full/time-short/time-long/date-short/date-long) | `packages/i18n/i18n/src/formatting/date/date.ts` | L4–L41 | Verbatim port | | `formatTime` standalone | `packages/i18n/i18n/src/formatting/date/date.ts` | L79–L83 | Verbatim port (helper) | | `formatDateOnly` standalone | `packages/i18n/i18n/src/formatting/date/date.ts` | L89–L97 | Verbatim port (helper) | | `formatDateRange` standalone | `packages/i18n/i18n/src/formatting/date/date.ts` | L104–L116 | Verbatim port | | `getDayNames` (`getWeekInfo()`-aware) | `packages/i18n/i18n/src/formatting/date/date.ts` | L126–L160 | Verbatim port | | `getMonthNames` | `packages/i18n/i18n/src/formatting/date/date.ts` | L166–L180 | Verbatim port | | `toBcp47` BCP-47 normalisation | `packages/i18n/i18n/src/formatting/date/date.ts` | L43–L49 | Verbatim port; use `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared` | | `resolvePreset` preset+options merge | `packages/i18n/i18n/src/formatting/date/date.ts` | L51–L58 | Verbatim port | | `DateFormatPreset`, `DateFormatOptions`, `ExtendedDateOptions` types | `packages/i18n/i18n/src/formatting/date/date.types.ts` | L1–L24 | Verbatim port | ### Requirements with no source line | Requirement | Justification | |---|---| | `DateFormatter` class wrapper | New — symmetric with `NumberFormatter`, `RelativeTimeFormatter`, `ListFormatter`, `DisplayNameFormatter` | | `DateFormatterOptionsSchema` Zod schema | New — current monolith does not validate options | | `INVALID_FORMATTER_OPTIONS` mapping | Bound to the new Zod schema | | Per-(locale, options-hash) cache | New — current monolith constructs a fresh `Intl.DateTimeFormat` per call; Wave-3 formatter shape mandates the cache for parity with sibling packages | | OTel histogram `i18n.format.date.duration_ms` + cache-hit counter | New — canonical Wave-3 formatter shape mandates these; monolith date formatter currently does not emit either | ## Verification approach - `packages/i18n/i18n-formatter-date/src/formatter.test.ts` — port of monolith `date.test.ts`; all 8 presets render correctly; raw `Intl.DateTimeFormatOptions` work; preset + raw merged correctly via `resolvePreset`; class wrapper twins helper output; cache reuse on identical key; bogus locale → `UNSUPPORTED_LOCALE`; Zod failure → `INVALID_FORMATTER_OPTIONS` - `packages/i18n/i18n-formatter-date/src/formatter.spec-conformance.test.ts` — every requirement scenario runs as an executable test - `packages/i18n/i18n-formatter-date/src/helpers.test.ts` — `formatTime(d, "en-GB", false)` returns short-style time; `formatTime(d, "en-GB", true)` includes seconds; `formatDateOnly(d, "en-GB", false)` short style; `getDayNames("en-GB")` returns 7 strings starting from Monday (en-GB `getWeekInfo()` → 1); `getMonthNames("en-GB")` returns 12 strings; `getDayNames` falls back to Monday-start when `getWeekInfo()` is unavailable - `packages/i18n/i18n-formatter-date/src/observability.test.ts` — histogram named `i18n.format.date.duration_ms`; counter `i18n.format.cache_hit{formatter="date"}` increments only on cache hit - `packages/i18n/i18n-formatter-date/src/index.test.ts` — every documented public export resolves; subpaths resolve ## Impact - New universal-tier package — joins the rest of the i18n family. - Indirect consumer in Wave 6: `@suaveplan/i18n` slim core wires `formatDate` and `formatTime` into the default `FormatBridge` for ICU `{date, date, short}` and `{ts, time, long}` placeholder rendering. - `MANIFEST.md` and `CLAUDE.md` updated post-merge.