diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index f7bf43b..5fb76e6 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -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, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e49643d..f83d4d9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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"); } diff --git a/src/bindings/CalendarPreference.ts b/src/bindings/CalendarPreference.ts new file mode 100644 index 0000000..f2974ea --- /dev/null +++ b/src/bindings/CalendarPreference.ts @@ -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"; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index ea8abb8..9881811 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -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, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, 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, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 610676d..fff8c1c 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -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(({ 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(null); const [isRowHovered, setIsRowHovered] = React.useState(false); @@ -71,6 +74,12 @@ export const DownloadItem = React.memo(({ 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(({
- {download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'} + {dateAddedLabel}
), diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 034d03d..0e17839 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -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 = () => {
{t($ => $.properties.connections)} $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}>{connectionStatus}
{t($ => $.properties.speedCap)}{item.speedLimit || '-'}
{t($ => $.properties.category)}{categoryLabel(item.category)}
-
{t($ => $.properties.lastTry)}{formatLastTry(item.lastTry)}
+
{t($ => $.properties.lastTry)}{formatLastTry(item.lastTry, i18n.language, calendarPreference)}
-
{t($ => $.properties.dateAdded)}{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
+
{t($ => $.properties.dateAdded)}{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}
{t($ => $.properties.destination)}{saveLocation || baseDownloadFolder}
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 01c73be..19a22ae 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -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(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] diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 7b4c604..74902d6 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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); ))}
+
+
+ {t($ => $.settings.lookAndFeel.calendar)} + {t($ => $.settings.lookAndFeel.calendarDescription)} +
+ +
{t($ => $.settings.lookAndFeel.sidebarPosition)} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 018822d..488f053 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 66cfa7f..bdb6add 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -581,6 +581,11 @@ const fa = { languagePersian: 'فارسی', languageUkrainian: 'اوکراینی', languageRussian: 'روسی', + calendar: 'تقویم', + calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیش‌فرض است و زمان‌بندی یا مُهرهای زمانی ذخیره‌شده را تغییر نمی‌دهد.', + calendarGregorian: 'میلادی', + calendarPersian: 'هجری شمسی', + calendarHebrew: 'عبری', appTheme: 'ظاهر برنامه', theme: 'ظاهر', ariaLabel: 'ظاهر برنامه', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index aec1052..1bd733b 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -581,6 +581,11 @@ const he = { languagePersian: 'פרסית', languageUkrainian: 'אוקראינית', languageRussian: 'רוסית', + calendar: 'לוח שנה', + calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.', + calendarGregorian: 'גרגוריאני', + calendarPersian: 'פרסי', + calendarHebrew: 'עברי', appTheme: 'ערכת נושא לאפליקציה', theme: 'ערכת נושא', ariaLabel: 'ערכת נושא לאפליקציה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 8a50478..10bce48 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -581,6 +581,11 @@ const ru = { languagePersian: 'Персидский', languageUkrainian: 'Украинский', languageRussian: 'Русский', + calendar: 'Календарь', + calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.', + calendarGregorian: 'Григорианский', + calendarPersian: 'Персидский', + calendarHebrew: 'Еврейский', appTheme: 'Тема приложения', theme: 'Тема', ariaLabel: 'Тема приложения', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 5caf1b0..d7bc075 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -581,6 +581,11 @@ const uk = { languagePersian: 'Перська', languageUkrainian: 'Українська', languageRussian: 'Російська', + calendar: 'Календар', + calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.', + calendarGregorian: 'Григоріанський', + calendarPersian: 'Перський', + calendarHebrew: 'Єврейський', appTheme: 'Тема програми', theme: 'Тема', ariaLabel: 'Тема програми', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 58f0686..8ac81b4 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -581,6 +581,11 @@ const zhCN = { languagePersian: '波斯语', languageUkrainian: '乌克兰语', languageRussian: '俄语', + calendar: '日历', + calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。', + calendarGregorian: '公历', + calendarPersian: '波斯历', + calendarHebrew: '希伯来历', appTheme: '应用主题', theme: '主题', ariaLabel: '应用主题', diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index 244c467..0007581 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -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(); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 0b7a67a..5ebf04a 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -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 = Promise.resolve(); let pairingTokenHydrationRequest: Promise | 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; @@ -280,6 +288,7 @@ export const useSettingsStore = create()( persist( (set, get) => ({ theme: 'system', + calendarPreference: DEFAULT_CALENDAR_PREFERENCE, language: 'system', baseDownloadFolder: '~/Downloads', categorySubfoldersEnabled: true, @@ -342,6 +351,10 @@ export const useSettingsStore = create()( 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()( { 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()( }, 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()( 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, diff --git a/src/utils/dateTime.test.ts b/src/utils/dateTime.test.ts new file mode 100644 index 0000000..2b4490b --- /dev/null +++ b/src/utils/dateTime.test.ts @@ -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); + }); +}); diff --git a/src/utils/dateTime.ts b/src/utils/dateTime.ts new file mode 100644 index 0000000..82e778a --- /dev/null +++ b/src/utils/dateTime.ts @@ -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 '-'; + } + } +};