diff --git a/backend/src/lib/cost-calc.js b/backend/src/lib/cost-calc.js index 08bce82..d23d6ec 100644 --- a/backend/src/lib/cost-calc.js +++ b/backend/src/lib/cost-calc.js @@ -105,6 +105,25 @@ async function interpolatedValueAtDate(meterId, date) { return { value, before, after } } +// Classifies how a period's consumption figure was actually derived, for +// display next to a report row: +// - 'no_data' — no readings cover the period at all +// - 'partial' — real data only reaches as_of_date, short of periodEnd +// (the normal case for a still-open current period) +// - 'complete' — readings land exactly on both boundaries, no interpolation +// needed — either a lucky manual coincidence or, once a +// device feeds daily readings, the normal case +// - 'distributed' — full period covered, but one or both boundaries needed +// interpolating across a sparse-reading gap +function classifyBasis(hasData, periodStart, periodEnd, first, last) { + if (!hasData) return { status: 'no_data', as_of_date: null } + const lastDate = toISODate(last.reading_date) + if (lastDate < periodEnd) return { status: 'partial', as_of_date: lastDate } + const firstDate = toISODate(first.reading_date) + if (firstDate === periodStart && lastDate === periodEnd) return { status: 'complete', as_of_date: null } + return { status: 'distributed', as_of_date: null } +} + // Consumption for a meter over [periodStart, periodEnd] (inclusive). Interpolates // the meter's value at each boundary between the real readings bracketing it, // then takes the difference — so a period's consumption is prorated by days @@ -119,17 +138,21 @@ export async function getPeriodConsumption(meterId, periodStart, periodEnd) { if (!startPoint || !endPoint) { const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart)) const last = await readingOnOrBefore(meterId, periodEnd) - if (!first || !last || last.reading_date <= first.reading_date) { - return { consumption: null, first, last, has_data: false } + const hasData = !!(first && last && last.reading_date > first.reading_date) + const basis = classifyBasis(hasData, periodStart, periodEnd, first, last) + if (!hasData) { + return { consumption: null, first, last, has_data: false, ...basis } } const rawConsumption = Number(last.reading_value) - Number(first.reading_value) const consumption = await convertMeterConsumption(meterId, rawConsumption) - return { consumption, first, last, has_data: true } + return { consumption, first, last, has_data: true, ...basis } } + const first = startPoint.before + const last = endPoint.after const rawConsumption = endPoint.value - startPoint.value const consumption = await convertMeterConsumption(meterId, rawConsumption) - return { consumption, first: startPoint.before, last: endPoint.after, has_data: true } + return { consumption, first, last, has_data: true, ...classifyBasis(true, periodStart, periodEnd, first, last) } } // Trailing average daily consumption as of the meter's latest reading, @@ -391,10 +414,13 @@ async function splitCostAcrossSegments(meterId, rangeStart, rangeEnd, consumptio // or backdated after the fact) that falls inside the period. `asOfDate` is only // used as a fallback when the meter has no tariff-assignment history at all. export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) { - const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd) + const { consumption, first, last, has_data, status, as_of_date } = await getPeriodConsumption(meterId, periodStart, periodEnd) const daysInPeriod = daysInclusive(periodStart, periodEnd) const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd) - return { consumption, has_data, first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed } + return { + consumption, has_data, status, as_of_date, + first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed, + } } // Estimate for the remainder of an open period: actual consumption to date + diff --git a/frontend/src/index.css b/frontend/src/index.css index a082dc2..02293ca 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -344,6 +344,7 @@ table.data tr.clickable { cursor: pointer; } table.data tr.clickable:hover td { background: var(--body-bg); } table.data td.num { text-align: right; font-variant-numeric: tabular-nums; } table.data tr.total-row td { font-weight: 700; background: var(--body-bg); border-top: 2px solid var(--card-border); } +table.data tr.subtotal-row td { font-weight: 600; background: var(--body-bg); border-top: 1px solid var(--card-border); } table.data tr.anomaly-row td { background: var(--danger-bg); } /* ── Stats strip ───────────────────────────────────────────── */ diff --git a/frontend/src/pages/Estimates.tsx b/frontend/src/pages/Estimates.tsx index 3da9b02..e775079 100644 --- a/frontend/src/pages/Estimates.tsx +++ b/frontend/src/pages/Estimates.tsx @@ -1,11 +1,18 @@ -import { useCallback, useEffect, useState } from 'react' +import { Fragment, useCallback, useEffect, useState } from 'react' import { useAuth } from '../components/AuthGate' -import { can, formatMoney, formatUnits } from '../types' -import type { Category, EstimateReport } from '../types' +import { can, formatMoney, formatUnits, groupByCategory } from '../types' +import type { Category, EstimateReport, EstimateRow } from '../types' import * as api from '../api' const WINDOW_OPTIONS = [7, 14, 30] +function sumEstimateCosts(rows: EstimateRow[]) { + return rows.reduce((acc, r) => ({ + projected_consumption: acc.projected_consumption + (r.projected_consumption || 0), + total_pence: acc.total_pence + r.total_pence, + }), { projected_consumption: 0, total_pence: 0 }) +} + export default function Estimates() { const { user } = useAuth() const canEdit = can(user, 'estimates') @@ -84,25 +91,37 @@ export default function Estimates() { - + - {report.meters.map(m => ( - - - - - - - - - - - ))} + {groupByCategory(report.meters).map(group => { + const subtotal = sumEstimateCosts(group.rows) + return ( + + {group.rows.map(m => ( + + + + + + + + + + ))} + + + + + + + + ) + })}
MeterCategoryTrailing windowMeterTrailing window Daily rateActual to date Remaining daysProjected consumption Projected cost
{m.meter_name}{m.category_name}{m.trailing_window_days}d{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}{formatUnits(m.actual_to_date, m.unit_label)}{m.remaining_days}{formatUnits(m.projected_consumption, m.unit_label)}{formatMoney(m.total_pence)}
{m.meter_name}{m.trailing_window_days}d{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}{formatUnits(m.actual_to_date, m.unit_label)}{m.remaining_days}{formatUnits(m.projected_consumption, m.unit_label)}{formatMoney(m.total_pence)}
{group.category_name} subtotal{formatUnits(subtotal.projected_consumption, group.unit_label)}{formatMoney(subtotal.total_pence)}
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index d61e1aa..a2ecd25 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,9 +1,24 @@ -import { useCallback, useEffect, useState } from 'react' +import { Fragment, useCallback, useEffect, useState } from 'react' import { AlertTriangle } from 'lucide-react' -import { formatMoney, formatUnits } from '../types' -import type { Category, ConsumptionCostReport, RollupReport } from '../types' +import { formatMoney, formatUnits, groupByCategory, formatBasis } from '../types' +import type { Category, ConsumptionCostReport, RollupReport, MeterCostRow } from '../types' import * as api from '../api' +function extrasFor(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 sumMeterCosts(rows: MeterCostRow[]) { + return rows.reduce((acc, r) => ({ + consumption: acc.consumption + (r.consumption || 0), + usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, + standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, + extras_cost_pence: acc.extras_cost_pence + extrasFor(r), + vat_pence: acc.vat_pence + r.vat_pence, + total_pence: acc.total_pence + r.total_pence, + }), { consumption: 0, usage_cost_pence: 0, standing_cost_pence: 0, extras_cost_pence: 0, vat_pence: 0, total_pence: 0 }) +} + function currentPeriod(): string { const now = new Date() return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` @@ -56,9 +71,7 @@ export default function Reports() {
{formatMoney(report.totals.total_pence)}
Total cost
{formatMoney(report.totals.usage_cost_pence)}
Usage
{formatMoney(report.totals.standing_cost_pence)}
Standing
-
{formatMoney(report.totals.ccl_cost_pence)}
CCL
-
{formatMoney(report.totals.rab_levy_cost_pence)}
RAB Levy
-
{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}
Fixed extras
+
{formatMoney(extrasFor(report.totals))}
Extras
{formatMoney(report.totals.vat_pence)}
VAT
@@ -70,45 +83,57 @@ export default function Reports() { - - - - + + + - {report.meters.map(m => ( - - - - - - - - - - - - - ))} + {groupByCategory(report.meters).map(group => { + const subtotal = sumMeterCosts(group.rows) + return ( + + {group.rows.map(m => ( + + + + + + + + + + + ))} + + + + + + + + + + + + ) + })} - - + + - - - + +
MeterCategoryConsumptionUsageStandingCCLRAB LevyFixed extrasVATTotalMeterConsumptionUsageStandingExtrasVATTotalBasis
- {m.meter_name} - {!m.has_data && no data} - {m.rate_changed_mid_period && ( - `${s.tariff_name}: ${s.seg_start} – ${s.seg_end}`).join(', ')}> - rate changed - - )} - {m.category_name}{formatUnits(m.consumption, m.unit_label)}{formatMoney(m.usage_cost_pence)}{formatMoney(m.standing_cost_pence)}{formatMoney(m.ccl_cost_pence)}{formatMoney(m.rab_levy_cost_pence)}{formatMoney(m.metering_cost_pence + m.other_charges_cost_pence)}{formatMoney(m.vat_pence)}{formatMoney(m.total_pence)}
+ {m.meter_name} + {m.rate_changed_mid_period && ( + `${s.tariff_name}: ${s.seg_start} – ${s.seg_end}`).join(', ')}> + rate changed + + )} + {formatUnits(m.consumption, m.unit_label)}{formatMoney(m.usage_cost_pence)}{formatMoney(m.standing_cost_pence)}{formatMoney(extrasFor(m))}{formatMoney(m.vat_pence)}{formatMoney(m.total_pence)}{formatBasis(m.status, m.as_of_date)}
{group.category_name} subtotal{formatUnits(subtotal.consumption, group.unit_label)}{formatMoney(subtotal.usage_cost_pence)}{formatMoney(subtotal.standing_cost_pence)}{formatMoney(subtotal.extras_cost_pence)}{formatMoney(subtotal.vat_pence)}{formatMoney(subtotal.total_pence)}
Total{report.totals.consumption.toLocaleString(undefined, { maximumFractionDigits: 1 })}Total {formatMoney(report.totals.usage_cost_pence)} {formatMoney(report.totals.standing_cost_pence)}{formatMoney(report.totals.ccl_cost_pence)}{formatMoney(report.totals.rab_levy_cost_pence)}{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}{formatMoney(extrasFor(report.totals))} {formatMoney(report.totals.vat_pence)} {formatMoney(report.totals.total_pence)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 98225e1..e344f48 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -156,6 +156,11 @@ export interface CostBreakdown { rate_changed_mid_period: boolean } +// How a period's consumption figure was actually derived — see cost-calc.js's +// classifyBasis(). 'partial' covers the normal "still-open current period" +// case; as_of_date is only set then. +export type ConsumptionBasis = 'no_data' | 'partial' | 'complete' | 'distributed' + export interface MeterCostRow extends CostBreakdown { meter_id: number meter_name: string @@ -164,10 +169,39 @@ export interface MeterCostRow extends CostBreakdown { unit_label: string consumption: number | null has_data: boolean + status: ConsumptionBasis + as_of_date: string | null days_in_period: number tariff: Tariff | null } +// Groups rows sharing a category_id together, preserving first-seen order +// (meters already arrive sorted by category from the backend). +export function groupByCategory( + rows: T[] +): { category_id: number; category_name: string; unit_label: string; rows: T[] }[] { + const order: number[] = [] + const map = new Map() + for (const r of rows) { + if (!map.has(r.category_id)) { + order.push(r.category_id) + map.set(r.category_id, { category_id: r.category_id, category_name: r.category_name, unit_label: r.unit_label, rows: [] }) + } + map.get(r.category_id)!.rows.push(r) + } + return order.map(id => map.get(id)!) +} + +// "Complete/Distributed/Up to date/As of [date]/No data" — see ConsumptionBasis. +export function formatBasis(status: ConsumptionBasis, asOfDate: string | null): string { + if (status === 'no_data') return 'No data' + if (status === 'complete') return 'Complete' + if (status === 'distributed') return 'Distributed' + const today = new Date().toISOString().slice(0, 10) + if (asOfDate === today) return 'Up to date' + return asOfDate ? `As of ${new Date(asOfDate).toLocaleDateString('en-GB')}` : 'Partial' +} + export interface ConsumptionCostReport { period: { start: string; end: string; isCurrent: boolean } meters: MeterCostRow[]