From 1efcc941bb14896c68e4d1142605b103529839a9 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 28 Jul 2026 14:59:18 +0000 Subject: [PATCH] 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 --- backend/src/lib/utilities-client.js | 9 ++ backend/src/routes/weekly-actual.js | 11 +-- frontend/src/pages/WeeklyActual.tsx | 128 ++++++++++++++++++++++------ frontend/src/types.ts | 62 ++++++++++++++ 4 files changed, 180 insertions(+), 30 deletions(-) diff --git a/backend/src/lib/utilities-client.js b/backend/src/lib/utilities-client.js index 6edb09cb..0a819540 100644 --- a/backend/src/lib/utilities-client.js +++ b/backend/src/lib/utilities-client.js @@ -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()}`) +} diff --git a/backend/src/routes/weekly-actual.js b/backend/src/routes/weekly-actual.js index 51d46bbb..f6213310 100644 --- a/backend/src/routes/weekly-actual.js +++ b/backend/src/routes/weekly-actual.js @@ -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 { diff --git a/frontend/src/pages/WeeklyActual.tsx b/frontend/src/pages/WeeklyActual.tsx index 0db2fe65..2580ae62 100644 --- a/frontend/src/pages/WeeklyActual.tsx +++ b/frontend/src/pages/WeeklyActual.tsx @@ -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 ( +
+

Total Utility Cost — Last 12 Months vs Prior Year

+ + + + + `£${(v / 1000).toFixed(1)}k`} tick={{ fontSize: 10 }} width={52} /> + + + + + + +
+ ) +} + +function UtilitiesConsumptionCharts({ trend }: { trend: UtilityTrend }) { + return ( +
+ {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 ( +
+

{cat.category_name} Consumption ({cat.unit_label}) — Last 12 Months vs Prior Year

+ + + + + + + + + + + +
+ ) + })} +
+ ) +} + 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 (
-

No utility categories configured.

+

No meters configured.

) } + const estimateByMeter = new Map((estimate?.meters ?? []).map(e => [e.meter_id, e])) + return ( <>
- + - - - + + + - {costs.categories.map(c => ( - - - - - - - - - - - - ))} + {costs.meters.map(m => { + const est = estimateByMeter.get(m.meter_id) + return ( + + + + + + + + + + + + ) + })} - + - - - + + +
Meter Consumption Usage StandingCCLRAB LevyFixed ExtrasExtras VAT TotalBasisPeriod Estimate
{c.category_name}{fmtUnits(c.consumption, c.unit_label)}{fmtCcy(c.usage_cost_pence / 100)}{fmtCcy(c.standing_cost_pence / 100)}{fmtCcy(c.ccl_cost_pence / 100)}{fmtCcy(c.rab_levy_cost_pence / 100)}{fmtCcy((c.metering_cost_pence + c.other_charges_cost_pence) / 100)}{fmtCcy(c.vat_pence / 100)}{fmtCcy(c.total_pence / 100)}
{m.meter_name}{fmtUnits(m.consumption, m.unit_label)}{fmtCcy(m.usage_cost_pence / 100)}{fmtCcy(m.standing_cost_pence / 100)}{fmtCcy(extrasPence(m) / 100)}{fmtCcy(m.vat_pence / 100)}{fmtCcy(m.total_pence / 100)}{fmtBasis(m.status, m.as_of_date)}{est ? fmtCcy(est.total_pence / 100) : '—'}
Total{costs.totals.consumption.toLocaleString('en-GB', { maximumFractionDigits: 1 })} {fmtCcy(costs.totals.usage_cost_pence / 100)} {fmtCcy(costs.totals.standing_cost_pence / 100)}{fmtCcy(costs.totals.ccl_cost_pence / 100)}{fmtCcy(costs.totals.rab_levy_cost_pence / 100)}{fmtCcy((costs.totals.metering_cost_pence + costs.totals.other_charges_cost_pence) / 100)}{fmtCcy(extrasPence(costs.totals) / 100)} {fmtCcy(costs.totals.vat_pence / 100)} {fmtCcy(costs.totals.total_pence / 100)}{estimate ? fmtCcy(estimate.totals.total_pence / 100) : '—'}
@@ -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)}

)} + {trend && trend.this_year.length > 0 && ( +
+ + +
+ )} ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3e5bc9fc..8b3af85e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 +} + +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 }