Water Softener is configured as a submeter (parent_meter_id) of Main Hotel
Water, but estimates.js, reports.js, and internal.js's cost + estimate
routes summed every active meter flatly regardless of parent/child, double-
counting the softener's consumption on top of the main meter's own reading.
Sub-meter rows are still shown individually (useful for diagnostics), just
excluded from the category subtotal/total sums, with a 'submeter' badge on
the row explaining why. Fixes the water figures on Estimates, Reports, and
downstream consumers of internal.js (Directors report, Weekly Actuals).
iOS Safari has no beforeinstallprompt API, so the portal's Install
button (?install=1) silently did nothing there. IosInstallHint now
shows Share -> Add to Home Screen steps on Safari, or a prompt to
switch to Safari first if opened in another iOS browser/in-app
webview (those can't install PWAs on iOS at all).
Also added apple-mobile-web-app-title + apple-touch-icon so the
home screen icon isn't a page screenshot, and added the
beforeinstallprompt handler that was missing entirely (Android
Install button did nothing either).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
The water-softener totaliser publishes litres but the water category is
m3 — without a conversion factor its readings would land in the DB
1000x too high. Also, mqtt_topic was only settable via direct SQL since
the Meters form never exposed it.
Water and Oil are seeded default categories with no meters installed
yet, so they were showing up as all-zero rows/flat-line charts in the
reports app's new per-meter table and trend charts. Skip any category
with zero active meters in both the shared cost-breakdown loop and
the trend endpoint's category list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/api/internal/costs and /estimate now include a meters array (same
consumption/cost-split/basis rows as the in-app Reports/Estimates
pages) alongside the existing category totals, so external callers
(reports app's Weekly Actuals) can render a per-meter table instead of
just category rollups.
Adds /api/internal/trend?months=12 — per-category cost/consumption for
the last N months plus the same N months a year earlier, computed in
one call for a "this year vs last year" chart.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reports and Estimates now group meters by category with a per-category
subtotal (in that category's own unit) instead of one flat list — the
grand-total row no longer sums consumption across categories, since
mixing kWh/m3/L is meaningless; money totals still sum fine.
On Reports: CCL, RAB levy and fixed extras collapse into one "Extras"
column/stat (still separately editable/stored, just merged for
display). New "Basis" column classifies how each meter's figure was
derived — Complete, Distributed (interpolated across a sparse-reading
gap), Up to date / As of [date] (real data short of period end), or
No data — via a new classifyBasis() in cost-calc.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prepares for a future electric pulse-reading device without needing
one yet: a new meter_window_consumption table stores real per-window
(Day/Night) daily kWh computed from hourly device telemetry before it
gets purged, and computeCost() now prefers real data over the tariff's
fallback_split_pct for whatever it covers, filling any gap (or the
whole thing, today) with the existing % split. Zero behaviour change
until a meter has mqtt_topic set and a TOU tariff assigned — the new
table stays empty and every calculation degrades to exactly the
current fallback-only path.
scheduler.js's computeDailyWindowSplit() buckets each hour's telemetry
delta into whichever tariff_rate_windows row its time-of-day falls
into (previously-unused start_time/end_time/days_of_week columns),
runs once daily before the hourly-aggregate purge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
remainingDays was computed as days-from-today to period-end, while
actual-to-date consumption only ever reflects data up to the meter's
last real reading. With sparse readings the last reading sits days
before today, so that gap was counted in neither the actual nor the
projected total. Project from the last reading's date instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reports previously used the raw delta between whichever real readings
happened to fall nearest a period's start/end, un-prorated — accurate
only when readings land close to calendar boundaries. With sparse
readings this misattributes days on either side to the neighbouring
period instead.
getPeriodConsumption() now interpolates the meter's value at each
boundary between the readings bracketing it (linear by day-count),
then diffs those — equivalent to a "daily rate carried forward until
the next reading" spreadsheet approach, generalised across however
many sparse-reading gaps a period spans. Falls back to the old
nearest-reading behaviour when a boundary can't be interpolated (period
starts before the first-ever reading or ends after the latest one).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
routes/meters.js already imported lib/mqtt.js and lib/history.js in an
earlier uncommitted change; a prior commit staged the whole meters.js
file (picking up those imports) without staging the modules themselves,
crashing production with ERR_MODULE_NOT_FOUND. Completing the commit:
package.json (mqtt dependency), index.js (wires connectMqtt/startScheduler,
both already error-guarded if the broker isn't reachable), and the three
lib files themselves.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gas meters are read in raw volume (ft3 on our imperial meter, m3 on
newer metric ones), never kWh — but tariff rates and CCL/RAB levy are
all p/kWh. Add gas_volume_unit + gas_calorific_value to meters, and
convert reading deltas to kWh in cost-calc.js using the standard
UK gas-billing formula (matches the exact calculation printed on
supplier invoices) before any cost math runs.
Also fixes meter/readings pages showing raw gas dial readings
mislabeled as "kWh" — they now show the actual physical unit
(ft³/m³) via a new reading_unit_label, while computed consumption
stays correctly labeled kWh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RAB levy funds nuclear electricity generation, so it can't apply to
gas/oil/water tariffs. Hide the field for non-electric categories and
clear any stray value if a tariff's category changes or is saved as
non-electric.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The June electricity bill showed £252.72 (7.5% of the bill) in charges the
cost model didn't account for: Nuclear RAB Levy, Metering Charge, and DUoS
Availability/Excess/Reactive. RAB levy and metering charge are modeled
directly (same shape as CCL / standing charge); the DUoS charges need kVA
capacity and kVArh reactive energy that only the supplier's own meter reads,
so they're covered by one adjustable "other network charges" £/month field
to update manually from each bill.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rate windows were matched by substring (label.includes(key)), so
overlapping labels like "Day"/"Weekday" could silently cross-match to
the wrong rate. Switched to an exact match.
The "split must total 100%" check was advisory-only in the UI and
never validated server-side, letting a tariff save with a split that
doesn't sum to 100% and permanently mis-cost that meter's usage.
Enforced on both the API (POST/PATCH /api/tariffs) and the Save
button.
Also aligned the app's portal category to 'Hotel' (already correct in
auth/src/db.js) here and in stack-init/install-stack.sh, which had
both drifted to 'Operations'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rate changes are often scheduled ahead of time or confirmed by finance
after the fact and backdated — assign-tariff already supported any
effective_from date, but cost-calc previously priced a whole period at
a single tariff (whichever was in force on the period-end date),
silently mispricing the days on the other side of a change.
getTariffSegments() finds every tariff assignment overlapping a date
range; getMeterCostForPeriod/getMeterEstimateForPeriod now split
consumption pro-rata by day count across segments and price each
against its own tariff, summing to the period total. Estimates project
the remaining days across a future-scheduled change the same way,
rather than assuming today's tariff holds for the rest of the period.
Also consolidates internal.js's cost/estimate routes (previously their
own duplicate calc) onto the same shared functions reports.js and
estimates.js use, so the Directors' report can't drift from the in-app
numbers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>