Files
2026-05-27 23:29:53 +01:00

10 KiB

Capability Spec: i18n-formatter-date (i18n-formatter-date)

Package: @suaveplan/i18n-formatter-date Status: PROPOSED — first delta; becomes canonical spec on archive.

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED 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<string, Intl.DateTimeFormat> 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"