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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user