feat(settings): add opt-in calendar localization

This commit is contained in:
NimBold
2026-07-23 03:08:52 +03:30
parent 3f298715a6
commit 5463d27ed3
18 changed files with 258 additions and 24 deletions
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_CALENDAR_PREFERENCE,
formatDateTime,
isCalendarPreference
} from './dateTime';
describe('date/time formatting', () => {
const instant = new Date('2026-03-21T14:30:00.000Z');
it('uses Gregorian explicitly by default, including in localized UI languages', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
expect(formatDateTime(instant, { locale: 'fa', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-gregory', options).format(instant)
);
expect(DEFAULT_CALENDAR_PREFERENCE).toBe('gregorian');
});
it('supports the opt-in Persian and Hebrew calendars', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
);
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
);
});
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-');
expect(isCalendarPreference('gregorian')).toBe(true);
expect(isCalendarPreference('lunar')).toBe(false);
expect(isCalendarPreference(null)).toBe(false);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { CalendarPreference } from '../bindings/CalendarPreference';
import { resolveAppLocale } from '../i18n/locales';
export type { CalendarPreference } from '../bindings/CalendarPreference';
export const CALENDAR_PREFERENCES = ['gregorian', 'persian', 'hebrew'] as const satisfies readonly CalendarPreference[];
export const DEFAULT_CALENDAR_PREFERENCE: CalendarPreference = 'gregorian';
export const isCalendarPreference = (value: unknown): value is CalendarPreference =>
typeof value === 'string' && (CALENDAR_PREFERENCES as readonly string[]).includes(value);
type DateTimeInput = Date | string | number;
interface DateTimeFormatConfig {
locale?: string | null;
calendar?: CalendarPreference;
options?: Intl.DateTimeFormatOptions;
}
const calendarIdentifier = (calendar: CalendarPreference): string =>
calendar === 'gregorian' ? 'gregory' : calendar;
const localeWithCalendar = (locale: string | null | undefined, calendar: CalendarPreference): string => {
const resolvedLocale = resolveAppLocale(locale);
return `${resolvedLocale}-u-ca-${calendarIdentifier(calendar)}`;
};
const dateFromInput = (value: DateTimeInput): Date =>
value instanceof Date ? new Date(value.getTime()) : new Date(value);
/**
* Format a user-facing timestamp with an explicit calendar. Gregorian is
* passed explicitly because some localized browser defaults use a regional
* calendar even when the user has not opted into one.
*/
export const formatDateTime = (
value: DateTimeInput,
config: DateTimeFormatConfig = {}
): string => {
const date = dateFromInput(value);
if (Number.isNaN(date.getTime())) return '-';
const calendar = config.calendar && isCalendarPreference(config.calendar)
? config.calendar
: DEFAULT_CALENDAR_PREFERENCE;
const options = config.options ?? {};
try {
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, calendar),
options
).format(date);
} catch {
// WebViews can have incomplete ICU calendar data. Keep the display useful
// and deterministic rather than exposing a RangeError to the UI.
try {
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, DEFAULT_CALENDAR_PREFERENCE),
options
).format(date);
} catch {
return '-';
}
}
};