From 874dc1526395d6d44caeabc108f7065843dae1f2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 3 Jun 2026 18:20:35 +0300 Subject: [PATCH] fix: respect time format preference in UI Add shared time formatting helpers that apply the Appearance time format preference consistently. Update visible time labels across chat, quota usage, scheduled tasks, tunnels, context details, git views, PR metadata, and passkey settings to use the selected 12-hour, 24-hour, or automatic format. Leave date-only and non-UI formatting untouched so unrelated behavior does not change. --- .../components/chat/message/MessageBody.tsx | 5 +- .../src/components/chat/message/timeFormat.ts | 7 ++- .../components/layout/ContextSidebarTab.tsx | 15 +++-- packages/ui/src/components/layout/Header.tsx | 21 ++++--- .../ui/src/components/layout/VSCodeLayout.tsx | 14 ++--- .../sections/openchamber/PasskeySettings.tsx | 9 ++- .../sections/openchamber/TunnelSettings.tsx | 9 ++- .../components/sections/usage/UsageCard.tsx | 4 +- .../components/sections/usage/UsagePage.tsx | 12 ++-- .../session/ScheduledTasksDialog.tsx | 9 ++- .../components/views/git/HistoryCommitRow.tsx | 12 ++-- .../views/git/PullRequestSection.tsx | 12 +++- packages/ui/src/lib/quota/utils.ts | 11 ++-- packages/ui/src/lib/timeFormat.ts | 58 +++++++++++++++++++ 14 files changed, 143 insertions(+), 55 deletions(-) create mode 100644 packages/ui/src/lib/timeFormat.ts diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index eda9f26e..7f93bbf6 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1038,6 +1038,7 @@ const AssistantMessageBody = React.memo(({ const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks); const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks); const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const vscodeApi = useRuntimeAPIs().vscode; const isSortedRenderMode = chatRenderMode === 'sorted'; const collapsedPreviewCount = 7; @@ -1757,9 +1758,9 @@ const AssistantMessageBody = React.memo(({ : (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null); if (timestamp === null) return null; - const formatted = formatTimestampForDisplay(timestamp); + const formatted = formatTimestampForDisplay(timestamp, timeFormatPreference); return formatted.length > 0 ? formatted : null; - }, [messageCompletedAt, messageCreatedAt]); + }, [messageCompletedAt, messageCreatedAt, timeFormatPreference]); const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1'; const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode; diff --git a/packages/ui/src/components/chat/message/timeFormat.ts b/packages/ui/src/components/chat/message/timeFormat.ts index bd92f08e..610b4aff 100644 --- a/packages/ui/src/components/chat/message/timeFormat.ts +++ b/packages/ui/src/components/chat/message/timeFormat.ts @@ -1,4 +1,5 @@ -const pad2 = (value: number): string => String(value).padStart(2, '0'); +import { formatTimeForPreference } from '@/lib/timeFormat'; +import type { TimeFormatPreference } from '@/stores/useUIStore'; const isSameDay = (left: Date, right: Date): boolean => { return ( @@ -18,7 +19,7 @@ const isValidTimestamp = (timestamp: number): boolean => { return Number.isFinite(timestamp) && !Number.isNaN(new Date(timestamp).getTime()); }; -export const formatTimestampForDisplay = (timestamp: number): string => { +export const formatTimestampForDisplay = (timestamp: number, timeFormatPreference: TimeFormatPreference): string => { if (!isValidTimestamp(timestamp)) { return ''; } @@ -26,7 +27,7 @@ export const formatTimestampForDisplay = (timestamp: number): string => { const date = new Date(timestamp); const now = new Date(); - const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`; + const timePart = formatTimeForPreference(date, timeFormatPreference); if (isSameDay(date, now)) { return timePart; diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index 15879613..a14918d0 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -11,6 +11,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; type SessionMessage = { info: Message; parts: Part[] }; @@ -229,9 +231,9 @@ const formatMoney = (value: number): string => { return `$${value.toFixed(2)}`; }; -const formatDateTime = (timestamp: number | null): string => { +const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => { if (!timestamp || !Number.isFinite(timestamp)) return '-'; - return new Date(timestamp).toLocaleString(undefined, { + return formatDateTimeForPreference(timestamp, timeFormatPreference, { month: 'short', day: 'numeric', year: 'numeric', @@ -240,9 +242,9 @@ const formatDateTime = (timestamp: number | null): string => { }); }; -const formatMessageDateMeta = (timestamp: number | null): string => { +const formatMessageDateMeta = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => { if (!timestamp || !Number.isFinite(timestamp)) return '-'; - return new Date(timestamp).toLocaleString(undefined, { + return formatDateTimeForPreference(timestamp, timeFormatPreference, { month: 'short', day: 'numeric', hour: 'numeric', @@ -273,6 +275,7 @@ const resolveProviderAndModel = ( export const ContextPanelContent: React.FC = () => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); const [expandedRawMessages, setExpandedRawMessages] = React.useState>({}); const [copiedRawMessageId, setCopiedRawMessageId] = React.useState(null); @@ -416,7 +419,7 @@ export const ContextPanelContent: React.FC = () => { {viewModel.createdAt && ( <> · - {formatDateTime(viewModel.createdAt)} + {formatDateTime(viewModel.createdAt, timeFormatPreference)} )} @@ -547,7 +550,7 @@ export const ContextPanelContent: React.FC = () => { {capitalizeRole(role)} {message.info.id} - {formatMessageDateMeta(messageCreatedAt)} + {formatMessageDateMeta(messageCreatedAt, timeFormatPreference)} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index dc02f2cc..2c3b850d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -41,7 +41,9 @@ import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { updateDesktopSettings } from '@/lib/persistence'; +import { formatTimeForPreference } from '@/lib/timeFormat'; import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import type { TimeFormatPreference } from '@/stores/useUIStore'; import { getAllModelFamilies, getDisplayModelName, @@ -356,6 +358,7 @@ type DesktopServicesMenuProps = { remoteUpdateError: string | null; onOpenRemoteUpdate: () => void; showPredValues: boolean; + timeFormatPreference: TimeFormatPreference; }; const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ @@ -391,6 +394,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ remoteUpdateError, onOpenRemoteUpdate, showPredValues, + timeFormatPreference, }: DesktopServicesMenuProps) { const { t } = useI18n(); return ( @@ -511,7 +515,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
{t('header.services.rateLimits')} - {formatTime(quotaLastUpdated)} + {formatTime(quotaLastUpdated, timeFormatPreference)}
@@ -572,7 +576,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) : null; const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted); + const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference); return (
@@ -714,13 +718,10 @@ const formatCompactHeaderLabel = (value: string): string => { return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed; }; -const formatTime = (timestamp: number | null) => { +const formatTime = (timestamp: number | null, timeFormatPreference: 'auto' | '12h' | '24h') => { if (!timestamp) return '-'; try { - return new Date(timestamp).toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - }); + return formatTimeForPreference(timestamp, timeFormatPreference, { fallback: '-' }); } catch { return '-'; } @@ -791,6 +792,7 @@ export const Header: React.FC = ({ const activeMainTab = useUIStore((state) => state.activeMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); @@ -2089,6 +2091,7 @@ export const Header: React.FC = ({ remoteUpdateChecking={remoteUpdateChecking} remoteUpdateError={remoteUpdateError} onOpenRemoteUpdate={openRemoteInstanceUpdate} + timeFormatPreference={timeFormatPreference} /> = ({
{t('header.services.rateLimits')} - {formatTime(quotaLastUpdated)} + {formatTime(quotaLastUpdated, timeFormatPreference)}
@@ -2533,7 +2536,7 @@ export const Header: React.FC = ({ : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) : null; const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted); + const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference); return (
diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index fdd509e0..d238d2ba 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -30,19 +30,18 @@ import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_ import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { updateDesktopSettings } from '@/lib/persistence'; +import { formatTimeForPreference } from '@/lib/timeFormat'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { UsageWindow } from '@/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; +import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); -const formatTime = (timestamp: number | null) => { +const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference) => { if (!timestamp) return '-'; try { - return new Date(timestamp).toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - }); + return formatTimeForPreference(timestamp, timeFormatPreference, { fallback: '-' }); } catch { return '-'; } @@ -583,6 +582,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated); const quotaDisplayMode = useQuotaStore((state) => state.displayMode); const showPredValues = useQuotaStore((state) => state.showPredValues); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); @@ -839,7 +839,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on
- {t('vscodeLayout.quota.lastUpdated', { time: formatTime(quotaLastUpdated) })} + {t('vscodeLayout.quota.lastUpdated', { time: formatTime(quotaLastUpdated, timeFormatPreference) })}
{!hasRateLimits && ( @@ -899,7 +899,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on
)} - {formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted)} + {formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference)} diff --git a/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx b/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx index 55315631..ae4cda65 100644 --- a/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx +++ b/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx @@ -15,8 +15,9 @@ import { type StoredPasskey, } from '@/lib/passkeys'; import { useI18n } from '@/lib/i18n'; +import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; -const formatTimestamp = (timestamp: number | null, neverUsedText: string) => { +const formatTimestamp = (timestamp: number | null, neverUsedText: string, timeFormatPreference: TimeFormatPreference) => { if (!timestamp || !Number.isFinite(timestamp)) { return neverUsedText; } @@ -24,11 +25,13 @@ const formatTimestamp = (timestamp: number | null, neverUsedText: string) => { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', + hour12: timeFormatPreference === 'auto' ? undefined : timeFormatPreference === '12h', }).format(timestamp); }; export const PasskeySettings: React.FC = () => { const { t } = useI18n(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const [supportsPasskeys, setSupportsPasskeys] = React.useState(false); const [isLoading, setIsLoading] = React.useState(true); const [isRegistering, setIsRegistering] = React.useState(false); @@ -230,10 +233,10 @@ export const PasskeySettings: React.FC = () => { {passkey.lastUsedAt ? t('settings.openchamber.passkeys.item.lastUsed', { - time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed')), + time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference), }) : t('settings.openchamber.passkeys.item.added', { - time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed')), + time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference), })}
diff --git a/packages/ui/src/components/session/ScheduledTasksDialog.tsx b/packages/ui/src/components/session/ScheduledTasksDialog.tsx index e1d72bdc..8f6e6e29 100644 --- a/packages/ui/src/components/session/ScheduledTasksDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTasksDialog.tsx @@ -14,6 +14,8 @@ import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { useUIStore } from '@/stores/useUIStore'; +import { formatTimeForPreference } from '@/lib/timeFormat'; +import type { TimeFormatPreference } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore'; @@ -100,11 +102,11 @@ const formatSchedule = (task: ScheduledTask, t: ReturnType['t']) return t('sessions.scheduledTasks.dialog.schedule.cron', { cron: task.schedule.cron || '' }); }; -const formatClockTime = (value?: number): string => { +const formatClockTime = (value: number | undefined, timeFormatPreference: TimeFormatPreference): string => { if (!value || !Number.isFinite(value)) { return ''; } - return new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + return formatTimeForPreference(value, timeFormatPreference); }; const formatRelativeTime = (value: number | undefined, t: ReturnType['t']): string => { @@ -174,6 +176,7 @@ export function ScheduledTasksDialog() { const open = useUIStore((state) => state.isScheduledTasksDialogOpen); const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen); const isMobile = useUIStore((state) => state.isMobile); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const projects = useProjectsStore((state) => state.projects); const activeProject = useProjectsStore((state) => state.getActiveProject()); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); @@ -471,7 +474,7 @@ export function ScheduledTasksDialog() { <> {formatRelativeTime(nextAt, t)} · - {formatClockTime(nextAt)} + {formatClockTime(nextAt, timeFormatPreference)} ) : ( diff --git a/packages/ui/src/components/views/git/HistoryCommitRow.tsx b/packages/ui/src/components/views/git/HistoryCommitRow.tsx index e4a28efc..7f938860 100644 --- a/packages/ui/src/components/views/git/HistoryCommitRow.tsx +++ b/packages/ui/src/components/views/git/HistoryCommitRow.tsx @@ -18,6 +18,8 @@ import type { LanedCommit } from './gitGraph'; import { GitGraphSegment } from './GitGraphSegment'; import * as git from '@/lib/gitApi'; import { toast } from '@/components/ui/toast'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; const HISTORY_DIFF_REQUEST_TIMEOUT_MS = 15000; const HISTORY_DIFF_LARGE_CHANGED_LINES = 500; @@ -77,14 +79,13 @@ interface HistoryCommitRowProps { onActionSuccess?: () => void; } -function formatCommitDate(date: string) { +function formatCommitDate(date: string, timeFormatPreference: TimeFormatPreference) { const value = new Date(date); if (Number.isNaN(value.getTime())) { return date; } - return value.toLocaleString(undefined, { - hour12: false, + return formatDateTimeForPreference(value, timeFormatPreference, { year: 'numeric', month: 'short', day: 'numeric', @@ -146,6 +147,7 @@ export const HistoryCommitRow = React.memo(({ onActionSuccess, }: HistoryCommitRowProps) => { const { t } = useI18n(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const isGraphMode = mode === 'graph'; type PendingAction = | 'checkout' | 'cherryPick' | 'revert' @@ -397,8 +399,8 @@ export const HistoryCommitRow = React.memo(({ {entry.author_name} · - - {formatCommitDate(entry.date)} + + {formatCommitDate(entry.date, timeFormatPreference)}
· diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 09183068..4ac6d5e6 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -28,6 +28,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -309,6 +310,7 @@ export const PullRequestSection: React.FC<{ onGeneratedDescription?: () => void; }> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], onGeneratedDescription }) => { const { t } = useI18n(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); @@ -627,8 +629,14 @@ export const PullRequestSection: React.FC<{ if (!Number.isFinite(ts)) { return value; } - return new Date(ts).toLocaleString(); - }, []); + return formatDateTimeForPreference(ts, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }, [timeFormatPreference]); const connectedGitHubLogin = React.useMemo(() => { const login = githubAuthStatus?.user?.login; diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index c6f980d9..fbac3d0d 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -1,3 +1,6 @@ +import { formatDateTimeForPreference, formatTimeForPreference } from '@/lib/timeFormat'; +import type { TimeFormatPreference } from '@/stores/useUIStore'; + export const clampPercent = (value: number | null): number | null => { if (typeof value !== 'number' || !Number.isFinite(value)) { return null; @@ -22,6 +25,7 @@ export const formatQuotaValueLabel = ( export const formatQuotaResetLabel = ( resetAt: number | null, fallback?: string | null, + timeFormatPreference: TimeFormatPreference = 'auto', ): string => { if (!resetAt) { return fallback ?? ''; @@ -37,13 +41,10 @@ export const formatQuotaResetLabel = ( const isToday = resetDate.toDateString() === now.toDateString(); if (isToday) { - return resetDate.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - }); + return formatTimeForPreference(resetDate, timeFormatPreference, { fallback: fallback ?? '' }); } - return resetDate.toLocaleString(undefined, { + return formatDateTimeForPreference(resetDate, timeFormatPreference, { month: 'short', day: 'numeric', weekday: 'short', diff --git a/packages/ui/src/lib/timeFormat.ts b/packages/ui/src/lib/timeFormat.ts new file mode 100644 index 00000000..de2fedc9 --- /dev/null +++ b/packages/ui/src/lib/timeFormat.ts @@ -0,0 +1,58 @@ +import type { TimeFormatPreference } from '@/stores/useUIStore'; + +type TimePrecision = 'minute' | 'second'; + +const getHour12Option = (preference: TimeFormatPreference): boolean | undefined => { + if (preference === '12h') return true; + if (preference === '24h') return false; + return undefined; +}; + +export const getUses24HourForPreference = (preference: TimeFormatPreference, locale: string): boolean => { + if (preference === '24h') return true; + if (preference === '12h') return false; + + try { + const options = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions(); + if (typeof options.hour12 === 'boolean') { + return !options.hour12; + } + return options.hourCycle === 'h23' || options.hourCycle === 'h24'; + } catch { + return true; + } +}; + +export const formatTimeForPreference = ( + timestamp: number | Date, + preference: TimeFormatPreference, + options: { precision?: TimePrecision; hour?: 'numeric' | '2-digit'; fallback?: string } = {}, +): string => { + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + if (!Number.isFinite(date.getTime())) { + return options.fallback ?? ''; + } + + return date.toLocaleTimeString(undefined, { + hour: options.hour ?? 'numeric', + minute: '2-digit', + second: options.precision === 'second' ? '2-digit' : undefined, + hour12: getHour12Option(preference), + }); +}; + +export const formatDateTimeForPreference = ( + timestamp: number | Date, + preference: TimeFormatPreference, + options: Intl.DateTimeFormatOptions, +): string => { + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + if (!Number.isFinite(date.getTime())) { + return ''; + } + + return date.toLocaleString(undefined, { + ...options, + hour12: options.hour ? getHour12Option(preference) : options.hour12, + }); +};