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).
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
/**
|
|
* EnergyCharts — self-contained Recharts wrapper for Modbus device trend charts.
|
|
*
|
|
* Design decisions (M5 decision 11):
|
|
* - Recharts is imported ONLY in this file (isolation, easy to swap later).
|
|
* - The component is fully self-contained: it owns its own data fetching via
|
|
* useReadings / useMetrics hooks and renders loading/error/empty states.
|
|
* - Time-range selection is internal; readings are always fetched with a window
|
|
* + limit cap — never a full-table pull.
|
|
* - Metric labels and units come from GET /metrics; missing keys in payload are
|
|
* tolerated (a null data point is emitted so the line simply has a gap).
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recharts imports — keep ALL recharts imports inside this file.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip as RechartsTooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import {
|
|
Stack,
|
|
Group,
|
|
Text,
|
|
Loader,
|
|
Alert,
|
|
SegmentedControl,
|
|
Paper,
|
|
Title,
|
|
Badge,
|
|
Box,
|
|
} from '@mantine/core'
|
|
|
|
import { useReadings, useMetrics } from './hooks'
|
|
import type { MetricInfo, AutoRefreshOptions } from './hooks'
|
|
import { formatMetricValue } from './format'
|
|
import { formatLocalTime, formatLocalDateTime } from '../utils/datetime'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Must match the limit passed to useReadings below. */
|
|
const CHART_READINGS_LIMIT = 1000
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface EnergyChartsProps {
|
|
/** UUID of the Modbus device to chart. */
|
|
uuid: string
|
|
/** Friendly name for the chart title. */
|
|
deviceName: string
|
|
/** Auto-refresh options forwarded to useReadings. */
|
|
autoRefresh?: AutoRefreshOptions
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Time-range presets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TimePreset {
|
|
label: string
|
|
value: string
|
|
/** How many milliseconds of history to show. */
|
|
spanMs: number
|
|
}
|
|
|
|
const TIME_PRESETS: TimePreset[] = [
|
|
{ label: '1 h', value: '1h', spanMs: 60 * 60 * 1000 },
|
|
{ label: '6 h', value: '6h', spanMs: 6 * 60 * 60 * 1000 },
|
|
{ label: '24 h', value: '24h', spanMs: 24 * 60 * 60 * 1000 },
|
|
]
|
|
|
|
const DEFAULT_PRESET = '1h'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metric colour palette — cycles through a fixed set
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LINE_COLORS = [
|
|
'#4c9cdb',
|
|
'#f59f00',
|
|
'#51cf66',
|
|
'#f03e3e',
|
|
'#cc5de8',
|
|
'#20c997',
|
|
'#fd7e14',
|
|
'#74c0fc',
|
|
]
|
|
|
|
function lineColor(index: number): string {
|
|
return LINE_COLORS[index % LINE_COLORS.length]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: format a recorded_at timestamp for the X-axis tick
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function formatTimeTick(isoString: string): string {
|
|
return formatLocalTime(isoString)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: safe numeric read from payload — returns null if key absent/non-numeric
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function safePayloadValue(
|
|
payload: Record<string, unknown> | null | undefined,
|
|
key: string,
|
|
): number | null {
|
|
if (!payload) return null
|
|
const raw = payload[key]
|
|
if (raw === null || raw === undefined) return null
|
|
const n = Number(raw)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EnergyCharts component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) {
|
|
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
|
|
|
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
|
|
|
// Pass spanMs rather than fixed start/end so that every refetchInterval-triggered
|
|
// queryFn call computes end=now() and rolls the window forward — new readings
|
|
// recorded after mount will appear without a manual page refresh.
|
|
// Switching presets changes spanMs → different queryKey → immediate re-fetch.
|
|
const readingsQuery = useReadings(
|
|
uuid,
|
|
{ spanMs: selectedPreset.spanMs, limit: CHART_READINGS_LIMIT },
|
|
autoRefresh,
|
|
)
|
|
const metricsQuery = useMetrics(uuid)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Derive chart data: [{recorded_at, voltage: 230.2, current: 1.3, ...}, ...]
|
|
// -------------------------------------------------------------------------
|
|
|
|
const metricsData = metricsQuery.data?.metrics
|
|
|
|
const chartData = useMemo(() => {
|
|
const metrics: MetricInfo[] = metricsData ?? []
|
|
const readings = readingsQuery.data?.items ?? []
|
|
return readings.map((row) => {
|
|
const point: Record<string, string | number | null> = {
|
|
recorded_at: row.recorded_at,
|
|
}
|
|
for (const m of metrics) {
|
|
// Tolerate missing keys — null produces a gap in the line, not a crash.
|
|
point[m.key] = safePayloadValue(row.payload as Record<string, unknown>, m.key)
|
|
}
|
|
return point
|
|
})
|
|
}, [readingsQuery.data, metricsData])
|
|
|
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
|
|
|
// -------------------------------------------------------------------------
|
|
// States: loading / error / empty
|
|
// -------------------------------------------------------------------------
|
|
|
|
const isLoading = readingsQuery.isLoading || metricsQuery.isLoading
|
|
const isError = readingsQuery.isError || metricsQuery.isError
|
|
|
|
/**
|
|
* True when the returned row count equals the limit cap, meaning the window
|
|
* contains more data than was fetched. The backend returns the most-recent N
|
|
* rows in this case, so the chart shows the latest segment — but a hint is
|
|
* shown so the user knows the full window is not displayed.
|
|
*/
|
|
const isTruncated =
|
|
!isLoading &&
|
|
!isError &&
|
|
(readingsQuery.data?.items.length ?? 0) >= CHART_READINGS_LIMIT
|
|
|
|
return (
|
|
<Paper withBorder p="md" data-testid={`energy-charts-${uuid}`}>
|
|
<Stack gap="sm">
|
|
{/* Header row */}
|
|
<Group justify="space-between" align="center" wrap="nowrap">
|
|
<Title order={4} data-testid={`energy-charts-title-${uuid}`}>
|
|
{deviceName}
|
|
</Title>
|
|
<SegmentedControl
|
|
size="xs"
|
|
value={activePreset}
|
|
onChange={setActivePreset}
|
|
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
|
data-testid={`energy-charts-preset-${uuid}`}
|
|
/>
|
|
</Group>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<Group justify="center" py="lg" data-testid={`energy-charts-loading-${uuid}`}>
|
|
<Loader size="sm" />
|
|
<Text size="sm" c="dimmed">
|
|
Loading readings…
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{!isLoading && isError && (
|
|
<Alert color="red" data-testid={`energy-charts-error-${uuid}`}>
|
|
Failed to load readings or metrics. Please try again.
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Empty */}
|
|
{!isLoading && !isError && chartData.length === 0 && (
|
|
<Text
|
|
size="sm"
|
|
c="dimmed"
|
|
ta="center"
|
|
py="lg"
|
|
data-testid={`energy-charts-empty-${uuid}`}
|
|
>
|
|
No readings in this time window.
|
|
</Text>
|
|
)}
|
|
|
|
{/* Truncation notice — shown when window has more data than the fetch limit */}
|
|
{isTruncated && (
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
ta="right"
|
|
data-testid={`energy-charts-truncated-${uuid}`}
|
|
>
|
|
Showing the most recent {CHART_READINGS_LIMIT} readings; full long-range trend pending downsampling
|
|
</Text>
|
|
)}
|
|
|
|
{/* Chart */}
|
|
{!isLoading && !isError && chartData.length > 0 && (
|
|
<Box data-testid={`energy-charts-chart-${uuid}`}>
|
|
{/* Render one chart per metric for clarity (avoids mixed Y-axis units) */}
|
|
{metrics.map((metric, idx) => {
|
|
// Check if this metric has any non-null data points; skip if all null.
|
|
const hasData = chartData.some((pt) => pt[metric.key] !== null)
|
|
if (!hasData) return null
|
|
|
|
return (
|
|
<Box key={metric.key} mb="md">
|
|
<Group gap="xs" mb={4}>
|
|
<Text size="xs" fw={600} c="dimmed">
|
|
{metric.label}
|
|
</Text>
|
|
{metric.unit && (
|
|
<Badge size="xs" variant="outline" color="gray">
|
|
{metric.unit}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
<LineChart
|
|
data={chartData}
|
|
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" opacity={0.4} />
|
|
<XAxis
|
|
dataKey="recorded_at"
|
|
tickFormatter={formatTimeTick}
|
|
tick={{ fontSize: 10 }}
|
|
minTickGap={40}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 10 }}
|
|
width={50}
|
|
tickFormatter={(v: number) =>
|
|
metric.unit ? `${v} ${metric.unit}` : String(v)
|
|
}
|
|
/>
|
|
<RechartsTooltip
|
|
formatter={(value) => {
|
|
const formatted = formatMetricValue(value, metric)
|
|
if (formatted === '—') return ['—', metric.label] as [string, string]
|
|
return [
|
|
`${formatted}${metric.unit ? ' ' + metric.unit : ''}`,
|
|
metric.label,
|
|
] as [string, string]
|
|
}}
|
|
labelFormatter={(label) => {
|
|
return formatLocalDateTime(String(label))
|
|
}}
|
|
/>
|
|
<Legend />
|
|
<Line
|
|
type="monotone"
|
|
dataKey={metric.key}
|
|
name={metric.label}
|
|
stroke={lineColor(idx)}
|
|
dot={false}
|
|
isAnimationActive={false}
|
|
connectNulls={false}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
)
|
|
}
|