feat: Created useTimezone hook and timezone-aware format utilities, wir…
- "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
This commit is contained in:
parent
98c3d73c69
commit
87cbfe87ee
7 changed files with 88 additions and 26 deletions
|
|
@ -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 && (
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
||||
Last checked: {ytdlpStatus.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
|
||||
Last checked: {ytdlpStatus.lastUpdated ? formatLocalDateTime(ytdlpStatus.lastUpdated, timezone) : 'Never'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
13
src/frontend/src/hooks/useTimezone.ts
Normal file
13
src/frontend/src/hooks/useTimezone.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Record<string, string>>({
|
||||
|
|
@ -176,7 +165,7 @@ export function ActivityPage() {
|
|||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(item.createdAt)}
|
||||
{formatTimestamp(item.createdAt, timezone)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<span
|
||||
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
|
||||
title={item.publishedAt ?? ''}
|
||||
title={item.publishedAt ? formatLocalDateTime(item.publishedAt, timezone) : ''}
|
||||
>
|
||||
{formatRelativeTime(item.publishedAt)}
|
||||
</span>
|
||||
|
|
@ -676,7 +678,7 @@ export function ChannelDetail() {
|
|||
render: (item) => (
|
||||
<span
|
||||
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
|
||||
title={item.downloadedAt ?? ''}
|
||||
title={item.downloadedAt ? formatLocalDateTime(item.downloadedAt, timezone) : ''}
|
||||
>
|
||||
{formatRelativeTime(item.downloadedAt)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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<QueueStatus | ''>('');
|
||||
|
||||
// Query with 5s auto-refresh
|
||||
|
|
@ -145,7 +148,7 @@ export function Queue() {
|
|||
width: '110px',
|
||||
render: (item) => (
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{formatTime(item.startedAt)}
|
||||
{formatTime(item.startedAt, timezone)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -155,7 +158,7 @@ export function Queue() {
|
|||
width: '110px',
|
||||
render: (item) => (
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{formatTime(item.completedAt)}
|
||||
{formatTime(item.completedAt, timezone)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<SystemInfoRow
|
||||
icon={<Activity size={14} style={{ color: 'var(--text-muted)' }} />}
|
||||
label="Last Scan"
|
||||
value={new Date(missingScanData.data.lastRun).toLocaleString()}
|
||||
value={formatLocalDateTime(missingScanData.data.lastRun, timezone)}
|
||||
/>
|
||||
<SystemInfoRow
|
||||
icon={<Search size={14} style={{ color: 'var(--info)' }} />}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue