diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 8ca22876..75b11c58 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: 22 + node-version-file: '.nvmrc' cache: pnpm - name: Cache turbo build setup @@ -65,7 +65,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: 22 + node-version-file: '.nvmrc' cache: pnpm - name: Cache turbo build setup diff --git a/.github/workflows/website-tests.yml b/.github/workflows/website-tests.yml index 1f8e9138..e6749af4 100644 --- a/.github/workflows/website-tests.yml +++ b/.github/workflows/website-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: 22 + node-version-file: '.nvmrc' cache: pnpm - name: Install dependencies @@ -50,7 +50,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: 22 + node-version-file: '.nvmrc' cache: pnpm - name: Install dependencies diff --git a/.node-version b/.node-version index e2228113..d845d9d8 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.19.0 +24.14.0 diff --git a/.npmrc b/.npmrc index bd7f3b44..932e962b 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -node-version=22.19.0 +node-version=24.14.0 diff --git a/apps/sandbox/vite.config.ts b/apps/sandbox/vite.config.ts index d5f03f03..8d8b92df 100644 --- a/apps/sandbox/vite.config.ts +++ b/apps/sandbox/vite.config.ts @@ -1,11 +1,12 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig, normalizePath, type Plugin } from 'vite'; +import { mirrorTemplatesToSrc } from './scripts/shared'; + const htmlCdnDir = resolve(__dirname, '../../packages/html/cdn'); const htmlCdnI18nRegistry = normalizePath(resolve(htmlCdnDir, 'i18n.dev.js')); const cdnSandboxMainSrc = resolve(__dirname, 'src/cdn/main.ts'); @@ -108,7 +109,6 @@ function sandboxTemplateSyncPlugin(): Plugin { return { name: 'sandbox-template-sync', async buildStart() { - const { mirrorTemplatesToSrc } = await import(pathToFileURL(resolve(__dirname, 'scripts/shared.ts')).href); await mirrorTemplatesToSrc(); }, }; diff --git a/packages/utils/src/time/format.ts b/packages/utils/src/time/format.ts index 00d0498a..6d61bcd8 100644 --- a/packages/utils/src/time/format.ts +++ b/packages/utils/src/time/format.ts @@ -16,9 +16,48 @@ type DurationFormatConstructor = new ( options?: { style?: TimeFormatOptions['style']; hoursDisplay?: 'auto' | 'always' } ) => { format: (duration: DurationRecord) => string }; -const DurationFormat = (Intl as typeof Intl & { DurationFormat: DurationFormatConstructor }).DurationFormat; +const DurationFormat = (Intl as typeof Intl & { DurationFormat?: DurationFormatConstructor }).DurationFormat; -const durationFormatters = new Map>(); +type DurationFormatter = { format: (duration: DurationRecord) => string }; + +const durationFormatters = new Map(); + +/** + * `Intl.DurationFormat` is unavailable on Node < 23 (SSR/prerender) and pre-2024 evergreen + * browsers, so degrade gracefully per the documented browser-support fallback policy. + * Digital output stays exact; localized phrase styles fall back to English. + */ +function createFallbackFormatter( + style: NonNullable, + hoursDisplay?: 'auto' | 'always' +): DurationFormatter { + if (style === 'digital') { + const pad = (value: number): string => String(value).padStart(2, '0'); + return { + format: (duration) => { + const body = `${pad(duration.minutes ?? 0)}:${pad(duration.seconds ?? 0)}`; + const showHours = hoursDisplay === 'always' || duration.hours !== undefined; + return showHours ? `${duration.hours ?? 0}:${body}` : body; + }, + }; + } + + const units: Array<[keyof DurationRecord, string]> = [ + ['hours', 'hour'], + ['minutes', 'minute'], + ['seconds', 'second'], + ]; + return { + format: (duration) => + units + .filter(([unit]) => duration[unit] !== undefined) + .map(([unit, label]) => { + const value = duration[unit] ?? 0; + return `${value} ${label}${value === 1 ? '' : 's'}`; + }) + .join(', '), + }; +} function localeCacheKey(locale?: string | string[]): string { if (locale === undefined) return ''; @@ -35,12 +74,16 @@ function getDurationFormatter( locale?: string | string[], style: NonNullable = 'long', hoursDisplay?: 'auto' | 'always' -): InstanceType { +): DurationFormatter { const key = `${localeCacheKey(locale)}:${style}:${hoursDisplay ?? ''}`; let formatter = durationFormatters.get(key); if (!formatter) { - const options = hoursDisplay === undefined ? { style } : { style, hoursDisplay }; - formatter = new DurationFormat(locale, options); + if (DurationFormat) { + const options = hoursDisplay === undefined ? { style } : { style, hoursDisplay }; + formatter = new DurationFormat(locale, options); + } else { + formatter = createFallbackFormatter(style, hoursDisplay); + } durationFormatters.set(key, formatter); } return formatter; diff --git a/packages/utils/src/time/tests/format.test.ts b/packages/utils/src/time/tests/format.test.ts index 87eb9f36..a66bb130 100644 --- a/packages/utils/src/time/tests/format.test.ts +++ b/packages/utils/src/time/tests/format.test.ts @@ -1,7 +1,29 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format'; +const hasDurationFormat = typeof (Intl as { DurationFormat?: unknown }).DurationFormat === 'function'; + +type FormatModule = typeof import('../format'); + +/** + * Re-import the module with `Intl.DurationFormat` removed so the module-level capture + * resolves to `undefined` and `getDurationFormatter` uses `createFallbackFormatter`. + * Runs the fallback path deterministically regardless of the Node version. + */ +async function loadWithFallback(): Promise { + const intl = Intl as { DurationFormat?: unknown }; + const original = intl.DurationFormat; + intl.DurationFormat = undefined; + vi.resetModules(); + try { + return await import('../format'); + } finally { + if (original === undefined) delete intl.DurationFormat; + else intl.DurationFormat = original; + } +} + describe('formatTime', () => { it('formats seconds only', () => { expect(formatTime(0)).toBe('0:00'); @@ -82,7 +104,7 @@ describe('formatTimeAsPhrase', () => { expect(formatted).not.toMatch(/remaining$/i); }); - it('uses Intl.DurationFormat', () => { + it.runIf(hasDurationFormat)('uses Intl.DurationFormat', () => { const en = formatTimeAsPhrase(125, { locale: 'en' }); const de = formatTimeAsPhrase(125, { locale: 'de' }); expect(en.length).toBeGreaterThan(0); @@ -95,9 +117,15 @@ describe('formatTimeAsPhrase', () => { expect(formatTimeAsPhrase(Infinity)).toBe(''); }); - it('throws when Intl.DurationFormat rejects the locale', () => { + it.runIf(hasDurationFormat)('throws when Intl.DurationFormat rejects the locale', () => { expect(() => formatTimeAsPhrase(90, { locale: 'not-a-valid-bcp47-tag!!!' })).toThrow(RangeError); }); + + it.skipIf(hasDurationFormat)('falls back to an English phrase without Intl.DurationFormat', () => { + expect(formatTimeAsPhrase(125)).toBe('2 minutes, 5 seconds'); + expect(formatTimeAsPhrase(3661)).toBe('1 hour, 1 minute, 1 second'); + expect(formatTimeAsPhrase(-30)).toMatch(/remaining$/i); + }); }); describe('secondsToIsoDuration', () => { @@ -126,3 +154,64 @@ describe('secondsToIsoDuration', () => { expect(secondsToIsoDuration(Infinity)).toBe('PT0S'); }); }); + +describe('createFallbackFormatter', () => { + describe('digital style (via formatTime)', () => { + it('pads minutes and seconds', async () => { + const { formatTime: format } = await loadWithFallback(); + expect(format(5)).toBe('0:05'); + expect(format(65)).toBe('1:05'); + expect(format(600)).toBe('10:00'); + }); + + it('shows hours when present', async () => { + const { formatTime: format } = await loadWithFallback(); + expect(format(3661)).toBe('1:01:01'); + expect(format(36000)).toBe('10:00:00'); + }); + + it('forces hours display when guided by an hours-long duration', async () => { + const { formatTime: format } = await loadWithFallback(); + expect(format(35, 3600)).toBe('0:00:35'); + }); + }); + + describe('text style (via formatTimeAsPhrase)', () => { + it('joins present units with a comma', async () => { + const { formatTimeAsPhrase: format } = await loadWithFallback(); + expect(format(3661)).toBe('1 hour, 1 minute, 1 second'); + expect(format(125)).toBe('2 minutes, 5 seconds'); + }); + + it('pluralizes based on the unit value', async () => { + const { formatTimeAsPhrase: format } = await loadWithFallback(); + expect(format(1)).toBe('1 second'); + expect(format(2)).toBe('2 seconds'); + expect(format(3600)).toBe('1 hour'); + expect(format(7200)).toBe('2 hours'); + }); + + it('omits units that are absent from the record', async () => { + const { formatTimeAsPhrase: format } = await loadWithFallback(); + expect(format(30)).toBe('30 seconds'); + expect(format(120)).toBe('2 minutes'); + expect(format(0)).toBe('0 seconds'); + }); + + it('ignores non-digital style variants (all render as long-form text)', async () => { + const { formatTimeAsPhrase: format } = await loadWithFallback(); + expect(format(125, { style: 'short' })).toBe('2 minutes, 5 seconds'); + expect(format(125, { style: 'narrow' })).toBe('2 minutes, 5 seconds'); + }); + + it('wraps negative durations in the English remaining phrase', async () => { + const { formatTimeAsPhrase: format } = await loadWithFallback(); + expect(format(-30)).toBe('30 seconds remaining'); + }); + + it('applies formatRemaining for negative durations', async () => { + const { formatTimeAsPhrase: format } = await loadWithFallback(); + expect(format(-30, { formatRemaining: (duration) => `${duration} left` })).toBe('30 seconds left'); + }); + }); +});