Add per-meter usage chart on MeterDetail for device-fed meters

Consumes the existing /history endpoint (raw/hourly/daily-reading
telemetry) to show a consumption chart with a range picker, for
meters that have an mqtt_topic set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 21:09:52 +00:00
parent f5b0b01ff1
commit 85c843c9ef
5 changed files with 517 additions and 7 deletions

View file

@ -1,6 +1,6 @@
import type {
Category, Meter, MeterDetail, Tariff, Reading, ConsumptionCostReport, RollupReport,
EstimateReport, AppConfig,
EstimateReport, AppConfig, MeterHistory,
} from './types'
const BASE = '/utilities/api'
@ -66,6 +66,9 @@ export function deleteMeterImage(meterId: number): Promise<Meter> {
export function uploadUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
export function fetchMeterHistory(id: number, from: string, to: string): Promise<MeterHistory> {
return request(`/meters/${id}/history?from=${from}&to=${to}`)
}
// Tariffs
export function fetchTariffs(categoryId?: number): Promise<Tariff[]> {

View file

@ -1,11 +1,32 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { ArrowLeft, Gauge } from 'lucide-react'
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAuth } from '../components/AuthGate'
import { can, formatUnits } from '../types'
import type { MeterDetail as MeterDetailType, Tariff } from '../types'
import type { MeterDetail as MeterDetailType, Tariff, MeterHistoryPoint } from '../types'
import * as api from '../api'
const USAGE_RANGES = [
{ key: '2d', label: '48h', ms: 2 * 24 * 60 * 60 * 1000 },
{ key: '7d', label: '7 days', ms: 7 * 24 * 60 * 60 * 1000 },
{ key: '30d', label: '30 days', ms: 30 * 24 * 60 * 60 * 1000 },
{ key: '90d', label: '90 days', ms: 90 * 24 * 60 * 60 * 1000 },
]
function formatUsageTick(ts: number, rangeKey: string) {
const d = new Date(ts)
return rangeKey === '2d'
? d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
const RESOLUTION_LABEL: Record<MeterHistoryPoint['resolution'], string> = {
raw: 'Usage (live)',
hourly: 'Usage (hourly)',
'daily-reading': 'Usage (daily)',
}
export default function MeterDetail() {
const { id } = useParams()
const navigate = useNavigate()
@ -19,6 +40,9 @@ export default function MeterDetail() {
const [assignTariffId, setAssignTariffId] = useState<number | ''>('')
const [assignFrom, setAssignFrom] = useState(new Date().toISOString().slice(0, 10))
const [imageFile, setImageFile] = useState<File | null>(null)
const [usageRange, setUsageRange] = useState(USAGE_RANGES[1])
const [usage, setUsage] = useState<MeterHistoryPoint[]>([])
const [usageLoading, setUsageLoading] = useState(false)
const load = useCallback(() => {
if (!id) return
@ -28,11 +52,25 @@ export default function MeterDetail() {
useEffect(() => {
if (meter) api.fetchTariffs(meter.category_id).then(setTariffs).catch(() => {})
}, [meter?.category_id])
useEffect(() => {
if (!meter?.mqtt_topic) return
setUsageLoading(true)
const to = new Date()
const from = new Date(to.getTime() - usageRange.ms)
api.fetchMeterHistory(meter.id, from.toISOString(), to.toISOString())
.then(h => setUsage(h.series))
.catch(() => setUsage([]))
.finally(() => setUsageLoading(false))
}, [meter?.id, meter?.mqtt_topic, usageRange])
if (error) return <div className="page"><div className="error-banner">{error}</div></div>
if (!meter) return <div className="page"><div className="loading-state">Loading</div></div>
const currentTariff = meter.tariff_history.find(t => !t.effective_to)
const usageChartData = usage
.filter((p): p is MeterHistoryPoint & { delta: number } => p.delta != null)
.map(p => ({ ts: new Date(p.date).getTime(), delta: Number(p.delta), resolution: p.resolution }))
const usageTotal = usageChartData.reduce((sum, p) => sum + (p.delta > 0 ? p.delta : 0), 0)
async function uploadImage() {
if (!imageFile || !meter) return
@ -114,6 +152,62 @@ export default function MeterDetail() {
</div>
</div>
{meter.mqtt_topic && (
<>
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>Usage</span>
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto' }}>
{USAGE_RANGES.map(r => (
<button
key={r.key}
className={`btn btn-sm${usageRange.key === r.key ? ' btn-primary' : ''}`}
onClick={() => setUsageRange(r)}
>
{r.label}
</button>
))}
</div>
</div>
<div className="card">
{usageLoading ? (
<div className="loading-state">Loading</div>
) : usageChartData.length === 0 ? (
<div className="empty-state">No telemetry recorded yet for this range.</div>
) : (
<>
<div className="stats-strip" style={{ marginBottom: 12 }}>
<div className="stat-box">
<div className="stat-value">{formatUnits(usageTotal, meter.unit_label)}</div>
<div className="stat-label">Total usage ({usageRange.label})</div>
</div>
</div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={usageChartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="ts"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={ts => formatUsageTick(ts, usageRange.key)}
tick={{ fontSize: 10 }}
/>
<YAxis tick={{ fontSize: 10 }} width={56} />
<Tooltip
labelFormatter={(ts: any) => new Date(ts).toLocaleString('en-GB')}
formatter={(value: any, _name: any, item: any) => [
`${Number(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} ${meter.unit_label}`,
item?.payload ? RESOLUTION_LABEL[item.payload.resolution as MeterHistoryPoint['resolution']] : 'Usage',
]}
/>
<Area type="monotone" dataKey="delta" stroke="#c9a84c" fill="#c9a84c" fillOpacity={0.2} strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</>
)}
</div>
</>
)}
{meter.children.length > 0 && (
<>
<div className="section-title">Sub-meters</div>

View file

@ -78,6 +78,20 @@ export interface MeterDetail extends Meter {
recent_readings: Reading[]
}
export interface MeterHistoryPoint {
date: string
value: number
delta: number | null
resolution: 'raw' | 'hourly' | 'daily-reading'
}
export interface MeterHistory {
meter_id: number
from: string
to: string
series: MeterHistoryPoint[]
}
export interface RateWindow {
id?: number
tariff_id?: number