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
+17
View File
@@ -202,6 +202,21 @@ pub enum Theme {
Nord,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum CalendarPreference {
Gregorian,
Persian,
Hebrew,
}
impl Default for CalendarPreference {
fn default() -> Self {
Self::Gregorian
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -267,6 +282,8 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
#[serde(default)]
pub calendar_preference: CalendarPreference,
#[serde(default = "default_language_preference")]
pub language: String,
pub base_download_folder: String,
+9 -2
View File
@@ -1,6 +1,6 @@
use crate::ipc::{
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode,
SchedulerSettings, SettingsTab, Theme,
AppFontSize, CalendarPreference, ListRowDensity, MediaCookieSource, PersistedSettings,
PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
};
use serde_json::{Map, Value};
use std::collections::HashMap;
@@ -197,6 +197,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"language",
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
);
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
@@ -416,6 +417,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
calendar_preference: CalendarPreference::Gregorian,
language: "system".to_string(),
base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true,
@@ -703,6 +705,7 @@ mod tests {
"perServerConnections": 5,
"showNotifications": "yes",
"theme": "not-a-theme",
"calendarPreference": "lunar",
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
},
"version": 3
@@ -715,6 +718,10 @@ mod tests {
assert_eq!(settings.per_server_connections, 5);
assert!(settings.show_notifications);
assert!(matches!(settings.theme, crate::ipc::Theme::System));
assert!(matches!(
settings.calendar_preference,
crate::ipc::CalendarPreference::Gregorian
));
assert_eq!(settings.site_logins.len(), 1);
assert_eq!(settings.site_logins[0].id, "valid");
}
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CalendarPreference = "gregorian" | "persian" | "hebrew";
+2 -1
View File
@@ -1,5 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppFontSize } from "./AppFontSize";
import type { CalendarPreference } from "./CalendarPreference";
import type { ListRowDensity } from "./ListRowDensity";
import type { MediaCookieSource } from "./MediaCookieSource";
import type { ProxyMode } from "./ProxyMode";
@@ -8,4 +9,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+12 -3
View File
@@ -4,6 +4,8 @@ import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown, GripVertical } fr
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../store/useSettingsStore';
import { formatDateTime } from '../utils/dateTime';
import {
downloadProgressColorClass,
formatDownloadTotal,
@@ -59,7 +61,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onQueueDragStart,
onClick,
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false);
@@ -71,6 +74,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
})();
const dateAddedLabel = download.dateAdded
? formatDateTime(download.dateAdded, {
locale: i18n.language,
calendar: calendarPreference
})
: '-';
const updateActionPosition = React.useCallback(() => {
const row = rowRef.current;
@@ -327,9 +336,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
<span
className="download-cell-content download-date-value tabular-nums"
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
title={dateAddedLabel}
>
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
{dateAddedLabel}
</span>
</div>
),
+15 -9
View File
@@ -18,19 +18,25 @@ import {
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
type LoginMode = 'matching' | 'custom' | 'none';
const formatLastTry = (value?: string): string => {
const formatLastTry = (
value: string | undefined,
locale: string,
calendar: CalendarPreference
): string => {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '-'
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return formatDateTime(value, {
locale,
calendar,
options: { dateStyle: 'medium', timeStyle: 'short' }
});
};
export const PropertiesModal = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const categoryLabel = (category: string) => {
switch (category) {
case 'Musics': return t($ => $.navigation.categories.musics);
@@ -55,7 +61,7 @@ export const PropertiesModal = () => {
: undefined
));
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
// Form states
const [url, setUrl] = useState('');
@@ -373,9 +379,9 @@ export const PropertiesModal = () => {
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry, i18n.language, calendarPreference)}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
<div className="flex gap-1.5 col-span-4 min-w-0">
+14 -8
View File
@@ -11,6 +11,7 @@ import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../utils/dateTime';
const days = [
{ value: 0, key: 'su' },
@@ -54,9 +55,10 @@ function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'non
}
export default function SchedulerView() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const setScheduler = useSettingsStore(state => state.setScheduler);
const queues = useDownloadStore(state => state.queues);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
@@ -75,14 +77,18 @@ export default function SchedulerView() {
const scheduledRun = nextScheduledRun(draft);
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
return scheduledRun.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
return formatDateTime(scheduledRun, {
locale: i18n.language,
calendar: calendarPreference,
options: {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
});
}, [draft, t]);
}, [calendarPreference, draft, i18n.language, t]);
const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings]
+20
View File
@@ -693,6 +693,11 @@ runEngineChecks(false);
{ value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) },
{ value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) },
] as const;
const calendarOptions = [
{ value: 'gregorian', label: t($ => $.settings.lookAndFeel.calendarGregorian) },
{ value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) },
{ value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) },
] as const;
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type;
@@ -870,6 +875,21 @@ runEngineChecks(false);
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.calendar)}</span>
<small>{t($ => $.settings.lookAndFeel.calendarDescription)}</small>
</div>
<select
value={settings.calendarPreference}
onChange={(event) => settings.setCalendarPreference(event.target.value as typeof settings.calendarPreference)}
className="app-control w-48"
>
{calendarOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
+5
View File
@@ -581,6 +581,11 @@ const common = {
languagePersian: 'Persian',
languageUkrainian: 'Ukrainian',
languageRussian: 'Russian',
calendar: 'Calendar',
calendarDescription: 'Choose the calendar used for displayed dates and times. Gregorian is the default; this does not change scheduling or stored timestamps.',
calendarGregorian: 'Gregorian',
calendarPersian: 'Persian',
calendarHebrew: 'Hebrew',
appTheme: 'App Theme',
theme: 'Theme',
ariaLabel: 'App theme',
+5
View File
@@ -581,6 +581,11 @@ const fa = {
languagePersian: 'فارسی',
languageUkrainian: 'اوکراینی',
languageRussian: 'روسی',
calendar: 'تقویم',
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیش‌فرض است و زمان‌بندی یا مُهرهای زمانی ذخیره‌شده را تغییر نمی‌دهد.',
calendarGregorian: 'میلادی',
calendarPersian: 'هجری شمسی',
calendarHebrew: 'عبری',
appTheme: 'ظاهر برنامه',
theme: 'ظاهر',
ariaLabel: 'ظاهر برنامه',
+5
View File
@@ -581,6 +581,11 @@ const he = {
languagePersian: 'פרסית',
languageUkrainian: 'אוקראינית',
languageRussian: 'רוסית',
calendar: 'לוח שנה',
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
calendarGregorian: 'גרגוריאני',
calendarPersian: 'פרסי',
calendarHebrew: 'עברי',
appTheme: 'ערכת נושא לאפליקציה',
theme: 'ערכת נושא',
ariaLabel: 'ערכת נושא לאפליקציה',
+5
View File
@@ -581,6 +581,11 @@ const ru = {
languagePersian: 'Персидский',
languageUkrainian: 'Украинский',
languageRussian: 'Русский',
calendar: 'Календарь',
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
calendarGregorian: 'Григорианский',
calendarPersian: 'Персидский',
calendarHebrew: 'Еврейский',
appTheme: 'Тема приложения',
theme: 'Тема',
ariaLabel: 'Тема приложения',
+5
View File
@@ -581,6 +581,11 @@ const uk = {
languagePersian: 'Перська',
languageUkrainian: 'Українська',
languageRussian: 'Російська',
calendar: 'Календар',
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
calendarGregorian: 'Григоріанський',
calendarPersian: 'Перський',
calendarHebrew: 'Єврейський',
appTheme: 'Тема програми',
theme: 'Тема',
ariaLabel: 'Тема програми',
+5
View File
@@ -581,6 +581,11 @@ const zhCN = {
languagePersian: '波斯语',
languageUkrainian: '乌克兰语',
languageRussian: '俄语',
calendar: '日历',
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
calendarGregorian: '公历',
calendarPersian: '波斯历',
calendarHebrew: '希伯来历',
appTheme: '应用主题',
theme: '主题',
ariaLabel: '应用主题',
+18
View File
@@ -21,6 +21,24 @@ describe('last used download directory preference', () => {
});
});
describe('calendar preference', () => {
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
vi.clearAllMocks();
useSettingsStore.setState({ calendarPreference: 'gregorian' });
expect(useSettingsStore.getState().calendarPreference).toBe('gregorian');
useSettingsStore.getState().setCalendarPreference('persian');
await new Promise(resolve => setTimeout(resolve, 0));
expect(useSettingsStore.getState().calendarPreference).toBe('persian');
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state.calendarPreference).toBe('persian');
});
});
describe('useSettingsStore global speed limit persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
+18 -1
View File
@@ -21,6 +21,11 @@ import {
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
import i18n from '../i18n';
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
import {
DEFAULT_CALENDAR_PREFERENCE,
isCalendarPreference,
type CalendarPreference
} from '../utils/dateTime';
let settingsQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
@@ -155,6 +160,7 @@ const tauriStorage: StateStorage = {
export type {
ActiveView,
AppFontSize,
CalendarPreference,
ListRowDensity,
MediaCookieSource,
PostQueueAction,
@@ -169,6 +175,7 @@ export type SidebarPosition = 'auto' | 'left' | 'right';
export interface SettingsState {
theme: Theme;
calendarPreference: CalendarPreference;
language: AppLocalePreference;
baseDownloadFolder: string;
categorySubfoldersEnabled: boolean;
@@ -224,6 +231,7 @@ export interface SettingsState {
showKeychainModal: boolean;
setTheme: (theme: Theme) => void;
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
setLanguage: (language: AppLocalePreference) => void;
setBaseDownloadFolder: (path: string) => void;
approveDownloadRoot: (path: string) => Promise<string>;
@@ -280,6 +288,7 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
theme: 'system',
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
language: 'system',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
@@ -342,6 +351,10 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setCalendarPreference: (calendarPreference) => {
info('Settings updated: calendarPreference');
set({ calendarPreference });
},
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
@@ -512,7 +525,7 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 5,
version: 6,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
@@ -548,6 +561,7 @@ export const useSettingsStore = create<SettingsState>()(
},
partialize: (state): PersistedSettingsSnapshot => ({
theme: state.theme,
calendarPreference: state.calendarPreference,
language: state.language,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
@@ -608,6 +622,9 @@ export const useSettingsStore = create<SettingsState>()(
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
: currentState.theme,
calendarPreference: isCalendarPreference(persisted.calendarPreference)
? persisted.calendarPreference
: currentState.calendarPreference,
language: isAppLocalePreference(persisted.language)
? persisted.language
: currentState.language,
+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 '-';
}
}
};