Files
home-automation/frontend/src/energy/CostView.tsx
T
tliu93 effe434637 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).
2026-06-24 13:22:07 +02:00

404 lines
13 KiB
TypeScript

/**
* CostView — cost trends + detail + summary.
*
* Features:
* - Date range selector: Today / This month / Custom (SegmentedControl + DateInput).
* - Recharts AreaChart showing import_cost, export_revenue, net_cost per period.
* - Table showing per-15min periods with time, kWh values, costs, degraded badge.
* - Summary cards: metered import/export, fixed costs, credits, total payable.
* - "Recompute" button with confirmation.
* - Loading/error/empty states, degraded period highlighting.
*
* Recharts imports are isolated to this file only.
*/
import { useState } from 'react'
import {
Stack,
Text,
TextInput,
Loader,
Center,
Alert,
Table,
Group,
Badge,
ScrollArea,
SegmentedControl,
Button,
Paper,
SimpleGrid,
Modal,
Title,
} from '@mantine/core'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
import { formatLocalTime } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Cost limit — prevent accidental full-table pulls
// ---------------------------------------------------------------------------
const COSTS_MAX_LIMIT = 500
// ---------------------------------------------------------------------------
// Date range helpers
// ---------------------------------------------------------------------------
function getTodayRange(): { start: string; end: string } {
// 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(now.getFullYear(), now.getMonth(), 1)
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return { start: start.toISOString(), end: end.toISOString() }
}
// ---------------------------------------------------------------------------
// Summary cards
// ---------------------------------------------------------------------------
interface SummaryCardProps {
label: string
value: string
testId?: string
}
function SummaryCard({ label, value, testId }: SummaryCardProps) {
return (
<Paper withBorder p="sm" data-testid={testId}>
<Stack gap={4}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={600} size="lg">
{value}
</Text>
</Stack>
</Paper>
)
}
// ---------------------------------------------------------------------------
// CostView — main component
// ---------------------------------------------------------------------------
type RangePreset = 'today' | 'month' | 'custom'
export function CostView() {
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
// Date strings in YYYY-MM-DD format for custom range
const [customStartStr, setCustomStartStr] = useState('')
const [customEndStr, setCustomEndStr] = useState('')
const [showRecomputeConfirm, setShowRecomputeConfirm] = useState(false)
// Compute effective date range
const { start, end } = (() => {
if (rangePreset === 'today') return getTodayRange()
if (rangePreset === 'month') return getThisMonthRange()
return {
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
}
})()
const costsQuery = useEnergyCosts(start, end, COSTS_MAX_LIMIT)
const summaryQuery = useEnergyCostSummary(start, end)
const recomputeMutation = useRecomputeCosts()
async function handleRecompute() {
setShowRecomputeConfirm(false)
await recomputeMutation.mutateAsync({ start, end })
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
const currency = costsQuery.data?.items[0]?.currency ?? summaryQuery.data?.currency ?? 'EUR'
return (
<Stack gap="lg" data-testid="cost-view">
{/* Date range selector */}
<Group align="flex-start" gap="md" wrap="wrap">
<Stack gap="xs">
<Text size="sm" fw={500}>
Date range
</Text>
<SegmentedControl
value={rangePreset}
onChange={(v) => setRangePreset(v as RangePreset)}
data={[
{ label: 'Today', value: 'today' },
{ label: 'This month', value: 'month' },
{ label: 'Custom', value: 'custom' },
]}
data-testid="cost-range-control"
/>
</Stack>
{rangePreset === 'custom' && (
<Group gap="sm" align="flex-end">
<TextInput
label="From"
type="date"
value={customStartStr}
onChange={(e) => setCustomStartStr(e.currentTarget.value)}
data-testid="cost-custom-start"
/>
<TextInput
label="To"
type="date"
value={customEndStr}
onChange={(e) => setCustomEndStr(e.currentTarget.value)}
data-testid="cost-custom-end"
/>
</Group>
)}
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end">
<Button
variant="outline"
color="orange"
size="sm"
onClick={() => setShowRecomputeConfirm(true)}
loading={recomputeMutation.isPending}
data-testid="cost-recompute-button"
>
Recompute
</Button>
</Group>
</Group>
{/* Summary cards */}
{summaryQuery.isLoading && (
<Center>
<Loader size="sm" />
</Center>
)}
{summaryQuery.isError && (
<Alert color="red" data-testid="summary-error">
Failed to load cost summary.
</Alert>
)}
{summaryQuery.data && (
<Stack gap="xs">
<Title order={6} c="dimmed">
Summary
</Title>
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
<SummaryCard
label="Import (kWh)"
value={summaryQuery.data.metered_import.toFixed(3)}
testId="summary-import"
/>
<SummaryCard
label="Export (kWh)"
value={summaryQuery.data.metered_export.toFixed(3)}
testId="summary-export"
/>
<SummaryCard
label={`Fixed costs (${currency})`}
value={summaryQuery.data.fixed_costs.toFixed(2)}
testId="summary-fixed"
/>
<SummaryCard
label={`Credits (${currency})`}
value={summaryQuery.data.credits.toFixed(2)}
testId="summary-credits"
/>
<SummaryCard
label={`Total payable (${currency})`}
value={summaryQuery.data.total_payable.toFixed(2)}
testId="summary-total"
/>
</SimpleGrid>
</Stack>
)}
{/* Cost chart */}
{costsQuery.isLoading && (
<Center py="xl" data-testid="costs-loading">
<Loader />
</Center>
)}
{costsQuery.isError && (
<Alert color="red" data-testid="costs-error">
Failed to load cost periods. Please refresh.
</Alert>
)}
{costsQuery.data && costsQuery.data.items.length === 0 && (
<Alert color="gray" data-testid="costs-empty">
No cost data available for the selected period.
</Alert>
)}
{costsQuery.data && costsQuery.data.items.length > 0 && (
<>
{/* Area chart */}
<Stack gap="xs" data-testid="cost-chart">
<Title order={6} c="dimmed">
Cost trends ({currency})
</Title>
<ResponsiveContainer width="100%" height={220}>
<AreaChart
data={costsQuery.data.items.map((item) => ({
time: formatLocalTime(item.period_start),
import_cost: item.import_cost,
export_revenue: item.export_revenue,
net_cost: item.net_cost,
}))}
margin={{ top: 4, right: 16, left: 0, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v: number) => v.toFixed(2)} />
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
/>
<Legend />
<Area
type="monotone"
dataKey="import_cost"
stroke="#2196f3"
fill="#bbdefb"
name="Import cost"
dot={false}
/>
<Area
type="monotone"
dataKey="export_revenue"
stroke="#4caf50"
fill="#c8e6c9"
name="Export revenue"
dot={false}
/>
<Area
type="monotone"
dataKey="net_cost"
stroke="#ff9800"
fill="#ffe0b2"
name="Net cost"
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</Stack>
{/* Detail table */}
<Stack gap="xs">
<Title order={6} c="dimmed">
Period detail
</Title>
<ScrollArea>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
style={{ fontSize: 12 }}
data-testid="costs-table"
>
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Import kWh</Table.Th>
<Table.Th>Export kWh</Table.Th>
<Table.Th>Import cost</Table.Th>
<Table.Th>Export rev.</Table.Th>
<Table.Th>Net cost</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{costsQuery.data.items.map((item, idx) => (
<Table.Tr
key={idx}
style={item.degraded ? { opacity: 0.6 } : undefined}
data-testid={`cost-row-${idx}`}
>
<Table.Td>
<Text size="xs">
{formatLocalTime(item.period_start)}
</Text>
</Table.Td>
<Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td>
<Table.Td>{(item.r1_kwh + item.r2_kwh).toFixed(3)}</Table.Td>
<Table.Td>{item.import_cost.toFixed(4)}</Table.Td>
<Table.Td>{item.export_revenue.toFixed(4)}</Table.Td>
<Table.Td>{item.net_cost.toFixed(4)}</Table.Td>
<Table.Td>
{item.degraded && (
<Badge color="orange" size="xs" data-testid={`cost-degraded-${idx}`}>
degraded
</Badge>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
</>
)}
{/* Recompute confirmation modal */}
{showRecomputeConfirm && (
<Modal
opened
onClose={() => setShowRecomputeConfirm(false)}
title="Recompute costs?"
size="sm"
data-testid="recompute-confirm-modal"
>
<Stack gap="md">
<Text size="sm">
This will recompute all cost periods for the selected date range. Continue?
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setShowRecomputeConfirm(false)}
data-testid="recompute-cancel"
>
Cancel
</Button>
<Button
color="orange"
onClick={handleRecompute}
data-testid="recompute-confirm"
>
Recompute
</Button>
</Group>
</Stack>
</Modal>
)}
</Stack>
)
}