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'
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
<Accordion.Control>
<Group gap="sm">
<Text size="sm" fw={500}>
From: {new Date(v.effective_from).toLocaleDateString()}
From: {formatLocalDate(v.effective_from)}
</Text>
{v.effective_to ? (
<Text size="xs" c="dimmed">
To: {new Date(v.effective_to).toLocaleDateString()}
To: {formatLocalDate(v.effective_to)}
</Text>
) : (
<Badge color="green" size="xs">
@@ -195,7 +196,7 @@ function ContractTable({
<Table.Td>{contract.currency}</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{new Date(contract.created_at).toLocaleDateString()}
{formatLocalDate(contract.created_at)}
</Text>
</Table.Td>
<Table.Td>
+10 -14
View File
@@ -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() {
<ResponsiveContainer width="100%" height={220}>
<AreaChart
data={costsQuery.data.items.map((item) => ({
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() {
>
<Table.Td>
<Text size="xs">
{new Date(item.period_start).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{formatLocalTime(item.period_start)}
</Text>
</Table.Td>
<Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td>
+2 -1
View File
@@ -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) {
</Badge>
{data.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>
)}
</Group>
+3 -11
View File
@@ -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))
}}
/>
<Legend />
+2 -1
View File
@@ -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,
}))