frontend: show Energy/DSMR/meter times in local timezone, 24h

Backend serializes SQLite-read (naive) UTC datetimes without a Z, so the browser
was parsing them as local time -> the UI effectively showed UTC values.

- New src/utils/datetime.ts: treats a tz-less timestamp as UTC, then formats in
  the browser's local timezone with hour12:false (no AM/PM).
- Applied to Energy tabs (contracts/prices/costs/DSMR), per-device readings &
  trends, and the meter readings table on the Records page.
- CostView Today/This month now use local day boundaries (queries stay UTC ISO).
- Untouched: location/poo (raw Zulu strings), the map, and query-window params.
- Tests are timezone-independent (verified under TZ=America/Los_Angeles).
This commit is contained in:
2026-06-24 13:22:07 +02:00
parent 9d3e28a529
commit effe434637
9 changed files with 301 additions and 37 deletions
+4 -3
View File
@@ -33,6 +33,7 @@ import {
} from './hooks' } from './hooks'
import { ContractForm } from './ContractForm' import { ContractForm } from './ContractForm'
import apiClient from '../api/client' import apiClient from '../api/client'
import { formatLocalDate } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Version history modal // Version history modal
@@ -96,11 +97,11 @@ function VersionHistoryModal({ contractId, contractName, onClose }: VersionHisto
<Accordion.Control> <Accordion.Control>
<Group gap="sm"> <Group gap="sm">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
From: {new Date(v.effective_from).toLocaleDateString()} From: {formatLocalDate(v.effective_from)}
</Text> </Text>
{v.effective_to ? ( {v.effective_to ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
To: {new Date(v.effective_to).toLocaleDateString()} To: {formatLocalDate(v.effective_to)}
</Text> </Text>
) : ( ) : (
<Badge color="green" size="xs"> <Badge color="green" size="xs">
@@ -195,7 +196,7 @@ function ContractTable({
<Table.Td>{contract.currency}</Table.Td> <Table.Td>{contract.currency}</Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{new Date(contract.created_at).toLocaleDateString()} {formatLocalDate(contract.created_at)}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
+10 -14
View File
@@ -42,6 +42,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks' import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
import { formatLocalTime } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cost limit — prevent accidental full-table pulls // Cost limit — prevent accidental full-table pulls
@@ -54,17 +55,18 @@ const COSTS_MAX_LIMIT = 500
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getTodayRange(): { start: string; end: string } { function getTodayRange(): { start: string; end: string } {
const start = new Date() // Use local date boundary so "today" matches what the user sees in the display.
start.setUTCHours(0, 0, 0, 0) const now = new Date()
const end = new Date(start) const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end.setUTCDate(end.getUTCDate() + 1) const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
return { start: start.toISOString(), end: end.toISOString() } return { start: start.toISOString(), end: end.toISOString() }
} }
function getThisMonthRange(): { start: string; end: string } { 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 now = new Date()
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) const start = new Date(now.getFullYear(), now.getMonth(), 1)
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) const end = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return { start: start.toISOString(), end: end.toISOString() } return { start: start.toISOString(), end: end.toISOString() }
} }
@@ -261,10 +263,7 @@ export function CostView() {
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
<AreaChart <AreaChart
data={costsQuery.data.items.map((item) => ({ data={costsQuery.data.items.map((item) => ({
time: new Date(item.period_start).toLocaleTimeString([], { time: formatLocalTime(item.period_start),
hour: '2-digit',
minute: '2-digit',
}),
import_cost: item.import_cost, import_cost: item.import_cost,
export_revenue: item.export_revenue, export_revenue: item.export_revenue,
net_cost: item.net_cost, net_cost: item.net_cost,
@@ -343,10 +342,7 @@ export function CostView() {
> >
<Table.Td> <Table.Td>
<Text size="xs"> <Text size="xs">
{new Date(item.period_start).toLocaleTimeString([], { {formatLocalTime(item.period_start)}
hour: '2-digit',
minute: '2-digit',
})}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td> <Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td>
+2 -1
View File
@@ -26,6 +26,7 @@ import {
Badge, Badge,
} from '@mantine/core' } from '@mantine/core'
import { useDsmrLatest } from './hooks' import { useDsmrLatest } from './hooks'
import { formatLocalDateTime } from '../utils/datetime'
/** Default auto-refresh interval (ms) — DSMR ingest stores ~one row per 10 s. */ /** Default auto-refresh interval (ms) — DSMR ingest stores ~one row per 10 s. */
const AUTO_REFRESH_INTERVAL_MS = 10_000 const AUTO_REFRESH_INTERVAL_MS = 10_000
@@ -117,7 +118,7 @@ function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
</Badge> </Badge>
{data.recorded_at && ( {data.recorded_at && (
<Text size="sm" c="dimmed" data-testid="dsmr-recorded-at"> <Text size="sm" c="dimmed" data-testid="dsmr-recorded-at">
recorded at {new Date(data.recorded_at).toLocaleString()} recorded at {formatLocalDateTime(data.recorded_at)}
</Text> </Text>
)} )}
</Group> </Group>
+3 -11
View File
@@ -43,6 +43,7 @@ import {
import { useReadings, useMetrics } from './hooks' import { useReadings, useMetrics } from './hooks'
import type { MetricInfo, AutoRefreshOptions } from './hooks' import type { MetricInfo, AutoRefreshOptions } from './hooks'
import { formatMetricValue } from './format' import { formatMetricValue } from './format'
import { formatLocalTime, formatLocalDateTime } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -107,12 +108,7 @@ function lineColor(index: number): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function formatTimeTick(isoString: string): string { function formatTimeTick(isoString: string): string {
try { return formatLocalTime(isoString)
const d = new Date(isoString)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return isoString
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -300,11 +296,7 @@ export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProp
] as [string, string] ] as [string, string]
}} }}
labelFormatter={(label) => { labelFormatter={(label) => {
try { return formatLocalDateTime(String(label))
return new Date(String(label)).toLocaleString()
} catch {
return String(label)
}
}} }}
/> />
<Legend /> <Legend />
+2 -1
View File
@@ -32,6 +32,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { useEnergyPrices } from './hooks' import { useEnergyPrices } from './hooks'
import { formatLocalTime } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Time range helpers // Time range helpers
@@ -61,7 +62,7 @@ interface TibberChartProps {
function TibberChart({ points, currency }: TibberChartProps) { function TibberChart({ points, currency }: TibberChartProps) {
const data = points.map((p) => ({ 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, buy: p.buy,
sell: p.sell, sell: p.sell,
})) }))
+2 -1
View File
@@ -48,6 +48,7 @@ import { DsmrPanel } from '../energy/DsmrPanel'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks' import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format' import { formatMetricValue } from '../energy/format'
import { formatLocalDateTime } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Delete confirmation modal (with 409 "disable instead" hint) // Delete confirmation modal (with 409 "disable instead" hint)
@@ -311,7 +312,7 @@ function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
</Text> </Text>
{latest.recorded_at && ( {latest.recorded_at && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{new Date(latest.recorded_at).toLocaleString()} {formatLocalDateTime(latest.recorded_at)}
</Text> </Text>
)} )}
</Group> </Group>
+3 -6
View File
@@ -35,6 +35,7 @@ import type { PooRecord, LocationRecord } from '../records'
import { useDevices, useMetrics, useReadings } from '../energy/hooks' import { useDevices, useMetrics, useReadings } from '../energy/hooks'
import type { ModbusDevice, MetricInfo } from '../energy/hooks' import type { ModbusDevice, MetricInfo } from '../energy/hooks'
import { formatMetricValue } from '../energy/format' import { formatMetricValue } from '../energy/format'
import { formatLocalDateTime } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -359,13 +360,9 @@ interface MeterReadingsTableProps {
device: ModbusDevice device: ModbusDevice
} }
/** Format an ISO timestamp for display. */ /** Format an ISO timestamp for display (local timezone, 24-hour). */
function formatRecordedAt(iso: string): string { function formatRecordedAt(iso: string): string {
try { return formatLocalDateTime(iso)
return new Date(iso).toLocaleString()
} catch {
return iso
}
} }
function MeterReadingsTable({ device }: MeterReadingsTableProps) { function MeterReadingsTable({ device }: MeterReadingsTableProps) {
+172
View File
@@ -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)
})
})
+103
View File
@@ -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
}
}