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 ( +
No utility categories configured.
+No meters configured.
| + | Meter | Consumption | Usage | Standing | -CCL | -RAB Levy | -Fixed Extras | +Extras | VAT | Total | +Basis | +Period 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) : '—'} |