refactor: add date utility for consistent UTC handling

This commit is contained in:
Sam Chau
2026-01-17 14:31:48 +10:30
parent 3d1e55e46c
commit 7c1952d264
7 changed files with 103 additions and 18 deletions

View File

@@ -569,6 +569,34 @@ Examples:
- `src/lib/server/db/queries/arrInstances.ts` - `src/lib/server/db/queries/arrInstances.ts`
- `src/lib/server/db/queries/jobs.ts` - `src/lib/server/db/queries/jobs.ts`
**Date Handling**
SQLite stores timestamps from `CURRENT_TIMESTAMP` as UTC in the format
`"YYYY-MM-DD HH:MM:SS"` without a timezone indicator. JavaScript's `Date`
constructor interprets strings without timezone info as local time, causing
incorrect display.
Use the shared date utilities in `src/lib/shared/dates.ts`:
```typescript
import { parseUTC, toUTC } from "$shared/dates";
// Parse SQLite timestamp to Date object (correctly interpreted as UTC)
const date = parseUTC("2026-01-17 03:21:52"); // Date in UTC
// Normalize to ISO 8601 string with Z suffix
const iso = toUTC("2026-01-17 03:21:52"); // "2026-01-17T03:21:52Z"
```
**Never** manually append `'Z'` or manipulate timestamp strings directly. Always
use these utilities to ensure consistent timezone handling across the codebase.
For SQL queries that compare timestamps, normalize both sides:
```sql
datetime(replace(replace(timestamp_col, 'T', ' '), 'Z', '')) <= datetime('now')
```
#### PCD (Profilarr Compliant Database) #### PCD (Profilarr Compliant Database)
PCDs are git repositories containing versioned configuration data—quality PCDs are git repositories containing versioned configuration data—quality

51
src/lib/shared/dates.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Date utilities for handling SQLite timestamps
*
* SQLite stores timestamps from CURRENT_TIMESTAMP as UTC in the format
* "YYYY-MM-DD HH:MM:SS" (no timezone indicator). JavaScript's Date constructor
* interprets strings without timezone info as local time, causing incorrect
* display.
*
* These utilities normalize SQLite timestamps to proper ISO 8601 format
* so JavaScript correctly interprets them as UTC.
*/
/**
* Normalizes a SQLite timestamp to ISO 8601 format with UTC indicator.
* Handles both SQLite format ("YYYY-MM-DD HH:MM:SS") and ISO format.
*
* @param timestamp - SQLite or ISO timestamp string
* @returns ISO 8601 formatted string with Z suffix, or null if input is null/undefined
*
* @example
* toUTC("2026-01-17 03:21:52") // "2026-01-17T03:21:52Z"
* toUTC("2026-01-17T03:21:52") // "2026-01-17T03:21:52Z"
* toUTC("2026-01-17T03:21:52Z") // "2026-01-17T03:21:52Z" (unchanged)
* toUTC(null) // null
*/
export function toUTC(timestamp: string | null | undefined): string | null {
if (!timestamp) return null;
// Already has Z suffix - return as-is
if (timestamp.endsWith('Z')) return timestamp;
// Replace space with T (SQLite format) and add Z
return timestamp.replace(' ', 'T') + 'Z';
}
/**
* Parses a SQLite timestamp into a JavaScript Date object.
* Normalizes the timestamp to UTC before parsing.
*
* @param timestamp - SQLite or ISO timestamp string
* @returns Date object, or null if input is null/undefined
*
* @example
* parseUTC("2026-01-17 03:21:52") // Date object in UTC
* parseUTC(null) // null
*/
export function parseUTC(timestamp: string | null | undefined): Date | null {
const normalized = toUTC(timestamp);
if (!normalized) return null;
return new Date(normalized);
}

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { Clock, Timer, CheckCircle, PauseCircle } from 'lucide-svelte'; import { Clock, Timer, CheckCircle, PauseCircle } from 'lucide-svelte';
import { parseUTC } from '$shared/dates';
export let enabled: boolean; export let enabled: boolean;
export let schedule: number; // minutes export let schedule: number; // minutes
@@ -20,8 +21,7 @@
}); });
// Calculate times // Calculate times
// Database stores timestamps without timezone - append Z to parse as UTC $: lastRunTime = parseUTC(lastRunAt)?.getTime() ?? null;
$: lastRunTime = lastRunAt ? new Date(lastRunAt.endsWith('Z') ? lastRunAt : lastRunAt + 'Z').getTime() : null;
$: scheduleMs = schedule * 60 * 1000; $: scheduleMs = schedule * 60 * 1000;
$: nextRunTime = lastRunTime ? lastRunTime + scheduleMs : null; $: nextRunTime = lastRunTime ? lastRunTime + scheduleMs : null;
$: timeUntilNext = nextRunTime ? nextRunTime - now : null; $: timeUntilNext = nextRunTime ? nextRunTime - now : null;
@@ -49,14 +49,10 @@
return `${seconds}s`; return `${seconds}s`;
} }
// Parse timestamp - append Z if needed to treat as UTC
function parseTimestamp(str: string): Date {
return new Date(str.endsWith('Z') ? str : str + 'Z');
}
// Format absolute time // Format absolute time
function formatTime(isoString: string): string { function formatTime(isoString: string): string {
const date = parseTimestamp(isoString); const date = parseUTC(isoString);
if (!date) return '-';
return date.toLocaleTimeString('en-US', { return date.toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
@@ -65,7 +61,8 @@
} }
function formatDate(isoString: string): string { function formatDate(isoString: string): string {
const date = parseTimestamp(isoString); const date = parseUTC(isoString);
if (!date) return '-';
const today = new Date(); const today = new Date();
const yesterday = new Date(today); const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);

View File

@@ -13,6 +13,7 @@
import SearchAction from '$ui/actions/SearchAction.svelte'; import SearchAction from '$ui/actions/SearchAction.svelte';
import { createSearchStore } from '$stores/search'; import { createSearchStore } from '$stores/search';
import { alertStore } from '$alerts/store'; import { alertStore } from '$alerts/store';
import { parseUTC } from '$shared/dates';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Column } from '$ui/table/types'; import type { Column } from '$ui/table/types';
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts'; import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
@@ -66,8 +67,14 @@
// Format last synced date // Format last synced date
function formatLastSynced(date: string | null): string { function formatLastSynced(date: string | null): string {
if (!date) return 'Never'; const d = parseUTC(date);
return new Date(date).toLocaleDateString(); if (!d) return 'Never';
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
} }
// Handle row click - navigate to database details // Handle row click - navigate to database details

View File

@@ -5,12 +5,13 @@
import { Tag, Clock, Zap, Shield, Calendar } from 'lucide-svelte'; import { Tag, Clock, Zap, Shield, Calendar } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { parseUTC } from '$shared/dates';
export let profiles: DelayProfileTableRow[]; export let profiles: DelayProfileTableRow[];
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
// SQLite stores timestamps without timezone info, treat as UTC const date = parseUTC(dateString);
const date = new Date(dateString + 'Z'); if (!date) return '-';
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',

View File

@@ -6,12 +6,13 @@
import { marked } from 'marked'; import { marked } from 'marked';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { parseUTC } from '$shared/dates';
export let expressions: RegularExpressionTableRow[]; export let expressions: RegularExpressionTableRow[];
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
// SQLite stores timestamps without timezone info, treat as UTC const date = parseUTC(dateString);
const date = new Date(dateString + 'Z'); if (!date) return '-';
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Bell } from 'lucide-svelte'; import { Bell } from 'lucide-svelte';
import { parseUTC } from '$shared/dates';
import type { NotificationHistoryRecord } from '$db/queries/notificationHistory.ts'; import type { NotificationHistoryRecord } from '$db/queries/notificationHistory.ts';
export let history: NotificationHistoryRecord[]; export let history: NotificationHistoryRecord[];
export let services: Array<{ id: string; name: string }>; export let services: Array<{ id: string; name: string }>;
function formatDateTime(date: string): string { function formatDateTime(date: string): string {
// SQLite stores as UTC without timezone indicator, append Z to parse correctly const d = parseUTC(date);
const utcDate = date.endsWith('Z') ? date : date.replace(' ', 'T') + 'Z'; return d ? d.toLocaleString() : '-';
return new Date(utcDate).toLocaleString();
} }
function getServiceName(serviceId: string): string { function getServiceName(serviceId: string): string {