From 87cbfe87ee3d55d1499bd04929d4632258415bf8 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 07:26:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Created=20useTimezone=20hook=20and=20ti?= =?UTF-8?q?mezone-aware=20format=20utilities,=20wir=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/hooks/useTimezone.ts" - "src/frontend/src/utils/format.ts" - "src/frontend/src/pages/Activity.tsx" - "src/frontend/src/pages/Queue.tsx" - "src/frontend/src/pages/ChannelDetail.tsx" - "src/frontend/src/pages/System.tsx" - "src/frontend/src/components/HealthStatus.tsx" GSD-Task: S08/T02 --- src/frontend/src/components/HealthStatus.tsx | 6 ++- src/frontend/src/hooks/useTimezone.ts | 13 +++++ src/frontend/src/pages/Activity.tsx | 19 ++------ src/frontend/src/pages/ChannelDetail.tsx | 8 +-- src/frontend/src/pages/Queue.tsx | 11 +++-- src/frontend/src/pages/System.tsx | 6 ++- src/frontend/src/utils/format.ts | 51 ++++++++++++++++++++ 7 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 src/frontend/src/hooks/useTimezone.ts 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 }); +}