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:
parent
f5b0b01ff1
commit
85c843c9ef
5 changed files with 517 additions and 7 deletions
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue