Switch Utilities table to per-meter rows, add 12-month trend charts

Table now matches the format used elsewhere in the app: one row per
meter (Consumption/Usage/Standing/Extras/VAT/Total/Basis/Period
Estimate) instead of a category rollup, mirroring the utilities app's
own Reports/Estimates pages (basis text, merged extras column).

Adds two charts below the table: total utility cost (this year vs
prior year) and one small per-category consumption chart (this year
vs prior year), both fed by the new /api/internal/trend endpoint,
aligned to the report's own month so paging to an older week shows
the right trailing-12-month window rather than today's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 14:59:18 +00:00
parent 0afecc2e24
commit 1efcc941bb
4 changed files with 180 additions and 30 deletions

View file

@ -39,3 +39,12 @@ export async function getUtilityCosts(period) {
export async function getUtilityEstimate() {
return utilFetch(`/api/internal/estimate?period=current`)
}
// GET /api/internal/trend?months=12&end=YYYY-MM — per-category cost/consumption
// for the last N months (ending at `end`, defaulting to the current month)
// plus the same N months a year earlier
export async function getUtilityTrend(months = 12, end) {
const qs = new URLSearchParams({ months: String(months) })
if (end) qs.set('end', end)
return utilFetch(`/api/internal/trend?${qs.toString()}`)
}

View file

@ -1,6 +1,6 @@
import { requireAuth } from '../auth.js'
import { getSetting } from '../db.js'
import { getUtilityCosts, getUtilityEstimate } from '../lib/utilities-client.js'
import { getUtilityCosts, getUtilityEstimate, getUtilityTrend } from '../lib/utilities-client.js'
async function fcFetch(path) {
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
@ -294,19 +294,20 @@ export async function weeklyActualRoutes(fastify) {
// Fetched best-effort for the report's calendar month — a failure here
// (e.g. utilities app not deployed, or key not configured) must not break
// the rest of the report.
let utilities = { costs: null, estimate: null, error: null }
let utilities = { costs: null, estimate: null, trend: null, error: null }
try {
const period = `${year}-${String(month).padStart(2, '0')}`
const now = new Date()
const isCurrentPeriod = year === now.getFullYear() && month === now.getMonth() + 1
const [costs, estimate] = await Promise.all([
const [costs, estimate, trend] = await Promise.all([
getUtilityCosts(period),
isCurrentPeriod ? getUtilityEstimate() : Promise.resolve(null),
getUtilityTrend(12, period),
])
utilities = { costs, estimate, error: null }
utilities = { costs, estimate, trend, error: null }
} catch (err) {
utilities = { costs: null, estimate: null, error: err.message }
utilities = { costs: null, estimate: null, trend: null, error: err.message }
}
return {

View file

@ -10,7 +10,7 @@ import {
CartesianGrid,
} from 'recharts'
import { getWeeklyActual } from '../api'
import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry } from '../types'
import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry, UtilityConsumptionBasis, UtilityTrend } from '../types'
// ── Formatters ─────────────────────────────────────────────────────────────
@ -475,8 +475,75 @@ function SalesHistorySection({
// ── Section: Utilities ──────────────────────────────────────────────────────
function extrasPence(m: { ccl_cost_pence: number; rab_levy_cost_pence: number; metering_cost_pence: number; other_charges_cost_pence: number }): number {
return m.ccl_cost_pence + m.rab_levy_cost_pence + m.metering_cost_pence + m.other_charges_cost_pence
}
function fmtBasis(status: UtilityConsumptionBasis, asOfDate: string | null): string {
if (status === 'no_data') return 'No data'
if (status === 'complete') return 'Complete'
if (status === 'distributed') return 'Distributed'
if (asOfDate === localDateStr(new Date())) return 'Up to date'
return asOfDate ? `As of ${fmtShortDate(asOfDate)}` : 'Partial'
}
function UtilitiesCostTrendChart({ trend }: { trend: UtilityTrend }) {
const chartData = trend.this_year.map((ty, i) => ({
label: ty.label,
'This Year': ty.total_pence / 100,
'Last Year': (trend.last_year[i]?.total_pence ?? 0) / 100,
}))
return (
<div className="wa-chart-card">
<h4>Total Utility Cost Last 12 Months vs Prior Year</h4>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="label" tick={{ fontSize: 9 }} />
<YAxis tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="This Year" stroke={CHART_COLORS.thisWeek} strokeWidth={2.5} dot={false} />
<Line type="monotone" dataKey="Last Year" stroke={CHART_COLORS.budget} strokeWidth={2} dot={false} strokeDasharray="5 3" />
</LineChart>
</ResponsiveContainer>
</div>
)
}
function UtilitiesConsumptionCharts({ trend }: { trend: UtilityTrend }) {
return (
<div className="wa-charts-row">
{trend.categories.map(cat => {
const chartData = trend.this_year.map((ty, i) => ({
label: ty.label,
'This Year': ty.by_category[cat.category]?.consumption ?? 0,
'Last Year': trend.last_year[i]?.by_category[cat.category]?.consumption ?? 0,
}))
return (
<div className="wa-chart-card" key={cat.category}>
<h4>{cat.category_name} Consumption ({cat.unit_label}) Last 12 Months vs Prior Year</h4>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="label" tick={{ fontSize: 9 }} />
<YAxis tick={{ fontSize: 10 }} width={52} />
<Tooltip />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="This Year" stroke={CHART_COLORS.rooms} strokeWidth={2.5} dot={false} />
<Line type="monotone" dataKey="Last Year" stroke={CHART_COLORS.budget} strokeWidth={2} dot={false} strokeDasharray="5 3" />
</LineChart>
</ResponsiveContainer>
</div>
)
})}
</div>
)
}
function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilities'] }) {
const { costs, estimate, error } = utilities
const { costs, estimate, trend, error } = utilities
if (error || !costs) {
return (
@ -486,55 +553,60 @@ function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilitie
)
}
if (costs.categories.length === 0) {
if (costs.meters.length === 0) {
return (
<div className="wa-utilities-placeholder">
<p>No utility categories configured.</p>
<p>No meters configured.</p>
</div>
)
}
const estimateByMeter = new Map((estimate?.meters ?? []).map(e => [e.meter_id, e]))
return (
<>
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>Meter</th>
<th>Consumption</th>
<th>Usage</th>
<th>Standing</th>
<th>CCL</th>
<th>RAB Levy</th>
<th>Fixed Extras</th>
<th>Extras</th>
<th>VAT</th>
<th>Total</th>
<th>Basis</th>
<th>Period Estimate</th>
</tr>
</thead>
<tbody>
{costs.categories.map(c => (
<tr key={c.category}>
<td className="wa-row-label">{c.category_name}</td>
<td className="wa-num">{fmtUnits(c.consumption, c.unit_label)}</td>
<td className="wa-num">{fmtCcy(c.usage_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(c.standing_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(c.ccl_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(c.rab_levy_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy((c.metering_cost_pence + c.other_charges_cost_pence) / 100)}</td>
<td className="wa-num">{fmtCcy(c.vat_pence / 100)}</td>
<td className="wa-num">{fmtCcy(c.total_pence / 100)}</td>
{costs.meters.map(m => {
const est = estimateByMeter.get(m.meter_id)
return (
<tr key={m.meter_id}>
<td className="wa-row-label">{m.meter_name}</td>
<td className="wa-num">{fmtUnits(m.consumption, m.unit_label)}</td>
<td className="wa-num">{fmtCcy(m.usage_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(m.standing_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(extrasPence(m) / 100)}</td>
<td className="wa-num">{fmtCcy(m.vat_pence / 100)}</td>
<td className="wa-num">{fmtCcy(m.total_pence / 100)}</td>
<td>{fmtBasis(m.status, m.as_of_date)}</td>
<td className="wa-num">{est ? fmtCcy(est.total_pence / 100) : '—'}</td>
</tr>
))}
)
})}
<tr className="wa-total-row">
<td className="wa-row-label">Total</td>
<td className="wa-num">{costs.totals.consumption.toLocaleString('en-GB', { maximumFractionDigits: 1 })}</td>
<td className="wa-num" title="Not summed — mixes units across fuel types"></td>
<td className="wa-num">{fmtCcy(costs.totals.usage_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(costs.totals.standing_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(costs.totals.ccl_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy(costs.totals.rab_levy_cost_pence / 100)}</td>
<td className="wa-num">{fmtCcy((costs.totals.metering_cost_pence + costs.totals.other_charges_cost_pence) / 100)}</td>
<td className="wa-num">{fmtCcy(extrasPence(costs.totals) / 100)}</td>
<td className="wa-num">{fmtCcy(costs.totals.vat_pence / 100)}</td>
<td className="wa-num">{fmtCcy(costs.totals.total_pence / 100)}</td>
<td></td>
<td className="wa-num">{estimate ? fmtCcy(estimate.totals.total_pence / 100) : '—'}</td>
</tr>
</tbody>
</table>
@ -544,6 +616,12 @@ function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilitie
Projected total for the month ({estimate.period.remaining_days} day{estimate.period.remaining_days === 1 ? '' : 's'} remaining to estimate): {fmtCcy(estimate.totals.total_pence / 100)}
</p>
)}
{trend && trend.this_year.length > 0 && (
<div style={{ marginTop: 16 }}>
<UtilitiesCostTrendChart trend={trend} />
<UtilitiesConsumptionCharts trend={trend} />
</div>
)}
</>
)
}

View file

@ -152,20 +152,82 @@ export interface UtilityCostTotals {
total_pence: number
}
export type UtilityConsumptionBasis = 'no_data' | 'partial' | 'complete' | 'distributed'
export interface UtilityMeterCost {
meter_id: number
meter_name: string
category_id: number
category: string
category_name: string
unit_label: string
consumption: number | null
has_data: boolean
status: UtilityConsumptionBasis
as_of_date: string | null
usage_cost_pence: number
standing_cost_pence: number
ccl_cost_pence: number
rab_levy_cost_pence: number
metering_cost_pence: number
other_charges_cost_pence: number
vat_pence: number
total_pence: number
rate_changed_mid_period: boolean
}
export interface UtilityMeterEstimate {
meter_id: number
meter_name: string
category_id: number
category: string
category_name: string
unit_label: string
trailing_window_days: number
daily_rate: number | null
actual_to_date: number | null
remaining_days: number
projected_consumption: number | null
total_pence: number
}
export interface UtilityCosts {
period: { start: string; end: string; days_in_period: number }
categories: UtilityCategoryCost[]
meters: UtilityMeterCost[]
totals: UtilityCostTotals
}
export interface UtilityEstimate {
period: { year: number; month: number; start: string; end: string; remaining_days: number }
meters: UtilityMeterEstimate[]
totals: { total_pence: number; projected_consumption: number }
}
export interface UtilityTrendCategoryMeta {
category: string
category_name: string
unit_label: string
}
export interface UtilityTrendMonth {
year: number
month: number
label: string
total_pence: number
by_category: Record<string, { consumption: number; total_pence: number }>
}
export interface UtilityTrend {
categories: UtilityTrendCategoryMeta[]
this_year: UtilityTrendMonth[]
last_year: UtilityTrendMonth[]
}
export interface UtilitiesReport {
costs: UtilityCosts | null
estimate: UtilityEstimate | null
trend: UtilityTrend | null
error: string | null
}