diff --git a/frontend/src/energy/ContractManager.tsx b/frontend/src/energy/ContractManager.tsx index b7df932..b80dc62 100644 --- a/frontend/src/energy/ContractManager.tsx +++ b/frontend/src/energy/ContractManager.tsx @@ -33,6 +33,7 @@ import { } from './hooks' import { ContractForm } from './ContractForm' import apiClient from '../api/client' +import { formatLocalDate } from '../utils/datetime' // --------------------------------------------------------------------------- // Version history modal @@ -96,11 +97,11 @@ function VersionHistoryModal({ contractId, contractName, onClose }: VersionHisto - From: {new Date(v.effective_from).toLocaleDateString()} + From: {formatLocalDate(v.effective_from)} {v.effective_to ? ( - To: {new Date(v.effective_to).toLocaleDateString()} + To: {formatLocalDate(v.effective_to)} ) : ( @@ -195,7 +196,7 @@ function ContractTable({ {contract.currency} - {new Date(contract.created_at).toLocaleDateString()} + {formatLocalDate(contract.created_at)} diff --git a/frontend/src/energy/CostView.tsx b/frontend/src/energy/CostView.tsx index acfd103..ba057dd 100644 --- a/frontend/src/energy/CostView.tsx +++ b/frontend/src/energy/CostView.tsx @@ -42,6 +42,7 @@ import { ResponsiveContainer, } from 'recharts' import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks' +import { formatLocalTime } from '../utils/datetime' // --------------------------------------------------------------------------- // Cost limit — prevent accidental full-table pulls @@ -54,17 +55,18 @@ const COSTS_MAX_LIMIT = 500 // --------------------------------------------------------------------------- function getTodayRange(): { start: string; end: string } { - const start = new Date() - start.setUTCHours(0, 0, 0, 0) - const end = new Date(start) - end.setUTCDate(end.getUTCDate() + 1) + // Use local date boundary so "today" matches what the user sees in the display. + const now = new Date() + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) return { start: start.toISOString(), end: end.toISOString() } } function getThisMonthRange(): { start: string; end: string } { + // Use local date boundary so "this month" matches what the user sees in the display. const now = new Date() - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) + const start = new Date(now.getFullYear(), now.getMonth(), 1) + const end = new Date(now.getFullYear(), now.getMonth() + 1, 1) return { start: start.toISOString(), end: end.toISOString() } } @@ -261,10 +263,7 @@ export function CostView() { ({ - time: new Date(item.period_start).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }), + time: formatLocalTime(item.period_start), import_cost: item.import_cost, export_revenue: item.export_revenue, net_cost: item.net_cost, @@ -343,10 +342,7 @@ export function CostView() { > - {new Date(item.period_start).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} + {formatLocalTime(item.period_start)} {(item.d1_kwh + item.d2_kwh).toFixed(3)} diff --git a/frontend/src/energy/DsmrPanel.tsx b/frontend/src/energy/DsmrPanel.tsx index 4a2c21a..fafe2c7 100644 --- a/frontend/src/energy/DsmrPanel.tsx +++ b/frontend/src/energy/DsmrPanel.tsx @@ -26,6 +26,7 @@ import { Badge, } from '@mantine/core' import { useDsmrLatest } from './hooks' +import { formatLocalDateTime } from '../utils/datetime' /** Default auto-refresh interval (ms) — DSMR ingest stores ~one row per 10 s. */ const AUTO_REFRESH_INTERVAL_MS = 10_000 @@ -117,7 +118,7 @@ function DsmrContent({ isLoading, isError, data }: DsmrContentProps) { {data.recorded_at && ( - recorded at {new Date(data.recorded_at).toLocaleString()} + recorded at {formatLocalDateTime(data.recorded_at)} )} diff --git a/frontend/src/energy/EnergyCharts.tsx b/frontend/src/energy/EnergyCharts.tsx index 477223a..4602878 100644 --- a/frontend/src/energy/EnergyCharts.tsx +++ b/frontend/src/energy/EnergyCharts.tsx @@ -43,6 +43,7 @@ import { import { useReadings, useMetrics } from './hooks' import type { MetricInfo, AutoRefreshOptions } from './hooks' import { formatMetricValue } from './format' +import { formatLocalTime, formatLocalDateTime } from '../utils/datetime' // --------------------------------------------------------------------------- // Constants @@ -107,12 +108,7 @@ function lineColor(index: number): string { // --------------------------------------------------------------------------- function formatTimeTick(isoString: string): string { - try { - const d = new Date(isoString) - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - } catch { - return isoString - } + return formatLocalTime(isoString) } // --------------------------------------------------------------------------- @@ -300,11 +296,7 @@ export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProp ] as [string, string] }} labelFormatter={(label) => { - try { - return new Date(String(label)).toLocaleString() - } catch { - return String(label) - } + return formatLocalDateTime(String(label)) }} /> diff --git a/frontend/src/energy/TibberPrices.tsx b/frontend/src/energy/TibberPrices.tsx index 8832606..143a009 100644 --- a/frontend/src/energy/TibberPrices.tsx +++ b/frontend/src/energy/TibberPrices.tsx @@ -32,6 +32,7 @@ import { ResponsiveContainer, } from 'recharts' import { useEnergyPrices } from './hooks' +import { formatLocalTime } from '../utils/datetime' // --------------------------------------------------------------------------- // Time range helpers @@ -61,7 +62,7 @@ interface TibberChartProps { function TibberChart({ points, currency }: TibberChartProps) { const data = points.map((p) => ({ - time: new Date(p.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + time: formatLocalTime(p.starts_at), buy: p.buy, sell: p.sell, })) diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx index fdf9f18..f9518c8 100644 --- a/frontend/src/pages/EnergyPage.tsx +++ b/frontend/src/pages/EnergyPage.tsx @@ -48,6 +48,7 @@ import { DsmrPanel } from '../energy/DsmrPanel' import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks' import { ApiError } from '../api/client' import { formatMetricValue } from '../energy/format' +import { formatLocalDateTime } from '../utils/datetime' // --------------------------------------------------------------------------- // Delete confirmation modal (with 409 "disable instead" hint) @@ -311,7 +312,7 @@ function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) { {latest.recorded_at && ( - {new Date(latest.recorded_at).toLocaleString()} + {formatLocalDateTime(latest.recorded_at)} )} diff --git a/frontend/src/pages/RecordsPage.tsx b/frontend/src/pages/RecordsPage.tsx index a6fc6b2..622e05e 100644 --- a/frontend/src/pages/RecordsPage.tsx +++ b/frontend/src/pages/RecordsPage.tsx @@ -35,6 +35,7 @@ import type { PooRecord, LocationRecord } from '../records' import { useDevices, useMetrics, useReadings } from '../energy/hooks' import type { ModbusDevice, MetricInfo } from '../energy/hooks' import { formatMetricValue } from '../energy/format' +import { formatLocalDateTime } from '../utils/datetime' // --------------------------------------------------------------------------- // Constants @@ -359,13 +360,9 @@ interface MeterReadingsTableProps { device: ModbusDevice } -/** Format an ISO timestamp for display. */ +/** Format an ISO timestamp for display (local timezone, 24-hour). */ function formatRecordedAt(iso: string): string { - try { - return new Date(iso).toLocaleString() - } catch { - return iso - } + return formatLocalDateTime(iso) } function MeterReadingsTable({ device }: MeterReadingsTableProps) { diff --git a/frontend/src/utils/datetime.test.ts b/frontend/src/utils/datetime.test.ts new file mode 100644 index 0000000..cf523b0 --- /dev/null +++ b/frontend/src/utils/datetime.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for src/utils/datetime.ts + * + * Key invariants verified (timezone-agnostic where possible): + * 1. A tz-less string is parsed identically to the same string with "Z" appended + * (i.e. treated as UTC regardless of the machine's local timezone). + * 2. Formatted output never contains AM / PM / am / pm (always 24-hour). + * 3. Strings that already carry a timezone marker are NOT double-offset. + * 4. Invalid / empty input does not throw — returns original string or safe fallback. + * 5. Pure date strings (no T) are handled without crashing. + */ + +import { describe, it, expect } from 'vitest' +import { + parseBackendTimestamp, + formatLocalDateTime, + formatLocalTime, + formatLocalDate, +} from './datetime' + +// --------------------------------------------------------------------------- +// parseBackendTimestamp — core UTC-fallback logic +// --------------------------------------------------------------------------- + +describe('parseBackendTimestamp', () => { + it('returns the same Date for tz-less and Z-suffixed strings (UTC equivalence)', () => { + const noTz = parseBackendTimestamp('2026-06-24T10:27:00') + const withZ = parseBackendTimestamp('2026-06-24T10:27:00Z') + // Both must represent the exact same UTC instant. + expect(noTz.getTime()).toBe(withZ.getTime()) + }) + + it('does not double-offset a string that already has Z', () => { + const d1 = parseBackendTimestamp('2026-06-24T10:27:00Z') + const d2 = parseBackendTimestamp('2026-06-24T10:27:00') + expect(d1.getTime()).toBe(d2.getTime()) + }) + + it('correctly parses a string with +HH:MM offset', () => { + // 2026-06-24T12:27:00+02:00 == 2026-06-24T10:27:00Z + const withOffset = parseBackendTimestamp('2026-06-24T12:27:00+02:00') + const utc = parseBackendTimestamp('2026-06-24T10:27:00Z') + expect(withOffset.getTime()).toBe(utc.getTime()) + }) + + it('correctly parses a string with -HH:MM offset', () => { + // 2026-06-24T02:27:00-08:00 == 2026-06-24T10:27:00Z + const withOffset = parseBackendTimestamp('2026-06-24T02:27:00-08:00') + const utc = parseBackendTimestamp('2026-06-24T10:27:00Z') + expect(withOffset.getTime()).toBe(utc.getTime()) + }) + + it('handles pure date strings (no T) without crashing', () => { + const d = parseBackendTimestamp('2026-06-24') + expect(isNaN(d.getTime())).toBe(false) + }) + + it('returns Invalid Date for empty string', () => { + const d = parseBackendTimestamp('') + expect(isNaN(d.getTime())).toBe(true) + }) + + it('returns Invalid Date for garbage input', () => { + const d = parseBackendTimestamp('not-a-date') + expect(isNaN(d.getTime())).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// formatLocalDateTime — no AM/PM, 24-hour +// --------------------------------------------------------------------------- + +describe('formatLocalDateTime', () => { + it('produces the same output for tz-less and Z-suffixed strings (UTC equivalence)', () => { + const noTz = formatLocalDateTime('2026-06-24T10:27:00') + const withZ = formatLocalDateTime('2026-06-24T10:27:00Z') + // Both should format to the same local representation. + expect(noTz).toBe(withZ) + }) + + it('does not contain AM or PM (24-hour enforcement)', () => { + // Use noon UTC — a time where AM/PM distinction matters. + const result = formatLocalDateTime('2026-06-24T14:30:00Z') + expect(result).not.toMatch(/[Aa][Mm]|[Pp][Mm]/) + }) + + it('does not throw or return undefined for empty string', () => { + const result = formatLocalDateTime('') + expect(result).toBe('') + }) + + it('does not throw or return undefined for invalid string', () => { + const result = formatLocalDateTime('not-a-date') + // Should return the original string as a safe fallback. + expect(result).toBe('not-a-date') + }) + + it('returns a non-empty string for a valid timestamp', () => { + const result = formatLocalDateTime('2026-06-24T10:27:00Z') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) +}) + +// --------------------------------------------------------------------------- +// formatLocalTime — compact HH:mm, no AM/PM +// --------------------------------------------------------------------------- + +describe('formatLocalTime', () => { + it('produces the same output for tz-less and Z-suffixed strings', () => { + const noTz = formatLocalTime('2026-06-24T10:27:00') + const withZ = formatLocalTime('2026-06-24T10:27:00Z') + expect(noTz).toBe(withZ) + }) + + it('does not contain AM or PM', () => { + const result = formatLocalTime('2026-06-24T14:30:00Z') + expect(result).not.toMatch(/[Aa][Mm]|[Pp][Mm]/) + }) + + it('returns a non-empty string for a valid timestamp', () => { + const result = formatLocalTime('2026-06-24T10:27:00Z') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('does not throw for empty string', () => { + expect(() => formatLocalTime('')).not.toThrow() + expect(formatLocalTime('')).toBe('') + }) + + it('does not throw for invalid string', () => { + expect(() => formatLocalTime('bad')).not.toThrow() + expect(formatLocalTime('bad')).toBe('bad') + }) +}) + +// --------------------------------------------------------------------------- +// formatLocalDate — date only +// --------------------------------------------------------------------------- + +describe('formatLocalDate', () => { + it('produces the same output for tz-less and Z-suffixed strings', () => { + // Pick a time that won't cross a date boundary in any timezone + // (noon UTC — safe for UTC-12 through UTC+14). + const noTz = formatLocalDate('2026-06-24T12:00:00') + const withZ = formatLocalDate('2026-06-24T12:00:00Z') + expect(noTz).toBe(withZ) + }) + + it('handles pure date strings without crashing', () => { + expect(() => formatLocalDate('2026-06-24')).not.toThrow() + const result = formatLocalDate('2026-06-24') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('does not throw for empty string', () => { + expect(() => formatLocalDate('')).not.toThrow() + expect(formatLocalDate('')).toBe('') + }) + + it('does not throw for invalid string', () => { + expect(() => formatLocalDate('not-a-date')).not.toThrow() + expect(formatLocalDate('not-a-date')).toBe('not-a-date') + }) + + it('returns a non-empty string for a valid date', () => { + const result = formatLocalDate('2026-06-01T00:00:00Z') + expect(result.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts new file mode 100644 index 0000000..9bc120b --- /dev/null +++ b/frontend/src/utils/datetime.ts @@ -0,0 +1,103 @@ +/** + * datetime.ts — shared time formatting utilities for Energy/Modbus displays. + * + * Root cause addressed: the backend stores timestamps as naive ISO strings without + * timezone info (e.g. "2026-06-24T10:27:00", no Z/offset). Browsers parse these as + * *local* time by default, but the values are actually UTC. We fix this by treating + * any string that lacks a timezone marker as UTC (appending "Z" before parsing). + * + * Scope: Energy domain + Modbus meter readings ONLY. + * Do NOT use these for Location/Poo timestamps — those already use raw strings. + */ + +/** + * Timezone-marker detection regex. + * Matches a trailing Z (any case) or a UTC offset like +05:30, -08:00, +0530, -0800. + */ +const TZ_MARKER_RE = /([zZ]|[+-]\d{2}:?\d{2})$/ + +/** + * Parse a backend ISO timestamp, treating strings without a timezone marker as UTC. + * + * - If the string already has Z / +HH:MM / -HHMM → parse as-is. + * - If no timezone marker → append "Z" so the browser reads it as UTC. + * - Pure date strings like "2026-06-24" (no 'T') → parse as-is (Date handles them). + * - Invalid / empty input → returns an Invalid Date (does NOT throw). + */ +export function parseBackendTimestamp(iso: string): Date { + if (!iso || typeof iso !== 'string') return new Date(NaN) + + const hasTComponent = iso.includes('T') + + if (hasTComponent && !TZ_MARKER_RE.test(iso)) { + // No timezone marker on a datetime string — treat as UTC. + return new Date(iso + 'Z') + } + + return new Date(iso) +} + +// --------------------------------------------------------------------------- +// Locale formatting helpers +// All use the browser's local timezone (no explicit timeZone option) and +// 24-hour clock (hour12: false). +// --------------------------------------------------------------------------- + +/** + * Local date + time, 24-hour, e.g. "6/24/2026, 12:27:00". + * Used for: LatestReadingsCard recorded_at, DSMR recorded_at, chart tooltips, + * meter readings table (RecordsPage), cost period detail table. + */ +export function formatLocalDateTime(iso: string): string { + try { + const d = parseBackendTimestamp(iso) + if (isNaN(d.getTime())) return iso + return d.toLocaleString(undefined, { + hour12: false, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + } catch { + return iso + } +} + +/** + * Local time only, compact HH:mm (24-hour), e.g. "12:27". + * Used for: chart X-axis ticks, cost chart X-axis, Tibber price chart X-axis. + */ +export function formatLocalTime(iso: string): string { + try { + const d = parseBackendTimestamp(iso) + if (isNaN(d.getTime())) return iso + return d.toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return iso + } +} + +/** + * Local date only, e.g. "6/1/2026" or "2026-06-01" depending on locale. + * Used for: contract effective_from / effective_to dates, contract created_at. + */ +export function formatLocalDate(iso: string): string { + try { + const d = parseBackendTimestamp(iso) + if (isNaN(d.getTime())) return iso + return d.toLocaleDateString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) + } catch { + return iso + } +}