mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add i18n foundation with English locale and UI wiring (#1589)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Wesley Luyten <me@wesleyluyten.com>
This commit is contained in:
co-authored by
Cursor
Wesley Luyten
parent
fa768da757
commit
768bf09da0
@@ -1,11 +1,79 @@
|
||||
import { isNumber } from '../predicate/predicate';
|
||||
|
||||
export type TimeFormatOptions = {
|
||||
/** BCP 47 tag(s) for {@link Intl.DurationFormat} (and percent formatting where applicable). */
|
||||
locale?: string | string[];
|
||||
/** Called only when `seconds` is negative; formats the localized remaining-time phrase for the duration body. */
|
||||
formatRemaining?: (duration: string) => string;
|
||||
/** Passed to `Intl.DurationFormat`; defaults to `"long"`. */
|
||||
style?: 'long' | 'short' | 'narrow' | 'digital';
|
||||
};
|
||||
|
||||
const UNIT_LABELS = [
|
||||
{ singular: 'hour', plural: 'hours' },
|
||||
{ singular: 'minute', plural: 'minutes' },
|
||||
{ singular: 'second', plural: 'seconds' },
|
||||
] as const;
|
||||
|
||||
type DurationFormatConstructor = new (
|
||||
locales?: string | string[],
|
||||
options?: { style?: TimeFormatOptions['style'] }
|
||||
) => { format: (duration: object) => string };
|
||||
|
||||
const DurationFormat = (Intl as typeof Intl & { DurationFormat?: DurationFormatConstructor }).DurationFormat;
|
||||
|
||||
const percentFormatters = new Map<string, Intl.NumberFormat>();
|
||||
const durationFormatters = new Map<string, InstanceType<NonNullable<typeof DurationFormat>>>();
|
||||
|
||||
function localeCacheKey(locale?: string | string[]): string {
|
||||
if (locale === undefined) return '';
|
||||
return Array.isArray(locale) ? locale.join('\0') : locale;
|
||||
}
|
||||
|
||||
function isEnglishLocale(locale?: string | string[]): boolean {
|
||||
const tag = Array.isArray(locale) ? locale[0] : locale;
|
||||
if (!tag) return true;
|
||||
return tag === 'en' || tag.startsWith('en-');
|
||||
}
|
||||
|
||||
function getPercentFormatter(locale?: string | string[]): Intl.NumberFormat | undefined {
|
||||
const key = localeCacheKey(locale);
|
||||
let formatter = percentFormatters.get(key);
|
||||
if (!formatter) {
|
||||
try {
|
||||
formatter = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 });
|
||||
percentFormatters.set(key, formatter);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function formatVolumePercentFallback(fraction: number): string {
|
||||
const percent = Math.round(Math.min(1, Math.max(0, fraction)) * 100);
|
||||
return `${percent}%`;
|
||||
}
|
||||
|
||||
function getDurationFormatter(
|
||||
locale?: string | string[],
|
||||
style: NonNullable<TimeFormatOptions['style']> = 'long'
|
||||
): InstanceType<NonNullable<typeof DurationFormat>> | undefined {
|
||||
if (!DurationFormat) return undefined;
|
||||
|
||||
const key = `${localeCacheKey(locale)}\0${style}`;
|
||||
let formatter = durationFormatters.get(key);
|
||||
if (!formatter) {
|
||||
try {
|
||||
formatter = new DurationFormat(locale, { style });
|
||||
durationFormatters.set(key, formatter);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function isValidTime(value: number): boolean {
|
||||
return isNumber(value) && Number.isFinite(value);
|
||||
}
|
||||
@@ -118,3 +186,69 @@ export function secondsToIsoDuration(seconds: number): string {
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable duration using {@link Intl.DurationFormat} when available.
|
||||
*
|
||||
* Negative `seconds` denote remaining time: the absolute value is formatted, then wrapped in a
|
||||
* localized phrase via {@link TimeFormatOptions.formatRemaining}; otherwise `{duration} remaining`.
|
||||
*/
|
||||
export function formatDuration(seconds: number, options?: TimeFormatOptions): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
const totalSeconds = Math.floor(positiveSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secondsPart = totalSeconds % 60;
|
||||
|
||||
const record: Partial<{ hours: number; minutes: number; seconds: number }> = {};
|
||||
if (hours > 0) record.hours = hours;
|
||||
if (minutes > 0) record.minutes = minutes;
|
||||
if (secondsPart > 0 || (hours === 0 && minutes === 0)) record.seconds = secondsPart;
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
const durationFormatter = getDurationFormatter(options?.locale, options?.style ?? 'long');
|
||||
if (durationFormatter) {
|
||||
body = durationFormatter.format(record);
|
||||
} else {
|
||||
body = formatTimeAsPhrase(positiveSeconds);
|
||||
}
|
||||
} catch {
|
||||
body = formatTimeAsPhrase(positiveSeconds);
|
||||
}
|
||||
|
||||
// Some ICU builds return an empty string for a zero-length duration; fall back to the phrase formatter.
|
||||
if (!body.trim()) {
|
||||
body = formatTimeAsPhrase(positiveSeconds);
|
||||
}
|
||||
|
||||
if (negative) {
|
||||
const formatRemaining = options?.formatRemaining;
|
||||
if (formatRemaining) return formatRemaining(body);
|
||||
if (isEnglishLocale(options?.locale)) return `${body} remaining`;
|
||||
return body;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Format a volume fraction (0–1) with {@link Intl.NumberFormat} `style: "percent"`. */
|
||||
export function formatVolumePercent(fraction: number, locale?: string | string[]): string {
|
||||
const value = !isNumber(fraction) || !Number.isFinite(fraction) ? 0 : Math.min(1, Math.max(0, fraction));
|
||||
|
||||
try {
|
||||
const formatter = getPercentFormatter(locale) ?? getPercentFormatter(undefined);
|
||||
if (formatter) {
|
||||
return formatter.format(value);
|
||||
}
|
||||
} catch {
|
||||
// fall through to simple percent string
|
||||
}
|
||||
|
||||
return formatVolumePercentFallback(value);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format';
|
||||
import { formatDuration, formatTime, formatTimeAsPhrase, formatVolumePercent, secondsToIsoDuration } from '../format';
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats seconds only', () => {
|
||||
@@ -99,6 +99,80 @@ describe('formatTimeAsPhrase', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats positive duration', () => {
|
||||
expect(formatDuration(90)).toContain('1');
|
||||
expect(formatDuration(90)).toMatch(/minute/i);
|
||||
expect(formatDuration(90)).toMatch(/30/);
|
||||
expect(formatDuration(300)).toMatch(/5/);
|
||||
expect(formatDuration(300)).toMatch(/minute/i);
|
||||
});
|
||||
|
||||
it('adds remaining suffix for negative seconds', () => {
|
||||
expect(formatDuration(-30)).toMatch(/30/);
|
||||
expect(formatDuration(-30)).toMatch(/remaining$/i);
|
||||
});
|
||||
|
||||
it('uses formatRemaining only for negative durations', () => {
|
||||
expect(formatDuration(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/^quedan /);
|
||||
expect(formatDuration(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/30/);
|
||||
expect(formatDuration(90, { formatRemaining: () => 'should-not-appear' })).toBe(formatDuration(90));
|
||||
});
|
||||
|
||||
it('omits English remaining suffix for non-English locales without formatRemaining', () => {
|
||||
const formatted = formatDuration(-30, { locale: 'es' });
|
||||
expect(formatted).toMatch(/30/);
|
||||
expect(formatted).not.toMatch(/remaining$/i);
|
||||
});
|
||||
|
||||
it('uses Intl.DurationFormat when supported; otherwise matches formatTimeAsPhrase', () => {
|
||||
const DurationFormatConstructor = (Intl as typeof Intl & { DurationFormat?: unknown }).DurationFormat;
|
||||
const hasDurationFormat = typeof DurationFormatConstructor === 'function';
|
||||
const phrase = formatTimeAsPhrase(125);
|
||||
if (hasDurationFormat) {
|
||||
const en = formatDuration(125, { locale: 'en' });
|
||||
const de = formatDuration(125, { locale: 'de' });
|
||||
expect(en.length).toBeGreaterThan(0);
|
||||
expect(de.length).toBeGreaterThan(0);
|
||||
expect(en).not.toBe(de);
|
||||
} else {
|
||||
expect(formatDuration(125, { locale: 'en' })).toBe(phrase);
|
||||
expect(formatDuration(125, { locale: 'ja' })).toBe(phrase);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(formatDuration(NaN)).toBe('');
|
||||
expect(formatDuration(Infinity)).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to formatTimeAsPhrase when locale is invalid', () => {
|
||||
const phrase = formatTimeAsPhrase(90);
|
||||
expect(formatDuration(90, { locale: 'not-a-valid-bcp47-tag!!!' })).toBe(phrase);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatVolumePercent', () => {
|
||||
it('uses Intl percent style', () => {
|
||||
expect(formatVolumePercent(0.75)).toMatch(/75/);
|
||||
expect(formatVolumePercent(0.75)).toMatch(/%/);
|
||||
});
|
||||
|
||||
it('clamps to 0–100%', () => {
|
||||
expect(formatVolumePercent(-1)).toBe(formatVolumePercent(0));
|
||||
expect(formatVolumePercent(2)).toBe(formatVolumePercent(1));
|
||||
});
|
||||
|
||||
it('handles invalid fraction', () => {
|
||||
expect(formatVolumePercent(Number.NaN)).toMatch(/0/);
|
||||
expect(formatVolumePercent(Number.NaN)).toMatch(/%/);
|
||||
});
|
||||
|
||||
it('falls back when locale is invalid', () => {
|
||||
expect(formatVolumePercent(0.75, 'not-a-invalid-bcp47-tag!!!')).toBe('75%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsToIsoDuration', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(secondsToIsoDuration(0)).toBe('PT0S');
|
||||
|
||||
Reference in New Issue
Block a user