diff --git a/src/frontend/src/components/HealthStatus.tsx b/src/frontend/src/components/HealthStatus.tsx
index d97399d..92eb688 100644
--- a/src/frontend/src/components/HealthStatus.tsx
+++ b/src/frontend/src/components/HealthStatus.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
import type { ComponentHealth } from '@shared/types/api';
-import { formatBytes } from '../utils/format';
+import { formatBytes, formatLocalDateTime } from '../utils/format';
+import { useTimezone } from '../hooks/useTimezone';
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
import type { UseMutationResult } from '@tanstack/react-query';
@@ -36,6 +37,7 @@ interface HealthStatusProps {
}
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
+ const timezone = useTimezone();
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
@@ -292,7 +294,7 @@ function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
{/* Last Updated */}
{!ytdlpLoading && ytdlpStatus && (
- Last checked: {ytdlpStatus.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
+ Last checked: {ytdlpStatus.lastUpdated ? formatLocalDateTime(ytdlpStatus.lastUpdated, timezone) : 'Never'}
)}
diff --git a/src/frontend/src/hooks/useTimezone.ts b/src/frontend/src/hooks/useTimezone.ts
new file mode 100644
index 0000000..488e1d5
--- /dev/null
+++ b/src/frontend/src/hooks/useTimezone.ts
@@ -0,0 +1,13 @@
+import { useAppSettings } from '../api/hooks/useSystem';
+
+/** Browser's local timezone as fallback. */
+const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+/**
+ * Returns the user-configured timezone from app settings,
+ * falling back to the browser's timezone if settings haven't loaded yet.
+ */
+export function useTimezone(): string {
+ const { data: settings } = useAppSettings();
+ return settings?.timezone || BROWSER_TIMEZONE;
+}
diff --git a/src/frontend/src/pages/Activity.tsx b/src/frontend/src/pages/Activity.tsx
index a7e1410..93477b5 100644
--- a/src/frontend/src/pages/Activity.tsx
+++ b/src/frontend/src/pages/Activity.tsx
@@ -6,22 +6,10 @@ import { Pagination } from '../components/Pagination';
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
import { SkeletonActivityList } from '../components/Skeleton';
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
-import { formatRelativeTime } from '../utils/format';
+import { formatRelativeTime, formatTimestamp } from '../utils/format';
+import { useTimezone } from '../hooks/useTimezone';
import type { DownloadHistoryRecord } from '@shared/types/index';
-// ── Helpers ──
-
-function formatTimestamp(iso: string): string {
- const d = new Date(iso);
- return d.toLocaleString(undefined, {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- });
-}
-
function formatEventType(type: string): string {
return type
.split('_')
@@ -58,6 +46,7 @@ const EVENT_TYPES = [
// ── Component ──
export function ActivityPage() {
+ const timezone = useTimezone();
const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history');
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState>({
@@ -176,7 +165,7 @@ export function ActivityPage() {
fontFamily: 'var(--font-mono)',
}}
>
- {formatTimestamp(item.createdAt)}
+ {formatTimestamp(item.createdAt, timezone)}
),
},
diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx
index ee167c2..99dafb1 100644
--- a/src/frontend/src/pages/ChannelDetail.tsx
+++ b/src/frontend/src/pages/ChannelDetail.tsx
@@ -44,7 +44,8 @@ import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
-import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount } from '../utils/format';
+import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount, formatLocalDateTime } from '../utils/format';
+import { useTimezone } from '../hooks/useTimezone';
import type { ContentItem, MonitoringMode } from '@shared/types/index';
// ── Helpers ──
@@ -70,6 +71,7 @@ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
// ── Component ──
export function ChannelDetail() {
+ const timezone = useTimezone();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -662,7 +664,7 @@ export function ChannelDetail() {
render: (item) => (
{formatRelativeTime(item.publishedAt)}
@@ -676,7 +678,7 @@ export function ChannelDetail() {
render: (item) => (
{formatRelativeTime(item.downloadedAt)}
diff --git a/src/frontend/src/pages/Queue.tsx b/src/frontend/src/pages/Queue.tsx
index 2689032..2e5507b 100644
--- a/src/frontend/src/pages/Queue.tsx
+++ b/src/frontend/src/pages/Queue.tsx
@@ -6,11 +6,13 @@ import { SkeletonQueueList } from '../components/Skeleton';
import { DownloadProgressBar } from '../components/DownloadProgressBar';
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
+import { formatShortDateTime } from '../utils/format';
+import { useTimezone } from '../hooks/useTimezone';
import type { QueueItem, QueueStatus } from '@shared/types/index';
// ── Helpers ──
-function formatTime(iso: string | null): string {
+function formatTime(iso: string | null, timezone: string): string {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
@@ -19,7 +21,7 @@ function formatTime(iso: string | null): string {
if (diffMs < 60_000) return 'just now';
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`;
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ return formatShortDateTime(iso, timezone);
}
// ── Status Tab Options ──
@@ -48,6 +50,7 @@ function QueueItemProgress({ item }: { item: QueueItem }) {
// ── Component ──
export function Queue() {
+ const timezone = useTimezone();
const [statusFilter, setStatusFilter] = useState('');
// Query with 5s auto-refresh
@@ -145,7 +148,7 @@ export function Queue() {
width: '110px',
render: (item) => (
- {formatTime(item.startedAt)}
+ {formatTime(item.startedAt, timezone)}
),
},
@@ -155,7 +158,7 @@ export function Queue() {
width: '110px',
render: (item) => (
- {formatTime(item.completedAt)}
+ {formatTime(item.completedAt, timezone)}
),
},
diff --git a/src/frontend/src/pages/System.tsx b/src/frontend/src/pages/System.tsx
index ca92754..613e736 100644
--- a/src/frontend/src/pages/System.tsx
+++ b/src/frontend/src/pages/System.tsx
@@ -2,7 +2,8 @@ import { RefreshCw, Server, Activity, Cpu, HardDrive, Search } from 'lucide-reac
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp, useMissingScanStatus, useTriggerMissingScan } from '../api/hooks/useSystem';
import { HealthStatus } from '../components/HealthStatus';
import { SkeletonSystem } from '../components/Skeleton';
-import { formatBytes } from '../utils/format';
+import { formatBytes, formatLocalDateTime } from '../utils/format';
+import { useTimezone } from '../hooks/useTimezone';
// ── Helpers ──
@@ -21,6 +22,7 @@ function formatUptime(seconds: number): string {
// ── Component ──
export function SystemPage() {
+ const timezone = useTimezone();
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
@@ -168,7 +170,7 @@ export function SystemPage() {
}
label="Last Scan"
- value={new Date(missingScanData.data.lastRun).toLocaleString()}
+ value={formatLocalDateTime(missingScanData.data.lastRun, timezone)}
/>
}
diff --git a/src/frontend/src/utils/format.ts b/src/frontend/src/utils/format.ts
index 6488ae2..e10af70 100644
--- a/src/frontend/src/utils/format.ts
+++ b/src/frontend/src/utils/format.ts
@@ -3,6 +3,9 @@
*
* Consolidates format helpers that were previously duplicated across
* ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity.
+ *
+ * Timezone-aware helpers accept an IANA timezone string (e.g. "America/New_York").
+ * Pass the value from useTimezone() at the call site.
*/
/** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */
@@ -60,3 +63,51 @@ export function formatSubscriberCount(count: number | null): string | null {
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
}
+
+// ── Timezone-aware formatters ──
+
+/**
+ * Format an ISO date string as a full timestamp in the given timezone.
+ * Example: "Jan 5, 2:30:45 PM"
+ */
+export function formatTimestamp(isoString: string | null, timezone: string): string {
+ if (!isoString) return '—';
+ const d = new Date(isoString);
+ if (isNaN(d.getTime())) return '—';
+ return d.toLocaleString(undefined, {
+ timeZone: timezone,
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+}
+
+/**
+ * Format an ISO date string as a short date+time in the given timezone.
+ * Example: "Jan 5, 02:30 PM"
+ */
+export function formatShortDateTime(isoString: string | null, timezone: string): string {
+ if (!isoString) return '—';
+ const d = new Date(isoString);
+ if (isNaN(d.getTime())) return '—';
+ return d.toLocaleDateString(undefined, {
+ timeZone: timezone,
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+/**
+ * Format an ISO date string as a locale date+time string in the given timezone.
+ * Uses the browser's default locale format. Example: "1/5/2025, 2:30:45 PM"
+ */
+export function formatLocalDateTime(isoString: string | null, timezone: string): string {
+ if (!isoString) return '—';
+ const d = new Date(isoString);
+ if (isNaN(d.getTime())) return '—';
+ return d.toLocaleString(undefined, { timeZone: timezone });
+}