18 Commits
Author SHA1 Message Date
Florian Schmidt a08e9f781f Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
2026-09-20 12:44:32 +02:00
Florian Schmidt 5a6f34a467 Analysis: bound the freshness query, let window sums skip chunks, share the Overview's catalog
ci / build-test (push) Successful in 2m34s
Three things the 1,000-meter x 10-year measurement found, each proved by
EXPLAIN or a statement count before and after. No tally moves.

Freshness had two jobs in one unbounded query. The mark -- when a meter
last delivered -- is now stored on meter_rollup_state and maintained by
every recompute, with a one-pass backfill in the migration, so an
import-only meter keeps its years-old last activity without reading a
single raw row. The rhythm that decides stale versus live is sampled
inside a 90-day window and only for meters that actually have a live
source; a source silent for longer than that is re-read unbounded, so it
is still called stale by its own rhythm rather than by a default. The
portfolio query went from 13.8 ms planning plus 36.1 ms execution across
all 123 reading chunks to 0.58 plus 0.44 ms across four.

Window sums took their time bounds only from the unnest join, so the
planner could not exclude chunks: a 1,960-window case scanned 1.39 M rows
in parallel and spilled a 45 MB sort. Repeating the overall min and max as
constants makes it five chunks and nested-loop index scans, 121.5 ms to
8.7 ms.

The Overview read the catalog three times, once for the quantities and
once for each of its two bills. One context and one catalog snapshot now
feed all three: 31 statements per load to 23.

The final timings on an idle machine are in docs/ANALYSIS_REPORT.md: the
brief's target request (100 meters, ten years, monthly) is 286 ms against
two seconds, and a startup rebuild of 1,000 meters is 279 s.
2026-09-20 11:16:48 +02:00
Florian Schmidt ec51419f64 Docs: the analysis rework's implementation report and screenshots
ci / build-test (push) Successful in 2m23s
The brief (§12) asks for a report: which of its findings A01-A14 were
fixed and how, how physical and virtual results now agree, which cost and
aggregation decisions were applied to existing data, what was tested, and
what is still missing. docs/ANALYSIS_REPORT.md is that, with a file path
and a pinning test for every row, the acceptance scenarios with their
evidence, the measured performance, and the limitations -- including the
three the note already records (raw retention not enforced, monthly
imports never interpolated, a full recompute per ingested reading) and the
three hotspots the measurement found.

The screenshots it references show the Overview, a physical and a virtual
meter, and an energy type's history at desktop and phone widths, plus two
"before" shots at c0f52db for comparison: the old Overview, and the old
virtual meter page, which offered a flow link instead of any analysis.
2026-09-20 10:43:15 +02:00
Florian Schmidt 8940ef25c3 Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
2026-09-20 10:29:13 +02:00
Florian Schmidt c0f52dbb6f Release 0.3.0
ci / build-test (push) Successful in 1m50s
version-tag / tag-if-newer (push) Successful in 7s
Editing VERSION on master is what tags v0.3.0 and publishes the image, so
this is the whole change.

What an operator gets: meter swaps, resets, tank levels and deliveries
recorded from the meter page, the navigation around it, and consumption
attributed to the months it accrued in rather than to whichever reading
closed the interval.

The first start after the update re-derives all stored consumption before
the web server listens, so the instance is briefly unreachable and
historical monthly figures can shift once. README's "Upgrading to 0.3.0"
has the details and the one log line worth checking afterwards.
2026-09-17 21:09:27 +02:00
Florian Schmidt aacdc28d70 Meters: record events from the UI, and book consumption in the months it accrued in
Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.

Meter events from the UI

Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.

A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.

Navigation

The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.

A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.

Months

A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.

Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.

ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.

Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.

Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.

What an operator sees once

Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.

Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
2026-09-17 21:09:17 +02:00
Florian Schmidt 0319e5527f Release 0.2.0
ci / build-test (push) Failing after 38s
version-tag / tag-if-newer (push) Failing after 35s
First release carrying the German UI. Editing VERSION on master is what
tags v0.2.0 and publishes the image, so this is the whole change.

Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
2026-08-13 23:27:54 +02:00
Florian Schmidt bfa0b537ee i18n: ship the UI in English and German
ci / build-test (push) Failing after 35s
The last open item on the M7 list. Number and currency formatting was
already locale-aware, but every string in the UI was an English literal,
so a German instance read half in each language -- German data, English
chrome. This translates all of it and adds the machinery to keep it
translated.

Strings live in Localization/Strings.resx (English, neutral) and
Strings.de.resx. The neutral file generates a strongly-typed accessor at
build time, aliased as S in _Imports.razor, so components reference
compiled properties -- @S.Common_Save, not a string key. That choice is
the point: across 4,500 lines of markup, a key lookup that silently
falls back to its own name is a defect you find in production, while a
renamed property is a build error. Generation runs in MSBuild rather
than the IDE designer, so dotnet build alone reproduces it anywhere.

Resource fallback is the hazard here. Ask for a key the German satellite
lacks and ResourceManager quietly serves the English one -- correct at
runtime, disastrous at release time, because a half-translated build
looks perfectly healthy. StringResourceTests reads each satellite with
tryParents: false, which is the only way to see what one actually
contains, and fails on a missing or blank translation, a placeholder
that changed arity, an orphan, or a key nothing references.

Three things needed more than substitution:

- Domain enums reached the screen as bare identifiers. They stay bare in
  the model -- they are persisted as text and appear in the REST API, so
  their names are part of the data contract -- and DisplayNames is now
  the single place that decides how each value is spoken. Every arm ends
  in a fallback returning the identifier, so a value added later cannot
  throw mid-render; EnumDisplayNameTests is what stops that safety net
  quietly becoming the shipping behaviour.

- Infrastructure was writing display text: FlowService's "Other (X)",
  MeterPeriodView's "Generation"/"Consumption", the HA connection-test
  verdicts, the updater's snackbar, the CSV importer's row warnings.
  Each now returns an outcome value and the UI supplies the words, which
  is where the reader's language is known. Diagnostics that are not ours
  -- an HTTP status, systemd's stderr, an exception message -- are passed
  through untranslated, and every English summary is kept alongside the
  outcome so log lines never move with the UI language. The UpdateRunner
  change is additive only; no gate was touched.

- Importer warnings carry their arguments rather than a finished
  sentence, so the numbers inside them pick up the reader's grouping. A
  register that reads 2.940,19 everywhere else must not read 2940.19
  only inside a warning.

Switching language is a redirect through /culture/set followed by a full
reload, not an interactive state change: a Blazor Server circuit is fixed
to the culture of the request that opened it. That makes the endpoint a
redirector taking its target from the query string, so anything but a
local path is refused rather than followed. Preference order is the
cookie, then Accept-Language, then MeterVault__Locale -- an instance can
be pinned to one language and a reader can still switch.

Locale keeps its documented default of "en". Format now follows
CurrentCulture instead of a hardcoded de-DE, so an instance with nothing
configured and a browser asking for English will show English number
formatting where it previously showed German; set MeterVault__Locale=de
to pin the old behaviour. The importer's de-DE parsing is untouched and
stays that way -- that dialect is a property of the spreadsheets, not of
whoever is looking at the dashboard.

Anything that comes from the database -- meter names, energy-type display
names, category names -- is user data and is never translated.

Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
2026-08-13 16:36:25 +02:00
schmidt.florian af786c7b28 Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s
Entering a reading by hand previously meant POST /api/v1/readings with an
API key, or a one-row CSV through the import wizard. SourceType.Manual
existed in the enum but nothing was behind it. This adds the click path,
built for the case it is actually used in: walking to each manual meter
with a phone in hand.

"Add reading" on the Readings tab opens a dialog prefilled with the
meter's last register value and the current local time, both editable:

- An on-screen keypad, because a register is read standing at the meter.
  It behaves like a calculator against the prefill - the first digit
  replaces it (a fresh register), while backspace edits it in place,
  which is the common case since only a register's last digits move.
- Typed input accepts both separators (last one wins), so a German and
  an English phone keyboard both do the right thing. ReadingEntry owns
  that rule and is unit-tested; it deliberately differs from
  GermanNumber, where a lone dot really is a thousands separator.
- A live parsed-value echo plus delta-since-last, which is the net that
  catches a mistyped digit before it is committed.
- Decrease / replaces-existing / future / backdated surfaced before
  saving, and DST spring-forward gaps refused rather than shifted.

The verdict line sits in a fixed-height, no-wrap slot above the keypad.
That is load-bearing, not cosmetic: an alert that appears there when the
value dips below the last reading moves the keys out from under the
user's thumb mid-entry, which is a guaranteed mistype on a phone. The
long-form explanation goes below the keypad, where reflow is harmless.

Saving goes through IngestionService.IngestByMeterAsync, so the
monotonic-decrease guard and inline renormalization apply exactly as for
any other ingest. A new optional quality parameter stamps the row
ReadingQuality.Manual; null preserves today's behaviour, so a source
re-reporting the same timestamp updates the value without silently
relabelling a hand-entered or imported reading.

Also: the meter-detail tabs now render times in the instance timezone
per SDD section 10, instead of raw UTC. Without it a reading entered at
18:00 reads back as 16:00. Side effect is that historic imported monthly
rows show 01:00/02:00 rather than 00:00 - correct, if noisier.

Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
2026-08-01 10:25:05 +02:00
schmidt.florian 1f575c9da2 Update: drop the API-key requirement from the update trigger
ci / build-test (push) Successful in 1m14s
Owner's call: MeterVault__AllowInAppUpdate is now the whole gate. One click on
the banner, no key, no prompt, and the REST endpoint no longer asks for one
either.

What that means, recorded so it is not rediscovered later: with the flag on,
anything that can reach MeterVault can trigger a rebuild and restart. On the
realistic threat model that is a repeatable denial of service — minutes of
downtime and a pegged CPU per request — rather than code injection, because the
build comes from the owner's own repository. It becomes remote code execution
if that repository is ever compromised. The flag still defaults off, and that
default is now the only thing between an upgrade and an open trigger, so
UpdateRunnerTests pins it along with the fact that configuring API keys does not
imply consent to rebuild the host.

Kept one guard, which is not authentication: the REST endpoint requires an
X-MeterVault-Update header. Without it any website could POST to the endpoint
through the browser of someone on the network — a plain HTML form is enough,
and no key means nothing else would stop it. A form cannot set a custom header
and a cross-origin fetch that tries is stopped by a preflight nothing here
answers, so this costs a deliberate caller one flag and costs the button
nothing, since it runs over the Blazor circuit rather than HTTP.

The confirmation dialog stays, now purely as a guard against a stray click
costing several minutes of downtime. Every triggered update is logged as a
warning: with no key there is no caller to attribute it to, and the restart
discards anything held in memory.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:39:12 +02:00
schmidt.florian 9eb3f7d53c Update: add an opt-in "Update now" button, gated on an API key
ci / build-test (push) Successful in 1m9s
Adds the button, plus POST /api/v1/system/update for driving it from Home
Assistant or curl. Both pull the latest source, rebuild and restart the service.

The gating is the substance of this change. The updater builds whatever is on
the branch and the LXC runs MeterVault as root, so triggering it is
root-equivalent on that host, and the web UI has no login — "reachable from the
dashboard" alone would mean any device on the network could take the machine.
Three independent conditions must hold before anything runs: the operator set
MeterVault__AllowInAppUpdate, at least one API key is configured, and the caller
presented one, compared in constant time so retries cannot time out the key.

AllowAnonymousApi cannot reach it. That flag opens reads, and opening reads must
not open root, so the endpoint checks the presented key itself rather than
relying on the shared group filter that honours it. Availability is re-checked
inside LaunchAsync rather than trusting the caller to have done so.

The UI button asks for the key every time instead of remembering it: with no
login, a browser left open on the dashboard would otherwise be a standing
permission to execute code on the host. The key is cleared from component state
immediately, and a wrong key and a keyless deployment give the same message so
an unauthenticated caller cannot tell them apart.

Launched detached through systemd-run: the updater restarts the service, so a
child process would be killed part-way through, leaving the app down with a
half-published build. --collect reaps the transient unit so a later update is
not blocked by the remains of the previous one.

Off by default, and where there is no /usr/bin/update — a container, a dev box —
it reports that rather than half-running something. Tests pin every refusal,
including through the real HTTP pipeline; none of them launch anything.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:23:47 +02:00
schmidt.florian cf7e0396f0 Dashboard: report when a newer release is available
ci / build-test (push) Successful in 1m13s
The instance had no idea what version it was: VERSION drives tagging and the
image publish, but was never stamped into the assemblies, so a running build
reported 1.0.0 forever. Directory.Build.props now stamps it into every project.

The dashboard compares that against the newest tag in the source repository and
shows a banner when behind. A plain GET of a public tag list -- nothing about
the instance is sent -- cached six hours, failing quiet.

Two things it deliberately does not do. It never blocks a render: the banner
paints from the cached answer and refreshes after first render, so a cold start
or an unreachable repository costs nothing rather than holding the dashboard
open for an HTTP timeout. And it never guesses: an unknown version on either
side shows no banner at all, because a banner that cannot clear trains people
to ignore the next real one.

Version comparison is numeric on exactly three components, not System.Version
and not string order. Tags are written vX.Y.Z, the assembly reports X.Y.Z with
a +commithash suffix, and "0.10.0" sorts below "0.9.0" as a string -- each of
those is a way the banner sticks or never appears. Prerelease suffixes compare
equal to their release so an rc tag does not nag. Gitea does not promise semver
ordering, so the highest tag wins rather than the first.

The command shown depends on the install: the LXC has `update`, a container is
replaced by pulling an image, and telling container users to run `update` sends
them after a command that does not exist.

No update *button*. The UI has no authentication and the LXC runs the app as
root, and `update` builds whatever is on master, so a click would be an
unauthenticated path to arbitrary code execution for anything on the LAN. The
README now states the no-auth position plainly rather than leaving it implied.

Tests cover the parse and ordering cases that would strand a banner, the Gitea
payload shape captured from the live API, unreachable and garbage responses,
and that the VERSION file actually reaches the assembly -- read from MeterVault's
own assembly rather than GetEntryAssembly(), which under `dotnet test` is the
test host and reported a confident wrong answer. The suite makes no outbound
request: the app factory disables the check.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:17:52 +02:00
schmidt.florian 8fe5f4411b Fix defects found auditing the ingestion, import and connector changes
ci / build-test (push) Successful in 1m16s
An audit of this session's commits found several real problems, three of which
lose or expose data. Ordered by severity.

Live recompute was not atomic. RecomputeMeterAsync clears a meter's series with
ExecuteDelete, which commits by itself when no transaction is ambient, and only
then adds the rebuilt rows. Between the two the meter had *no* consumption:
a dashboard read reported zero, and a crash or cancelled request made the loss
permanent, for data the SDD treats as the long-term source of truth (§5.5).
Import and the events API already wrapped their recomputes; live ingestion,
which I added this session, did not. Now shares one transaction, joining an
ambient one rather than nesting.

The MQTT backfill migration counted brokers without regard to is_enabled. One
live broker plus a disabled leftover counted two, declined to backfill, and left
those sources unbound — which under endpoint-scoped routing means silently and
permanently dead. The "two or more is ambiguous" reasoning did not hold there:
the worker only ever connected to enabled endpoints. Corrected by a follow-up
migration rather than an edit, since the original may already have run; it
touches only rows still NULL, so hand-made bindings are safe.

A mapping edited after a dry run committed the *old* staged rows under the
*new* mapping. Readings went to the previous meter while the batch recorded the
current mapping — wrong data, provenance contradicting it, no exception. The
earlier fix re-validated but did not detect staleness. Commit now compares the
mapping against the one the preview was staged under and refuses.

"Test connection" sent a stored token to whatever Base URL was in the dialog.
Encrypting secrets at rest means the UI can decrypt what the operator can no
longer read, so this turned the button into an exfiltration primitive: point it
at any host, the token arrives as a Bearer header. A stored token now only goes
to the origin it was saved for; testing elsewhere requires typing it again.

A source that cannot ingest looked identical to a healthy one. Endpoint-scoped
routing made unbound and mis-bound sources silently dead, while the Sources tab
showed no connector at all and the delete dialog still promised sources would be
"unlinked". Added a Connector column that names the fault, stopped offering
disabled connectors (both workers filter on IsEnabled), and made the delete
warning say ingestion stops.

Virtual meters rendered four zero tiles: they evaluate on read and only
materialize when a cost category references them (§14.1), so summing
consumption is a confident lie about a working meter. They now report nothing
and the page explains why.

Re-importing an overlapping file failed at the database with EF's "An error
occurred while saving the entity changes", naming neither meter nor date — the
diagnosis problem a3af483 set out to fix, via the path its guard could not see.
Checked up front now, bounded by each meter's staged range.

The LXC updater left the service stopped on any failure. set -e plus an
explicit stop means Restart=always does not apply, so an OOM-killed publish or
a brief Gitea outage took MeterVault down until someone noticed. An EXIT trap
restarts the previous build and says so.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:52:48 +02:00
schmidt.florian cedd60ab45 Audit fixes: batch recompute, negative-baseline percentages, key-ring persistence
ci / build-test (push) Successful in 1m17s
Three defects found reviewing the last few commits.

Deriving consumption on ingest made the batch reading endpoint quadratic. A
recompute rewrites a meter's entire consumption series, and POST
/api/v1/readings ran one per reading -- 500 readings for one meter meant 500
full rewrites. IngestByMeterAsync takes renormalize:false and the endpoint
normalizes each touched meter once after the batch.

Percentage change divided by a possibly negative baseline. A net-export meter
going from -100 to -150 exported half again as much and would have been
reported as "+50%", reading as more consumption. A non-positive baseline now
reports no basis rather than a confident lie.

The data-protection key ring had no persistent home outside Docker Compose. The
LXC installer now creates /var/lib/metervault/keys at 0700 -- the app would
otherwise create it under the default umask, leaving a key ring world-readable
-- and the Unraid template maps it, since without that every UI-entered secret
was lost whenever the container was recreated. README documents the variable
and the trust boundary: keys on disk protect against leaked database content,
not against an attacker who already has the host.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:39:56 +02:00
schmidt.florian ad896db051 Normalization: apportion a long unread gap across the months it covers
A counter delta is booked at the reading that closes it. That is right at the
reporting cadence -- a monthly series books December against the 1 January
reading, which is what the reference sheet does -- and wrong after an outage.
Observed on Solar 1: 78 days of generation arrived as one July row, leaving
June looking like the array was switched off.

An interval containing two or more complete calendar months is now divided
across them in proportion to elapsed time. The meter recorded a total, not a
shape, so every row a split produces is marked Estimated. The sum is exact: the
final segment absorbs the rounding remainder, so a split never creates or
destroys energy.

Counting whole months *contained* rather than boundaries *crossed* is what
makes the rule safe. A monthly series contains exactly one whole month per
interval and is untouched, so the golden fixtures keep measuring the normalizer
rather than the splitter; and a reading landing hours late cannot tip the rule
and hand the new month a sliver. GapSplittingIsInertOnFixturesTests asserts the
rule declines to fire on every reference interval, so this cannot drift into
the oracle unnoticed.

Not apportioned: swap and reset amounts (explicit corrections booked at their
event -- apportioning one would rewrite a number the operator supplied), a
rejected decrease, and a zero delta, which would otherwise fan out into rows
that say nothing.

Segments are stamped at their end, keeping the existing convention that a row
records the period ending at its timestamp -- so nothing shifts relative to how
unsplit intervals are already labelled.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:39:42 +02:00
schmidt.florian 95c51842e8 Meter detail: lead with periods and change, not register totals
ci / build-test (push) Successful in 1m23s
The headline tiles were lifetime consumption, a raw reading count and the
register span. None of those answer why someone opens a meter: how much this
month, more or less than last, where the year lands, what it costs. A
cumulative counter's register value is an accident of when the meter was
installed.

MeterPeriodService buckets consumption by calendar month in the instance
timezone -- via date_trunc(... AT TIME ZONE) rather than EF grouping, because a
reading at 00:30 local on 1 January is 23:30 on 31 December in UTC and would be
booked to the wrong month (SDD §10). It reports generation for a generation
counter and consumption otherwise, so a PV meter stops claiming it consumed
0 kWh.

Month- and year-to-date are compared against a projection of the current period
rather than its running total. Three days into a month, "12 kWh vs 340 kWh last
month" reads as a collapse in usage when nothing has changed. The projection is
straight-line on elapsed days -- wrong for anything seasonal, but the honest
reading of "at this rate" -- and the UI marks it with a leading ~.

A 12-month bar strip gives the shape at a glance. A meter with nothing
normalized yet returns an empty history rather than a flat line, which would
look like a meter reading zero.

Register span, reading count and lifetime total move into a collapsed panel.
Still there when needed for an audit, no longer the first thing you see.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:10:32 +02:00
schmidt.florian 62d102c335 Ingestion: derive consumption on ingest, and poll HA in minutes not seconds
ci / build-test (push) Successful in 1m13s
Live ingestion wrote the raw reading and stopped there. Import, the REST push
endpoint and the meter editor all recompute afterwards; the MQTT/Tasmota/HA
path was the one that did not, so a polled reading landed in `reading` and
every derived figure stayed frozen at the last import. Observed on a
GenerationCounter: 45 readings, 44 consumption rows, generation pinned to the
register value of the last imported reading.

Recompute inline rather than behind a debounce. Normalizing a whole meter is
cheap at metering cadence and a background dirty-set worker is machinery this
does not yet need; the remark on RenormalizeAsync records when it would.

Fixes a latent bug this surfaced in NormalizationService: ExecuteDelete drops
the consumption rows in the database but leaves them in the change tracker, so
a second recompute on the same context threw an identity conflict on
(meter, time, kind). One worker scope ingesting two readings was enough to hit
it. Detach the stale entries after the delete.

Poll interval is now minutes, default 60, replacing seconds/60. A meter answers
"how much this month, what will it cost" — an hourly sample answers that
exactly as well as a per-second one, with far less raw volume (SDD §5.5). The
`pollSeconds` key no longer binds, so existing sources fall back to the 60
default and move from every-60-seconds to hourly, which is the intent. A source
that had deliberately set e.g. 300 seconds also lands on 60 minutes.

Two test cleanups now delete consumption before the meter: live ingestion never
produced any before, so the FK had nothing to trip on.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 18:45:57 +02:00
schmidt.florian e23df37a3f Connectors: allow secrets to be entered in the UI, encrypted at rest
ci / build-test (push) Successful in 1m12s
Reference-only secrets (SDD §6.4) meant adding a connector required editing a
file on the server and restarting the service. In practice that leads to the
token being pasted into the env-var *name* field, which fails with "environment
variable '<token>' is not set" and gives no hint what went wrong.

Add a second storage form, chosen per connector: type the secret in and it is
encrypted via ASP.NET Core data protection before it is stored. The env-var
reference stays as an equal alternative — this widens the choice rather than
replacing it. Exactly one form survives a save, so a stale secret cannot linger
and silently win; EndpointSecret.Resolve is the single resolution path.

The guarantee that matters is preserved: no plaintext in the database, so
pg_dump and JSON exports carry nothing usable. The trust boundary is stated
plainly in §6.4 — the key ring is on disk, so this protects against leaked
database content, not an attacker who already has the host, which is the same
boundary an env var has.

Details worth noting:
- Key ring defaults to /var/lib/metervault/keys, outside the app directory,
  because the LXC updater republishes /opt/metervault on every update. Docker
  gets a named volume. Overridable via MeterVault__DataProtectionKeyPath.
- Undecryptable ciphertext (key ring lost) falls back rather than throwing: an
  ingestion worker on a timer should degrade, not crash.
- The stored secret is never sent to the browser; a blank field means
  "unchanged", not "cleared".
- MQTT usernames are stored as-is — §6.4 covers tokens and passwords, and
  encrypting a username would only blank the field on every edit.
- ExportService drops *_enc values: bound to the originating key ring, so
  useless where an export would be restored. Expect to re-enter after a restore.
- HaConnectionTester now takes a resolved token, so the admin UI can test a
  token that has been typed but not yet saved.

SDD §6.4 and §9 updated to describe both forms rather than contradict the code.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 15:41:52 +02:00
478 changed files with 99781 additions and 3263 deletions
+235 -31
View File
@@ -6,39 +6,101 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
**Status: implemented (M0M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
**Status: implemented (M0M7) + the dashboard/analysis rework (next release 0.4.0).**
- **Size:** five projects, ~2,490 tests (Core 1,733, Integration 753 incl. 2 opt-in performance facts), working Docker deploy.
- **Docs:**
- `docs/SDD.md` is the design reference. It marks in place every section the system now deviates from (list: note D-58). Its milestone map (§12) matches the git history (M0…M7).
- The rework's work order is `docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`. Its decisions are D-01…D-58 and amendments A-01…A-39 in `docs/ANALYSIS_IMPLEMENTATION_NOTE.md`. **Read that note before touching analysis, costing, rollups, virtual meters or the analysis pages.**
- Outcome and acceptance evidence: `docs/ANALYSIS_REPORT.md`. User-facing changes: `docs/RELEASE_NOTES.md`.
- **Pages:**
- Overview `/`, Analysis `/trends`, Meters `/meters`, the meter hub `/meters/{id}`, energy type `/energy/{id}`, Solar `/solar`, Tanks & consumables `/consumables`, import `/import` + `/import/wizard`, Configuration `/admin/*`. See *Analysis & navigation*.
- Their read models live in `Infrastructure/Dashboard`: `DashboardService.GetOverviewAsync`, `SolarService`, `ConsumableService` (+ pure `TankLevels`), `MeterDetailService` (the paged record tabs), `FlowService`. All of them sit on the two shared readers.
- PV, grid and load meters are found by mode and by *effective* role (`MeterRoleRules.Effective`, A-07). The role is stored as the `role` token in `Meter.Meta` (`MeterRoles`/`MeterMeta`). It is saved only through `MeterRoleAssignment`, which keeps a role unique per type among meters in service and names the meter it moved from. Nothing is found by name.
- **Admin write-CRUD** (SDD §8.7): MudBlazor inline-dialog pages for energy type *definitions*, meters, a meter's ingest sources (meter Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`; secrets as an env-var reference or typed in and encrypted at rest).
- The shared `Shared/MeterEditor.razor`:
- recomputes the meter when mode, baseline, install date, unit, role or tank change (`RecomputeNeeded`);
- owns the totals override, tank setup and the meter's own cost-category memberships (type-level ones are only named);
- for virtual meters, holds the calculation editor (`Shared/MeterEditing/`, `App/MeterEditing/`, preview through `MeterDraftAnalysis`).
- Deleting a meter or type goes through `EntityDeletion`, which also removes its scoped tariffs. The delete dialog names the virtual meters that read the meter (`VirtualMeterService`, D-33).
- `/admin/settings` is read-only: the effective config (env-driven, not DB-stored) plus the analysis data state: revision, zone, meters pending a rebuild, and raw retention "Not enforced".
- **Manual readings:** the header's "Add reading" (also on the Readings tab and as a quick entry) opens `MeterPage/ManualReadingDialog.razor`.
- It is touch-first: prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter and a live parsed-value + delta-since-last readout. The decrease guard is surfaced before saving.
- Its verdict comes from its own queries for the entered time (latest reading, the previous reading on the normalizer's timeline, a swap/reset that explains a decrease, the reading at T with its flags), never from a page of rows (D-50).
- It saves through `IngestionService.IngestByMeterAsync(quality: Manual)`, so the reading is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest.
- The dialog deliberately reserves fixed space for its verdict line: anything that reflows moves the keys out from under the user's thumb mid-entry.
- **Home Assistant reading:** an HA connector (`BaseUrl` + `TokenEnv`) plus an HA source (entity id) drives `HomeAssistantWorker`'s REST poll.
- With the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`), `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time. The poll worker skips WS endpoints, so each endpoint is served once.
- `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic. `HaConnectionTester` powers the connector "Test connection" button.
- **Meter topology & flow:** a `MeterLink` is a directed `from→to` edge: the downstream meter is a *subsection* of the upstream one, and several parents are allowed.
- Links are **topology only** and never define or change a virtual meter's calculation (D-25).
- Edit them under Energy type → Flow → "Manage connections". `MeterLinkService` checks for cycles, other types, duplicates and legacy virtual meters inside a transaction with `LOCK TABLE meter_link`; it is built inside the dialog, not registered in DI. A physical meter's upstream field in the editor also edits them.
- The Flow tab draws a hand-rolled SVG **Sankey** (`SankeyChart.razor`; ApexCharts has no Sankey type) from `FlowService.FromResultAsync`, over the same reader result as the page:
- Each node has its canonical period value. An edge carries the downstream meter's value, split proportionally across parents (marked estimated) and capped at the parent. The unaccounted remainder becomes "Other".
- A pure-sum virtual meter is drawn with its calculation inputs, marked calculated. Other virtual meters and meters in another unit appear only in the flow table.
- A node without data is named as such, never shown as a fake 0.
- **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch`. The `/import` page lists batches, with one-click revert and the meters/categories each batch wrote to.
- **`instant_rate`:** `InstantRateNormalizer` integrates the rate over time (trapezoidal).
- **Meter events from the UI** (swap, counter reset, tank level, delivery, note) go through `MeterEventService` (`Infrastructure/Ingestion`), never ad-hoc inserts.
- `MeterEventRules.RecordableFor(mode)` (Core) decides which events a mode offers. There is no `Correction`: nothing reads it.
- `Validate` gives the dialog the same verdict the save reaches. Every record or delete recomputes the meter in one transaction.
- A swap/reset is stored as the event at T **plus a manual reading of the new register's start value at exactly T** (flagged `MeterSwap`/`CounterReset`). The boundary window is `(prevReading, reading]`, so this books the old tail at T and later readings count from the new start. Never write the old final value as the reading at T: it double-counts, then rejects every new-register reading.
- Deleting a swap removes its start reading only while it is still the untouched start value. Only `Manual` readings are deletable in the UI.
- **Navigation conventions:**
- The meter page is the per-meter hub. Header actions: primary entry by mode, the "Record event" menu, Edit.
- Link into it with `MeterLinks` (`/meters/{id}?tab=…&action=…`). The action is consumed once after the interactive render and dropped from the address.
- The app-bar "Find a meter" dialog opens a meter's Analysis tab with the current period, plus the same quick entry.
- `NavState.NotifyEnergyTypesChanged()` / `NotifyMetersChanged()` tell the per-circuit nav to reload. Raise them from anything that creates or deletes types, meters or tanks.
- **Connector detour:** a source that lacks a usable connector detours through `/admin/connectors?new=…|edit=…&meter=…` (`MeterLinks.Source`/`NewConnector`/`EditConnector`). It comes back to that source dialog with the connector picked and everything typed restored: the page saves the open dialog to the circuit-scoped `DraftStore` on dispose, and only Cancel discards it. The way back is a meter id, never a URL, so it cannot redirect off-site.
- The Sources tab links each connector to its editor, the source dialog has "Edit connector", and the connector list shows which meters use each connector.
- The Overview shows small setup notes from `DashboardService.GetCostSetupAsync``CostSetup.FirstGap` (no meters/tariffs next to the cost card, no categories/members under the composition). They are never a prerequisite for seeing quantities.
- **UI language:** English + German end to end. See *Localization*.
- **Demo:** set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo: meters 15 Strom (Haus, Netz, Auto, Solar 1, Solar 2), 6 Wasser, 7 Öltank + 8 Brenner (Heizöl), 9 Summe Solar (virtual `m4 + m5`, generation, not costed).
## Source of truth
`docs/SDD.md` is the authoritative spec and build brief — read it before implementing anything. Key protocol from §0 that governs all work here:
- **Build strictly in milestone order (§12, M0→M7).** Each milestone is independently runnable and testable; do not start Mn+1 until Mn's tests pass.
- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix.
- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing.
- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design).
- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix. The seeded bill is pinned to the sheet's `Jahreskosten` (`SeededBillTests`, D-44).
- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing. For analysis, costing and page behaviour, check the implementation note (D-nn/A-nn) first; a new decision gets a new `A-nn` there.
- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design). Pure analysis rules belong in `Core/Analysis`, reads in `Infrastructure/Analysis`/`Costing`, and presentation in `App`.
## Committed tech stack (do not re-litigate; see SDD §4.1)
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR.
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR. No bUnit/Playwright: browser checks are manual/CDP-scripted.
## Project layout
```
/src/Core domain entities + enums; pure Normalization engine (mode strategies,
expression evaluator); Parsing (German dialect); Costing (TariffResolver)
/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale);
Import (CsvImporter, profiles, ImportService), Ingestion (MQTT/HA workers,
IngestionService), Normalization service, Costing/Dashboard/Backup services
/src/App ASP.NET Core host: Blazor Server UI (Components/), REST API (Api/), hosted
workers, Program.cs (Serilog, migrate+seed on startup, /healthz)
/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, tariff resolver
/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures,
import commit/revert, ingestion, cost, CAgg refresh, API, export, render
/src/Core domain entities + enums; pure Normalization engine (mode strategies); Parsing (German
dialect); Costing (legacy TariffResolver)
/src/Core/Analysis pure analysis rules: Time (PeriodResolver, BucketPlanner, ComparisonResolver, Change),
Coverage (runs, evaluator, matched coverage, provenance), Rollups, Quantities (units,
normalized quantity, roles, tariff units), Totals (policy, category cover), Virtual
(formula parser, validator, dependency graph, evaluator, legacy derivation),
Costing (CostCalculator, TariffBook, CostAmount)
/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale); Import (CsvImporter,
profiles, ImportService); Ingestion (MQTT/HA workers, IngestionService, MeterEventService);
Normalization (NormalizationService + AnalysisDataWriter, NormalizationUpgrade);
Analysis (AnalysisReader, AnalysisCatalog, AnalysisQueries, VirtualDefinitionUpgrade,
MeterDraftAnalysis); Costing (CostReader, BillRun; CostService = legacy API adapter);
Dashboard (page read models); Backup (JSON export/import)
/src/App ASP.NET Core host: Program.cs (Serilog, migrate+seed+upgrades on startup, /healthz),
REST API (Api/), Components/ (Pages/, Shared/, Shared/Analysis/), Analysis/ (URL contract,
chart/table/attention models, CSV export), AnalysisPage/, Energy/, MeterDetails/,
MeterEditing/, TariffEditing/, Theme/, Localization/, link helpers (MeterLinks,
AnalysisLinks, TariffLinks), InstanceClock, InstanceCurrency
/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, Analysis/ (periods, DST, coverage,
totals, virtual formulas, cost calculator)
/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures, import commit/revert,
ingestion, rollups, reader, cost engine, seeded bill, API contracts, export, render;
pure UI-model tests (Analysis/, MeterPage/, Overview/, Editor/, Specialized/);
Performance/ (trait Category=Performance, opt-in)
/deploy Dockerfile, docker-compose.yml (app + timescaledb), build-and-push.ps1, unraid-template.xml
```
Central package versions live in `Directory.Packages.props`; shared build/style in
`Directory.Build.props` + `.editorconfig`. Snake_case table/column mapping via
`Directory.Build.props` + `.editorconfig` (`TreatWarningsAsErrors`). Snake_case table/column mapping via
`UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`).
## Commands
@@ -48,14 +110,19 @@ dotnet build # build the solution
dotnet test # all tests (Integration.Tests needs Docker for Testcontainers)
dotnet test tests/Core.Tests # unit tests only (no Docker needed)
dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Reconciliation" # one class/area
dotnet test tests/Integration.Tests --filter "FullyQualifiedName~StringResource" # both resx files complete
$env:METERVAULT_PERF='1'; dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests" # ~10 min
dotnet ef migrations add <Name> -p src/Infrastructure -s src/App -o Persistence/Migrations
dotnet run --project src/App # run app + workers locally (needs a Timescale DB)
docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together
```
**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable/CAgg DDL lives in
raw-SQL migrations; continuous-aggregate creation + policies use `migrationBuilder.Sql(..., suppressTransaction: true)`, one statement each; CAgg policy `end_offset` must be ≥ one bucket. Tests
pause the compression job (historical fixture data would otherwise deadlock imports).
**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable DDL lives in raw-SQL migrations, one
statement each with `migrationBuilder.Sql(..., suppressTransaction: true)` where Timescale needs it. The old continuous
aggregates (and their "`end_offset` ≥ one bucket" rule) only matter for the historical migrations: the `AnalysisRollups`
migration dropped them (D-17) and purged stored consumption of virtual meters. Tests pause the compression job (historical
fixture data would otherwise deadlock imports). An UPDATE of rows in compressed chunks needs TimescaleDB's decompression
cap lifted for that statement (see `NormalizationUpgrade`).
## Core architecture (the part that spans multiple files)
@@ -64,24 +131,161 @@ pause the compression job (historical fixture data would otherwise deadlock impo
```
sources (Tasmota/HA/MQTT/manual/CSV)
→ Ingestion workers write raw `reading` rows (immutable audit truth)
→ Normalization derives append-only `consumption` (deltas in base unit)
→ TimescaleDB continuous aggregates roll consumption to hourly/daily/monthly/yearly
→ Cost engine joins aggregates with time-ranged `tariff`
→ Blazor dashboard + REST API read aggregates + cost views
→ NormalizationService.RecomputeMeterAsync derives append-only `consumption` (deltas in base unit, each row
with its source interval) and, in the same transaction and by diff, the per-meter rollups by local day
and month (`consumption_rollup`, `consumption_rollup_month`), coverage runs (`meter_coverage`) and
`meter_rollup_state` (revision, zone, normalized unit, kind) — AnalysisDataWriter, D-10 D-16
→ AnalysisReader: quantities of physical meters (rollups + ≤ 2 edge days of `consumption`), virtual meters
(evaluated on read from their sources) and per-type measures, for one resolved period and bucket plan
→ CostReader: the bill (BillRun → Core CostCalculator), month by month from time-ranged `tariff`
→ Blazor pages, REST API (/api/v1) and CSV export (/export/analysis.csv) read only those two readers
```
**Invariants that shape everything:**
- **Raw `reading` is immutable audit truth.** Everything derived (consumption, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number.
- **Dashboards and charts read aggregates only — never scan `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). Raw is kept for a bounded window (default 3y); `consumption` + aggregates are the long-term source of truth.
- **`meter.mode` (measurement mode) is the central abstraction** for how raw readings become consumption (SDD §5.2): `cumulative_counter`, `generation_counter`, `runtime_counter` (Δhours × rate), `consumable_balance` (tank: deliveries usage + forecast), `direct_delta`, `instant_rate`, `virtual` (expression over other meters). New ingestion/normalization logic dispatches on mode.
- **Nothing domain-specific is hardcoded.** Energy types are data. Cost **categories are decoupled from energy types** (Heizung may be oil today, heat-pump tomorrow). PV self-consumption/savings/net are **virtual meters** with user-defined expressions, not special-cased code. Tariffs are time-ranged (price history), scoped global / per-type / per-meter.
- **Raw `reading` is immutable audit truth.** Everything derived (consumption, rollups, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number. Every write path recomputes the meter inline: live ingestion (`IngestionService.RenormalizeAsync`), import and revert, events, manual readings and deletions, meter edits, the events API. Without it, readings never become consumption and the rollups go stale. There is no cache and no refresh job.
- **Consumption is attributed to the months it accrued in** (`GapAttribution`, SDD §7.1).
- **Division:** a plain increase whose interval crosses a *local* month boundary (instance timezone) is divided at those boundaries by elapsed time. Each share is stamped inside its month (the closing reading keeps its own row when it lies in that month) and marked `Estimated`. Only cumulative/generation counters divide. Tank, runtime, direct-delta and instant-rate intervals are booked whole, so months they straddle read `Unresolved` (A-16). Swaps, resets and decreases are never divided.
- **Month labels:** imported monthly-table rows are month-end snapshots. The **importer** marks them `ReadingFlags.MonthLabel` when the date cell named a month (`IsMonthLabel` = the flag). Never infer it from a midnight-on-the-1st stamp: a day-dated "01.08.2026" is an instant. A manual correction keeps the flag; a live/API value or a swap start reading written onto that instant clears it (`IngestionService.UpsertAsync`).
- **One ordering:** `EffectiveTime` reads a label as the end of its month. **`ReadingTimeline`** (Core) orders by effective time, then stamp. `CounterNormalizerBase` and `RuntimeCounterNormalizer` use it, and so do the ingestion decrease guard, `MeterEventService.GetContextAsync` and the manual-entry dialog (via `RegisterNeighbours`), so none of them disagrees about which reading is "previous".
- **Consequences:** consecutive rows span exactly one month and book unchanged, so the golden fixtures reconcile. A live reading after the last imported row counts from that month's end, and a sheet imported after live readings does not double-count.
- **Boundaries:** a swap/reset stamped exactly at a label sits at the start of that label's local month (`ReadingTimeline.BoundaryTime`), and `RegisterBoundary.Advance` never counts a start value above the reading. `StampTime` keeps a label's row inside its own local month (zones behind UTC; also used by `DirectDeltaNormalizer`). A non-label row closing exactly at a local midnight is stamped 1 s earlier, inside the day it closes (D-11).
- **Guards:** `NormalizationEngine` coalesces rows that still share a (time, kind) key. `GapAttribution.LocalMidnight` verifies its answer, so contradictory zone data cannot stall the month walk. `GapSplittingIsInertOnFixturesTests` pins that no fixture interval is divided, in UTC or Berlin.
- **Everything buckets in the configured `MeterVault__TimeZone`**: normalization, rollups, `AnalysisReader`, `CostReader`, the export. Requested dates become instants through `PeriodResolver` / `InstanceTimeZone.StartOf` (local midnight, never UTC midnight). A hard-coded zone or a UTC-midnight range would re-file the divided shares. `Program` post-configures the zone id to its IANA form (`InstanceTimeZone.Canonical`) and logs an error when .NET or PostgreSQL does not know it.
- **Startup upgrades, in order; none may crash startup:**
1. Migrations, then the seed.
2. `VirtualDefinitionUpgrade` (D-28): an expression-less virtual meter whose same-type links imply an unambiguous sum gets it stored. Idempotent and logged; anything else is "needs configuration".
3. `NormalizationUpgrade`. `app_setting` records `normalization_revision` (now **3**) and `normalization_zone`.
- Before revision 2 it first flags the month rows of older imports: reference profiles by name, wizard `MonthName` batches, and wizard `Auto` batches whose every row sits on the 1st across ≥ 2 months (logged per batch). It lifts the decompression cap for that one UPDATE. If flagging fails, nothing is rebuilt or recorded.
- Then it rebuilds consumption + rollups + coverage + state of **every** meter when the revision or zone differs. Otherwise it rebuilds only meters whose `meter_rollup_state` is missing or outdated, plus `normalization_pending`. Virtual meters are purged, since they store nothing. Each meter runs in its own transaction.
- A meter whose oldest consumption predates its oldest reading or event is skipped and logged instead of truncating history. A failing meter is logged, kept pending and retried at the next start.
- Until its rebuild runs, a meter reads as `Pending` ("analysis being prepared"), never "no data". **Bump `CurrentRevision` whenever the engine books existing readings differently.** The rebuild runs before the web server listens; roughly 0.1 s per monthly meter and ~1.4 s per meter with a year of hourly data (it grows with the reading count).
- **Dashboards and charts read rollups only — never `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). `consumption` + rollups are the analytical history. `reading` is read by the paged Readings tab, the manual-entry checks, and — only for a meter with a live source, only over the last 90 days — the rhythm query behind D-18 (A-40). The freshness *mark* is `meter_rollup_state.last_reading_at`, written by the recompute behind every write path, so an import-only meter's mark is exact without touching the raw chunks. **Raw retention is not enforced** (D-57, a documented blocker): every recompute rebuilds a meter from its readings, so dropping old readings would destroy history. `/admin/settings` and the Readings tab say so.
- **`meter.mode` (measurement mode) is the central abstraction** for how raw readings become consumption (SDD §5.2): `cumulative_counter`, `generation_counter`, `runtime_counter` (Δhours × rate), `consumable_balance` (tank: deliveries usage + forecast), `direct_delta`, `instant_rate`, `virtual` (a formula over other meters, **evaluated on read, never stored**: D-27, SDD §14.1). New ingestion/normalization logic dispatches on mode. `NormalizedQuantity` (D-20) gives each meter's analysis (kind, unit):
- runtime: `h`, or the tank unit with a fixed rate;
- instant rate: the rate unit without `/h` (W → Wh);
- tank: the tank unit;
- virtual: its declared result unit.
`Units` is the only unit normalizer (m3 = m³). Raw units appear only on the Readings tab.
- **Nothing domain-specific is hardcoded.** Energy types are data. Cost **categories are decoupled from energy types** (Heizung may be oil today, heat-pump tomorrow). PV self-consumption/net are **virtual meters** with user-defined formulas, not special-cased code. Formulas are quantities only: prices are not part of expressions, and a virtual meter's cost follows its `costRule` (D-39). Tariffs are time-ranged (price history), scoped global / per-type / per-meter.
**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. **Timescale-specific DDL — `create_hypertable`, compression policies, continuous aggregates, retention — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables.
**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. The rollup, coverage and state tables are plain tables with a cascading FK to `meter` (D-12). **Timescale-specific DDL — `create_hypertable`, compression policies — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables.
**Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
**Time & DST (SDD §10):**
- Store UTC everywhere. Bucket and display in the configured instance timezone (`MeterVault__TimeZone`, default `Europe/Berlin`); never hard-code Berlin.
- A period resolves once per request into a local inclusive date range (display) and a **half-open** UTC range `[from, to)` (every query). `to` is the local midnight after the end date, or the captured "now" for to-date periods.
- Days are local midnights (a DST day has 23 or 25 hours), weeks start Monday, and months and years are local.
- "Now" comes from the registered `TimeProvider`: pages read it once through `InstanceClock` (`Now`, `Today`). Services never read the clock (D-01). Tests use `FixedTimeProvider`.
- Rows whose interval closes after now are never actuals. They are reported as "recorded after now" (D-04, A-04, A-05, A-14, A-20).
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path) resolved at runtime.
## Analysis layer (Core/Analysis + Infrastructure/Analysis + Infrastructure/Costing)
- **Two readers are the only read path.** Pages, `/api/v1`, the CSV export, Solar, Consumables, Flow and the Overview all read figures through `AnalysisReader` (quantities) and `CostReader` (money). Never add a figure that queries `consumption` or `reading` directly.
- **`AnalysisReader`:** one request = scope + resolved period + `BucketPlan` + comparison.
- It loads `AnalysisCatalog` once: meters, tanks, links, rollup states, validated virtual definitions and the totals classification.
- It expands virtual dependencies in memory, then reads each table once for all physical meters involved (`AnalysisQueries`, Dapper): month rollups for month/year buckets, day rollups for day/week buckets, at most two partial edge days from `consumption`, coverage, freshness.
- The 400-point and 6-series limits are checked before any SQL runs.
- **`CostReader`:** runs `BillRun` → Core `CostCalculator` with a `TariffBook`: one catalog, tariff and manual-cost load, plus one reader pass for every meter any figure prices.
- `CostService` is only the adapter behind `/api/v1/consumption|cost`. `DashboardService.GetMonthlyTrendAsync`/`GetCategoryBreakdownAsync`/`GetCategoryDifferenceAsync` and `FlowService.GetFlowAsync(DateOnly…)` are legacy entry points used only by tests.
- **Status, never a silent 0 (D-14):**
- Every bucket has a `BucketStatus`:
- `Available`: covered; a zero is a true zero.
- `Partial`: only part of the bucket is covered.
- `Missing`: nothing covers it.
- `Unresolved`: data only at a coarser resolution than the bucket.
- `Invalid`: a calculation failed.
- `Pending`: the meter is being rebuilt.
- It is derived from coverage runs and their resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser; A-02, A-03), never from the amount.
- A bucket no run covers is `Unresolved`, not `Missing`, when the meter measures more coarsely than that bucket and is simply not read yet — a tank dipped once a year books nothing until the next dipstick (`CoverageEvaluator.AwaitsMeasurement`, A-41). Such data also stops `auto` at month buckets (`ResolutionClassifier.PlanningResolution`), and a comparison line on a page with several figures names the one it is about (`ComparisonSummary.Subject`).
- Separate dimensions: `Provenance` flags (measured, manual, imported, estimated, derived, opening balance), `ValueIssue` (why a value is not plain) and freshness (stale live source vs historical import, D-18).
- Outside `[InstalledAt, RetiredAt]` a meter is a known zero (D-24).
- A first reading with unknown start is an opening balance: partial, excluded from comparisons, with "Set install date" offered (A-01).
- **Virtual meters (D-25 D-33, A-08, A-12, A-15):**
- The definition is the `Meter.Meta` keys `expression` (`m<id>` references), `referencedMeterIds` (derived and rewritten on save), `resultKind` (consumption, generation, net, indicator), `resultUnit` and `costRule` (none, sourceCosts, ownQuantity), written through `VirtualDefinitionJson`.
- `FormulaParser` builds an AST: `+ * /`, parentheses, numbers; at most 2,000 characters and depth 64. An unknown identifier is an error, never 0.
- `VirtualValidator` + `DependencyGraph` check on save and on read: syntax, unknown/self references, loops (with their path), kind/unit rules. `+`/`` need the same kind and unit or a declared net; meter × or ÷ meter is an indicator, which is non-additive and never totalled or costed.
- `VirtualEvaluator`, fed per-day source coverage from `CoverageEvaluator` (A-12):
- strict: a missing source → `Missing` with the source named; an observed zero is valid;
- a non-finite result or a loop → `Invalid` with the reason and dependency path;
- the period total is the formula over the joint coverage; non-linear formulas are marked non-additive (ratio of totals);
- results carry every source's series (the page's "source contributions").
- Legacy meters without an expression are read as their implied link sum with status `Legacy` until `VirtualDefinitionUpgrade` stores it (`LegacyVirtualDerivation`).
- The editor previews unsaved definitions through `MeterDraftAnalysis`. Export/import remaps meter ids inside definitions and carries `meter_link` (D-32).
- **Totals (D-22, D-23):** `TotalsPolicy`/`TotalsGraph` classify each type's meters into the measures Use, GridImport, Export, Generation and Runtime.
- **Use** is the `total_load` meter, else the consumption roots.
- Links out of supply meters (grid, generation, generation-kind virtual) are supply edges. Other links make the target a **breakdown** of its parent.
- Measures are never added across units. Virtual meters are *analysis views*: never added on top of their sources.
- `Meta.totals = auto|always|never` overrides this. `always` lets a virtual meter replace its sources in totals and bill, and is refused (naming the meter) when an ancestor or dependent already counts.
- Seeded result: Strom use = Haus, breakdown = Auto, grid import = Netz, generation = Solar 1 + Solar 2, Summe Solar analysis-only.
- **The bill (D-34 D-43, A-15 A-19, A-21, A-22, A-26):**
- **What is billed:** per type, `grid_import` meters if any, else the use meters; separately priced subsections at their own price, taken out of the parent (D-35, A-19). The feed-in credit applies only to `grid_export` meters. Generation, runtime and virtual views are never billed. Months with use but no grid meter in service are unavailable (A-17).
- **Pricing:** the price of the **15th of each local month** (D-36); every bucket is cut into local months, so the bucket size never changes a total. An interval longer than a month is priced whole only when its months share a price (A-16).
- **Tariff units** must fit the meter's normalized unit and the instance currency, else `UnitMismatch` (D-37). Bonus, Discount and Tax are **not applied** (D-57).
- **Missing prices:** `NotPriced` (no tariff at any date: an attention item, never a partial total) vs `PriceGap` (a hole in a priced history) vs a valid explicit **zero**. A bucket with nothing booked is unknown ("No data"), never "Priced" (A-26).
- **Standing charges** accrue per local day over the scope's service period, **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18).
- **Manual costs** are booked once, in full, on their `PeriodStart` day (D-41).
- **Categories** price the non-overlapping cover of their members (`CategoryCover`). The disjoint categories, Uncategorized and the standing-charge rows form the **composition**, which reconciles to the bill. Overlapping categories are "views", never summed; the donut appears only for non-negative slices (D-42). A category whose members price nothing says so (A-22).
- **Virtual costs** follow the named `costRule` (D-39, A-15): `sourceCosts` for pure sums, `ownQuantity` for linear formulas, `none` otherwise and for generation sums (so Summe Solar is not costed).
- **Changes (D-06 D-09, A-10, A-13, A-23):**
- `ComparisonResolver` shifts in calendar units (MTD/YTD at the same elapsed wall time) and pairs buckets by index (`PairBuckets`). `MatchedCoverage` measures a change only over what both periods cover.
- `Change.Between` always gives the absolute difference; the percentage is not applicable for a baseline ≤ 0.
- A cost change uses one rule everywhere, `OverviewComparison.Between`: totals if both are complete, else the paired buckets complete on both sides, else "not comparable".
- Projections are separate, labelled and suppressed on thin coverage.
## Analysis & navigation (App)
- **URL state is the page state** (`AnalysisQuery`, D-46/D-47):
- Keys: `scope=portfolio|type|category|meter|meters`, `id`/`ids` (≤ 6), `metric=consumption|generation|export|runtime|net|cost|balance`, `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom` with `from`/`to` (local, inclusive), `bucket=auto|day|week|month|year` (≤ 400 points), `compare=none|prev-period|prev-year|year:YYYY`.
- Defaults (`AnalysisDefaults`): Overview `mtd`, history pages `12m` (12 buckets ending with the current partial month), always `prev-year` (A-13). Defaults are **never written**.
- An invalid token falls back to the default with a notice. `all` spans availability (D-19).
- Page-specific keys: `tab`, `action`, the energy page's `view=total|meters`, the Overview's `chart=`.
- Toolbar and tab changes **replace** the history entry (`AnalysisNavigation.Replace`); drill-downs **push** (`AnalysisNavigation.UriFor`, `DrillInto`, `MeterDrill`, D-51, A-24, A-25).
- Build links only with `MeterLinks`, `AnalysisLinks`, `TariffLinks` and `AnalysisNavigation`. They carry the period against the *target* page's defaults.
- **Tabs by key, never by index:** meter tabs `analysis|readings|normalized|events|tariffs|sources|calculation` (`MeterLinks.VisibleTabs`/`ResolveTab`/`PanelIndex`). A virtual meter has no Readings or Normalized tab and has Calculation instead of Sources. Legacy `tab=consumption` → normalized. Energy tabs `overview|history|flow|meters` (`AnalysisLinks.ResolveEnergyTab`/`EnergyTabIndex`). One-shot `action=` stays separate from the analysis reload.
- **Page pattern:**
1. `AnalysisQuery.Parse(Nav.Uri, Defaults)`. If equal to the last query, stop: a tab change or action drop is not a new analysis.
2. `AnalysisPeriods.ResolveAsync(query, Clock.Now)`.
3. Read through a small loader: `MeterAnalysisLoader`, `AnalysisPageLoader`, `EnergyAnalysisLoader`, `DashboardService.GetOverviewAsync`, `SolarService`/`ConsumableService.GetAsync`.
4. Build the chart/table inputs **inside the load**, since they format eagerly and re-key the chart.
5. Commit one value through `LoadSequencer.RunAsync` into a `LoadState<T>`. Superseded loads are cancelled and never committed.
6. Render with `LoadPanel`: initial placeholder, refreshing (old value dimmed), `PanelError` with Retry.
- The initial load happens in `OnParametersSet`, because render tests read prerendered HTML. Subscribe to `Nav.LocationChanged` for query-only changes, and dispose.
- **Shared components** (`Components/Shared/Analysis`): `PageHeader`, `AnalysisBreadcrumbs` (Overview → type → meter, carrying the period), `PeriodToolbar` (presets, custom dates with one Apply, bucket with the refused-size hint, comparison incl. calendar years, metric, Reset, "Export CSV"), `AnalysisChart`, `AnalysisTable`, `MetricCard`, `ChangeChip`, `ValueStatus`, `EmptyPeriodState` (available dates + "Go to latest data"), `PendingState`, `PanelError`, `LoadPanel`, `RefreshIndicator`, `ProjectionNote`, `ComparisonSummary`, `AttentionList`, `SeriesContributions`.
- `AnalysisChart` (ApexCharts) has one axis per unit, nullable points, no smoothing or joining across gaps, and a real zero line for signed data. It follows the theme.
- `AnalysisTable` is the accessible equivalent of every chart and scrolls in its own region. Its rows read **newest first** with the total above them, as every dated list here does (A-42); the chart beside it and the CSV export stay chronological. "Largest changes by meter" also lists the meters it cannot rank, with their values and their status (`MeterChanges.Of`, A-43).
- Their rules are pure classes in `App/Analysis`: `FigureText` (the status words beside every figure, culprit meters by name), `ChangeDisplay` (metric polarity: more generation is good), `AnalysisChartModel`/`AnalysisChartOptions`, `AnalysisTableModel`, `AttentionItems` (D-53: one targeted action each, e.g. `TariffLinks.For(MissingPrice)``/admin/tariffs?scope=&id=&component=&from=&action=new`), `FormulaText`.
- **Missing ≠ zero ≠ not priced** on every page (brief §4.3, A-28):
- A true zero is a number and an outlined bar on the baseline. An unknown bucket is a gap marked "" in the chart and "—" plus its reason in the table. Qualified values (partial, estimated) are marked "*".
- An empty chart says *why*: no data, only coarser data (naming the resolution, with a button for that interval), or no price.
- The empty-state test is `BucketStatus.Missing`, never a value of 0.
- **CSV export** `GET /export/analysis.csv` (`AnalysisExportEndpoints`, D-55): same keys as the pages, one row per bucket and series, local ISO bounds with offset (end exclusive), invariant numbers, empty cells for unknown values, `status`/`provenance`/`cost`/`cost_status`/`currency`/`comparison_value`. A formula-looking text cell is prefixed with `'`. Bad requests get a 400 with a reason. It is a UI endpoint, so no API key.
- **Theme:** `ThemeState` (scoped) persists light/dark in the `mv-theme` cookie. `App.razor` reads the cookie so prerender and the language switch keep the mode; the default is dark. Charts use a transparent background and palette colours, and are re-keyed on `ThemeState.Changed`. The sidebar's expanded groups use cookie `mv-nav` (`NavGroups`). Both are written through `BrowserPreferences``metervault.js` `setPreference`, which whitelists exactly those two names.
- **Currency:** `InstanceCurrency` (`MeterVault__Currency`, default EUR) and `Format.Money`/`MoneySigned`/`CurrencySymbol` for every amount. `Format.Euro` is gone. Never write € in code or resx; user-entered tariff units are data.
- **Formatting:** `Format.Quantity` (unit, "—" when unknown), `Format.PeriodRange`, `Format.BucketLabel` (year only across years; real dates for partial units), `Format.ChangeText`, `Format.MonthYear`.
- **Tests that pin this:**
- URL contract and links: `AnalysisQueryTests`, `AppLinkTests`, `AnalysisNavigationTests`, `LocalTimeEntryTests.Tab_keys_resolve_by_key_and_mode`.
- Loads: `LoadSequencerTests`.
- Chart, table and attention models: `AnalysisChartModelTests`, `AnalysisTableModelTests`, `AttentionItemsTests`.
- Rendered HTML: `AnalysisComponentRenderTests` (framework `HtmlRenderer`), `DashboardRenderTests`, `OverviewPageTests`, `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
- Page logic and loaders: `MeterPageLogicTests`, `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyPageTests`, `EnergyTypePageTests`, `OverviewDataTests`, `CostConsistencyTests`.
- Reader and costs: `AnalysisReaderTests`, `AnalysisDataTests`, `CostReaderTests`, `SeededBillTests`.
- Contracts and export: `ApiContractTests`, `AnalysisExportEndpointTests`.
- Shell: `ShellPreferenceTests`.
- Pure Core: `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests` (DST/leap/New York on a frozen clock), `TotalsPolicyTests`, `VirtualEvaluatorTests`, `CostCalculator*Tests`.
- Timings (opt-in): `Performance/ReaderTimingTests`.
**In-app update (`UpdateRunner`):** the Overview shows a banner (in its `PageHeader`, apart from analytical status) when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it.
**Localization (SDD §12, M7 — en + de):**
- **Resources:** UI strings live in `src/App/Localization/Strings.resx` (neutral = English) and `Strings.de.resx`. MSBuild generates a **strongly-typed** `Strings` class from the neutral resx (see the `EmbeddedResource` block in `MeterVault.App.csproj`), aliased as `S` in `_Imports.razor`. Components write `@S.Common_Save`, never a string key, so a stale key is a build error. `Loc.F(S.Key, args)` formats the `{0}` ones.
- **Adding a string means editing both resx files.** `StringResourceTests` fails the build on a missing or blank translation, a placeholder mismatch, an orphan, or a key nothing references: resource fallback would otherwise hide a half-translated release.
- **Enums:** domain and analysis enums stay bare identifiers (they are persisted as text and appear in the REST API and CSV). `DisplayNames.Display()` (partials `DisplayNames.Analysis.cs`, `DisplayNames.Problems.cs`) is the single place that decides how each value is *spoken*. `EnumDisplayNameTests` fails if a value in `LocalizedEnums` has no wording.
- **MudBlazor's own labels** are translated by `MeterVaultMudLocalizer`.
- **User data** from the database (meter, energy-type and category names) is **never** translated.
- **Language choice:** per request. The cookie the `/culture/set` endpoint writes, else `Accept-Language`, else `MeterVault__Locale` (default `en`). Switching must be a **full reload** (`forceLoad`): a Blazor Server circuit is fixed to the culture of the request that opened it.
- **Formatting:** `Format.*` formats against `CurrentCulture`, so numbers and month labels follow the reader. The CSV importer's de-DE parsing is unrelated and unchanged, because that dialect belongs to the files, not the reader.
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring.
## Reference-data behaviours the code must reproduce (from `sampledata/`)
@@ -91,7 +295,7 @@ These CSVs are the German-dialect *Energiebilanz* spreadsheet export and define
- **Two date formats:** `Monat YYYY` (German month names, monthly tables) and `DD.MM.YYYY` (event rows).
- **Skip inline summary rows** (`Total`, `Heute`, `Seitbeginn Tage`, `Seit YYYY`) and **all-zero future placeholder rows** (e.g. Dec 2026) — do not ingest them. **Negatives are valid** (savings, grid balance).
- **Water register swaps mid-series** (…861 → 2 → 15): consumption must stay continuous across the boundary via a `meter_swap` event.
- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus Netz`, `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten Ersparnis` — but implement these as **user-definable virtual-meter expressions**, not hardcoded formulas.
- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus Netz` (a virtual meter `m1 m2`, checked through `VirtualEvaluator`), `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten Ersparnis`. Implement these as **user-definable virtual meters and cost rules**, not hardcoded formulas; prices are never inside an expression. The sheet's `Kosten` is Netz × price, which is why the bill bills grid import (D-34).
- **Heating oil is the versatility stress test:** a consumable/tank model where consumption is derivable two ways — tank-level Δ, or burner runtime × rate (rate `fixed` from nozzle spec, or `empirical` = Δlevel ÷ Δhours). Early rows (19972004) carry deliveries only (no burner hours yet). Includes cm→litre dipstick calibration and forecast-to-empty.
## Git
+10
View File
@@ -15,6 +15,16 @@
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!-- VERSION on master is the single source of release truth: editing it tags and publishes
(.gitea/workflows/version-tag.yml). Stamp it into the assemblies too, so a running instance
can say which version it is and compare itself against the newest tag. Without this the app
reports 1.0.0 forever and an update banner would be meaningless. -->
<PropertyGroup>
<MeterVaultVersionFile>$(MSBuildThisFileDirectory)VERSION</MeterVaultVersionFile>
<MeterVaultVersion Condition="Exists('$(MeterVaultVersionFile)')">$([System.IO.File]::ReadAllText('$(MeterVaultVersionFile)').Trim().TrimStart('v'))</MeterVaultVersion>
<Version Condition="'$(MeterVaultVersion)' != ''">$(MeterVaultVersion)</Version>
</PropertyGroup>
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
<IsPackable>false</IsPackable>
+1
View File
@@ -15,6 +15,7 @@
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="10.0.9" />
</ItemGroup>
<ItemGroup Label="App / UI">
+136 -20
View File
@@ -16,27 +16,92 @@ full design.
- **Immutable raw readings** on a TimescaleDB hypertable; a normalized, append-only **consumption**
layer on top — reproducible, auditable.
- **Seven measurement modes** (cumulative/generation registers, burner runtime, tank/consumable,
direct delta, instant rate, virtual). Handles meter swaps, counter resets, tank dip-sticks with
calibration, and **virtual meters** defined by an expression (PV self-consumption, savings, net).
direct delta, instant rate, virtual). Handles meter swaps, counter resets and tank dip-sticks with
calibration.
- **Virtual meters** with a validated formula over other meters: sum, difference or free formula,
e.g. `Solar 1 + Solar 2`, or self-consumption as `Haus Netz`. They are analysed exactly like
physical meters: history, comparisons, source contributions, and costs where a cost rule applies.
They are computed from their sources on every read, so they never go stale. A missing source month
reads "no data", never a silent zero.
- **Tariff engine** with time-ranged price history (unit/base/feed-in), scoped global / per type /
per meter; **cost categories** decoupled from energy types; meterless manual costs.
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/
less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %,
savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h,
forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff
timeline, events), one-click reference-data load, CSV dry-run.
- **Per-energy-type flow pages** (Electricity, Water, …): a **Sankey diagram** of the meter chain —
a downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), arrow
thickness ∝ amount, with an auto-computed "Other/unmetered" remainder. Meters can have several
upstreams (a merge, e.g. grid + solar → house).
- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on
mode/baseline change, and cycle-safe upstream-meter wiring), ingest sources, tariffs, cost
categories, and MQTT/Home-Assistant connectors; a "Test connection" for Home Assistant;
effective-settings view.
per meter. The bill counts each energy type's grid import (or its household use), not every meter
that happens to exist. Standing charges are counted once per scope. **Cost categories** are
decoupled from energy types. Meterless manual costs are supported. The currency is configurable.
- **Rollups by local day and month**, written together with the consumption, so dashboards never
scan raw readings. The Settings page shows how far each meter's analysis data is built.
- **One period everywhere**: every page shares a period toolbar (month to date, last month, year
to date, previous year, last 12/24 months, all history, custom dates), a bucket size (day, week,
month or year) and a comparison (previous period, previous year, or any calendar year). The
selection lives in the URL, so reload, Back and shared links keep it.
- **Pages:**
- **Overview** of the selected period: cost with its composition, one card per energy type,
history chart, "what changed", and attention items with a direct fix.
- **Analysis** page to explore the portfolio, an energy type, a cost category, one meter or up to
six meters side by side, by quantity or cost.
- **Energy type** pages with Overview, History, a **Sankey flow** of the meter chain, and a meter
list. A downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), and
the unmetered remainder is shown as "Other".
- A **meter hub** with tabs for Analysis, Readings, Normalized data, Events, Tariffs and Sources
(Calculation for virtual meters). The record tabs are paged over the full history.
- **Solar** (generation, self-consumption, feed-in, autarky, savings, with setup help for missing
meter roles) and **Tanks & consumables** (last dipstick, estimate now, deliveries, burner
runtime, forecast).
- **Honest numbers**: a true zero, missing data, data that is only monthly, and a missing price
are shown differently everywhere, in cards, charts, tables and the **CSV export** of any view.
- **Admin UI**: full create/edit/delete for energy types, meters (with a calculation editor and
live preview for virtual meters, friendly meter roles, and a recompute when a change needs one),
ingest sources, tariffs (unit check; "Add tariff" links from a missing price open the editor
prefilled), cost categories, and MQTT/Home-Assistant connectors. Includes a "Test connection" for
Home Assistant and an effective-settings view.
- English and German UI, light and dark theme, usable down to phone width.
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
## Upgrading to 0.4.0
0.4.0 reworks the dashboards and how analysis and costs are computed. Read
[`docs/RELEASE_NOTES.md`](docs/RELEASE_NOTES.md) first: some figures change on purpose. Back up the
database (`pg_dump`), then start the new image once and let it finish:
- **The first start rebuilds every meter's analysis data** (normalization revision 3): consumption,
the new day/month rollups and coverage. This happens before the web server listens, so the app is
unreachable while it runs and Compose may report the container unhealthy. Let it finish rather
than killing it. The time grows with the number of raw readings: about 0.1 s per monthly meter,
~0.6 s per meter with ten years of daily readings, ~1.4 s per meter with a year of hourly
readings. A 1,000-meter test dataset (1.3 M readings) took about six minutes. Progress is logged,
and a meter that fails is retried at the next start.
- **Virtual meters without a formula** get the sum their links imply stored as an explicit formula.
The log names the meters converted and those that still need configuration.
- **The bill changes**: the grid meter is billed instead of every meter of a type, feed-in is
credited only on a grid-export meter, a missing tariff is "not priced" instead of 0, and standing
charges count once per scope. The seeded demo now matches the spreadsheet's yearly costs.
- **The REST API only adds fields**: see the release notes for `costStatus`, `costAvailability`,
`costRule`, `notCosted`, `missingPrices`, `status` and `latestMonth`.
- **Rolling back** to 0.3.0: the old version ignores the new tables and never read the dropped
continuous aggregates. Consumption stays as 0.4.0 booked it until each meter next ingests a
reading. Stored virtual-meter formulas remain; 0.3.0 ignores them and sums links again.
## Upgrading to 0.3.0
This release changes where consumption lands. Back the database up first (`pg_dump`), then start the
new image once and let it finish:
- **The first start re-derives all stored consumption** before the web server listens. The app is
unreachable while it runs (Compose may report the container unhealthy after ~105 s) — let it finish
rather than killing it. Progress and any meter it could not rebuild are logged.
- **Figures change once.** Consumption between two readings is now attributed to the months it
accrued in instead of landing entirely on the later reading, so historical months and their costs
can shift; rows that had to be divided are marked *estimated*.
- **Imported monthly tables are marked as such** in place before anything is recomputed, so they keep
reconciling. If that step fails nothing is rebuilt and the whole upgrade simply runs again next
start.
- **Check the log once** for `had its dates auto-detected`: a CSV imported through the wizard with the
date format left on auto-detect is treated as a monthly table when all its rows sit on the 1st.
If such a sheet really was day-dated, revert that batch on `/import` and import it again with the
day format.
- **Rolling back** to 0.2.0 leaves the re-attributed consumption in place; it is re-derived under the
old rules only as each meter next ingests a reading.
## Quick start (Docker)
```bash
@@ -66,18 +131,69 @@ Configuration is via environment variables (`Section__Key` double-underscore map
| Variable | Purpose |
|----------|---------|
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
| `MeterVault__TimeZone` | IANA timezone for buckets, display **and month attribution** (default `Europe/Berlin`). Changing it re-derives every meter's stored consumption at the next start, and historical monthly figures can shift. It must be an id both .NET and PostgreSQL know; anything else falls back to UTC and is reported in the log. |
| `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. |
| `MeterVault__Currency` | Currency code of every amount (default `EUR`). Tariffs in another currency are reported as not fitting, never converted. |
| `MeterVault__RawRetentionDays` | Shown on the Settings page but **not enforced**: raw readings are kept, because every recompute rebuilds a meter from them. |
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
| `MeterVault__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — no key required, so anything that can reach MeterVault can trigger one; see below |
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
returns 401. Set at least one API key (or open it explicitly for a trusted network).
Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name*
of an environment variable, resolved at runtime.
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
> and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
> user header — or keep it on a trusted network.
The dashboard compares the running build against the newest tag in the source repository and shows a
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
`MeterVault__UpdateCheckEnabled=false`.
### Updating from the UI (opt-in)
`MeterVault__AllowInAppUpdate=true` adds an **Update now** button to that banner, and a
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
```bash
curl -X POST http://metervault:8760/api/v1/system/update -H "X-MeterVault-Update: 1"
```
Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
is unavailable. Readings are untouched; ingestion resumes on restart. LXC only: containers are
replaced by pulling a new image, and the endpoint reports that rather than pretending.
> **That flag is the whole gate — there is no key and no prompt.** With it on, anything that can
> reach MeterVault can trigger a rebuild and restart. Realistically that is a repeatable denial of
> service (minutes of downtime and a busy CPU per request), not code injection, because the build
> comes from your own repository — but it becomes remote code execution if that repository is ever
> compromised. It defaults off. Enable it only on a network you trust, or behind an authenticating
> proxy.
>
> The `X-MeterVault-Update` header is **not** authentication: it stops a *different website* driving
> the endpoint through the browser of someone on your network, which a plain HTML form could
> otherwise do. The UI button does not need it — it runs over the Blazor circuit, which a foreign
> page cannot reach. Every triggered update is logged as a warning, since with no key there is no
> caller to attribute it to.
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
or JSON export carries nothing usable.
Keep the key ring on persistent storage outside the app directory — the default
`/var/lib/metervault/keys` survives an LXC update, and the Compose file mounts a named volume for it.
Lose it and every UI-entered secret must be re-entered. The key ring is on disk, so this protects
against leaked database content, not against an attacker who already has the host; that is the same
trust boundary an environment variable has.
## Pushing readings (Home Assistant)
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.3.0
+8
View File
@@ -30,15 +30,22 @@ services:
environment:
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
ASPNETCORE_ENVIRONMENT: Production
# Buckets, display AND month attribution. Changing it re-derives stored consumption at the next
# start; must be an IANA id both .NET and PostgreSQL know.
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
# (idempotent). Leave false for a clean instance.
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
# Key ring for connector secrets typed into the admin UI. On the named volume below so it
# survives image updates — lose it and every stored token must be re-entered.
MeterVault__DataProtectionKeyPath: /var/lib/metervault/keys
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
# MeterVault__ApiKeys__0: your-secret-key
# MeterVault__AllowAnonymousApi: "true"
volumes:
- metervault_keys:/var/lib/metervault/keys
ports:
- "${METERVAULT_PORT:-8760}:8760"
healthcheck:
@@ -51,3 +58,4 @@ services:
volumes:
metervault_db:
metervault_keys:
+9
View File
@@ -23,6 +23,7 @@
: "${INSTALL_DIR:=/opt/metervault}"
: "${SOURCE_DIR:=/opt/metervault-src}"
: "${ENV_FILE:=/etc/metervault/environment}"
: "${KEYRING_DIR:=/var/lib/metervault/keys}"
: "${DB_NAME:=metervault}"
: "${DB_USER:=metervault}"
@@ -203,6 +204,13 @@ EOF
chmod 600 "${ENV_FILE}"
}
# Key ring for connector secrets typed into the admin UI (SDD §6.4). The app creates this itself if
# missing, but with the default umask — created here instead so it is 0700 from the start, and so it
# is visibly outside /opt/metervault, which the updater republishes on every run.
write_keyring_dir() {
install -d -m 0700 "${KEYRING_DIR}"
}
write_systemd() {
cat <<'EOF' >/etc/systemd/system/metervault.service
[Unit]
@@ -246,6 +254,7 @@ main() {
install_dotnet_sdk
build_metervault
write_env
write_keyring_dir
write_systemd
systemctl daemon-reload 2>/dev/null || true
+15
View File
@@ -27,6 +27,21 @@ fi
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
# Anything between the stop and the restart can fail under `set -e`: a dotnet publish OOM-killed in a
# small container, a Gitea outage mid-fetch, a transient compile error on master. systemd's
# Restart=always does not cover a unit stopped on purpose, so without this the service simply stays
# down until someone notices. Bring the old build back up and say what happened — a failed update
# should cost the new version, not the running one.
restore_service_on_failure() {
local code=$?
if [[ ${code} -ne 0 ]]; then
echo "Update failed (exit ${code}). Restarting the previous build…" >&2
systemctl start metervault || echo "Could not restart metervault — check 'systemctl status metervault'." >&2
fi
exit "${code}"
}
trap restore_service_on_failure EXIT
echo "Stopping metervault…"
systemctl stop metervault || true
+6 -2
View File
@@ -18,9 +18,13 @@
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing, display and month attribution. Changing it re-derives stored consumption at the next start." Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/>
<Config Name="Language" Target="MeterVault__Locale" Default="en" Mode="" Description="Default UI language: en or de. Each visitor can switch it in the app bar." Type="Variable" Display="always" Required="false">en</Config>
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank and the API stays closed (401)." Type="Variable" Display="always" Required="false" Mask="true"/>
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
<Config Name="Secret key ring" Target="/var/lib/metervault/keys" Default="/mnt/user/appdata/metervault/keys" Mode="rw" Description="Encryption keys for connector secrets entered in the web UI. Must persist: without this mapping every stored token is lost when the container is recreated." Type="Path" Display="always" Required="true">/mnt/user/appdata/metervault/keys</Config>
</Container>
+820
View File
@@ -0,0 +1,820 @@
# Analysis rework: implementation note
Companion to [DASHBOARD_ANALYSIS_CHANGE_BRIEF.md](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md). This is the Phase 1
deliverable that resolves the brief's open choices. Every decision has an ID (D-nn) so code, tests and the final
report can refer to it. Written against `c0f52db`; revised after an adversarial design review.
The note was kept current through Phase 5:
- §11: amendments from the Phase 1 module review.
- §12: amendments from the acceptance review.
- §13: decisions recorded with the final documentation.
- §14: amendments from the performance measurement.
- §15: amendments from the first reports of a live instance.
- §9 and §10: extended with what the implementation measured and changed.
The outcome is in [ANALYSIS_REPORT.md](ANALYSIS_REPORT.md), the user-facing changes in
[RELEASE_NOTES.md](RELEASE_NOTES.md).
## 1. What the code review established
The brief's findings A01A14 are confirmed by the source, with these refinements:
- **Nothing evaluates a virtual meter.** `VirtualNormalizer` and `ExpressionEvaluator` run only in tests.
`NormalizationService` skips virtual meters. The only value in production is `FlowService`'s sum of
incoming links, which ignores any stored formula. SDD §14.1 is not implemented in either direction.
- **The expression evaluator is unsafe for user input.** It has no AST, unknown identifiers evaluate to 0, and
recursion is unbounded.
- **Consumption rows store only the interval end.** There is no unit column, and `Estimated` covers four
different cases. Coverage cannot be recovered from sums; it has to be captured during normalization.
- **Month division only exists for cumulative and generation counters.** Tanks, runtime, direct-delta and
instant-rate modes book a whole interval at its end. The same is true of swaps, resets, decreases and first
readings.
- **The continuous aggregates cannot be used:** they are Berlin-only, materialized-only on TimescaleDB ≥ 2.13,
and never backfilled. No reader uses them, yet they are refreshed hourly.
- **Raw retention is not implemented.** Turning it on would destroy history, because every recompute rebuilds a
meter from the readings that remain.
- **The seeded costs are wrong in a way the spreadsheet proves.** The sheet bills `Kosten = Netz × price`, but
the seed prices Haus + Netz + Auto. The seed is also missing the water price rise to 7.00 €/m³ in 2026.
- **The tooling gaps are real.** There is no clock abstraction, no bUnit or Playwright, and the API responses
have no contract tests.
## 2. Periods and comparisons
- **D-01 Clock.** `TimeProvider` is registered. Pages and API endpoints read "now" once per request and resolve
a period with the pure resolver, then pass the resolved period down. Services never read the clock. Where one
genuinely needs "now" (freshness, forecast), it takes a trailing optional `TimeProvider? time = null`. Tests use
a small `FixedTimeProvider`.
- **D-02 Presets and URL tokens.**
- `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom`, with `from`/`to` (yyyy-MM-dd) used only for
`custom`.
- Overview default: `mtd`. History pages default: `12m`, which is 12 calendar buckets ending with the current
partial month.
- `all` spans the availability metadata (D-19), not a fixed century.
- A page default applies only when no period key is present. An invalid token falls back to the default and
shows a notice.
- **D-03 Bounds.**
- A period resolves once, in the instance zone, into two forms: a local inclusive date range for display, and
a half-open UTC range `[from, to)` for queries.
- `to` is the local midnight after the end date, or the captured "now" for to-date periods.
- Quantities, costs, comparisons and exports all use the same bounds.
- **D-04 Now and the future.**
- Actual figures stop at "now". A row counts as recorded after now when its source interval ends after now.
Examples: a current-month label row, a future-stamped Tasmota row.
- Such rows are excluded from actuals and reported in a separately labelled "recorded after now" block (rows,
amount, dates), with an attention item.
- A range entirely in the future reports "not yet occurred".
- **D-05 Buckets.**
- Buckets are `day|week|month|year|auto`. Weeks start on Monday in local time.
- `auto` picks one bucket for the whole chart: the coarsest resolution any plotted series needs, at most 400
points.
- An explicit bucket above 400 points is refused with a coarser suggestion.
- Bucket bounds are local midnights, clipped to the period.
- **D-06 Comparisons.**
- `compare=none|prev-period|prev-year|year:YYYY`. `year:YYYY` needs a year-aligned range.
- Shifting uses local calendar units, never durations:
- Whole years shift by years, and whole months by months. Anything else shifts by days.
- `12m`/`24m` compare with the N months before. `all` has no comparison.
- The cut-off maps as local date plus wall time:
- A nonexistent time takes the first valid instant after the gap.
- An ambiguous time takes the occurrence with "now"'s offset if one matches, otherwise the first occurrence.
- A day that does not exist in the target month (31st, 29 Feb) cuts at that month's end.
- **D-07 Matched coverage.**
- A change figure is "confident" only over the range both periods actually cover. The current period's covered
range is shifted, intersected with the comparison's coverage, and trimmed to whole buckets where resolution is
coarser than the cut.
- Both requested ranges and the matched range are shown.
- An empty match means "not comparable": absolute values only, no percentage.
- **D-08 Change figures.**
- The absolute difference is always shown.
- The percentage is "not applicable" when the baseline is ≤ 0 or unavailable.
- Colours depend on the metric: more consumption is not "good", more generation is.
- **D-09 Projections** are separate and labelled "Projection (straight-line from N days)".
- Method: the covered rate × the remaining days. Standing charges are added exactly per day.
- A projection is suppressed when:
- coverage ends more than 2× the meter's typical interval before now;
- covered elapsed time is under 7 days (month) or 30 days (year);
- the resolution is coarser than the period.
- A change chip never compares a projection with an actual.
## 3. Data layer
- **D-10 Engine intervals.** Every `Consumption` row carries its source interval: `IntervalStart`,
`IntervalEnd` and `Divided`, as EF-ignored properties, so the schema does not change. Each mode sets them:
| Mode | Interval |
|---|---|
| Counters | previous effective reading → this one, with `GapSegment` bounds for divided shares |
| Runtime | previous effective time |
| Tank | previous TankLevel event |
| Instant rate | previous sample |
| Direct delta | previous reading, or the labelled month for a label |
| First reading | the labelled month for a label; `[InstalledAt, t]` when set; otherwise an unknown start (D-14) |
`Coalesce` keeps the minimum start and the maximum end.
- **D-11 Midnight stamps.** A non-label row whose interval end falls exactly on a local midnight is stamped
1 second earlier, inside the day it describes, mirroring `InsideSegment`. Label rows keep `StampTime`.
`[from, to)` stays everywhere.
- **D-12 Tables.** These are plain tables, not hypertables. Each has an FK to `meter` with `ON DELETE CASCADE`.
They are written by `RecomputeMeterAsync` in the caller's transaction, by diff, so only changed rows are
touched.
| Table | Key | Columns |
|---|---|---|
| `consumption_rollup` | `(meter_id, day, kind)` | `amount`, `measured`, `manual`, `imported`, `estimated`, `rows`, `flags` (baseline-delta, divided) |
| `consumption_rollup_month` | `(meter_id, month, kind)` | same columns; month and year reads use it |
| `meter_coverage` | `(meter_id, span_from)` | `span_to`, `resolution_class`, `divided_at_months`, `gap_reason` |
| `meter_rollup_state` | `(meter_id)` | `revision`, `zone`, `normalized_unit`, `kind`, `built_at` |
All local dates use the configured zone.
- **D-13 Coverage runs.**
- Consecutive intervals of the same resolution class merge into one run. The classes are ≤ 1 h, ≤ 1 day,
≤ 7 days, ≤ 1 local month, and coarser.
- An interval longer than a month is its own run.
- These open a gap instead of coverage:
- an unexplained decrease;
- a reset without PrevValue;
- an instant-rate gap longer than max(1 h, 10 × the median sample interval);
- deliveries before a tank's first level.
- Coverage is capped at now.
- **D-14 Bucket status.**
- **missing:** no run overlaps the bucket.
- **partial:** runs cover only part of it.
- **unresolved:** an undivided interval crosses the bucket edge by more than 5 % of the bucket length. The only
exception is an edge at a local month boundary the normalizer divided at.
- **available:** everything else. An available bucket with no rows is a true zero.
- **Opening balance:** a first reading with unknown start marks its bucket "partial (opening balance, start
unknown)". It is excluded from comparisons and projections, and the UI offers to set an install date.
- Provenance is a separate dimension, derived from the per-quality amounts plus `derived` for virtual meters.
- **D-15 Reading a period.**
- Rollups (month table for month/year buckets, day table otherwise) cover complete local days.
- For at most two partial edge days per range, one direct `consumption` query covers
`[edge-day midnight, cutoff)` (`meter_id = ANY(@ids)`).
- Each request makes one query per table and one tariff load. Virtual dependencies are expanded in memory
first. The 400-point and 6-series limits are enforced before any SQL runs.
- **D-16 Rebuild.**
- `CurrentRevision` becomes 3, because the engine books differently (D-11, intervals).
- The startup upgrade rebuilds consumption, rollups and coverage, and records `meter_rollup_state`.
- A meter whose state is missing or outdated (revision, or zone ≠ the reader's zone) reads as "analysis being
prepared", never as "no data".
- The migration purges derived rows of virtual meters. `RecomputeMeterAsync` purges them if a meter becomes
virtual.
- The upgrade skips and logs any meter whose oldest consumption predates its oldest reading or event, instead
of truncating history (D-44).
- **D-17 Continuous aggregates.** The new migration removes their policies and drops the three views.
`Monthly_continuous_aggregate_refreshes_and_matches_base` is replaced by a rollup-equals-consumption test
(water Dec 2022 = 14 m³).
- **D-18 Freshness.**
- The last reading or event time is the freshness mark.
- A live source is stale when that time is older than the larger of 3 × the median of its last 20 intervals
and 3 × its poll interval.
- Import-only meters are "historical", never "stale".
- **D-19 Availability.**
- A quantity scope's availability is its coverage.
- A cost scope's availability is its billed meters' coverage plus its manual costs' `PeriodStart` days.
- Both are capped at now.
- "Latest period with data" is the latest local month ≤ now in that union. It is returned with its month and
basis (meters / manual / both).
## 4. Quantities, units and totals
- **D-20 Normalized quantity.** A Core function `NormalizedQuantity(meter, tank, definition)` returns
`(kind, unit)`.
- Kinds: consumption, generation, export, runtime, and for virtual meters also net or indicator.
- Units by mode:
| Mode | Unit |
|---|---|
| RuntimeCounter | `h`, or the tank unit with a Fixed rate (kind stays runtime; provenance estimated) |
| InstantRate | the rate unit without `/h` (W→Wh, kW→kWh) |
| ConsumableBalance | the tank unit |
| Virtual | its declared result unit |
| Others | `Meter.Unit` |
- Aliases are normalized (m3 = m³).
- The normalized unit is stored in `meter_rollup_state`. Raw units appear only on the Readings tab.
- **D-21 Roles.** `total_load`, `grid_import` and `grid_export` are unique per energy type; saving a role moves
it and says who held it. The editor shows localized names and one-line meanings, and offers each role only for
compatible modes.
- **D-22 Per-type totals algorithm.** Pure and ordered:
1. **Supply meters** are grid_import or grid_export meters, GenerationCounter meters, and generation-kind
virtual meters. A link out of a supply meter is a *supply* edge and never makes its target a submeter.
2. **Containment.** A link from a physical, consumption-kind, non-supply meter makes the target a breakdown of
its parent.
3. **Measures per type:**
- *Use* is the total_load meter if there is one, otherwise the consumption roots. Consumption roots are
physical consumption-kind meters that are not supply meters, have no containment parent and are not
runtime meters. Tanks count.
- *Grid import* is the grid_import meters.
- *Export* is the grid_export meters, which are never consumption.
- *Generation* is the GenerationCounter roots.
- *Runtime* is the runtime meters.
4. Measures are never added across units.
5. Virtual meters are analysis views. Retired meters keep their history.
6. Seeded result: use = {Haus}, breakdown = {Auto}, grid import = {Netz}, generation = {Solar 1, Solar 2},
analysis-only = {Summe Solar}, runtime = {Brenner}, water use = {Wasser}, oil use = {Öltank}.
- **D-23 Override.**
- `Meter.Meta.totals` is `auto|always|never`. `always` on a virtual meter replaces its expanded dependencies in
that measure and in the bill. `always` on a meter whose ancestor or dependent is already counted is refused
on save, naming the other meter. `never` removes a meter from the measures it would join.
- The resulting cover is shared by the quantity totals and the bill.
- **D-24 Lifecycle.** Outside `[InstalledAt, RetiredAt]`, when set, a meter contributes a known zero to totals
and to virtual evaluation.
## 5. Virtual meters
- **D-25 Definition.**
- `Meter.Meta` holds `expression`, `referencedMeterIds` (always derived from the expression and rewritten on
save), `resultKind` (consumption|generation|net|indicator), `resultUnit` and
`costRule` (none|sourceCosts|ownQuantity).
- Topology links never define a calculation.
- **D-26 Formula.**
- The grammar is the existing one (`+ - * /`, parentheses, numbers). It is parsed to an AST, with limits of
2,000 characters and nesting depth 64.
- References are `m<id>`; any other identifier is invalid.
- Validation on save and on read covers: syntax, unknown or self references, cycles through nested virtual
meters (with the path), and kind/unit.
- Kind/unit rules for operands:
- `+`/`` need the same unit and kind, or a declared `net`.
- Meter × or ÷ meter needs a declared `resultUnit` and kind `indicator`.
- Indicators are non-additive, never totalled and never costed.
- **D-27 Evaluation.**
- Evaluation runs on read, per bucket, from the sources' rollups.
- A virtual meter's coverage is the intersection of its sources' coverage, and its resolution is the coarsest
among them.
- A missing source makes the bucket missing (strict); an observed zero is a valid input. A non-finite result
makes it invalid, with the reason.
- A period total is the formula applied to the sources' totals over the joint coverage. It is partial when that
coverage is smaller than the period. For a linear formula without a constant this equals the sum of its
buckets; otherwise the series is marked non-additive.
- The result carries every source's series, status and dependency path.
- **D-28 Legacy definitions.**
- At startup, an expression-less virtual meter whose same-type incoming links name sources of one unit and
kind gets the equivalent explicit sum. Meters are processed in dependency order, the run is idempotent, and
the counts are logged.
- Anything else is flagged "needs configuration".
- Until converted, the reader evaluates the implied sum with status "legacy — confirm".
- `ReferenceDataImporter` writes Summe Solar's definition directly: `m(Solar 1) + m(Solar 2)`, generation,
kWh.
- **D-29 One evaluator.** `VirtualNormalizer` is removed from `NormalizationEngine.CreateDefault`. Its tests and
the golden Netz Einsparung reconciliation move to the new evaluator over month buckets (≥ 20 matches, ±1 kWh).
- **D-30 Flow.**
- A pure-sum virtual meter's incoming edges are its calculation dependencies, drawn at each source's own value
and marked "calculated".
- Other virtual meters appear only in the table view.
- Links are capped at the parent's value, and proportional splits are marked estimated.
- The Flow tab gets "Manage connections".
- **D-31 Editor.**
- Sum, Difference and Advanced modes, with source pickers by name (showing unit, kind and install/retire
dates) and a live preview for the selected period.
- On a virtual meter's page, a Calculation tab replaces Sources. Register details and Readings are removed.
Events keeps Note.
- Saving a Sum offers to sync the incoming links.
- **D-32 Export/import** carries `meter_link` and remaps meter ids inside definitions.
- **D-33 Deleting a meter** lists the virtual meters that depend on it and requires confirmation.
## 6. Costs
- **D-34 Billing.**
- Per energy type, the grid_import meters are billed if the type has one, otherwise the *use* meters (D-22).
- Generation meters are never billed.
- The feed-in credit is the FeedIn price × the export of grid_export meters.
- Runtime and virtual meters are not billed unless D-39 applies.
- **D-35 Separately billed submeter.** A containment child with an applicable meter-scoped UnitPrice is billed at
its own price. Its monthly quantity is subtracted from its billed ancestor's for pricing. Quantity totals do
not change.
- **D-36 Prices.**
- The monthly convention is kept: the price valid on the 15th of each local month.
- Every bucket size is priced month by month, so week and year buckets are split by local month. Changing the
bucket never changes a total.
- **D-37 Tariff applicability.**
- A UnitPrice or FeedIn tariff applies only when the unit denominator matches the meter's normalized unit.
Known scales are converted (ct, per 100 L, per MWh).
- A parsed mismatch makes the cost "unavailable (unit)". An unparseable unit applies, with a warning.
- BasePrice units are per day, per month (the default) or per year.
- The tariff editor validates units on save and states that Bonus, Discount and Tax are not applied yet.
- **D-38 Coverage.**
- A billed scope with no UnitPrice tariff at any date is "not priced (no tariff)". That is an attention item,
not "unavailable".
- A gap inside a priced scope's tariff history makes the cost "unavailable" for those months.
- An explicit zero tariff is a valid zero.
- A missing FeedIn price is reported only where a grid_export meter exists.
- **D-39 Virtual costs.**
- `sourceCosts` adds the sources' metered costs. It is allowed only for pure sums and excludes scope-level
standing charges.
- `ownQuantity` prices the virtual quantity with normal precedence. It is allowed only for linear formulas
without a constant.
- The default is `sourceCosts` for pure sums and `none` otherwise. The rule is named next to every virtual
cost.
- A virtual meter is part of the bill only through D-23.
- **D-40 Standing charges.**
- A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service
period. The service period runs from the earliest InstalledAt or first data to the latest RetiredAt or now,
regardless of reading gaps.
- Meter-scoped charges stay on their meter.
- Type- and global-scoped charges are their own rows ("Standing charge — <type>" / "— global"), never split
across meters.
- **D-41 Manual costs** are booked in full on their `PeriodStart` local day, when that day is in `[from, to)`
and ≤ today. `PeriodEnd` is informational. A cost with `MeterId` set goes to that meter's categories.
- **D-42 Categories.**
- A category's cost is the priced cost of the non-overlapping cover of its members, using the bill algorithm
restricted to them, plus its manual costs. For example, Strom {Haus, Netz, Auto, Solar 1, Solar 2} gives
Netz × price, and a category {Auto} gives Auto × price.
- A type- or global-scoped standing-charge row joins a category only if the whole type (or, for global, every
billed meter) is a member.
- The composition is the disjoint categories, plus Uncategorized, plus standing-charge rows, and it reconciles
to the bill.
- A category that overlaps another, or covers meters outside the bill, is an "overlapping view" and stays
outside the composition.
- The donut is drawn only when every slice is ≥ 0; otherwise signed bars are used.
- **D-43 Currency.** `MeterVault__Currency` is used everywhere through one `Format.Money`.
- **D-44 Seed.**
- The water tariff 7.00 €/m³ from 2026-01-01 is added.
- Summe Solar gets an explicit definition.
- The seed tariffs are otherwise unchanged.
- Golden: the seeded yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022 (421.52),
2025 (7,907.64) and 2026 (2,940.19). This is computed on a frozen clock after 2026-05-31, with the tank
unpriced.
- 2023 and 2024 differ by 3.78 € and 0.46 €, because the sheet rounds its displayed prices. That is documented
and not tuned away.
## 7. API
- **D-45 Compatibility.**
- Contract tests for `/consumption`, `/cost` and `/dashboard/summary` are written before anything is rerouted.
- Every existing field and type is kept.
- `/consumption` and `/cost`:
- They keep exact-instant bounds, now converted with `ToUniversalTime()`.
- `cost` stays numeric (0 when nothing is priced), and `costStatus` and `missingPrices[]` are added.
- Virtual meters return evaluated values with a status.
- `/dashboard/summary`:
- It keeps its calendar month and year windows (legacy semantics, documented) but uses the new billing set.
- It adds `deltaPercentApplicable` and `latestMonth`.
- The numeric change is listed in the release notes.
## 8. Pages, navigation, state
- **D-46 URL state.**
- The query parses into an immutable `AnalysisQuery` value. Analysis reloads only when that value changes,
with a generation counter and cancellation.
- An action drop or tab change never reloads it.
- Toolbar and tab changes replace the history entry; drill-downs push. Defaults are never written on load, so
a deep-linked dialog is not dismissed.
- Initial loads stay in `OnInitialized`/`OnParametersSet`, because render tests read prerendered data.
- **D-47 Keys.**
- Meter tabs are `analysis|readings|normalized|events|tariffs|sources|calculation`. Legacy `consumption` maps
to `normalized`. On virtual meters, `sources` maps to `calculation` and `readings` to `analysis`.
- Energy-type tabs are `overview|history|flow|meters`.
- Analysis page scope is `scope=portfolio|type|category|meter|meters` with `id`/`ids` (at most 6), plus
`metric=consumption|generation|export|runtime|net|cost|balance`.
- Link helpers append the new keys after the existing ones.
- **D-48 Navigation.**
- Sidebar entries: Overview, Analysis, Meters, Energy types (with a retry item on error), Specialized views
(Solar, Tanks & consumables — always listed, with a setup state when unsupported), Data import,
Configuration.
- Expanded groups persist in a cookie. `NavState` gains `MetersChanged`.
- Breadcrumbs (Overview → type → meter) carry the period, and Back returns to the parent.
- Search shows a text label from the md breakpoint up, and its results link to Analysis (with the period) plus
quick entry.
- **D-49 Theme.**
- A scoped `ThemeState` is backed by a cookie that App reads, so prerender and the language switch keep the
mode.
- Charts use a transparent background and the theme's mode, and are re-keyed on theme or result change.
- Units and currency go into JS formatter strings. Points are nullable; there is no smoothing and no joining
across gaps.
- **D-50 Tables.**
- Readings, Normalized data and Events are paged server-side (100 rows), keyset-ordered, with `from`/`to`
filters.
- The manual-entry dialog runs its own queries (latest reading, reading at T with flags, boundaries), so its
verdicts never depend on a page of rows.
- **D-51 Drill-down.** Clicking a chart bucket keeps the scope, sets the bucket's range and the next finer
supported bucket. If there is none, it opens Normalized data filtered to that bucket.
- **D-52 Deep links.**
- Tariffs: `/admin/tariffs?scope=&id=&component=&from=&action=new` opens a pre-filled new-tariff dialog.
Missing-cost explanations link there with the first uncovered month.
- **D-53 Attention items.** Missing required price (scope and first month), stale live source, invalid or
unconverted virtual definition, recorded-after-now rows, possible overlap (a total_load and a grid_import root
that are not linked).
- **D-54 Solar and consumables.**
- Both adopt the shared toolbar, cards and charts. Units come from D-20.
- Solar shows a setup card for each missing role.
- Tanks show "Last dipstick: <value> on <date>" separately from "Estimated now (incl. deliveries since)". For a
historical range they show the balance at the range end. Deliveries are filtered to the range.
- The forecast is suppressed when the dipstick is older than 60 days.
- **D-55 CSV export.** The analysis table as CSV: one row per bucket and series, with local ISO bucket bounds,
timezone, invariant numbers, empty cells for unavailable values, and status, provenance, cost, cost status,
currency and the comparison value. Served by an App endpoint that takes the same URL keys.
## 9. Evidence, limitations, deviations
- **D-56 Evidence.**
- Frozen-clock tests cover New Year, Berlin DST in spring and autumn, 29 Feb, 31 Jan → Feb, and New York.
- Seeded goldens: D-44, the D-22 classification, and water Dec 2022 = 70 € / 14 m³.
- Worked virtual examples: A+B, AB, missing vs zero, nested, cycle, division by zero.
- A synthetic generator (test trait) for 1,000 meters × 10 years with recorded timings.
- Screenshots and a manual checklist (EN/DE × light/dark × 360/768/desktop) from the seeded instance. No bUnit
or Playwright is added.
- *As implemented:*
- The frozen-clock, seeded-golden and worked-virtual suites exist as planned. `docs/ANALYSIS_REPORT.md` lists
them with counts. At the end: Core 1,733 tests, Integration 746.
- The synthetic generator and timings are `tests/Integration.Tests/Performance` (trait `Category=Performance`,
skipped unless `METERVAULT_PERF=1`).
- The manual checklist ran as Chrome DevTools Protocol scripts against seeded instances: four acceptance
reviewers plus the page agents, in EN/DE, light/dark, at 1440/390/360 px. Those scripts and the screenshots are
outside the repository. Server-rendered pages are covered by `HtmlRenderer`-based render tests in EN and DE.
- **D-57 Limitations.**
- Raw retention is not implemented; `/admin/settings` labels it "not enforced", and the Readings tab explains
it. The brief's "Retained history" scenario is a documented blocker.
- Monthly imports are never interpolated to days (SDD §8.7 / §14.2 unchanged).
- A full recompute still runs per live reading.
- Bonus, Discount and Tax tariffs are not applied.
- *Measured and found later (see `docs/ANALYSIS_REPORT.md`):*
- The per-reading recompute is linear in a meter's reading count: ~0.1 s for a monthly meter, ~1.4 s for a year
of hourly data. The startup rebuild is ~20 % slower per meter than in 0.3.0.
- The freshness query (`AnalysisQueries.RecentReadingsAsync`) has no time bound, so it plans across every raw
chunk.
- The window-sum query (`AnalysisQueries.WindowSumsAsync`) gets no plan-time chunk exclusion.
- Both grow with history length, and neither is fixed.
- The billing basis cannot change month by month (A-17).
- Batteries are not modelled in Solar's calculated feed-in.
- The Solar page has no CSV export, because the export has no derived measures.
- **D-58 SDD deviations.**
- §14.1: virtual meters are computed on read, and nothing is materialized.
- §5.4 / §10: the configured zone is used, and rollups replace the continuous aggregates.
- §8.1: the Overview shows one selected period.
- §7.4: prices inside expressions are not supported; the `ownQuantity` cost rule covers savings.
- *Also marked in the SDD at the end of the rework:*
- §3 (FR-9, FR-11, FR-12, FR-16), §4.1 / §4.2 (no aggregates, the two-reader pipeline) and §5.1 (the new tables).
- §5.5 (raw retention not enforced, D-57).
- §7.1 (revision 3), §7.3 (tank "now" vs period), §7.5 (the bill, D-34 D-43).
- §8.0 (the shared contract), §8.2 §8.7 (the pages), and the monthly-history note (no interpolation).
- §9 (additive API fields, D-45, A-21), §10 (half-open bounds, `TimeProvider`), §11 §13 (layout, milestones,
tests) and §14.1 §14.4 plus the new §14.9 §14.14.
- Appendix B (Ersparnis is not an expression).
## 10. Deliberate behaviour changes
| Area | Old | New |
|---|---|---|
| Seeded Strom bill | Haus + Netz + Auto priced | Netz (grid import) billed; matches the sheet |
| Feed-in | credited on all generation | credited on grid_export only |
| Missing tariff | cost 0 | not priced / unavailable (D-38) |
| Standing charge | per meter per month with data | once per scope, per day of service |
| Midnight readings | booked in the next day | booked in the day they close (D-11) |
| "Last 12 months" | 1314 buckets, including a future month | 12 buckets, actuals up to now |
| Virtual meters | no analysis; flow sums links | full analysis from the formula |
| Overview "this year" | full year vs complete previous year | selected period vs matched coverage |
| Currency | hard-coded € | configured currency |
| Continuous aggregates | refreshed hourly, unused | dropped |
| API | — | additive fields only; the summary's values follow the new bill |
Added as the amendments and pages landed (the release notes, `docs/RELEASE_NOTES.md`, list them for users):
| Area | Old | New |
|---|---|---|
| Year and week buckets | a year priced at the 1 July price, a type/global base price per meter and bucket | every bucket priced month by month at the price of the 15th (D-36) |
| Separately priced subsection | added on top of its parent | billed at its own price, out of its parent (D-35, A-19) |
| Manual costs | in the summary but not the trend; a cost later this month counted at once | once, on its start day, once that day has come, everywhere (D-41) |
| Categories | sum of member meters' costs | priced non-overlapping cover; overlapping categories are views (D-42) |
| Intervals longer than a month (tank, runtime, direct delta) | booked whole in the later month, zeros between | months "only coarser data"; longer buckets priced when the months share one price (A-16) |
| Months without a grid meter in service | — | cost unavailable, with an attention item (A-17) |
| Meter fee on a meter no line prices | per meter and bucket | its own standing-charge row (A-18) |
| Rows recorded after now | counted in to-date totals | reported apart; a day holding one reads partial (D-04, A-14, A-20) |
| Summe Solar / generation sums | not costed (no analysis) | analysed; cost rule `none` (A-15) |
| Percentage against a negative baseline | divided by its absolute value | not applicable (D-08) |
| `/api/v1/cost` of generation, runtime, invalid meters | `Priced` (a generation meter could carry a negative feed-in cost) | `NotPriced` with `costRule`/`notCosted` (A-21) |
| `/api/v1/consumption` months without data | 0 | left out (only rows holding quantity data) |
| Deleting a meter or energy type | its scoped tariffs stayed behind and could be restored onto another meter | deleted with it (`EntityDeletion`); export/import skips such orphans (A-37) |
Tests rewritten on purpose (none weakened; each rewrite states the new rule):
- `MeterPeriodServiceTests`: deleted with `MeterPeriodService`. Its cases moved to `MeterAnalysisLoaderTests`, with
the virtual-null case replaced by positive and error-state cases (the worked A+B example, missing vs zero, an
invalid calculation).
- `FlowServiceTests.Virtual_sum_meter_aggregates_its_upstreams` became `Virtual_sum_meter_is_its_formula`, plus
legacy, non-sum, capped-link, missing-sub-meter, other-unit and after-now cases.
- The CAgg test became `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums`. `SchemaTests` now
pins that the aggregates and their jobs are gone.
- `FormatCultureTests`: the currency case became `Money_is_in_the_configured_currency_written_the_readers_way`, plus
formatter cases.
- `LocalTimeEntryTests.Tab_keys_map_to_panel_indexes` became `Tab_keys_resolve_by_key_and_mode`.
- `VirtualMeterTests` and `ElectricityReconciliationTests.Netz_einsparung_virtual_matches_the_sheet` now run through
`VirtualEvaluator` (D-29).
- `ExpressionEvaluatorTests`: deleted with the evaluator. `FormulaParserTests` pins that unknown identifiers are
errors.
- `DashboardRenderTests`: the literal labels of the old pages, replaced by assertions on the new ones in EN and DE.
- `DashboardServicesTests`: four Solar and tank tests moved to `Specialized/` with the new services' assertions.
- `AnalysisChartModelTests`: a missing bucket is marked "", not "*" (A-28). `MeterAnalysisLoaderTests` reads the
cost change's new type (A-23). `AttentionItemsTests` has the specific duplicate-role text.
## 11. Amendments after the Phase 1 module review
These refine the decisions above where the independently built Core modules met.
- **A-01 Opening balance.** An opening balance is never a coverage run. The rollup day it is booked in
carries a baseline-delta flag, which the coverage evaluator takes as an input. Matched coverage gets the
booked stamps of opening-balance rows. `CoverageGapReason.OpeningBalance` is removed.
- **A-02 DividedAtMonths.** This is true when no interval of a run straddles a local month boundary undivided.
An interval is either divided at month boundaries, or lies inside one local month. A zero increase across a
month boundary counts as divided, because a register that did not move is exactly 0 in every month.
- **A-03 Divided intervals** are classified at most `Month`: they are never coarser for month and year
buckets. Runs are rejoined by source interval identity, not by adjacency.
- **A-04 Capping at now.**
- Stored runs are uncapped and carry `LastIntervalStart`. A reader caps a run at now, but when now falls
inside the run's final interval, the run ends at `LastIntervalStart`. That final interval's row is
recorded after now (D-04).
- A to-date bucket counts as fully covered when its coverage reaches within one interval of the run's class
of its end. The consumption since the last reading is not yet known, and the bucket is not reported
Partial for that reason.
- One module owns capping: `CoverageRuns.CapAt`.
- **A-05 Recorded after now (Phase 2).** Each rollup day stores the latest interval end among its rows. A day
whose rows end after now is reported as recorded after now, not as an actual.
- **A-06 Auto bucket** is chosen from the period's nominal range (ytd → month, mtd → day), so one URL renders
the same way all year. The point limit is checked on the elapsed range.
- **A-07 Roles.**
- Analysis reads roles only through `MeterRoleRules.Effective`: case-insensitive, and only for modes that
may hold them.
- Virtual meters never hold a role.
- A role is unique among meters that are not retired. A retired meter keeps its role for its history, and
each meter counts only within its own service period (D-24).
- **A-08 Virtual result kinds** are consumption, generation, net or indicator, nothing else. Save writes the
effective (inferred) kind, unit and cost rule, so readers never re-infer them.
- **A-09 One classifier, one unit table.**
- The resolution classifier lives once, in `Coverage`.
- `Units` (Quantities) is the only unit normalizer, and every module uses its comparer.
- **A-10 Comparison mapping details (D-06).**
- A range starting on a day the target month lacks (30 March → February) starts at the end of that month,
so a range starting 30 March matches from 1 March.
- A "now" in the second pass of an autumn fold maps to the end of the fold when the target date has no fold,
which keeps the mapping monotonic.
- Comparison buckets are paired with the current buckets by index (`ComparisonResolver.PairBuckets`), never
planned separately.
- **A-11 A separately connected heat pump** (its own supply, not below the main meter) is modelled as its own
energy type with its own grid_import meter. Containment children with their own price remain D-35.
- **A-12 Joint coverage.** The reader derives each virtual source's per-day coverage and bucket states from
`CoverageEvaluator`, and feeds them to `VirtualEvaluator`. The evaluator does not re-derive coverage rules.
- **A-13 Default comparison.** D-06 lists the comparisons without fixing a default. Every page compares with the
previous year when `compare` is absent (`prev-year`): the Overview's month to date with the same elapsed days a
year earlier, a history page's last 12 months with the 12 months a year before. Compared with the period just
before, a seasonal utility would show the season as a trend. Like every default it applies only to an absent key
and is never written into a URL; `compare=none` and `compare=prev-period` remain one click away.
- **A-14 Capping inside an earlier interval (A-04).** When now falls inside an interval that is not the run's last
(two readings stamped ahead, a reading stamped weeks ahead whose month shares are several intervals, a sheet row that
carries the current month's register into a later month), the stored run does not say where that interval starts.
The run then ends at the earliest instant it can start: the later of the local month start of now and now minus one
interval of the run's class; a coarse run ends where it starts. Such a run is always divided at months, because an
undivided interval across a month edge is its own run. So coverage never claims time whose row is recorded after
now: a label run gives up exactly the current month, finer data at most one interval. The shares of such an interval
that closed before now stay actuals, as A-05 reads interval ends per share.
- **A-15 Virtual source costs (D-39).**
- `sourceCosts` adds, for each physical source, what that source's own scope costs: a consumption source at its unit
price (or its bill line), an export source as its feed-in credit, a generation or runtime source nothing. A sum
whose sources price nothing is not costed, and the reason is named (generation, runtime).
- The sources come from the formula's weights, through nested pure sums, each once: `m1 + m1 - m1 + m2` is m1 and m2.
- A generation sum defaults to `none`, because generation is never billed (D-34). The seed and the legacy derivation
write the default, so Summe Solar is stored with `none`. A stored `sourceCosts` on a generation sum costs nothing.
- A sum over a nested calculation that is not a pure sum is not a sum of metered costs. Its default is `none`. A
stored `sourceCosts` stays valid for the quantity but is taken as `none` on read (not costed: "a source calculation
is not a plain sum"), and is reported as `CostRuleNeedsPureSum` for the editor to refuse on save
(`VirtualValidation.CostRuleProblem`, `IsSavable`).
- **A-16 Intervals longer than a month (D-36).** Pricing month by month left a meter whose reading intervals span
several months (a tank dipped every few months, a quarterly delta, burner hours read quarterly) without a cost at
every bucket size. When a bucket of several months holds an unresolved month, the cost engine also reads the bucket
whole. If every month the bucket has data in has the same price (the same tariff outcome and converted unit price,
D-37), the bucket costs its quantity at that price. A price change inside it leaves it unavailable, with an attention
item naming the meter and the months. Month buckets stay unknown; year buckets and period totals are priced, and the
bucket size still never changes a total. The legacy adapters no longer turn such an unknown cost into a priced 0:
`ConsumableSummary.CostKnown`, `MeterPeriodView.YearToDateCostKnown`, and `costAvailability` on `/api/v1/cost`
(additive, D-45). The dashboard summary keeps D-45's numeric legacy windows.
- **A-17 Months without a grid meter (D-34 with D-24).** The billing basis is chosen per energy type for all time. In
a month where no billed grid_import meter is in service on every day (before its install date, after it retired
without a successor) while a use meter in service measured something, the grid meter's known zero would bill that use
as free. Such months are unavailable instead, with an attention item naming the grid meter and the months. Switching
the basis month by month is deferred: the category composition (D-42) would need the same per-month basis to stay
reconciled with the bill.
- **A-18 Meter fees without a line (D-40).** A meter-scoped standing charge of a physical meter that no line of the
figure prices (a PV or house meter behind the billed grid meter) accrues as its own standing-charge row on that meter,
over its service period: in the type's bill, the portfolio and the meter's own scope. In the composition it is a row
like the type's: it joins the one disjoint category that holds its meter, and is a slice of its own otherwise.
- **A-19 Kaskade (D-35 with D-22).** A consumer with its own meter-scoped unit price, linked directly below a billed
grid_import meter with no house meter in between, is billed at its own price and taken out of the grid meters that
link to it. D-22 still reads that link as a supply edge, so the measures do not change. A priced meter nothing links
is still reported as an unused meter price.
- **A-20 Withheld days are not complete (A-05, D-14).** A-05 withholds a whole rollup day or month once a row in it
closes after now, and that can take rows recorded before now with it (a current-month label beside live readings
takes the day's live share). Coverage cannot see this, so such a bucket, and a total holding it, reads partial with
the issue "recorded after now", never available: an empty day is not a true zero. The rows stay in the "recorded
after now" block.
## 12. Amendments after the acceptance review
These refine decisions where the acceptance review found a gap. No golden bill or reconciliation figure changes.
- **A-21 Not-costed meters on `/api/v1/cost` (A-16, D-45).** A meter without a cost rule (generation, runtime, an
indicator, a calculation that cannot be evaluated) returns `costStatus: NotPriced` and, as `costAvailability`, the
status of its quantity (`Invalid` for a loop or a division by zero) — never "Priced, Available" beside the numeric 0
D-45 keeps. Two additive fields say why: `costRule` (`MeterCostRule`) and `notCosted` (`MeterNotCostedReason`). A costed
meter's month whose quantity is invalid or pending never reports an available cost either. Release note: physical
generation and runtime meters changed from `Priced` to `NotPriced`.
- **A-22 A category whose members price nothing (D-39, D-42).** The cost math is unchanged: a calculated view, a
generation or runtime meter adds nothing to a category. The cost reader now reports it
(`CostAttentionKind.CategoryPricesNothing`, with the category and the members), for a category scope and for every
category of a portfolio read. The Analysis page shows the explanation instead of "No data yet", the Overview lists the
category in its composition as "No cost members not billed", and the meter editor says under a virtual meter's cost
categories that membership adds no cost.
- **A-23 One cost-change rule on every page (D-07).** The Overview's rule (`OverviewComparison.Between`: the totals when
both periods are complete, else the paired buckets complete on both sides, else not comparable) is used by the energy
type page, the Analysis page (cards and the table's total row) and the meter page (which now also states it from the
totals when both are complete), with the same "over the part both periods cover" caption. The Analysis page's
one-meter quantity view reads the meter's comparison cost for its cost card.
- **A-24 A measure's resolution (D-51).** A per-type measure carries the coarsest resolution of the meters it counts
(a virtual member's evaluated resolution), so a type or Solar view over monthly data never drills a month into days;
the Overview's own fallback is gone. Solar bounds drilling by every series it charts. Pages offer a click, a drill
column and a drill hint only where some bucket leads somewhere.
- **A-25 A virtual meter's bucket with nothing finer (D-51).** A virtual meter has no records, so where a physical meter
opens its Normalized data, a virtual meter's bucket opens the meter's own analysis over that bucket; its source
contributions link on to each source's records for it. The bucket that already is the whole view leads nowhere.
- **A-26 Nothing booked (D-19, D-41).** A cost bucket with no line, no charge, no manual cost and nothing missing stays
unknown in the engine (SeededBillTests) and now reads "No data" everywhere: never "Priced" beside "—", never complete,
and `Missing` (not `Available`) in the CSV export.
- **A-27 A tariff's value (D-38, D-52).** The tariff editor starts a new tariff without a value and refuses to save
without one, so the missing-price deep link cannot turn a gap into a free period by one click. A typed 0 for a unit
price, base price or feed-in is saved as the valid zero D-38 defines, with the note "A price of 0 makes this period
free of charge".
- **A-28 Chart marks (D-49, brief §4.3).** Bars are outlined in their colour, so a true zero is a line on the baseline
and a gap draws nothing; a bucket without a value is marked "" (its own note), a qualified value keeps "*". A chart
with nothing to draw says why: data only coarser than the buckets (naming the resolution, with the interval that
shows it) or a cost without a price — "no data" only when there is none. A unit mismatch in an attention item says
what does not fit: the currency, a base price's period, or the meter's unit.
- **A-29 Record tabs and "now" (D-04, D-50).** The record tabs list the whole named range, so their toolbar shows those
dates (the end of the month for month to date), and every row dated after now carries an "After now" mark.
- **A-30 Preview period (D-31).** The calculation preview opens on the period of the page the editor was opened from
and offers every preset, custom dates and all available history (the sources' own dates, however old), through the
shared toolbar; a range too long for months previews in years.
## 13. Amendments recorded with the final documentation
The page agents and the integration made these decisions while building. They are implemented and tested, but were
not written down above. They are recorded here so the note stays the complete list. None of them changes a golden
figure.
- **A-31 Page-specific URL keys (D-46, D-47).**
- The energy History tab uses `view=total|meters`.
- The Overview uses `chart=` for its chart selection.
- The record tabs reuse the page's `period`/`from`/`to`, and `all` there means no date bound.
- None of these keys belongs to `AnalysisUrlKeys`. They are written with replace and never reload the analysis.
- **A-32 Overview projection (D-09).**
- It is offered only for month or year to date, only from a complete figure, and only after 7 or 30 days.
- Metered use (net of feed-in credit) and standing charges are extended at their observed rate per elapsed day. D-09
said standing charges are added exactly per day; that is not done.
- Manual costs are kept as booked, not projected.
- **A-33 Series on one chart (D-15, brief §7.4).**
- The Analysis page draws comparison overlays for at most three series. With more, the comparison stays in the
table, with a note.
- The energy History "individual meters" view charts at most six meters and links to the Analysis page for the rest.
- A category is analysed by quantity only when all its meters share one kind and unit. The meters are then shown
side by side and never added, because members can overlap. Otherwise the page explains why and offers the
alternatives.
- **A-34 Context and counts on the meter page (brief §7.2, D-50).**
- Events and tariff changes in the range are listed under the chart, not drawn on it: the shared chart has no
annotation support.
- Record tab labels carry no counts, because that meant counting every row on each load. Each table states its own
count, capped at 10,000.
- **A-35 Solar figures (D-54).**
- Self-consumption is total load grid import, else generation grid export.
- Feed-in is the grid export meter, else generation self-consumption. The calculated form is labelled, and
batteries are not modelled.
- Site use is the total load meter, else self-consumption + grid import.
- Savings are self-consumption × the grid unit price, month by month through `CostCalculator`. The feed-in credit is
the cost reader's own line.
- Mixed units make a figure invalid, naming the units.
- **A-36 Tariff deep link (D-52, A-27).**
- The prefilled dialog opens once. `action`, `component` and `from` are then dropped from the address.
- `scope`/`id` stay and filter the list to the tariffs that can price that meter or type, with "Show all tariffs".
- The suggested unit follows the scope until the user types one.
- A stored tariff whose unit no longer fits shows an issue icon and cannot be saved again until the unit is fixed.
- Both admin and meter tariff lists show the effective end: the day before the next tariff of the same kind starts
(`TariffValidity`).
- **A-37 Deleting a meter or an energy type (D-32, D-33).** `tariff.scope_id` has no foreign key, so `EntityDeletion`
deletes the tariffs scoped to the meter or type together with it, in one transaction. For a meter, its readings and
consumption go too. Export/import no longer restores a tariff whose meter or type is gone onto whichever id replaces
it.
- **A-38 Where the toolbar sits (brief §7.2, §7.3).**
- The energy page has one toolbar above its four tabs:
- Interval and comparison show on Overview and History; metric and export only on History.
- A bucket refused for too many points is read again on auto, so every tab still shows figures while the toolbar
offers the coarser size.
- The meter page keeps its toolbar inside the Analysis tab, so its tab bar sits directly under the header.
- **A-39 Shell after the acceptance review.**
- Buttons, icon buttons, links, chips, tabs and nav links get a 2 px focus ring in the theme's text colour.
- `MeterVaultMudLocalizer` gives MudBlazor's own labels German text. The English values are MudBlazor's own.
- Meter → Sources links each source's connector to its editor, and the source dialog has "Edit connector". Both
keep the existing detour and its draft.
- The theme defaults to dark when no `mv-theme` cookie is set.
- The Calculation tab words a calculation problem as the attention list does, one wording per `VirtualProblemKind`.
## 14. Amendments from the performance measurement
The measurement of brief §9.10 / D-56 on a synthetic 1,000-meter × 10-year instance named three costs that grow with
history rather than with what a page asks for. They are fixed here. No golden bill, reconciliation figure or displayed
value changes; only what a request reads does.
- **A-40 What a request reads (D-15, D-18, D-56).**
- **The freshness mark is stored, not searched.** `meter_rollup_state` gains `last_reading_at`: the stamp of the
meter's latest raw reading, written by the recompute that every write path already runs. D-18 is unchanged — the
last reading or event time is still the mark — but a request no longer queries `reading` to find it, so an
import-only meter's mark stays exactly as old as its data without planning across a decade of raw chunks. The
migration backfills the column in one pass, so nothing waits for a rebuild.
- **Only a live source's rhythm is sampled, and only from the recent past.** The median of D-18 is consulted for a
meter with a live source, so the reading times are read for those meters alone, bounded by
`FreshnessRules.RecentWindow` (90 days) — the bound is what lets PostgreSQL exclude the older chunks at plan time.
A live meter that delivered nothing inside the window has no rhythm there; those few meters are read again over
their whole history, so a long-silent source is still called stale by its own rhythm. An instance without any live
source reads `reading` not at all.
- **The window-sum statement carries its overall bounds.** Its windows arrive through an `unnest` join, so their
bounds are columns and exclude no chunk. The minimum start and maximum end of the window set are repeated as
constants in the `WHERE` clause. No row outside them can match any window, so no tally changes; with enough
windows it is the difference between an indexed probe and a parallel scan of the whole hypertable with a sort
spilling to disk.
- **One Overview load holds one catalog.** The page's quantities, its bill and its comparison's bill are three
figures of one period. `DashboardService.GetOverviewAsync` loads the meters, tanks, links and rollup states once
and passes that snapshot to all three (`CostReader.ReadAsync(db, catalog, …)`), so every figure answers from the
same snapshot and the load is not repeated. The cards, the change table and the composition are derived from those
results and are never priced again. `OverviewReadBudgetTests` asserts the statement count of one load.
## 15. Amendments from the first reports of a live instance
Three findings from one real instance: imported history only, a Heizöl type whose tank is dipped with a stick
a few times a year and whose burner hours are noted every few months. Two of them are about what a figure says
when the measurement is coarser than the question, one is about the order and the completeness of the lists
beside it. No figure, golden reconciliation number or seeded bill total changes.
- **A-41 A meter read a few times a year (D-05, D-13, D-14, D-07, A-02, A-03, A-06, A-24).** An instance with imported
history only, a heating-oil tank dipped with a stick once a year and a burner whose hours are noted every few months,
showed that three separate rules break down when a meter's *measurement* is coarser than the question asked of it.
The pages it reported are the energy type page, the Overview, the Analysis page and Tanks & consumables; all four read
the two readers, so all four are fixed by the three changes below.
- **A bucket waiting for a measurement is unresolved, not empty.** A dipstick interval books nothing until the next
dipstick closes it, so "last 12 months" opening eleven days after the last one is covered by no run at all. The
coverage evaluator called that *missing* — "No data for this period" — beside a coverage panel listing four years of
it, and beside a burner that read "Only coarser data" for the same period. It is the same situation A-04 already
names for a bucket that ends at now: what has accrued since the last reading is not yet known, and that is no
shortfall. So a bucket that no run covers, that no *gap* run overlaps (a known hole keeps its own reason) and that
holds no opening balance is `Unresolved` with `CoarseResolution` when the meter's last coverage before it cannot
place a bucket of that size anyway, and lies within one interval of its own class (`LimitOf`, unbounded for data
coarser than a month). It carries that resolution, so the card, the coverage panel, the chart's "only coarser data"
and the table agree; it carries no number, because there is none. A meter that books its own buckets as it goes —
an hourly source gone silent, a monthly sheet asked about a later month — is unchanged: a bucket it does not cover
really has no data. `CoverageEvaluator.AwaitsMeasurement`, pinned by `CoverageEvaluatorTests`.
- **Auto coarsens for the data only as far as the data gains by it.** `ResolutionClass.Coarse` means no more than
"longer than a local month", and `MinimumSizeFor` maps it to years. Auto therefore answered a 12-month request with
one bar per year — which resolved exactly nothing, because a dipstick taken every autumn straddles a New Year as
surely as it straddles every month start, so the yearly buckets were unresolved too. The floor now comes from
`ResolutionClassifier.PlanningResolution(runs, zone)`: one decision for the whole chart (a chart has one bucket
size, D-05), the coarsest class among the plotted runs, with divided runs counted as monthly (A-03) and with the
class capped at `Month` as soon as any coarse run in the range crosses a local year edge. A meter read on the
quarter, whose intervals all lie inside one year, still charts in years — years do hold each of those whole
(`CostReviewFixTests`). The 400-point limit and explicit sizes are untouched, and `MinimumSizeFor` keeps its
meaning for drill-downs (D-51) and for the coarser interval the empty chart offers (A-28).
- **A comparison line names the figure it is about.** Matched coverage (D-07) belongs to one series: on one page the
tank's use shared no covered day with the year before while the burner beside it compared over October to May. The
page stated the *use* measure's verdict — "Not comparable: the periods share no covered days" — directly above a
table that showed the burner's matched comparison, and beside a runtime card showing its change. `ComparisonSummary`
takes a `Subject`, and the energy type's Overview and History, Solar and the Overview's cost line pass the figure
the line speaks of whenever they show more than one. Both statements are then true. The energy type's coverage
panel also lists a measure's own availability whenever it differs from the scope's, which is what made "no data"
read as a contradiction in the first place.
- **A-42 Every dated list reads newest first.**
- A table is read from the top, so its first row is the period the reader is in. `AnalysisTableModel.Build` turns the
plan round once, after the rows are built and paired with the comparison by index, and puts the **total row above**
them — it sums what follows it. Every page that renders `AnalysisTable` inherits this: the meter's Analysis tab, the
energy type's History tab, `/trends`, Solar, Tanks & consumables and the Overview's history table. The plan itself
is never reordered, so nothing that indexes it (drill-downs, comparison pairs, chart series) has to know.
- The same rule applies to the other dated lists that were still ascending: a virtual meter's calculation preview
(and its capped warnings), the tariffs of a meter's Tariffs tab and of `/admin/tariffs` (newest validity first
inside each component, so the price in force is at the top), and the tariff changes among the meter page's
contextual markers. The markers are now **one** list: events and price changes interleave by local day
(`MeterMarkerList`), because two lists one after the other made the dates run down, jump back up and run down again.
- Already newest first, and left alone: the record tabs (readings, normalized rows, events — D-50 keysets), the
deliveries of a tank, and the import batches.
- **The chart stays chronological** — it is read left to right — and so does the **CSV export** (D-55): a file is
sorted, charted and differenced by whatever opens it, and every one of those expects time to run forwards.
- Lists that are not dated keep their own order: meters by energy type and name, the flow table by node name, a
virtual meter's source contributions by the formula, connectors and sources by name and priority.
- **A-43 A meter with no comparable change is listed, not dropped.**
- "Largest changes by meter" ranked the meters whose change could be measured over the days both periods cover (D-07)
and showed nothing at all about the others. On the reported Heizöl page that left only the burner: the tank, read by
dipstick a few times a year, has no matched coverage against last year and so vanished although it has data.
- `MeterChanges.Of` now returns three things: the ranked changes (unchanged rule), **every other meter that has data
in either period**, and how many the caps left over. A row of the second list carries the meter's two totals as the
reader read them, so the page words each of them with the same status text every other figure gets (`FigureText`,
`DisplayNames`): "No data", "Only coarser data", "Being prepared". Only one case needs more than that — both totals
known yet no shared day — and it is said with the page's existing sentence, `Comparison_NotComparable`.
- A meter with nothing on either side stays out: repeating "no data" twice is not information.
- The two lists are capped separately, and the unranked one is ordered with the meters that have no change at all in
front, so a long ranking can never crowd out the very rows this fix is about. What neither list holds is counted and
said out loud ("Showing 4 of 10 meters"), never silently dropped.
- The caption "A change is measured over the dates both periods cover" now stands above the ranked rows only.
- **Checked and left as they are:** the Overview's "What changed" (`DashboardService.LineRows`/`CategoryRows`) already
ranks rows without a comparable change last instead of dropping them; it drops only rows with no *money* on either
side, and those are exactly the ones the composition panel lists with their price coverage and the attention list
offers a tariff for (D-42, D-53). The attention list caps with a "show all", the meter list and the flow table drop
nothing.
+439
View File
@@ -0,0 +1,439 @@
# Dashboard, navigation and historical analysis: implementation report
Final report for [DASHBOARD_ANALYSIS_CHANGE_BRIEF.md](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md) §12. It states which of
the brief's findings were fixed and how, how physical and virtual results now agree, which cost and aggregation
decisions were applied to existing data, what was tested, and what is still missing.
- **Base revision:** `c0f52db` (the revision the brief reviewed). **Release:** 0.4.0.
- **Decisions:** D-01 D-58 and amendments A-01 A-39 in
[ANALYSIS_IMPLEMENTATION_NOTE.md](ANALYSIS_IMPLEMENTATION_NOTE.md). Nothing here decides anything new; where a
behaviour is deliberate, the decision id says so.
- **User-facing changes and the upgrade path:** [RELEASE_NOTES.md](RELEASE_NOTES.md). This report does not repeat
them.
- **Build state at the end:** `dotnet build` 0 errors; Core.Tests 1,733 of 1,733 and Integration.Tests 751 of 751
passing (plus the two opt-in performance facts, skipped); the golden spreadsheet reconciliation and the seeded
bill goldens unmoved by the last fix round.
## 1. Findings A01 A14
One row per finding of the brief's §2. "Pinned by" names the test that would fail if the behaviour returned; a
class name without a method means the whole suite covers that area.
| Finding | What was wrong | What it is now | Where the code lives | Pinned by |
|---|---|---|---|---|
| **A01** / P0 — virtual meters excluded from meter analysis | `MeterPeriodService.GetAsync` returned `null` for `MeterMode.Virtual`; the meter page showed a notice and sent the user to the flow page. A test pinned the refusal. | A virtual meter is a full analysis subject: the reader expands its dependencies and evaluates it per bucket, returning period total, history, comparison, status, provenance, source contributions and a cost rule — the same shape a physical meter returns. `MeterPeriodService` and its test are deleted. | `src/Core/Analysis/Virtual/VirtualEvaluator.cs`, `src/Infrastructure/Analysis/AnalysisReader.cs`, `src/App/MeterDetails/MeterAnalysisLoader.cs`, `src/App/Components/Pages/MeterPage/MeterAnalysisTab.razor` | `AnalysisReaderTests.A_two_source_generation_sum_reads_like_a_physical_meter`; `MeterAnalysisLoaderTests.A_virtual_sum_of_two_generation_meters_reads_250_and_200_by_month_and_450_in_total` |
| **A02** / P0 — "virtual" meant different things per layer | `FlowService` summed every incoming link, `VirtualNormalizer` ran expressions (in tests only), and the editor offered upstream selection with no formula editor. A subtraction could disagree with the flow diagram. | One canonical definition in `Meter.Meta` (`expression`, `referencedMeterIds`, `resultKind`, `resultUnit`, `costRule`), one evaluator, one validator. Links are topology only and never change a calculation (D-25). The Sankey draws a pure sum from its calculation inputs, over the same reader result as the page. `VirtualNormalizer` and `ExpressionEvaluator` are deleted. | `src/Core/Analysis/Virtual/` (`VirtualDefinition`, `VirtualDefinitionJson`, `FormulaParser`, `VirtualValidator`, `DependencyGraph`), `src/App/MeterEditing/`, `src/App/Components/Shared/MeterEditing/`, `src/Infrastructure/Dashboard/FlowService.cs` | `FlowServiceTests.Virtual_sum_meter_is_its_formula`; `VirtualValidatorTests`; `VirtualDefinitionJsonTests`; `MeterEditorLogicTests` |
| **A03** / P0 — the virtual read/materialize pipeline was incomplete | Normalization skipped virtual meters and costing read stored consumption, so a virtual meter had neither stored nor computed values; assigning a cost category was not a fix. | Virtual meters are evaluated on read and store nothing (D-27; the migration purges the rows they used to have). Costing resolves them through the same evaluator and prices them by their named rule (D-39, A-15), independently of category membership. | `src/Core/Analysis/Virtual/VirtualEvaluator.cs`, `src/Infrastructure/Analysis/AnalysisRun.cs`, `src/Infrastructure/Costing/BillRun.cs`, `src/Infrastructure/Normalization/NormalizationService.cs` | `VirtualEvaluatorTests`; `CostReaderTests.Virtual_meters_are_costed_by_their_named_rule`; `SchemaTests.The_analysis_tables_are_plain_tables_that_go_with_their_meter` |
| **A04** / P0 — costs could count overlapping meters twice | The Overview summed every meter, the energy page summed every meter of the type beside a topology-root throughput, and categories deduplicated ids only inside one category. | One pure classification decides what counts: supply edges, containment, the measures Use / GridImport / Export / Generation / Runtime, and a per-meter `totals` override. The bill prices the grid import meters where a type has them, otherwise its use meters; a separately priced subsection is taken out of its parent. A category prices the non-overlapping cover of its members; an overlapping category is a view and stays out of the composition. | `src/Core/Analysis/Totals/TotalsPolicy.cs`, `TotalsGraph.cs`, `CategoryCover.cs`, `src/Core/Analysis/Costing/CostCalculator.cs`, `src/Infrastructure/Costing/CostReader.cs` | `TotalsPolicyTests` (incl. `The_seeded_topology_classifies_every_meter_as_the_note_pins`); `CategoryCoverTests`; `EnergyTypePageTests.An_overlapping_topology_totals_the_parent_with_the_child_as_a_breakdown`; `SeededBillTests.Standing_charges_and_categories_compose_the_portfolio_bill` |
| **A05** / P0 — zero, missing, unpriced and invalid were conflated | History filled absent months with 0 and hid all-zero history; a missing tariff resolved to 0; virtual evaluation substituted 0 for absent sources and for non-finite results. | Every bucket carries a `BucketStatus` (`Available`, `Partial`, `Missing`, `Unresolved`, `Invalid`, `Pending`) derived from coverage runs and their resolution class, never from the amount; provenance, value issue and freshness are separate dimensions. A missing price is `NotPriced` or `PriceGap`, an explicit 0 tariff is a valid zero, a non-finite virtual result is `Invalid` with its reason. The chart draws a true zero as an outlined bar on the baseline and an unknown bucket as a gap marked "". | `src/Core/Analysis/Coverage/` (`CoverageBuilder`, `CoverageEvaluator`, `CoverageRuns`, `ResolutionClassifier`), `src/Core/Analysis/Costing/CostAmount.cs`, `src/App/Analysis/FigureText.cs`, `src/App/Analysis/AnalysisChartModel.cs` | `CoverageEvaluatorTests`; `CostAmountTests`; `AnalysisChartModelTests.A_true_zero_bar_is_drawn_on_the_baseline_and_a_bucket_without_value_is_marked_as_such`; `AnalysisReaderTests.An_all_zero_year_is_visible_and_a_net_balance_stays_signed` |
| **A06** / P1 — inconsistent time ranges | No selector on the Overview, a fixed 12-month strip on the meter page, 24 months with Apply on Trends, 60 months elsewhere, and "all time" meaning 1,200 months. | One URL-borne period contract with one toolbar on every analysis page: `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom` with `from`/`to`, `bucket` and `compare`. The Overview defaults to `mtd`, history pages to `12m` (12 calendar buckets ending with the current partial month); `all` spans the availability metadata. Defaults are never written into the address. | `src/App/Analysis/AnalysisQuery.cs`, `AnalysisDefaults.cs`, `AnalysisPeriods.cs`, `src/Core/Analysis/Time/PeriodResolver.cs`, `src/App/Components/Shared/Analysis/PeriodToolbar.razor` | `AnalysisQueryTests`; `PeriodResolverTests.The_last_12_months_are_twelve_calendar_months_ending_with_the_current_partial_one`; `PeriodResolverTests.All_history_spans_the_available_data_and_stops_at_its_last_day_when_that_is_in_the_past` |
| **A07** / P0 — comparison and cut-off semantics differed | The summary asked for full calendar years, the breakdown ended at `asOf.AddMonths(1)`, the difference truncated to whole months, several pages derived "today" from UTC, and the meter quantity SQL had no upper bound. | A period resolves once per request, in the instance zone, into a local inclusive display range and a half-open UTC range `[from, to)` used by quantities, costs, comparisons and the export alike. "Now" comes from the registered `TimeProvider`, read once per page. Comparisons shift in calendar units and are measured only over the coverage both periods share; rows closing after now are excluded from actuals and reported apart. | `src/Core/Analysis/Time/PeriodResolver.cs`, `ComparisonResolver.cs`, `src/Core/Analysis/Coverage/MatchedCoverage.cs`, `src/App/InstanceClock.cs`, `src/App/Analysis/CostChanges.cs` | `PeriodResolverTests`; `ComparisonResolverTests`; `MatchedCoverageTests`; `AnalysisReaderTests.A_comparison_is_confident_only_over_the_coverage_both_periods_share`; `AnalysisReaderTests.Rows_that_close_after_now_are_left_out_and_reported`; `CostConsistencyTests.A_partial_period_states_the_same_matched_cost_change_on_every_page` |
| **A08** / P1 — too little history to investigate a change | Trends was one monthly total-cost chart, energy pages had no series at all, and the record tabs showed the latest 200 rows. | `/trends` is a scope × metric exploration page (portfolio, type, category, one meter, or up to six meters); energy types have a History tab with a total or per-meter view; the meter page has a full Analysis tab with chart, table, comparison overlay, projection and coverage. Record tabs page through the whole history, 100 rows at a time, keyset-ordered and filtered by the selected dates. Everything shown exports as CSV. | `src/App/AnalysisPage/`, `src/App/Energy/`, `src/App/Components/Pages/Energy/EnergyHistoryTab.razor`, `src/App/MeterDetails/RecordPager.cs`, `src/Infrastructure/Dashboard/MeterDetailService.cs`, `src/App/Analysis/AnalysisExportEndpoints.cs` | `AnalysisPageSelectionTests`; `AnalysisPageLoaderTests`; `MeterDetailServiceTests.Readings_page_newest_first_by_keyset_and_filter_by_a_half_open_range`; `MeterPageLogicTests.The_pager_walks_keyset_pages_and_back`; `AnalysisExportEndpointTests` |
| **A09** / P1 — Overview and Trends disagreed | The summary counted manual costs, the monthly trend did not; "latest month with data" gave an amount without its month and judged recency from consumption alone. | Both read the one bill. Manual costs are booked once, in full, on their `PeriodStart` day, everywhere. "Latest period with data" returns its month and its basis (meter data, manual costs or both), from the union of billed-meter coverage and manual-cost days, capped at now. One cost-change rule (`OverviewComparison.Between`) serves the Overview, the energy page, the Analysis page and the meter page. | `src/Core/Analysis/Costing/CostCalculator.cs`, `src/Infrastructure/Dashboard/OverviewModels.cs`, `src/Infrastructure/Costing/CostReader.cs`, `src/App/Energy/EnergyAnalysisLoader.cs` | `CostReaderTests.Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong`; `CostReaderTests.The_latest_period_with_data_includes_manual_costs`; `OverviewDataTests.The_seeded_previous_year_is_the_sheet_s_bill_and_every_panel_adds_up_to_it`; `CostConsistencyTests` |
| **A10** / P1 — navigation mixed analysis with specialized pages | Type links opened a page headed "flow", settings were spread across editor, Sources tab and admin, and a nav database error silently removed the energy-type links. | A fixed sidebar (Overview, Analysis, Meters, Energy types, Specialized views, Data import, Configuration) with persisted groups and a Retry item when the types cannot be loaded. Energy pages are titled with the type's own name and have Overview / History / Flow / Meters tabs. Breadcrumbs Overview → type → meter carry the period; link helpers are the only way links are built. | `src/App/Components/Layout/NavMenu.razor`, `src/App/NavGroups.cs`, `src/App/MeterLinks.cs`, `AnalysisLinks.cs`, `TariffLinks.cs`, `src/App/Analysis/AnalysisNavigation.cs`, `src/App/Components/Shared/Analysis/AnalysisBreadcrumbs.razor` | `AppLinkTests`; `AnalysisNavigationTests.Breadcrumbs_carry_the_period_up_and_end_at_the_current_page`; `ShellPreferenceTests`; `DashboardRenderTests` |
| **A11** / P1 — visual semantics varied | The meter history was a hand-built 110 px HTML bar chart using absolute values, and the chart components hard-coded dark mode. | One `AnalysisChart` (ApexCharts) with one axis per unit, nullable points, no smoothing or joining across gaps, a real zero line for signed data, outlined bars and a per-bucket mark, plus an accessible `AnalysisTable` for every chart. Light/dark lives in a scoped `ThemeState` backed by a cookie the app reads at prerender; charts re-key on a theme change. `SeriesChart`, `TrendChart`, `CategoryDonut` and `DeltaChip` are deleted. | `src/App/Components/Shared/Analysis/AnalysisChart.razor`, `AnalysisTable.razor`, `src/App/Analysis/AnalysisChartModel.cs`, `AnalysisChartOptions.cs`, `src/App/Theme/ThemeState.cs` | `AnalysisChartModelTests`; `AnalysisTableModelTests`; `AnalysisComponentRenderTests`; `ShellPreferenceTests.The_theme_defaults_to_dark_and_tokens_round_trip` |
| **A12** / P1 — misleading labels and units | Most cost views called `Format.Euro` while the meter page used the configured currency, and `Meter.Unit` was used for normalized period results. | `NormalizedQuantity(meter, tank, definition)` gives every meter its analysis kind and unit (runtime in `h` or the tank unit at a fixed rate, instant rate integrated, the tank unit, a virtual meter's declared unit); `Units` is the only normalizer, and raw units appear only on the Readings tab. Amounts go through `Format.Money` over `InstanceCurrency`; `Format.Euro` is gone and no `€` is written in code or resources. | `src/Core/Analysis/Quantities/NormalizedQuantity.cs`, `Units.cs`, `TariffUnit.cs`, `src/App/Format.cs`, `src/App/InstanceCurrency.cs` | `NormalizedQuantityTests`; `UnitsTests`; `TariffUnitTests`; `FormatCultureTests.Money_is_in_the_configured_currency_written_the_readers_way` |
| **A13** / P1 — rapid navigation discarded, no retry | An `_loading` early return dropped later requests, panels had no retry, and nav errors removed links. | Every page commits one value through `LoadSequencer.RunAsync` into a `LoadState<T>`: superseded loads are cancelled and never committed, and a failure keeps the previous value visible while reporting itself. `LoadPanel` renders the initial, refreshing and `PanelError`-with-Retry states; the nav keeps its group with a Retry item on error. Subscriptions are disposed with the circuit. | `src/App/Analysis/LoadSequencer.cs`, `src/App/Components/Shared/Analysis/LoadPanel.razor`, `PanelError.razor`, `RefreshIndicator.razor` | `LoadSequencerTests` (incl. `A_delayed_first_load_cannot_overwrite_the_later_one` and `A_failure_keeps_the_previous_value_visible_and_is_reported`) |
| **A14** / P1 — long-history reads scanned `consumption` | Several services aggregated `consumption` directly; the continuous aggregates were Berlin-only, amount-only, never backfilled and read by nothing; flow summed all meters before filtering by type. | Normalization writes per-meter day and month rollups, coverage runs and a rollup state by diff, in the same transaction as consumption. `AnalysisReader` reads the month table for month and year buckets, the day table otherwise, plus at most two partial edge days from `consumption`; limits are checked before any SQL runs. The three continuous aggregates and their jobs are dropped. Measured: 816 statements per request whatever the meter count. | `src/Infrastructure/Normalization/AnalysisDataWriter.cs`, `src/Core/Analysis/Rollups/RollupBuilder.cs`, `src/Infrastructure/Analysis/AnalysisQueries.cs`, `AnalysisReader.cs`, `src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.cs` | `SchemaTests.The_continuous_aggregates_and_their_refresh_jobs_are_gone`; `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums`; `AnalysisReaderTests`; `Performance/ReaderTimingTests` (opt-in) |
Two of the brief's starting points were kept rather than removed. `Core/Costing/TariffResolver` survives as the
legacy resolver behind `CostService`, which is now only the adapter under `/api/v1/consumption|cost`; and
`DashboardService.GetMonthlyTrendAsync` / `GetCategoryBreakdownAsync` / `GetCategoryDifferenceAsync` survive as
legacy entry points that only tests call. Nothing a page renders goes through either.
## 2. How physical and virtual results agree
### 2.1 One read path
Pages, `/api/v1`, the CSV export, Solar, Consumables, Flow and the Overview read quantities through
`AnalysisReader` and money through `CostReader`. There is no second path: a figure that queried `consumption` or
`reading` directly would be the only one able to disagree, and none is left. `AnalysisReader` loads its catalog
once, expands virtual dependencies **in memory**, then reads each table once for all physical meters involved.
A virtual series and a physical series therefore come out of the same rollup rows, the same coverage runs, the
same bucket plan and the same comparison.
### 2.2 The worked example (brief §5.4)
Generation meters A and B, complete monthly data, evaluated as `m(A) + m(B)`:
| Month | A | B | Virtual sum A+B |
|---|---:|---:|---:|
| January | 100 kWh | 150 kWh | 250 kWh |
| February | 80 kWh | 120 kWh | 200 kWh |
| **Period total** | **180 kWh** | **270 kWh** | **450 kWh** |
The sum reads 450 kWh for the two months, `Available`, kind `Generation`, unit kWh, with a year bucket of 450, the
contributions A +1 × 180 kWh and B +1 × 270 kWh, and the two physical series unchanged on their own pages. The
type's Generation measure over the same period is the same 250 / 200 / 450, and the sum is classed `AnalysisOnly`,
so it is never added on top of its sources. A separate `m(A) m(B)` meter reads 50 and 40 kWh, total 90 kWh,
kind `Net`, plotted below a real zero line and exported signed: it does not inherit the old flow service's sum.
Pinned by `VirtualEvaluatorTests.A_plus_B_is_250_and_200_by_month_and_450_in_total`,
`A_minus_B_is_minus_50_and_minus_40_and_stays_signed` and
`AnalysisReaderTests.A_two_source_generation_sum_reads_like_a_physical_meter`; reproduced in a running instance
during the acceptance review (scenarios V1 and V3).
### 2.3 Seeded Summe Solar
The seed now stores the definition directly: `expression = m4 + m5`, `resultKind = generation`,
`resultUnit = kWh`, `costRule = none`, `referencedMeterIds = [4, 5]`, with the links 4→9 and 5→9 kept as topology.
For 2025 it reads 4,750 kWh, which is Zähler Solar 1 (3,123) + Zähler Solar 2 (1,627), the same figure the Strom
type's Generation measure shows month by month. It is not costed, because generation is never billed (D-34, A-15),
and the page says so rather than showing a blank. An installation seeded by an earlier version gets the same
definition from `VirtualDefinitionUpgrade` at startup: the implied sum of the incoming links is stored when they
name sources of one unit and kind; the run is idempotent (meta and `updated_at` of every virtual meter were
byte-identical after a second start); an existing explicit expression always wins; and anything ambiguous, mixed
or looping is flagged "needs configuration" and logged. Pinned by
`NormalizedQuantityTests.Summe_Solar_is_the_generation_it_declares`, `LegacyVirtualDerivationTests`,
`VirtualManagementTests` and `TotalsPolicyTests`.
### 2.4 Joint coverage, missing versus observed zero
The reader derives each source's per-day coverage and bucket states from `CoverageEvaluator` and hands them to
`VirtualEvaluator`; the evaluator never re-derives coverage rules (A-12). From that:
- **A missing source is unknown, not zero.** With B absent in February, February is `Missing` with
`MissingSource` and the dependency path naming B, and the period total is `Partial` 250 — not a confident 80.
- **An observed zero is a value.** With B observed as 0 in February, February is a complete 80 and the two-month
total is a complete 330.
- **Partial and coarser coverage propagate.** A source covering part of a bucket makes it partial with the jointly
covered value; a monthly source cut inside its month is `Unresolved`, not a plausible-looking partial; a source
still being rebuilt makes every bucket `Pending`.
- **A meter outside its install and retire dates contributes a known zero**, while a gap inside its lifetime stays
missing (D-24).
- **Non-finite arithmetic and loops are explained, never numbers.** A division by zero is `Invalid` with the
reason; a cycle is `Invalid` with the loop path, and both ends are named by meter name, never by `#id`.
- **A period total is the formula over the joint coverage.** For a linear formula without a constant that equals
the sum of its buckets; otherwise the series is marked non-additive and the total is the ratio of totals. An
indicator (meter × or ÷ meter) is never additive, never totalled into a measure and never costed.
Pinned by `VirtualEvaluatorTests` and by
`AnalysisReaderTests.A_missing_source_is_unknown_and_an_observed_zero_is_a_value`,
`A_difference_stays_negative_and_nested_meters_resolve_once`, `A_dependency_loop_is_named_and_never_a_number` and
`A_division_by_zero_is_invalid_and_a_ratio_is_not_additive`.
### 2.5 Contributions, and what a virtual meter does not get
Every virtual result carries each source's own series, status and dependency path, which the page renders as
"Source meters" (`SeriesContributions`), with the weight the formula gave it and a link on to that source's
records. Nested sums appear nested. A virtual meter has no Readings and no Normalized data tab, and the page never
offers "add a reading" to fix missing history; it has a Calculation tab instead, showing the formula with meter
names beside the `m<id>` tokens and any problem worded exactly as the attention list words it. Where a physical
meter's bucket drills into its normalized records, a virtual meter's bucket opens its own analysis over that
bucket, from which each source links on (A-25). A virtual meter never holds a role, and it joins a type's totals or
the bill only through an explicit `totals = always` override, which is refused — naming the other meter — when an
ancestor or a dependent already counts.
## 3. Cost and aggregation decisions applied to existing data
The bill is computed by `BillRun``CostCalculator` over a `TariffBook`, from the same reader passes as the
quantities. What that changed for data that already exists:
| Decision | Applied as | Pinned by |
|---|---|---|
| **D-34** Billing set | Per energy type, the grid import meters where it has any, otherwise the use meters. Generation, runtime and virtual views are never billed. | `TotalsPolicyTests.The_seeded_bill_is_the_grid_import_for_electricity_and_household_use_elsewhere`; `SeededBillTests` |
| **D-34** Feed-in | Credited only on meters holding the `grid_export` role, at the FeedIn price. A missing feed-in price is an optional credit, reported only where an export meter exists. | `CostReaderTests.Export_is_credited_at_the_feed_in_price_and_a_missing_one_is_an_optional_credit` |
| **D-35 / A-19** Separately priced subsection | A containment child with its own meter-scoped unit price is billed at that price, and its monthly quantity is deducted from the ancestor that bills it. Quantities do not change. | `CostReaderTests.A_separately_billed_subsection_is_priced_at_its_own_price_out_of_its_parent`; `SeparatelyBilledSubmeterTests` |
| **D-36** Prices per local month | Every bucket is cut into local months and priced at the price valid on the 15th, so a year equals the sum of its months and the bucket size never changes a total. | `CostReaderTests.The_bucket_size_never_changes_a_total_and_every_part_is_priced_in_its_month`; `CostCalculatorPricingTests` |
| **A-16** Intervals longer than a month | A multi-month bucket holding an unresolved month is priced whole when every month it has data in shares one price; a price change inside it leaves it unavailable, with an attention item naming the meter and the months. | `CostReaderTests.A_monthly_import_prices_its_month_although_its_days_are_unresolved`; `CostCalculatorCoverageTests` |
| **D-40 / A-18** Standing charges | Accrued per local day over the scope's service period, **once per scope**, regardless of reading gaps. Type and global charges are their own rows; meter fees stay on their meter, including on a meter no bill line prices. | `CostReaderTests.A_standing_charge_accrues_once_per_scope_and_a_meter_fee_on_its_meter`; `CostCalculatorStandingChargeTests`; `CostConsistencyTests.The_energy_card_counts_the_meter_fees_on_bill_lines_like_every_other_card` |
| **D-41** Manual costs | Booked once, in full, on their `PeriodStart` local day, once that day has come, in every figure that covers it. | `CostReaderTests.Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong`; `CostCalculatorManualCostTests` |
| **D-42 / A-22** Categories | A category prices the non-overlapping cover of its members, plus its manual costs. The disjoint categories, Uncategorized and the standing-charge rows form the composition and reconcile to the bill; an overlapping category is a view. A category whose members price nothing says so instead of reading "No data yet". | `CategoryCoverTests`; `SeededBillTests.Standing_charges_and_categories_compose_the_portfolio_bill`; `CostReviewFixTests` |
| **D-37 / D-38** Price coverage | A tariff applies only when its unit fits the meter's normalized unit and the instance currency; otherwise `UnitMismatch`. No tariff at any date is `NotPriced` (an attention item, never a silent partial), a hole in a priced history is `PriceGap`, and an explicit 0 is a valid zero. | `CostReaderTests.A_missing_tariff_is_not_priced_and_a_zero_tariff_is_a_valid_zero`, `A_gap_in_a_price_history_makes_those_months_unavailable`, `A_price_in_another_unit_is_a_unit_mismatch_and_a_currency_follows_the_options` |
| **D-39 / A-15** Virtual cost rules | `sourceCosts` for pure sums (each physical source once, at what its own scope costs), `ownQuantity` for linear formulas, `none` otherwise and for generation sums. The rule is named next to every virtual cost. | `CostReaderTests.Virtual_meters_are_costed_by_their_named_rule`, `A_virtual_meter_counted_by_an_override_is_billed_by_its_cost_rule` |
| **A-17** Months without a grid meter | A month in which no billed grid meter was in service on every day, while a use meter measured something, is unavailable rather than free, with an attention item naming the meter and the months. | `CostCalculatorBillTests`; `CostReaderTests.A_retired_meter_is_a_known_zero_on_the_bill_and_missing_on_its_own_page` |
| **A-21 / A-26** Honest cost status | A meter with no cost rule reports `NotPriced` with `costRule` and `notCosted`, never a priced zero; a bucket with nothing booked reads "No data" on pages and `Missing` in the export, never "Priced" beside "—". | `ApiContractTests.A_meter_that_is_not_costed_or_cannot_be_evaluated_never_reads_as_a_priced_zero`; `AnalysisExportEndpointTests.A_month_with_nothing_booked_is_exported_as_no_data_not_as_an_available_priced_blank` |
| **D-43** Currency | One `Format.Money` over `MeterVault__Currency`; a tariff in another currency is reported as not fitting, never converted. | `FormatCultureTests`; acceptance scenario C8 (a USD instance shows no € or EUR on any amount) |
### 3.1 The seeded bill against the spreadsheet
`SeededBillTests.The_seeded_yearly_bill_equals_the_sheet_s_Jahreskosten` runs on a frozen clock after 2026-05-31,
with the oil tank unpriced:
| Year | Sheet `Jahreskosten` | 0.4.0 | Note |
|---|---:|---:|---|
| 2022 | 421.52 € | 421.52 € | within ±0.02 € |
| 2023 | — | — | differs by 3.78 €: the sheet multiplies by its rounded displayed price |
| 2024 | — | — | differs by 0.46 €, same cause |
| 2025 | 7,907.64 € | 7,907.65 € | Strom alone is 4,742.64 € = Zähler Netz × price, as the sheet bills it |
| 2026 | 2,940.19 € | 2,940.19 € | was 4,402.19 € in 0.3.0, which priced Haus + Netz + Auto and water at 5 €/m³ |
The 2026 figure is the most visible deliberate change in the release: the old sum of every meter is replaced by the
grid import meter, and the seed gained the water price rise to 7.00 €/m³ from 2026-01-01 (D-44). The two
differences are documented and not tuned away. The golden consumption reconciliation of all four fixture CSVs, and
the Netz Einsparung relation, are unchanged — the latter now computed through `VirtualEvaluator` instead of the
deleted normalizer (D-29).
### 3.2 What an existing installation sees change
The complete list, with old and new values, is the **Changed figures** table in
[RELEASE_NOTES.md](RELEASE_NOTES.md#changed-figures), together with the upgrade steps (the first start rebuilds all
analysis data at normalization revision 3, before the web server listens) and the additive REST API changes. It is
not repeated here.
## 4. What was tested
### 4.1 Suites and counts
| Suite | Count | Needs | Covers |
|---|---:|---|---|
| `tests/Core.Tests` | 1,733 | nothing | parsers, normalizers, swap→12, and `Analysis/`: periods, DST, comparisons, buckets, coverage, rollups, totals, category cover, virtual formulas and evaluation, the cost calculator |
| `tests/Integration.Tests` | 751 | Docker (TimescaleDB) for the database facts | reconciliation against the four golden CSVs, import commit and revert, ingestion and events, rollups, the reader, the cost engine, the seeded bill, API contracts, the export, rendered pages, plus the pure UI-model tests under `Analysis/`, `MeterPage/`, `Overview/`, `Editor/` and `Specialized/` |
| `tests/Integration.Tests/Performance` | 2 | `METERVAULT_PERF=1` | the synthetic 1,000-meter × 10-year dataset, reader and cost timings, statement counts, query plans; skipped by default |
All green at the end of the last fix round, where Integration rose from 727 to 746, and again after the performance
fixes of §5.4, which added five more.
### 4.2 Kinds of tests
- **Frozen-clock period and DST tests.** `PeriodResolverTests`, `ComparisonResolverTests` and `BucketPlannerTests`
cover half an hour into New Year, the 23 h spring day and the month containing it, the repeated autumn hour, 29
February, 31 March compared with all of February, and New York as a zone behind UTC. `AnalysisReaderTests`
asserts the same against real rollups ([24, 24], [23], [25], and [24, 24] in New York).
- **Golden fixture reconciliation.** `Reconciliation/` reconciles electricity, water, oil and costs against the
four `sampledata/` CSVs, including `GapSplittingIsInertOnFixturesTests` (no fixture interval is divided, in UTC
or in Berlin) and `CoverageOfFixturesTests`.
- **Seeded bill goldens.** `SeededBillTests` pins the yearly bill against the sheet, the composition, and the
manual-cost-only instance; `CostReconciliationTests` pins water Dec 2022 = 14 m³ / 70 € and rollup = consumption.
- **Reader and rollup tests.** `AnalysisReaderTests`, `AnalysisDataTests`, `AnalysisCatalogTests`,
`RollupBuilderTests`, `CoverageBuilderTests`, `CoverageEvaluatorTests`, `SchemaTests`.
- **Cost engine.** `CostCalculator*Tests` (bill, pricing, coverage, standing charges, manual costs),
`CostingTariffBookTests`, `CategoryCoverTests`, `CostReaderTests`, `CostConsistencyTests`.
- **Virtual evaluator and definitions.** `VirtualEvaluatorTests`, `VirtualValidatorTests`, `FormulaParserTests`
(an unknown identifier is an error, never 0), `DependencyGraphTests`, `LegacyVirtualDerivationTests`,
`VirtualManagementTests`, `MeterDraftPreviewTests`.
- **URL, link, chart and table models.** `AnalysisQueryTests`, `AppLinkTests`, `AnalysisNavigationTests`,
`LoadSequencerTests`, `AnalysisChartModelTests`, `AnalysisTableModelTests`, `AttentionItemsTests`,
`AnalysisCsvWriterTests`.
- **Rendered HTML** through the framework `HtmlRenderer`, in English and German: `AnalysisComponentRenderTests`,
`DashboardRenderTests`, `OverviewPageTests`, `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
- **API contracts.** `ApiContractTests` pins every existing field and type of `/consumption`, `/cost` and
`/dashboard/summary`, the offset-bounds acceptance, and the not-costed cases.
- **CSV export.** `AnalysisExportEndpointTests` and `AnalysisCsvWriterTests`: one row per bucket and series, local
ISO bounds with offset, invariant numbers, empty cells for unknown values, and a 400 with a reason for anything
the endpoint cannot answer.
- **Localization.** `StringResourceTests` fails the build on a missing or blank translation, a placeholder
mismatch, an orphan or an unreferenced key; `EnumDisplayNameTests` fails on a localized enum value with no
wording.
### 4.3 Acceptance scenarios
Four independent reviewers walked the brief's §11 scenarios against the built application — each in its own
worktree and its own seeded database, driving the browser with Chrome DevTools Protocol scripts and reading tests —
and grouped them as costs (C), time and history (T), virtual meters (V), and UI and navigation (U). Status is as
recorded at review time; every confirmed finding behind a "partial" was fixed afterwards (§4.4).
| Id | Scenario | Status | Evidence |
|---|---|---|---|
| C1 | Overlapping topology: parent 300, child 100 | pass | `EnergyTypePageTests.An_overlapping_topology_totals_the_parent_with_the_child_as_a_breakdown`; live, a linked parent/child pair reads 300 kWh on the type page, the Overview card, `/trends`, the portfolio measure and the CSV, never 400, with the child shown as a breakdown |
| C2 | Missing versus free tariff, and the Add-tariff deep link | partial | `CostReaderTests` (not priced / price gap / valid zero) pass; live, the oil tank reads "Not priced (no tariff)" while its 2,100 L stays visible, and the link opens the tariff dialog prefilled with scope, component and first uncovered month. Finding F1: the dialog could be saved with no value, turning a gap into a free period |
| C3 | Cost stability: manual costs once, standing charges once, bucket-invariant totals | pass | Five cost tests plus `SeededBillTests`; live, seeded 2025 totals 7,907.65 € (metered 5,807.64 + manual 2,100.01) at every bucket size, on the Overview, on `/trends` and in the CSV |
| C4 | Historical-only instance | pass | With `period=mtd` every page says "No data for this period", names the available dates and offers "Go to latest data"; the Overview names "Latest month with data: May 2026 (Meter data and manual costs)"; `prev-year` shows physical and virtual quantities and costs |
| C5 | Manual-cost-only instance | pass | `SeededBillTests` and `OverviewDataTests` agree; live, on an empty database with three manual costs, the Overview, Analysis, the category scope and the latest month all read the same figures without a single meter |
| C6 | Phase 4 exit: scopes reconcile across cards, charts, tables and exports | partial | Overview 7,907.65 €, Strom 4,742.64 €, the energy card, `/trends` (portfolio, type, category), the chart series read through CDP and the CSV all match row by row; quantities stay visible where cost is unavailable. Findings F2 (the energy card understated standing charges), F5 and F6 (chart and CSV wording) |
| C7 | Discoverability: Overview → type or breakdown row → scoped analysis; missing cost → tariff editor | partial | Links carry period, bucket and comparison; category rows land on the matching figure; the Add-tariff action opens once and is then dropped from the address. Findings F1 and F3 (a currency mismatch worded as a unit problem) |
| C8 | Currency | pass | With `MeterVault__Currency=USD` no page shows € or EUR on an amount in either language; the only EUR text is user-entered tariff units |
| T1 | Local-time boundaries, half-open bounds | pass | 232 Core and 90 Integration tests in the reviewer's worktree; frozen-clock coverage of New Year, both DST changes, 29 February, short months and New York; the SQL is `>= from AND < to` throughout `AnalysisQueries` |
| T2 | Future rows and partial periods | pass | Readings pushed at 23:00 today and six days ahead are excluded from a 363 kWh month to date, reported as "values dated after now … are not counted yet" with a link to those records, and comparisons state the matched dates |
| T3 | Zero and negative history, percentage rules | pass | A register that never moved gives 12 `Available` zeros and a 0 total; a net series stays signed; a zero or negative baseline gives "percentage not applicable" with the absolute difference still shown |
| T4 | Monthly legacy resolution | partial | Day buckets over monthly data are 31 `Unresolved` rows with the month total intact — no spike, no zeros — and `auto` never plans finer than the data. Findings F1 (type measures drilled a month into empty days), F3 and F4 |
| T5 | Retained history | documented blocker | Raw retention is not enforced (D-57). `/admin/settings` and the Readings tab say so, asserted in English and German by `AdminPagesRenderTests` |
| T6 | Import, correction and freshness without duplicates | pass | A throwaway test drove swap, manual-reading deletion, tariff edit, definition edit and an import commit through the reader: each shows in the next read, for the physical meter and for the virtual meter above it, with no duplicates and no cache |
| T7 | Compare two historical years; explain a spike; paged records | partial | Virtual 2024 against 2023 reads 4,778 versus 4,583 kWh with an overlay, comparison table and source contributions; the physical path drills a March bar into that month's records. Findings F2 (a virtual bucket was a dead end) and F5 |
| T8 | Phase 3 exit: Overview → type → meter → month and back | partial | The whole path was driven with CDP: breadcrumbs and Back return through each step with the dates intact. Findings F1 and F2 |
| V1 | Two-source virtual generation sum | pass | 250 / 200 / 450 kWh, generation, kWh, contributions, type history agreeing; seeded Summe Solar 2025 = 4,750 kWh = 3,123 + 1,627 |
| V2 | Missing versus zero source | pass | Missing B makes February `Missing` with the source named and the total `Partial` 250; an observed zero gives a complete 80 and a complete 330 total; the API and the CSV agree |
| V3 | Difference and nesting; named cycle error | partial | 50 / 40 below a real zero line, total 90 kWh; a nested sum resolves its leaves once; a cycle is `Invalid` with the loop path everywhere. Finding F1: two views named the meter as `#id` |
| V4 | Invalid arithmetic and non-additive rollup | partial | Division by zero gives "—" with "The formula has no finite result", the ratio total is the ratio of totals with a non-additive note, and the CSV writes an empty value with status `Invalid` — never 0 or infinity. Finding F4 (no reason column in the CSV) was refuted as outside D-55 |
| V5 | Legacy virtual configuration | pass | The seed stores the definition; an expression-less meter is converted at startup and logged; a rerun changes nothing (byte-identical meta); an existing formula wins over links; adding links afterwards changes no calculation |
| V6 | Virtual overlap | pass | Sources 100 + 150 and the virtual 250 stay 250 in the portfolio; with "always" the sum replaces its sources; the seeded bill and the Strom cost do not move |
| V7 | Inspect and configure a combined meter (EN and DE) | pass | Search → the meter's Analysis tab with the period carried and source contributions shown; the editor offers Sum by default, sources by name with unit, kind and dates, and a live preview |
| V8 | Phase 2 exit | pass | Every worked meter — sum, difference, nested, missing, zero, ratio, cycle — has the full Analysis tab without raw readings or categories; new calculated meters default to analysis-only; 145 filtered Integration and 342 Core tests |
| U1 | Rapid filter and navigation changes, URL state | pass | `LoadSequencerTests` plus CDP runs switching type, meter and period within 30 ms: the last request wins every time; toolbar and tab changes replace, drill-downs push |
| U2 | Compatibility actions and legacy tab keys | pass | Every legacy `?tab=`/`?action=` URL opened the intended tab and dialog once and then dropped `action` from the address, including `tab=consumption` → Normalized data and a virtual meter's `tab=sources` → Calculation |
| U3 | Theme, EN/DE, narrow screens, keyboard, tables | partial | The in-circuit theme toggle re-keys every chart; 23 resource tests pass; no page-wide overflow on 33 URLs at 360 px in German. Finding F3: the keyboard focus indicator was near-invisible |
| U4 | Sidebar, persisted groups, search, breadcrumbs | pass | The fixed structure renders as specified, the cookie survives a reload, the current route's group opens, the active item is visible, and search carries the period |
| U5 | Discoverability paths | partial | Quick entry from the header, the list and search; Flow → Manage connections names both ends; calendar-year comparison from the toolbar. Findings F5 (no way to edit the connector in use) and F6 (refuted) |
| U6 | Definition of done in the running app | partial | Checked at 1440 and 390 px, English and German, light and dark: the virtual meter has totals, comparison, table, export, contributions and its Calculation tab; a true zero reads "0 kWh Complete" against "— No data". Findings F1, F2 and F4 |
### 4.4 The review-and-fix round
The four reviewers raised 24 findings. Each was re-checked by an independent verifier before anything was changed:
20 were confirmed, three were refuted as deliberate design (C-F4, the "What changed" table omitting rows with no
figure on either side; V-F4, the CSV column set, which D-55 fixes; U-F6, sidebar entries opening at their own
default, which D-48 and D-02 intend), and one (T-F4, `cost_status` in the export) was left uncertain and is covered
by the fix for C-F6. The fix agent reports all 19 findings it took on as fixed — T-F2 and U-F4 were the same
problem and took one fix — with the build green and the golden figures unmoved. The decisions those fixes needed
are recorded as A-21 A-30 in §12 of the implementation note; A-31 A-39 in §13 record what the page agents and
the integration decided while building.
Tests added in that round: `ApiContractTests` (not-costed and unevaluable meters), `CostReviewFixTests`,
`CostConsistencyTests` (the same cost change on four pages, and matching standing charges) and
`MeterSourcesRenderTests`, plus cases in `AnalysisPageLoaderTests`, `MeterAnalysisLoaderTests`,
`MeterDraftPreviewTests`, `AttentionItemsTests`, `AnalysisChartModelTests`, `AnalysisNavigationTests`,
`AnalysisTableModelTests`, `AnalysisExportEndpointTests`, `TariffEditingTests` and `MeterPageLogicTests`.
Tests rewritten on purpose across the whole rework — none weakened, each rewrite stating the new rule — are listed
at the end of §10 of the implementation note.
## 5. Performance
Measured with the opt-in Performance trait (`METERVAULT_PERF=1`,
`dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests"`, about
10 minutes) against a deterministic synthetic instance: 1,000 meters over 10 years, ≈1.34 M readings, 7 energy
types, 21 links, 109 tariff rows, 64 manual costs and 20 virtual meters nested up to three levels.
Hardware: Ryzen 9 9950X3D (16 cores, 32 threads), 64 GB, Windows 11, Docker Desktop, PostgreSQL 16.6 with
TimescaleDB 2.17.2 (`jit=off`), .NET 10 Release. The figures below are the final run, taken on an otherwise idle
machine (host 713 % CPU) after the fixes of §5.4. The "first run" column is the earlier measurement, taken before
those fixes and while other work was building and testing on the same machine — it is kept to show what the fixes
moved, not as a like-for-like comparison.
### 5.1 Reader and cost requests
Median and p95 of 10 runs after 2 warm-ups; SQL is the number of commands one extra, untimed run sent.
| Request | Median ms | p95 ms | SQL | First run, median |
|---|---:|---:|---:|---:|
| 100 selected meters, 10 years by month, catalog cached — **the brief's 2 s target** | 286 | 472 | 8 | 374 |
| … the same request through the public API | 274 | 317 | 12 | 361 |
| Portfolio, last 12 months by month (measures only, as the Overview) | 70 | 75 | 12 | 268 |
| … with one series per meter (1,000 series) | 170 | 186 | 12 | 379 |
| … with the previous-year comparison | 156 | 177 | 14 | 414 |
| One energy type (402 meters), 10 years by month | 156 | 182 | 12 | 313 |
| … with one series per meter (the type page's table) | 698 | 731 | 12 | 917 |
| One meter: 10 years by week, or 365 days by day | 1216 | 1327 | 1011 | 2936 |
| Portfolio bill, 12 months by month, with categories | 144 | 204 | 16 | 320 |
| Portfolio bill, 10 years by month | 577 | 627 | 15 | 838 |
| Virtual meter nested three levels, 10 years by month | 197 | 216 | 10 | 245 |
| Virtual difference of a 40-meter and an 8-meter sum, 10 years by month | 300 | 344 | 11 | 377 |
| Catalog load: 1,000 meters, tanks, links, states, validation, classification | 6 | 10 | 4 | 7 |
| 1,000-meter selection with the limit raised, 12 months by month | 151 | 171 | 12 | 353 |
| Refused: 1,000 meters against the 6-series limit; portfolio or bill by day over 10 years | 0 | 0 | **0** | 0 |
The brief's target is met with margin: 286 ms median and 472 ms p95 against 2,000 ms. The statement count per
request is constant at 816 whether the request covers 1, 100 or 1,000 meters — there is no per-meter query storm.
Refused requests send no SQL at all, because the 400-point and 6-series limits are checked before any query is
built. Rollup reads use the primary-key index: 4 ms for 125 meters over 10 years of month rollups, 7 ms for 980
meters over 11 months.
### 5.2 Rebuild and per-reading recompute
Rebuilding all 1,000 meters at startup through `NormalizationUpgrade` took 279 s, about 3.6 meters per second.
One meter's full recompute is 5156 ms with monthly readings, 0.50 s for a daily meter (3,670 readings) and 1.16 s
for a meter with a year of hourly data (9,314 readings). That is also the cost of one ingested reading, because
every write path recomputes its meter in full (D-57).
### 5.3 Page loads, old against new
The same synthetic raw data was loaded into a database migrated by the old app (`c0f52db`) and one migrated by the
new app; each rebuilt its own derived data, then prerendered GETs were timed (median of 10), with the statements
per GET taken from a separate logging pass.
| Page | 0.3.0 median ms | new median ms | SQL per GET |
|---|---:|---:|---|
| `/` Overview | 28,175 | 2,384 | 48,244 → 205 |
| `/trends` | 5,101 | 1,290 | 6,004 → 30 |
| `/energy/1` | 3,244 | 1,136 | 2,426 → 42 |
| `/meters` | 222 | 163 | 4 → 8 |
| `/solar` | 92 | 18 | 32 → 20 |
The old Overview ran a meter load, a tariff load and a `time_bucket` query 8,021 times per request. The individual
meter pages and `/consumables` stayed in the same range (tens to low hundreds of milliseconds) and are not listed.
The new-side numbers in that table came from the foundation-era pages on a busy machine, so they are an order of
magnitude rather than a final figure; the finished pages, measured on the idle machine against the same
1,000-meter instance, load in 455 ms (`/`), 383 ms (`/meters`), 469 ms (`/solar`), 641 ms (`/energy/1`),
684 ms (`/trends`), 104 ms (`/meters/1`) and 152 ms (a nested virtual meter).
### 5.4 Hotspots found and fixed
The first measurement found three. All three are fixed (A-40), each proved by `EXPLAIN` or a statement count
before and after, with the tallies unchanged.
| Hotspot | Before | After |
|---|---|---|
| **The freshness query had no time bound.** `AnalysisQueries.RecentReadingsAsync` planned across all 123 raw chunks on every request, for every meter. | 13.8 ms planning + 36.1 ms execution for the portfolio (980 meters); 189 ms on a cold connection | 0.58 + 0.44 ms, over 4 chunks and only the 40 meters with a live source; an instance without a live source never reads `reading` at all |
| **Window sums could not exclude chunks.** `AnalysisQueries.WindowSumsAsync` took its bounds only from the `unnest` join. | a 1,960-window stress case: 42 chunks, parallel scan of 1.39 M rows, ~45 MB sort spill, 121.5 ms | 5 chunks, nested-loop index scans, 8.7 ms |
| **The Overview loaded the catalog three times**, once per read (quantities, bill, comparison bill). | 31 statements per load; 64 statements and 133 ms of SQL on the 1,000-meter instance | 23 statements per load; 39 statements and 81 ms of SQL |
The freshness fix splits what D-18 had conflated: the *mark* (a meter's last reading time) is now stored on
`meter_rollup_state` and maintained by every recompute, with a one-pass backfill in the `FreshnessMark` migration,
so an import-only meter keeps its years-old "last activity" without touching `reading`; the *rhythm* (the sample
intervals that decide stale versus live) is sampled inside a 90-day window, and only for meters that have a live
source. A live source that has been silent longer than that window is re-read unbounded, so it is still reported
stale by its own rhythm rather than by a default.
In-process work dominates what is left, and scales with series × buckets (roughly 1016 µs per series bucket).
That is the budget that matters for very large exports and tables, not for a normal page.
## 6. Remaining limitations and known gaps
**Documented in D-57:**
- **Raw retention is not enforced.** `MeterVault__RawRetentionDays` is displayed but nothing deletes readings,
because every recompute rebuilds a meter from the readings that remain. `/admin/settings` and the Readings tab
say so. This is the brief's "Retained history" acceptance scenario, and it stands as a documented blocker.
- **Monthly imports are never interpolated to days.** A day or week view of monthly data says "only coarser data"
and offers the monthly interval; it never invents measured daily detail.
- **A full recompute runs per ingested reading.** Fine for monthly and daily meters (0.10.6 s); about 1.4 s for a
meter with a year of hourly data, and it grows with history.
- **Bonus, Discount and Tax tariffs are stored but not applied.** The tariff editor says so.
**Further limitations, measured or decided during the work:**
- `AnalysisQueries.OpeningBalancesAsync` has the same unbounded shape the freshness query had (a lateral
`ORDER BY time LIMIT 1` over `consumption`, 6.4 ms in the heaviest measured request). It was left alone: smaller,
and `consumption` has 42 chunks against `reading`'s 123.
- The billing basis (grid meter or household use) is chosen per energy type for all time and cannot switch month by
month, because the category composition would need the same per-month basis to stay reconciled with the bill
(A-17).
- Batteries are not modelled: without a grid-export meter, Solar's feed-in is calculated, and labelled as such.
- The Solar page has no CSV export, because the export has no derived measures.
- An old explicit difference formula over two generation meters stores `meter_rollup_state.kind` as `Consumption`.
Nothing reads that column, and the meter's declared kind governs every figure.
- `Dashboard.razor` and `Trends.razor` subscribe to `LocationChanged` without checking that the location is still
their own page, so leaving them can start a load on a disposing component. No error from it appeared in the
application log.
- The navigation's retry-on-error path for energy types is code-reviewed only: no automated test covers it, and it
could not be forced in a running instance.
**Testing gaps:**
- **No bUnit and no Playwright** (a committed stack decision). Browser behaviour — interactive ApexCharts updates,
browser history, responsive layout, keyboard focus — was checked with Chrome DevTools Protocol scripts against
seeded instances by the page agents and the four acceptance reviewers, in English and German, light and dark, at
1440 / 390 / 360 px. Those scripts and their raw screenshots live outside the repository. Inside it,
server-rendered pages are covered by `HtmlRenderer`-based render tests in both languages.
- For three of the last-round fixes (naming a culprit meter, and the virtual-bucket dead end) the tests were
written after the fix, so they were never watched to fail; the reviewers' reproductions are the "before".
- The "After now" mark on record rows is covered only by a test: the seeded demo holds no rows dated after now, so
it could not be seen in a browser.
- The performance figures are preliminary (§5); re-run them on a quiet machine before quoting them as a property.
**Deviations from the SDD** are listed in D-58 and marked in place in [`SDD.md`](SDD.md). The largest is §14.1:
virtual meters are computed on read and nothing is materialized.
## 7. Screenshots
Taken from a seeded instance. "Before" is `c0f52db`; the rest is 0.4.0.
| Image | What it shows |
|---|---|
| [`screenshots/analysis/before-overview-desktop.png`](screenshots/analysis/before-overview-desktop.png) | The 0.3.0 Overview: no period selector, totals that summed every meter, and a cost card that needed cost categories configured before it said anything |
| [`screenshots/analysis/before-meter-virtual-desktop.png`](screenshots/analysis/before-meter-virtual-desktop.png) | The 0.3.0 meter page for a virtual meter: the "virtual" notice and a link to the flow page instead of any history |
| [`screenshots/analysis/overview-desktop.png`](screenshots/analysis/overview-desktop.png) | The new Overview at desktop width: the period toolbar, the cost split into metered use, standing charges, manual costs and feed-in credit, one card per energy type in its own unit, the history chart, "What changed", the cost composition and the attention items |
| [`screenshots/analysis/overview-mobile.png`](screenshots/analysis/overview-mobile.png) | The same page at phone width: the toolbar and cards wrap, and wide tables scroll in their own region |
| [`screenshots/analysis/meter-physical-desktop.png`](screenshots/analysis/meter-physical-desktop.png) | A physical meter's Analysis tab: tabs directly under the header, the period total with its status, the cost with its rule, the change against the comparison period, the chart with the previous-year overlay, the table, and "Data quality and coverage" |
| [`screenshots/analysis/meter-virtual-desktop.png`](screenshots/analysis/meter-virtual-desktop.png) | Summe Solar in 0.4.0: the same analysis as a physical meter, "Not costed — generation is never billed", the "Source meters" contributions, and a Calculation tab in place of Sources |
| [`screenshots/analysis/meter-virtual-mobile.png`](screenshots/analysis/meter-virtual-mobile.png) | The virtual meter page at phone width, in German |
| [`screenshots/analysis/energy-history-desktop.png`](screenshots/analysis/energy-history-desktop.png) | An energy type's History tab: one toolbar above the four tabs, the total or per-meter view with an explanation of how each meter counts, chart plus table, and Export CSV |
| [`screenshots/analysis/energy-history-mobile.png`](screenshots/analysis/energy-history-mobile.png) | The same tab at phone width |
## 8. Related documents
| Document | What it holds |
|---|---|
| [`DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md) | The work order: findings A01 A14, the target journeys, the acceptance scenarios, the definition of done |
| [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md) | Every decision (D-01 D-58) and amendment (A-01 A-39), the deliberate behaviour changes, the evidence and the limitations |
| [`RELEASE_NOTES.md`](RELEASE_NOTES.md) | What a user sees change in 0.4.0: the upgrade path, the changed figures, the API changes |
| [`SDD.md`](SDD.md) | The design reference, with each deviation marked in place |
| [`../CLAUDE.md`](../CLAUDE.md) | The architecture as it now is |
+372
View File
@@ -0,0 +1,372 @@
# Dashboard, navigation, and historical analysis: change brief for Claude Code
**Status:** proposed implementation brief; no application changes made by this review.
**Reviewed:** 2026-09-19, repository revision `c0f52db`.
**Scope:** overview dashboard, navigation, meter detail, energy-type pages, trends, virtual meters, and the relevant calculation services.
## 1. Objective and review boundaries
Make MeterVault feel like one coherent application in which users can find an option where they expect it, inspect historical data at any useful period, and understand why a particular number is unavailable.
The central requirement is that a virtual meter combining two meters must have the same applicable consumption/generation analysis as a physical meter: period totals, history, comparisons, quality information, and costs where a valid costing rule exists. Lack of raw readings is expected for a virtual meter and must not prevent derived analysis.
This is a source-code and product-flow review, not a browser usability test. Findings below are grounded in the checked-in Razor components, services, models, migrations, and tests. No running instance, production data, screenshots, or query timings were inspected. Layout improvements are implementation proposals; verify them in a running seeded instance before declaring completion.
Read [CLAUDE.md](../CLAUDE.md) and [SDD.md](SDD.md), especially §§5.45.5, 7.47.5, 8, 10, and 14.1. The historical analysis work also fills existing SDD §8.3 requirements. Treat the phases below as incremental work on the existing application, not a restart of M0M7. Proposed product defaults in this brief are explicit design decisions for this work, not claims about existing behavior. Document any necessary deviation from the SDD, including any deliberately changed reconciliation result.
## 2. Confirmed problems and their causes
Paths below are relative to the repository root. Method/component names are provided because line numbers will move during implementation.
| ID / priority | Finding and user impact | Evidence / implementation starting point |
|---|---|---|
| A01 / P0 | Virtual meters are explicitly excluded from meter period analysis. A functioning combined meter is redirected to the flow page instead of getting its own history. | `src/Infrastructure/Dashboard/MeterPeriodService.cs`, `GetAsync`: returns `null` for `MeterMode.Virtual`. `src/App/Components/Pages/MeterDetail.razor`: virtual notice; `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `A_virtual_meter_reports_nothing_rather_than_a_confident_zero` pins this limitation. |
| A02 / P0 | Virtual means different things in different layers. The flow service sums all upstream meters for every virtual meter; the core normalizer supports expressions; the editor exposes upstream selection without a formula editor. A subtraction formula can therefore disagree with flow. | `FlowService.GetFlowAsync`, `Core/Normalization/Normalizers/VirtualNormalizer.cs`, `Infrastructure/Normalization/MeterConfigFactory.ParseVirtual`, `Shared/MeterEditor.razor`. |
| A03 / P0 | The documented virtual read/materialization pipeline is incomplete in the inspected services. Normalization skips virtual meters, and costing reads stored consumption without resolving virtual expressions. Assigning a cost category is not a demonstrated fix for missing virtual analysis. | `Infrastructure/Normalization/NormalizationService.cs`, `RecomputeMeterAsync`; `Costing/CostService.cs`, `GetMeterCostsAsync` and `QueryConsumptionAsync`. Verify all write paths before introducing any materialization. |
| A04 / P0 | Costs can count overlapping meters more than once. Overview totals sum every meter; energy-type cost sums every meter of the type, whereas its throughput is a topology-root total. Categories deduplicate meter IDs only within that category, not parent/child coverage. | `DashboardService.ActiveMeterIdsAsync` / `TotalCostAsync`; `Pages/EnergyView.razor`, `LoadAsync`; `CostService.GetCategoryCostsAsync`. This is a structural risk; actual inflation depends on topology, memberships, and tariffs. |
| A05 / P0 | Zero, missing data, missing prices, and invalid calculations are conflated. History fills absent months with zero and hides all-zero history. Missing tariffs resolve to zero. Virtual evaluation substitutes zero for absent source timestamps and non-finite results. | `MeterPeriodService.BuildHistory`; `Core/Costing/TariffResolver.ResolveValue`; `VirtualNormalizer.Normalize`. `EnergyView.NodeValue` also falls back to zero. |
| A06 / P1 | Time ranges are inconsistent. Overview has no selector; meter detail has a fixed 12-month mini-chart; Trends defaults to 24 months with Apply; energy/Solar/consumables default to 60 months and reload immediately. “All time” is 1,200 months. | `Pages/Dashboard.razor`, `MeterDetail.razor`, `Trends.razor`, `EnergyView.razor`, `Solar.razor`, `Consumables.razor`. |
| A07 / P0 | Comparison and cutoff semantics differ. Dashboard summary requests full calendar years; breakdown ends at `asOf.AddMonths(1)`; difference truncates that span to whole months. Several pages derive today from UTC. Meter quantity SQL has no upper bound and starts at UTC Jan 1, while its cost query stops at now. | `DashboardService.GetSummaryAsync` / `GetCategoryDifferenceAsync`; `Dashboard.OnInitializedAsync`; `MeterPeriodService.MonthlySql` / `GetAsync` / `LoadCostsAsync`. Future-dated rows and local year boundaries can produce inconsistent totals. |
| A08 / P1 | History is too limited to investigate changes. Trends is total monthly cost only. Energy pages provide a flow diagram and meter list, without a historical series. Meter consumption/readings tabs show only the latest 200 records. | `Pages/Trends.razor`, `EnergyView.razor`; `MeterDetailService.MaxRows`; SDD §8.3 calls for more. |
| A09 / P1 | Overview and Trends can disagree even for matching dates: summary includes manual costs, monthly trend does not. “Latest month with data” supplies an amount without its month and derives recency only from consumption. | `DashboardService.TotalCostAsync`, `GetMonthlyTrendAsync`, `LatestMonthCostAsync`; `DashboardSummary`. A manual-cost-only instance is not handled consistently. |
| A10 / P1 | Navigation mixes analysis by energy type with specialized Solar/consumables pages. Type links open a page headed “flow,” not a general type overview. Important settings remain spread between the meter editor, Sources tab, and admin pages. Existing shortcuts help but do not provide a consistent analysis journey. | `Layout/NavMenu.razor`, `Pages/EnergyView.razor`, `MeterDetail.razor`, `Shared/MeterEditor.razor`, `MeterLinks.cs`. |
| A11 / P1 | Visual semantics vary. Meter history is a custom 110px HTML bar chart; other charts use ApexCharts. Its bars use absolute values, obscuring negative results. Chart components hardcode dark mode despite the app theme toggle. | `MeterDetail.BarStyle`; `Shared/SeriesChart.razor`, `TrendChart.razor`, `CategoryDonut.razor`; `Layout/MainLayout.razor`. |
| A12 / P1 | Labels and formatting can mislead: most cost views call `Format.Euro`, meter detail uses configured currency; `Meter.Unit` is the raw unit and is also used for normalized period results. This needs explicit handling for runtime conversions. | `App/Format.cs`; `MeterPeriodView.Unit`; `Core/Domain/Meter.cs`; `RuntimeCounterNormalizer`. |
| A13 / P1 | Rapid navigation/filter changes can be discarded by an `_loading` early return. Errors generally lack a panel-level retry state; nav DB errors silently remove the energy-type links. | `EnergyView.LoadAsync`, `Solar.LoadAsync`, `Trends.LoadAsync`, `NavMenu.LoadEnergyTypesAsync`. Reproduce stale-page behavior with delayed requests. |
| A14 / P1 | Long-history reads are aggregated directly from `consumption` in several services. Existing continuous aggregates are not a drop-in solution: the migration fixes the zone to Berlin and stores only amount sums, without coverage/quality. Flow sums all meters before filtering to the selected type. | `CostService.QueryConsumptionAsync`, `MeterPeriodService.MonthlySql`, `FlowService.GetFlowAsync`, `Persistence/Migrations/20260713094634_ContinuousAggregates.cs`. Actual performance and refresh behavior require measurement. |
P0 means calculation/meaning must be settled before exposing more totals. P1 is required for the finished user experience, not optional polish.
## 3. Target navigation and user journeys
### 3.1 Sidebar and terminology
Use one stable navigation structure:
```text
Overview /
Analysis /trends (retain route; improve existing page)
Meters /meters
Energy types expandable group of user-defined types
<type name> /energy/{id}
Specialized views
Solar /solar
Tanks & consumables /consumables
Data import /import
Configuration existing /admin/* routes
Energy types / Tariffs / Cost categories / Connectors / Settings
```
- “Energy types” is the analysis entry; “Configuration → Energy types” edits definitions. Make that distinction visible in page titles and descriptions.
- Keep specialized views grouped and available with useful setup states. Do not infer electricity or oil from names or IDs; scope relevant links by capabilities and configured roles.
- Preserve existing routes and bookmarked query parameters. Extend the current helpers rather than constructing competing URLs in individual components.
- Add breadcrumbs: `Overview → <energy type> → <meter>`. Preserve the incoming analysis period and selected metric through drill-down and Back navigation.
- Persist expanded navigation groups and ensure the active item is visible after reload. If type links fail to load, keep the group with an error/retry affordance instead of silently removing it.
- Keep global meter search and its quick-entry actions. Show search text on desktop and an accessible icon on narrow screens. Search results should link to analysis and appropriate entry actions without forcing a trip through the meter list.
### 3.2 Concrete discoverability requirements
| User intention | Required path |
|---|---|
| Understand this period's usage/cost | Overview → energy-type card or cost breakdown row → scoped Analysis |
| Explain a spike | Chart bucket → finer supported period / table → meter → relevant records/events |
| Inspect a combined meter | Meter search/list/type list → virtual meter → Analysis, with source contributions visible |
| Compare two historical years | Analysis → select scope and years → previous-year overlay and comparison table |
| Add a reading or delivery | Existing meter header and list quick action; no need to locate a tab first |
| Change the source/connector | Meter → Sources → Edit connection; preserve the existing connector detour and draft |
| Understand a missing cost | Cost panel explanation → tariff editor scoped to the relevant meter/type and dates |
| Configure a virtual sum | Add/edit meter → Virtual → Sum → select source meters by name → preview |
| Configure energy topology | Energy type → Flow → Manage connections, with clear source/destination names |
Keep the existing successful behaviors: shared meter editor, event rules, stable manual-entry keypad, tank setup shortcut, source draft restoration, one-shot URL actions, and nav refresh after energy-type edits.
## 4. Shared period and analysis contract
Create a reusable analysis query/result contract and period selector. Suggested names are illustrative; fit the existing project conventions.
### 4.1 Query state
An `AnalysisQuery` should carry scope (meter/type/category/explicit meter selection/overview), metric, local start date, local end date, bucket, comparison, and aggregation basis. Resolve relative presets against an injected `TimeProvider` and the configured instance timezone.
- Presets: this month to date, last complete month, year to date, previous calendar year, last 12 months, last 24 months, all available history, custom dates.
- Default Overview to month to date. Default history pages to last 12 months including the current partial month. This means exactly 12 calendar buckets, not 13 or a partial extra future month.
- Display the effective dates next to the preset, plus the timezone in the range details.
- The UI's inclusive end date becomes a local-midnight exclusive upper bound on the next day. To-date presets stop at the captured current instant. Use the same resolved bounds for quantities, costs, comparisons, and exports.
- Never silently include future-dated readings in a “to date” total. A deliberately selected future range should distinguish recorded future data from actual-to-date and projections.
- All available history comes from availability metadata, not an arbitrary century-long range.
- Presets apply immediately. Custom date editing applies once both dates form a valid range, using one consistent Apply interaction across pages.
- Encode state in query parameters, e.g. `/meters/42?tab=analysis&from=2025-01-01&to=2025-12-31&bucket=month&metric=generation&compare=previous-year`. Use stable invariant tokens and localized visible labels.
- Use the URL as the authoritative state for reload/share/back. Preserve one-shot `action` handling separately; changing filters must not reopen a reading or source dialog.
- Validate bounds, IDs, enum tokens, maximum series count, and bucket/point limits. Invalid input must produce a recoverable message or documented fallback.
### 4.2 Buckets and comparisons
- Support day, week, month, year, and Auto where the stored data supports them. Week starts Monday in the instance timezone; preserve actual start/end dates for partial weeks.
- Auto chooses an appropriate bucket with at most 400 visible points per series. Explicit choices that exceed the limit should offer a coarser bucket rather than silently truncate.
- Compare complete periods with complete periods. For MTD/YTD, default to the same elapsed calendar portion of the comparison period, including the local time-of-day cutoff. Clamp missing dates at shorter month/leap-year boundaries and show both exact ranges.
- Distinguish actual change from projection. A “vs last year” label must not secretly compare a current-year projection with a prior-year actual.
- Show absolute difference even when percentage is unavailable. A zero or negative baseline yields “percentage not applicable” by default; do not report 0% when a denominator is absent.
- Keep signed values signed. Generation increases and consumption increases do not share the same good/bad interpretation; use metric-specific or neutral colors and explicit wording.
- Projections remain secondary, explicitly labeled, and describe their method. Suppress projections for unavailable, stale, or insufficient coverage; do not extrapolate a lone old monthly reading as though it were live data.
### 4.3 Result and missing-data semantics
Return structured results rather than `null`, `[]`, or `0` with no explanation. Each series needs stable meter/scope identity, quantity kind, normalized unit/currency, available range, effective requested range, calculation basis, and bucket-level values/status.
Keep separate dimensions: availability (available/missing/partial/error), provenance (measured/manual/estimated/interpolated/derived), freshness, and price coverage. A derived value can be complete and current; these are not mutually exclusive states.
| Situation | Display and action |
|---|---|
| Valid observations yield zero | Show numeric zero, an actual chart point, and its coverage |
| No values in selected range, older history exists | “No data for this period”; show available dates and “Go to latest data” |
| No normalized history yet | Explain the mode-specific next step; do not assume every counter requires two readings because initial-baseline behavior already exists |
| Partial source coverage | Show a partial total only if meaningful, identify missing periods/sources, exclude it from confident comparisons |
| Valid quantities, no applicable tariff | Keep quantity analysis; show cost as unavailable with a tariff action |
| Explicit applicable zero-priced tariff | Show a valid zero cost |
| Invalid virtual expression / missing dependency | Name the problem and affected source; offer Edit calculation or Open source |
| Query/refresh error | Local panel error with Retry; distinguish retained stale data from current data |
| Valid virtual meter without raw readings | Show derived analysis; raw-reading controls are not applicable |
Do not promise an exact coverage percentage unless the stored metadata supports it. Monthly observations are not evidence of day-level completeness. Expose source resolution and known coverage bounds; where unknown, say so. Totals, charts, tables, comparisons, and exports must share these semantics.
## 5. Virtual meters as full analysis subjects
### 5.1 Canonical definition and editor
Separate **calculation dependencies** from **physical flow topology**. An upstream link says where energy flows; it must not silently overwrite a configured formula.
- Provide Sum, Difference, and Advanced expression modes in the shared editor, using source meter selectors with names, quantity kinds, and compatible units.
- Store one canonical definition: expression, referenced IDs, result kind, result unit, and supported evaluation/aggregation semantics. Derive referenced IDs from validated expressions or verify they agree exactly.
- Sum of two generation meters defaults to generation. Consumption sums default to consumption. Mixed-kind/net calculations require an explicit result meaning. Do not retain the current unconditional consumption kind.
- Validate self-reference, cycles including nested virtual meters, missing IDs, syntax, unit compatibility, and result semantics on save and again on read for legacy data. Reuse the restricted expression evaluator; do not evaluate arbitrary code.
- Give the editor a preview for the selected historical period, including per-source values and incomplete-data warnings. Show friendly source names beside any `m123` formula tokens.
- Show the formula and linked dependencies on virtual meter detail. Replace register/baseline/source-ingestion controls with appropriate calculation controls; keep any applicable note/event capability.
### 5.2 Existing data compatibility
The seeded `Summe Solar` meter is virtual with upstream links but no explicit formula (`Infrastructure/Import/ReferenceDataImporter.cs`). Do not break this example or existing installations configured the same way.
1. Preserve existing explicit expressions as authoritative.
2. For expression-less virtual meters with upstream links, compatible normalized units, and unambiguous quantity kind, migrate the existing implied sum to an explicit dependency definition. Update seed creation too.
3. Preserve the topology links as topology; changes to flow links after migration must not secretly alter a saved calculation. Offer an explicit calculation edit when desired.
4. Flag ambiguous/mixed-unit/cyclic/no-source definitions as needing configuration. Do not invent conversions or overwrite metadata unrelated to virtual calculations.
5. Make migration idempotent and report converted/unresolved meter counts. Never modify raw readings. Describe any historical semantic change in release notes.
### 5.3 Evaluation rules
Introduce a shared Infrastructure reader (for example `MeterSeriesService`) used by meter history, energy analysis, dashboard, costing, and flow value lookup. Physical meters read normalized aggregate data; virtual meters recursively resolve dependency series.
- Load all unique physical dependencies in bounded batches and evaluate the dependency graph in topological order. Detect cycles and enforce depth/series/point limits.
- Align source buckets by canonical instants and timezone, not exact raw timestamps or localized chart labels.
- Missing source data is unknown, not zero. Under the default strict policy, a sum bucket is complete only when all required inputs are available for that bucket. Explicitly known zero is a valid input. Preserve provenance from dependencies.
- Propagate nested failures with a useful dependency path. Non-finite arithmetic, including division by zero, produces an invalid bucket with an explanation, never a fabricated zero.
- Additive formulas such as `m1 + m2` and `m1 - m2` can roll up their evaluated base buckets. Arbitrary expressions are not necessarily additive: `sum(m1 / m2)` is not `sum(m1) / sum(m2)`.
- Define an evaluation basis for non-additive expressions and a metric-appropriate reducer (e.g. ratio of totals or weighted average). If those semantics are not supported, make that metric/granularity explicitly unavailable; do not silently change the formula's meaning when zooming.
- Preserve negative net values in history. Sankey rendering may use a separate nonnegative/directional representation, but its rendering limitation must not alter the canonical analysis value.
- Evaluation must not depend on cost-category membership. Implement read evaluation first. If costing needs materialized results under SDD §14.1, use the same evaluator with explicit dependency invalidation, rebuild rules, and tests.
- Invalidate caches after source ingestion, corrections, events, import/revert, normalization changes, definition edits, and relevant tariff edits. Avoid process-wide unbounded caches or recomputation per chart cell.
### 5.4 Minimum worked example
Given generation meters A and B with complete monthly data:
| Month | A | B | Virtual Sum A+B |
|---|---:|---:|---:|
| January | 100 kWh | 150 kWh | 250 kWh |
| February | 80 kWh | 120 kWh | 200 kWh |
The virtual meter shows 450 kWh for the two-month period, a generation label, both history points, source contribution details, and the same numbers in type analysis. It needs no raw readings and no cost category. If B is missing in February, that month is incomplete, not a confident 80 kWh. If B has an observed zero, the complete result is 80 kWh. A separate AB meter shows 50 and 40 kWh rather than inheriting the flow service's sum.
## 6. Totals, costs, and energy-type semantics
Do not achieve visual consistency by making every page sum all meters. Define a shared aggregation policy and include its selected basis in results and visible explanations.
### 6.1 Quantity totals
- Separate consumption, generation, runtime, tank balance, and net quantities. Same energy-type membership does not guarantee addable units or independent measurement coverage.
- Default physical throughput to the appropriate non-overlapping topology roots, with consumption and generation separate. List exactly which meters contribute and which are excluded.
- A virtual view of already-counted sources is visible and analyzable but excluded from an additive portfolio total by default. Explicit selections can replace source coverage with a virtual result; they must not add both.
- Detect known overlap using topology and virtual dependencies. Do not claim completeness where overlapping measurements cannot be established from configuration; request scope configuration through a clear page action.
- Distinguish throughput from billed import and total household use. Multi-parent topology and grid-plus-solar supply do not justify summing every node as “consumption.”
- Retired meters retain their historical contribution. `IsActive` controls current operation, not erasure from historical totals; respect effective install/retire dates where reliable.
### 6.2 Costs
- Make cost inclusion explicit at the relevant meter/scope configuration. Default new virtual meters to analysis-only for portfolio costing so enabling their analysis does not increase the bill.
- Meter detail may show the cost of a physical or virtual scope without automatically including that scope in portfolio totals.
- Virtual costing must name its rule: tariff applied to the virtual quantity, or aggregation of already-priced independent sources. These differ when sources have different tariffs. Default to unavailable until a valid rule is inferable or configured; never sum source costs and reprice the combined quantity together.
- Handle standing charges once for the intended billing scope. Do not replicate a type/global base charge across every analytical submeter and virtual view. Preserve existing tariff precedence and document any changed billing rule.
- Add applicable price coverage to cost results. Historical tariff gaps produce partial/unavailable cost; existence of any tariff anywhere is insufficient. Missing optional credits must be distinguishable from missing required unit prices.
- For a year with tariff changes, aggregate correctly priced underlying billing periods; do not price the whole year from a July sample. Changing chart granularity must not change the total bill. Keep the existing monthly pricing convention unless a deliberate change is documented and reconciled.
- Include manual costs exactly once in matching overview, trend, and category totals. Show uncategorized contributions rather than dropping them. If categories overlap, label them as overlapping views and do not present their sum/donut as a disjoint breakdown of the bill.
- Use signed bars/tables for cost credits and negative totals. A donut is appropriate only for a nonnegative, disjoint composition.
- Make “Latest month with data” return its actual period and availability basis, including manual costs. Never silently switch every dashboard panel to historical data; offer an explicit action to open that period.
- Use configured currency consistently. Use normalized quantity units for analysis and raw units only for raw register values; resolve runtime-to-volume conversion explicitly.
## 7. Page specifications
### 7.1 Overview dashboard
Use a consistent header, period toolbar, and compact coverage/freshness summary. The initial viewport should answer: what happened in this period, what changed, and where to investigate.
1. Period cost with coverage and comparison. Show usage/generation per energy type with their own units, rather than adding unlike quantities to one total.
2. Energy-type cards with quantity, available cost, comparison, and a clear link to that type's analysis. Quantity cards work even without tariffs or categories.
3. Shared historical chart with metric toggle and previous-period overlay; the selected range applies to every analytical panel.
4. Ranked change table by category or meter: current, previous, absolute delta, percentage where meaningful. Rows link to scoped analysis with the same dates.
5. Cost composition that reconciles to the selected scope, with explicit overlapping/uncategorized handling.
6. Compact attention items only for relevant issues: missing prices, missing data, invalid virtual dependencies, stale sources. Provide a targeted action for each.
Keep the update banner separate from analytical status. Avoid making configuration of cost categories a prerequisite for viewing valid quantities.
### 7.2 Meter detail
Move the tab/navigation bar directly below the identity and action header. Put the analytical content inside the default **Analysis** tab so Sources and Events do not sit below a long wall of charts.
- Tabs: Analysis, Readings where applicable, Normalized data, Events, Tariffs, Sources for physical input meters / Calculation for virtual meters.
- Preserve old `tab=readings|consumption|events|tariffs|sources` links with a compatibility mapping. Resolve tabs by stable keys and capability, not fixed numeric indexes after conditional tabs are introduced.
- Analysis: shared period controls; selected-period quantity/cost/comparison; actual-versus-projection distinction; full-size chart; year-over-year view; accessible table; CSV export; data-quality/coverage explanation.
- History table: period, quantity, cost, comparison, quality, and coverage. Include year in date labels across multi-year ranges. Chart selection can drill to supported finer detail while retaining scope.
- Show lifecycle events and tariff changes as optional contextual markers, bounded to the selected range. A chart click should lead to records/events capable of explaining that interval.
- Raw and normalized-data tabs need server-side date filtering and pagination with a stable ordering. The latest-200 view is not full history. Explain raw retention separately from retained analytical history.
- Virtual Analysis includes source contributions and formula details. Do not show fake register totals or suggest adding a raw reading to fix virtual history.
### 7.3 Energy-type page
Title the page with the energy type's user-defined name. Use **Overview / History / Flow / Meters** tabs, defaulting to Overview.
- Overview: separate appropriate quantity kinds, cost with billing basis, coverage, trends, and largest changes.
- History: shared chart/table, day/week/month/year selection, calendar-year comparison, and optional per-meter series. Offer “total” and “individual meters” views with overlap explanations.
- Flow: retain Sankey as a topology tool, using the canonical meter values. Mark inferred proportional allocations as estimates. Provide a textual/table equivalent and a connection-management entry point.
- No topology must not prevent analysis. Negative/net values remain available in History even if unsuitable for a ribbon.
- Meters: searchable list with period values, quality/coverage, physical/virtual distinction, and existing quick actions. A missing graph node must not become a fake zero meter value.
- Add appropriate links to Solar/consumables without making users rediscover a different period selector there.
### 7.4 Analysis page (existing `/trends`)
Replace the single monthly total-cost chart with one reusable exploration page. Scope selector: portfolio cost, energy type, category, individual meter, or explicit meter comparison. Metric selector: supported quantity kind or cost. Support at most six simultaneous meter series by default, with an explanation when the selection exceeds that limit.
Use the same series reader, toolbar, chart, and table as meter/type pages. Do not build a second formula engine here. Comparable meter quantities must have compatible normalized units; otherwise split charts or explain why the comparison is unavailable. Category analysis is always available for cost; quantity analysis needs a single compatible quantity kind/unit.
### 7.5 Solar and consumables
Adopt the shared toolbar, theme, cards, history components, and missing-data semantics while retaining their specialized measures. Distinguish current tank balance/forecast from historical period totals. Show the balance's observation date explicitly; a historical date range must not label today's balance as a historical observation.
Replace visible instructions to edit raw role tags with friendly meter-role configuration controls. Missing Solar context should identify the required role and provide a scoped setup path. Use configured units/conversions rather than assuming every generation counter is measured in kWh.
## 8. Shared UI and interaction standards
- Extract common components for page header, period toolbar, metric card, analysis chart/table, and availability state. Reuse MudBlazor and the existing ApexCharts integration.
- Use the existing theme for colors, spacing, borders, typography, and density. Charts must respond to light/dark changes in the current circuit.
- Chart points retain temporal identity, nullable value, and provenance; avoid reducing them to only `string Label, double Value` before rendering. Preserve numerical precision in calculation and round only for display.
- Display units in axes/tooltips and currency in cost series. Do not smooth or connect across unknown intervals. Render signed bars around a real zero baseline.
- Provide keyboard-accessible links/actions, chart table alternatives, touch-friendly controls, visible focus, and meanings that do not rely only on red/green.
- Use localized EN/DE strings in both resources and existing typed accessors. Format user data without translating meter/type names. Cover long German labels on narrow screens.
- Validate at approximately 360px, 768px, and desktop widths. Controls should wrap predictably; keep page-wide overflow out of the main layout and contain wide tables locally.
- Cancel superseded loads or use request-generation IDs so only the latest requested scope/range is committed. Publish a coherent result atomically; do not mix the previous type's chart with the next type's title.
- Expose initial loading, refresh, error/retry, and stale-but-visible states consistently. Dispose subscriptions/cancellation sources with the circuit/component.
## 9. Data access, history resolution, and performance
Preserve immutable raw readings and existing normalization rules, including month-label attribution, local-month splitting, swaps/resets, and import/revert behavior. Do not rescan raw readings to draw long-range charts.
1. Centralize physical/virtual analytical reads in Infrastructure. Keep the formula algebra, compatibility checks, and reducers pure in Core where practical; keep presentation out of the domain model.
2. Select meter IDs and time bounds in SQL before aggregation. Batch dependency and tariff reads. Avoid one full scan/context/tariff load per meter per panel.
3. Audit existing continuous aggregates before switching readers: timezone, real-time/materialized behavior, backfill after historical imports, late corrections, retention, and current incomplete buckets. Their comments are not proof of freshness.
4. Add migrations for required aggregate/metadata changes rather than editing previously applied migrations. Support the configured instance timezone, not a hardcoded Berlin-only implementation.
5. Preserve coverage/provenance and available resolution through aggregation. The existing amount-only aggregates cannot answer these questions by themselves; define auxiliary summaries or sufficient aggregate fields.
6. Legacy monthly data must remain honestly monthly. Daily/week views cannot display one month-end amount as a measured daily spike. If interpolation is offered, make it explicit, mark it interpolated, and preserve monthly totals; otherwise explain the unavailable resolution.
7. Never include both a refreshed aggregate bucket and its underlying consumption in a live-tail merge. Test exact refresh boundaries and backfilled periods.
8. Bound raw/event retrieval and chart output separately. Prove history still works after raw readings are removed by the retention policy.
9. Keep current public API contracts compatible. Reuse the shared reader behind relevant endpoints where possible; add versioned/additive fields for availability rather than silently changing existing numeric response types.
10. Record representative timings and query plans before/after. Proposed review target: cached metadata plus a 10-year monthly request for 100 selected meters should complete within two seconds on documented local test hardware. Treat this as a target to measure, not a verified property. Test 1,000-meter selection/query planning and enforce output limits without a per-meter scan storm.
## 10. Implementation phases and exit criteria
### Phase 1 — Shared semantics and regression fixtures
- Add frozen-clock period resolution, analysis contracts, zero/missing/partial semantics, normalized units, and explicit aggregation/cost policy.
- Add fixtures reproducing virtual absence, overlapping scopes, missing prices, historical-only data, and inconsistent cutoffs.
- Resolve proposal choices in this brief in a short implementation note; identify any incompatible legacy assumptions before migration.
**Exit:** deterministic tests pin interval boundaries, scope membership, missing-data rules, and arithmetic semantics. No new UI claims rely on unresolved totals.
### Phase 2 — Virtual evaluation and compatible migration
- Implement shared physical/virtual series reader, canonical definitions, validation, editor, compatibility migration, and source contribution results.
- Remove the blanket virtual rejection from period analysis. Replace the old “virtual returns null” test with positive and error-state behavior tests.
- Route flow and cost quantity lookup through the same evaluator, keeping flow rendering separate.
**Exit:** the worked A+B/AB examples and seeded Summe Solar have full analysis without raw readings/category setup; nested/error cases are explained; enabling analysis does not double portfolio totals.
### Phase 3 — History and page navigation
- Implement shared toolbar/chart/table; upgrade meter, energy-type, and Analysis pages.
- Add period-preserving URLs, breadcrumbs, compatibility tab routing, history pagination, exports, and stale-request protection.
**Exit:** a user can navigate Overview/type → meter → historical month and back without losing dates, and compare historical years for physical and virtual meters.
### Phase 4 — Overview and specialized consistency
- Rework dashboard quantities/costs/change tables, aligned manual costs, latest-data period, targeted setup actions, sidebar grouping, and Solar/consumable controls.
- Complete theme, currency, normalized-unit, localization, mobile, and accessibility work.
**Exit:** matching scopes/periods reconcile across cards, charts, tables, and exports. Valid quantity analysis remains visible when costs are unavailable.
### Phase 5 — Integration, performance, and documentation
- Run targeted regression suites and then the required full build/tests. Measure query behavior with historical and synthetic data.
- Walk through the seeded application in EN/DE, light/dark, and mobile/desktop. Capture representative before/after screenshots and any remaining limitations.
- Update SDD/CLAUDE descriptions that currently claim unsupported virtual behavior or no longer describe the navigation/calculation model.
**Exit:** all acceptance scenarios below pass or have an explicitly documented blocker. Do not mark the feature complete after only changing menus or removing the virtual `return null`.
## 11. Acceptance scenarios and validation
| Scenario | Required evidence |
|---|---|
| Two-source virtual generation sum | 100+150=250 and 80+120=200 by month; 450 total; generation units; physical and virtual pages/type history agree |
| Missing versus zero source | Missing B makes the bucket partial/unavailable; observed B=0 yields a complete sum |
| Difference and nesting | Negative AB plots below zero; nested dependencies resolve once; cycle reports a named dependency error |
| Invalid arithmetic | Division by zero and unsupported non-additive rollup produce explanations, never zero or infinity |
| Legacy virtual configuration | Seeded Summe Solar acquires a compatible definition; migration rerun changes nothing; existing formulas retain precedence |
| Overlapping topology | Parent 300 and child 100 display 300 for the non-overlapping parent scope, with 100 as a breakdown, not 400 |
| Virtual overlap | Sources 100+150 and virtual 250 remain a 250 portfolio quantity where that is the selected coverage, not 500; costing has equivalent explicit coverage |
| Missing versus free tariff | Valid quantity with missing required price has unavailable cost; explicit zero tariff has valid zero cost |
| Cost stability | Manual costs appear once; standing charges follow the chosen billing scope; changing chart bucket does not reprice annual totals from one sampled tariff |
| Historical-only instance | Current period explains no data and offers actual available dates; selecting historical year shows quantities/costs for physical and virtual meters |
| Manual-cost-only instance | Overview, trend, category breakdown, and latest-data month agree without requiring a meter |
| Local-time boundaries | Frozen clock tests at local New Year, Berlin spring/fall DST, leap day, shorter months, and a zone behind UTC; from/to are half-open consistently |
| Future rows and partial periods | To-date totals exclude future data; comparisons state actual matched dates; projections are explicitly separate |
| Zero/negative history | Legitimate all-zero year remains visible; negative net history is signed and percentage rules are consistent |
| Monthly legacy resolution | Monthly import stays monthly or explicitly interpolated; daily chart never invents measured detail |
| Retained history | Charts still work after old raw readings are absent; raw-record tab explains retention |
| Rapid filter/navigation change | Delayed first request cannot overwrite the later selected type/range; reload/back preserves URL state |
| Compatibility actions | Existing reading/event/edit/source URLs still open the intended action once; connector detour preserves typed draft and return context |
| UI consistency | Theme toggle updates charts; EN/DE resource tests pass; narrow-screen controls and keyboard/table alternatives work |
| Import/correction freshness | Import, revert, reading deletion, swap/reset, and tariff/definition edits refresh affected physical/virtual history without duplicates |
Use the existing suites as starting points:
- `tests/Core.Tests/VirtualMeterTests.cs`, `ExpressionEvaluatorTests.cs`, and relevant normalization tests.
- `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `FlowServiceTests.cs`, `CostSetupTests.cs`, and costing/reconciliation suites.
- `tests/Integration.Tests/DashboardRenderTests.cs`, `LocalTimeEntryTests.cs`, and `Localization/StringResourceTests.cs`.
Add tests around substantive data behavior and navigation state; do not rely on snapshots of markup alone. Server-render tests do not prove interactive ApexCharts updates, browser history, or responsive usability. Add meaningful interaction coverage using the repository's available tooling, or record a manual browser checklist when no suitable harness exists.
Commands for the implementing agent:
```powershell
dotnet build
dotnet test tests/Core.Tests
dotnet test tests/Integration.Tests
```
Integration tests need Docker/TimescaleDB. Report actual commands/results and prerequisites that prevented execution. Preserve golden CSV reconciliation unless a deliberate correctness fix is described with old/new values and a focused regression test. The source review that produced this brief did not run these tests.
## 12. Delivery requirements for Claude Code
Deliver working code in reviewable phases, necessary migrations, both language resources, substantive tests, and updated documentation. Keep the current stack and user-defined energy types. Do not rewrite ingestion, introduce a new frontend, or change raw-reading history to make charts look correct.
The final implementation report should state which findings were fixed, how physical and virtual results now agree, which cost/aggregation decisions were applied to existing data, what was tested, and any remaining limitations. Include screenshots of the new Overview, physical/virtual meter history, and energy-type history at desktop and mobile widths.
**Definition of done:** a user can find the correct action without guessing which page owns it, examine the same selected period consistently across the application, analyze a valid combined virtual meter as fully as its compatible physical inputs, and distinguish a true zero from unavailable or incomplete information.
+235
View File
@@ -0,0 +1,235 @@
# Release notes
User-visible changes per release. Earlier releases are described in the git history and in the README's upgrade
sections. Decision ids (D-nn, A-nn) refer to [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md).
## 0.4.0 — Dashboard, navigation and historical analysis (unreleased)
This release rebuilds how MeterVault analyses and prices data, and the pages that show it. Every page now works on
the same selected period and the same numbers. A virtual (calculated) meter can be analysed like a physical one. A
true zero, missing data, data that exists only per month, and a missing price are always told apart.
**Some figures change on purpose.** Most visibly, an energy type's bill now counts its grid meter, not every meter
that exists. [Changed figures](#changed-figures) lists every deliberate change. The seeded demo's yearly costs now
match the spreadsheet: 2025 comes to 7,907.65 € against the sheet's 7,907.64 €. The seeded "this year" cost for 2026
was 4,402.19 € in 0.3.0, which priced Haus + Netz + Auto and water at 5 €/m³. It is now 2,940.19 €, the sheet's
figure.
### Upgrading
Back up the database (`pg_dump`) first.
- **The first start rebuilds all analysis data** (normalization revision 3):
- For every meter it rebuilds consumption and the new day and month rollups plus their coverage. It records the
state of each meter.
- This runs **before the web server listens**. The app is unreachable meanwhile, and Compose may report the
container unhealthy. Let it finish.
- The time grows with the number of raw readings. One meter took about 0.05 s with monthly readings, ~0.5 s with ten
years of daily readings (3,700 readings), and ~1.2 s with a year of hourly readings (9,300 readings).
- A synthetic 1,000-meter × 10-year instance (1.34 M readings) took 4.7 minutes (279 s) on an idle Ryzen 9 9950X3D —
about 3.6 meters a second. It writes rollups and coverage on top of consumption, so it does more per meter than
the 0.3.0 rebuild did.
- Progress is logged every 100 meters. A meter that fails is logged, kept pending and retried at the next start.
A meter whose stored consumption is older than its oldest remaining reading is skipped and logged, so its
history is not truncated.
- Until a meter is rebuilt, its pages say "analysis being prepared", never "no data".
- **The migration records each meter's last reading time** in the analysis state table and fills it in for the meters
you already have, in one pass over the readings. It is what the pages show as "last activity" and what decides
whether a live source is late, so no page has to search the raw readings for it any more.
- **Virtual meters without a formula** (such as a seeded *Summe Solar* from an earlier version) are converted at
startup. When their incoming links name meters of one unit and kind, the implied sum is stored as an explicit
formula (D-28). The log lists the converted meters, and the meters that still need configuration because their
links are ambiguous, in mixed units, or loop. The conversion runs once; a rerun changes nothing. From then on, links
are topology only and never change a calculation.
- **The migration drops the unused continuous aggregates** and their hourly refresh jobs. It also deletes consumption
rows stored for virtual meters, which nothing read (D-17).
- **Check the attention items** on the Overview after the first start. They name missing prices with a direct "Add
tariff" link, invalid calculations, stale live sources, rows dated after now, and meters that may be counted twice.
- **Rolling back** to 0.3.0 works. The old version ignores the new tables and never read the dropped aggregates.
Consumption stays as 0.4.0 booked it until each meter next ingests a reading. Stored virtual formulas remain, and
0.3.0 goes back to summing links.
### What's new
**Navigation.**
- The sidebar has a fixed structure: Overview, Analysis, Meters, an **Energy types** group, **Specialized views**
(Solar, Tanks & consumables; always listed, with a setup hint when not configured), Data import, and
**Configuration**.
- "Configuration → Energy types" edits the definitions; the Energy types group is for analysis.
- Expanded groups are remembered, and the current page's group always opens. If the energy types cannot be loaded,
the menu shows an error with Retry instead of silently dropping them.
- Breadcrumbs (Overview → energy type → meter) keep the selected period, and so does Back.
- "Find a meter" opens the meter's analysis with the current period and still offers quick entry. It is a text
button on desktop and an icon on phones.
**One period everywhere.**
- Every analysis page has the same toolbar: this month to date, last month, year to date, previous year, last 12 or
24 months, all history, or custom dates with one Apply.
- It also offers a bucket size (automatic, day, week, month, year) and a comparison: previous period, same period
last year (the default), or any calendar year.
- The effective dates are shown next to the choice, with the time zone. The selection lives in the address, so
reload, Back and shared links reproduce the page.
- Rapid clicks can no longer leave one page showing another selection's data.
**Overview.**
- It shows one selected period (default month to date):
- the cost, split into metered use, standing charges, manual costs and feed-in credit;
- a card per energy type with its quantities in their own units, its cost and billing basis, the change and
freshness;
- a history chart with a table view;
- "What changed", by category or by meter;
- the cost composition;
- attention items, each with one targeted action.
- Changes are compared only over the part both periods cover, and the page states both date ranges.
- When the period has no data, the page names the dates that do have data and offers "Go to latest data". It never
silently switches to an older month.
**Analysis page** (`/trends`, formerly the cost trend).
- Explore everything, one energy type, a cost category, one meter, or up to six meters side by side, by quantity or
by cost.
- Compare calendar years with an overlay and a comparison table. Click a bar or a row to drill into a finer period.
- Export exactly what is shown as CSV.
**Energy type pages** are titled with the type's own name and have four tabs:
- **Overview:** measures such as total use, grid import, generation and runtime, each in its own unit and never added
across units; the cost with its billing basis; coverage; the largest changes.
- **History:** the total, or up to six individual meters with an explanation of how each one counts.
- **Flow:** the Sankey, now using the same values as every other page. Calculated and estimated connections are
marked. It comes with a table version and **Manage connections**.
- **Meters:** each meter's value for the period and its data quality.
**Meter page.**
- The tabs sit directly under the header: Analysis, Readings, Normalized data, Events, Tariffs, Sources. A calculated
meter has Calculation instead of Sources.
- The Analysis tab shows:
- the period total with its unit and status, and the cost with its rule, or the reason it has none;
- the change against the comparison period;
- a labelled projection, where there is enough data for one;
- a full chart with the previous-year overlay, and a table;
- a "Data quality and coverage" section;
- events and tariff changes in the range.
- The record tabs page through the **whole history** (100 rows at a time, filtered by the selected dates), no longer
just the latest 200. Rows dated after now are marked.
- Existing links such as `?tab=consumption`, `?tab=readings&action=reading` and `?tab=events&action=swap` still open
the intended tab and dialog once.
**Virtual (calculated) meters.**
- Create them with **Sum**, **Difference** or a **Formula** over other meters, picked by name. A live preview of the
selected period shows every source's values and flags incomplete months.
- The result kind (consumption, generation, net or indicator), unit and cost rule are stored with the formula.
- A virtual meter gets the same analysis as a physical one, plus "Source meters" showing each source's contribution.
- **The rules:**
- A missing source month makes the result "no data" for that month, never a silent zero.
- An observed zero is a real zero.
- A division by zero or a loop is reported, and names the meters involved.
- Differences stay negative.
- New calculated meters are *analysis only*: they never add to a type's totals or the bill. The "Always count" option
lets one replace its sources instead.
**Solar and Tanks & consumables** use the same toolbar, cards and charts.
- **Solar** works out self-consumption, feed-in, site use, savings and autarky from the meters' roles. For a missing
role it shows a setup card with candidate meters instead of raw role tags.
- **Tanks** keep "Last dipstick (date)" apart from "Estimated now". A past period shows the contents at its end, not
today's. The forecast is a labelled projection, hidden when the dipstick is older than 60 days.
**Tariffs and configuration.**
- An "Add tariff" link from a missing price opens the tariff editor once, prefilled with the scope, component and
first uncovered month.
- The unit is checked against what it prices: a wrong unit or currency blocks the save.
- A new tariff needs a value; a typed 0 is a deliberate free period.
- The editor notes that Bonus, Discount and Tax are stored but **not applied** yet.
- Meter roles have friendly names and one-line meanings. A role is unique per energy type, and saving it names the
meter it moves from.
- The Settings page shows the analysis data state and that raw retention is not enforced.
**Also:**
- CSV export of any analysis view (`/export/analysis.csv`): statuses, provenance, costs and comparison values; unknown
values are empty cells, never 0.
- The light/dark choice persists across reloads and language switches, and charts follow it immediately.
- Visible keyboard focus, and tables for every chart.
- MudBlazor's own labels are in German too.
- Pages work at phone width.
- Amounts use the configured currency (`MeterVault__Currency`) instead of a hard-coded €.
- On a page that shows several figures, the line about the compared dates names the figure it is about, so it can no
longer say "not comparable" above a table that compares another meter month by month (A-41).
- Every dated table reads newest first, with the total above the rows it sums: the meter's Analysis tab, an energy
type's History, the Analysis page, Solar, Tanks & consumables, the tariff lists and a virtual meter's preview. The
meter page's events and price changes are one dated list instead of two. Charts and the CSV export stay
chronological, because that is how they are read (A-42).
- "Largest changes by meter" no longer leaves out a meter it cannot rank: one that has data but no comparable change —
a tank dipped a few times a year — is listed under "Other meters" with both its values and the reason there is no
change. A meter with nothing on either side stays out, and whatever the caps leave over is counted out loud (A-43).
- Deleting a meter or an energy type also deletes the tariffs scoped to it.
- JSON export/import now carries meter connections, re-links virtual formulas to the new meter ids, and no longer
restores the tariffs of deleted meters onto other meters.
### Changed figures
Every change below is deliberate. The golden spreadsheet reconciliation (consumption of all four sheets, Netz
Einsparung) is unchanged. The seeded yearly bill matches the sheet's `Jahreskosten` within ±0.02 € for 2022, 2025 and
2026. It differs by 3.78 € (2023) and 0.46 € (2024) only because the sheet multiplies by unrounded prices.
| Area | 0.3.0 | 0.4.0 |
|---|---|---|
| What an energy type's bill counts | Every meter's cost was summed: Haus + Netz + Auto for the seeded Strom | The type's grid import meter when it has one, otherwise its household use. Submeters are breakdowns; generation is never billed. Seeded Strom is Zähler Netz × price, like the sheet (2025: 4,742.64 €) (D-22, D-34) |
| Feed-in credit | Credited on all generation | Only on a meter with the grid-export role, at the feed-in price (D-34) |
| A subsection with its own meter price | Added on top | Billed at its own price and taken out of the meter above it; quantities unchanged (D-35, A-19) |
| Missing tariff | Cost 0 | "Not priced (no tariff)" with an Add tariff action; a hole in a price history is a price gap (unavailable). The quantities stay visible. An explicit 0 tariff is still a valid zero (D-38) |
| Standing charges | Per meter, per month that had readings, and copied onto every meter of the type | Per local day over the scope's service period (including reading gaps), **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18) |
| Price of a longer bucket | Month buckets used the price of the 15th, year buckets the price of 1 July | Every bucket is priced month by month at the price of the 15th, so a year equals the sum of its months and changing the bucket never changes a total (D-36) |
| Months in which the billed grid meter was not yet (or no longer) in service | — (0.3.0 summed every meter) | Unavailable rather than free while use was measured, with an attention item naming the grid meter and the months (A-17) |
| Tank, runtime, direct-delta or instant-rate readings more than a month apart (a tank dipped every few months, quarterly burner hours) | Booked whole in the month of the later reading, with zeros in between | The months in between read "only coarser data" (not zero). A year or longer bucket is priced when all its months share one price; otherwise it is unavailable with an attention item (A-16) |
| A period that opens after the last reading of a meter read more coarsely than it (a tank dipped once a year, last dipped shortly before the period began) | "No data for this period", beside a coverage panel listing years of data | "Only coarser data", naming the resolution: the next dipstick will book it. The card, the chart, the table and the coverage panel now say the same thing, and the panel adds a measure's own dates when they differ from the type's. A meter that books its own buckets — an hourly source gone silent, a monthly sheet asked about a later month — still reads "no data" (A-41) |
| Automatic interval with data coarser than a month | The whole chart went to years, which resolved nothing: "last 12 months" became one bar per year | Months, with the buckets marked "only coarser data" and the coarser interval one click away. A meter read on the quarter, whose intervals lie inside one year, still charts in years (A-41) |
| Manual costs | Counted in the Overview but not in the trend; a cost dated later this month counted at once | Counted once, on their start day, when that day has come, everywhere: Overview, Analysis, categories, export (D-41) |
| Cost categories | Sum of their member meters' costs | The priced non-overlapping cover of their members plus their manual costs. Seeded Strom = Netz × price. A category overlapping another is shown as a view, apart from the composition. A category whose members price nothing says so (D-42, A-22) |
| Virtual meters | No analysis; the flow summed incoming links, ignoring any formula | Full analysis from the stored formula; the Sankey uses the same values (D-27, D-30) |
| Summe Solar and other generation sums | — | Analysed as generation (seeded 2025: 4,750 kWh = Solar 1 3,123 + Solar 2 1,627) and **not costed**, because generation is never billed (A-15) |
| Readings at exactly midnight | Booked in the following day | Booked in the day they close (D-11) |
| "Last 12 months" | 1314 buckets, including a partial future month | 12 calendar buckets ending with the current month; actuals stop at now (D-02) |
| Rows dated after now | Counted in "to date" totals | Excluded and shown as "recorded after now", e.g. a sheet row labelled the current month or a future-stamped reading. A day that holds such a row reads partial (D-04, A-14, A-20) |
| Overview "this month / this year" | Current month and year against the complete previous ones | The selected period against the same elapsed part of the comparison period, measured over what both cover (D-07) |
| "Latest month with data" | An amount without its month; consumption only | The month and its basis (meter data, manual costs or both) (D-19) |
| Percentages | A negative baseline was divided by its absolute value | "Not applicable" for a zero or negative baseline; the absolute difference is always shown (D-08) |
| Meter dates | — | Outside its install and retire dates a meter counts as a known zero; retired meters keep their history (D-24) |
| Currency | € hard-coded in most views | `MeterVault__Currency` everywhere; a tariff in another currency is reported as not fitting, never converted (D-43) |
| Continuous aggregates | Refreshed hourly, read by nothing | Dropped; rollup tables are written with each recompute (D-12, D-17) |
| Seed | — | Adds the water price of 7.00 €/m³ from 2026-01-01, and stores Summe Solar's formula (`m4 + m5`, generation, not costed). This affects new seeds; existing seeded instances get the formula through the startup conversion (D-44) |
### REST API
Every existing field keeps its name and type. What changed is only added as new fields. The numbers follow the new
engine, as listed above: actuals stop at now, virtual meters are evaluated, and costs are the bill's.
- **`GET /api/v1/consumption`:**
- New fields: `status`, `issue`, `kind`, `unit`.
- A month without data is left out instead of reported as 0.
- `from`/`to` with a UTC offset are accepted; they used to fail with a server error.
- Virtual meters return evaluated values.
- **`GET /api/v1/cost`:**
- New fields: `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the
state of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted`, `missingPrices[]`
(component, reason, scope, first and last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit`.
- `cost` stays numeric and is 0 when nothing could be priced; check `costStatus` and `costAvailability` before
trusting a 0.
- **Behaviour change:** generation and runtime meters, indicators and calculations that cannot be evaluated now
report `costStatus: NotPriced`, with `notCosted` giving the reason. They used to report `Priced`, and a generation
meter could carry a negative feed-in cost (A-21).
- **`GET /api/v1/dashboard/summary`:**
- New fields: `deltaPercentApplicable` per KPI, and `latestMonth` `{period, basis}`.
- The month and year windows are unchanged (the calendar month and year to now, against the whole previous ones),
but the values are the new bill. `deltaPercent` is 0 when not applicable.
### Known limitations
- **Raw retention is not enforced** (D-57). `MeterVault__RawRetentionDays` is shown but nothing deletes readings,
because every recompute rebuilds a meter from its readings.
- **Monthly data is never interpolated to days** (D-57). A day or week view of monthly data says "only coarser data"
and offers the monthly view.
- **Bonus, Discount and Tax tariffs are stored but not applied** (D-57).
- **Every live reading recomputes its meter in full** (D-57). That is fine for monthly and daily meters, but costs
about 1.2 s per reading for a meter with a year of hourly data, and grows with history.
- **Months cannot switch billing basis:** the billing basis (grid meter or household use) is chosen per energy type
for all time (A-17).
- **Batteries are not modelled.** Without a grid-export meter, Solar's feed-in is calculated, and labelled as such.
- **No CSV export on the Solar page**, because the export has no derived measures.
+584 -7
View File
@@ -5,6 +5,13 @@
> **What this is:** a self-hosted, local-first energy & utility metering platform that pulls meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading with a timestamp, and turns it into cost dashboards. Not limited to electricity/water/oil — energy types are user-defined.
>
> **Status:** design spec, pre-code. This document doubles as the build brief for Claude Code.
>
> **Implementation status (0.4.0):** M0M7 are implemented, and the dashboard/analysis rework
> ([`DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md)) replaced the aggregation, virtual-meter,
> cost and page model. Its decisions are numbered D-01 D-58 and A-01 A-39 in
> [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md). The original design text below is kept.
> Wherever the system now works differently, a **Deviation** or **Current behaviour** block says so in place (list:
> D-58). The results are in [`ANALYSIS_REPORT.md`](ANALYSIS_REPORT.md).
---
@@ -104,6 +111,14 @@ Early rows (19972004) only carry **deliveries** (no burner hours — tracking
| FR-18 | **Deploy** via Docker Compose (app + TimescaleDB); Unraid template; healthcheck endpoint; backup guidance. |
| FR-19 | **Auth**: optional local accounts *and* reverse-proxy trust (honour `X-Forwarded-User` behind Authelia/Traefik). |
> **Where the implementation differs (D-58):**
> - **FR-9:** prices are monthly (the 15th of each local month); there is no day-accurate proration mode. Bonus,
> discount and tax are stored but not applied (§7.5, D-57).
> - **FR-11:** rollups are by local day and month. Weeks and years are built from them, and there is no hourly level
> (§5.4).
> - **FR-12:** the Overview shows one selected period instead of fixed today/month/year cards (§8.1).
> - **FR-16:** raw retention is shown but not enforced (§5.5).
---
## 4. Architecture & tech stack
@@ -125,6 +140,10 @@ Early rows (19972004) only carry **deliveries** (no burner hours — tracking
> The **domain model and DB schema are UI-agnostic.** If a future maintainer swaps Blazor for an SPA, everything from §5–§7 and the REST API in §9 is reusable.
> **Deviation (D-17, D-58):** continuous aggregates are not used. They were dropped in favour of rollup tables that the
> recompute writes (§5.4). Dapper serves the hot-path reads of those tables (`AnalysisQueries`). No hosted service
> refreshes aggregates.
### 4.2 Components & data flow
```
@@ -146,6 +165,13 @@ Early rows (19972004) only carry **deliveries** (no burner hours — tracking
Ingestion writes **raw `reading`** rows. A normalization step derives **`consumption`** (append-only, base unit). Continuous aggregates roll consumption up to hourly/daily/monthly/yearly. The cost engine joins aggregates with time-ranged tariffs. The dashboard and API read aggregates + cost views (never scan raw for charts).
> **Current pipeline (D-12 D-17, D-27, D-34):** the recompute derives `consumption`. In the same transaction it
> writes per-meter rollups by local day and local month, plus coverage runs, all in the configured zone (§5.4). One
> shared **analysis reader** (`AnalysisReader`) reads those rollups. It evaluates virtual meters on read (§7.4) and
> classifies per-type totals. One **cost engine** (`CostReader`) prices the bill from those quantities and the
> time-ranged tariffs (§7.5). The pages, the REST API and the CSV export read only these two readers. None of them
> scans `reading`.
---
## 5. Data model & database
@@ -168,6 +194,18 @@ Design principles: raw readings are immutable audit truth; everything derived (c
- `import_batch` — provenance + revert for CSV/manual bulk loads.
- `app_setting` — currency, locale, timezone, retention, fallbacks.
Added since this sketch (see §5.4 and the note, D-12):
- `meter_link` — directed flow topology (`from → to`: the downstream meter is a subsection of the upstream one;
several parents are allowed). It is topology only: it never defines a virtual meter's calculation (D-25).
- `consumption_rollup` / `consumption_rollup_month` — per-meter sums of `consumption` by local day and local month,
with per-provenance amounts, row count, flags and the latest interval end.
- `meter_coverage` — per-meter coverage runs with their resolution class.
- `meter_rollup_state` — the revision, zone, normalized unit and kind each meter's analysis rows were built with.
> **Deviation (D-57):** the principle "long-horizon retention is served by aggregates, not by keeping every raw row
> forever" is not realised. Raw readings are kept indefinitely (§5.5).
### 5.2 Measurement modes (`meter.mode`)
| Mode | Meaning | Consumption derived by |
@@ -183,6 +221,10 @@ Design principles: raw readings are immutable audit truth; everything derived (c
### 5.3 Schema sketch (PostgreSQL + TimescaleDB)
> Illustrative DDL; EF Core migrations own the relational tables, and **raw-SQL migrations** own the Timescale-specific DDL (`create_hypertable`, compression, continuous aggregates, retention). Timescale objects are *not* expressible through EF's model builder.
>
> *Current:* the Timescale DDL in use is the two hypertables (`reading`, `consumption`) and raw compression. The
> continuous aggregates were dropped by the `AnalysisRollups` migration (D-17), and no retention policy exists (D-57).
> The rollup, coverage and state tables are ordinary EF-owned tables with `ON DELETE CASCADE` to `meter` (D-12).
```sql
CREATE TABLE energy_type (
@@ -353,6 +395,48 @@ CREATE TABLE app_setting (
### 5.4 Continuous aggregates & cost view
> **Deviation (analysis rework, `docs/ANALYSIS_IMPLEMENTATION_NOTE.md` D-10 D-17, D-36, D-58):** the continuous
> aggregates below were Berlin-only and materialized-only. They were never backfilled after historical imports, and
> no reader used them. The `AnalysisRollups` migration dropped them together with their refresh policies. The SQL
> below is kept as the original design. What replaced them:
>
> - **Rollup tables** (plain tables, FK to `meter` with `ON DELETE CASCADE`), all keyed by local dates of the
> **configured** zone:
>
> | Table | Key | Holds |
> |---|---|---|
> | `consumption_rollup` | `(meter_id, day, kind)` | amount; measured/manual/imported/estimated shares; row count; flags (baseline delta, divided); latest interval end |
> | `consumption_rollup_month` | `(meter_id, month, kind)` | the same per local month (read for month and year buckets) |
> | `meter_coverage` | `(meter_id, span_from)` | coverage runs: span, resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser), whether divided at months, gap reason, last interval start |
> | `meter_rollup_state` | `(meter_id)` | normalization revision, zone, normalized unit and kind, build time |
>
> - **Written by the recompute, by diff, in its transaction.** `NormalizationService.RecomputeMeterAsync` stages them
> through `AnalysisDataWriter` in the same transaction as the meter's `consumption`. Only changed rows are touched.
> Import, revert, events, manual readings, live ingestion and meter edits all reach the next read without a refresh
> job. No cache sits in between.
> - **Intervals.** Every normalized row carries its source interval (EF-ignored, D-10), so coverage is captured while
> normalizing instead of being guessed from sums. A non-label row that ends exactly on a local midnight is stamped one
> second earlier, inside the day it closes (D-11).
> - **Bucket status** (D-14) comes from coverage, never from the amount:
> - `Available`: covered. A zero is a true zero.
> - `Partial`: only partly covered.
> - `Missing`: nothing covers the bucket.
> - `Unresolved`: covered only by an undivided interval that crosses the bucket edge, e.g. monthly data asked by day.
> - `Invalid`: a calculation failed (virtual meters, §7.4).
> - `Pending`: the rollups are being rebuilt.
> A to-date read caps coverage at "now". Rows whose interval closes after now are reported, never counted (D-04,
> A-04, A-05, A-14, A-20).
> - **Reads.** Month and year buckets read the month table. Days and weeks read the day table. At most two partial
> edge days per range come straight from `consumption`. Every request makes one query per table, and the point and
> series limits are checked before any SQL runs (D-15).
> - **Rebuild.** Normalization revision 3 (D-16) rebuilds consumption, rollups and coverage for every meter at the
> next start (`NormalizationUpgrade`). It rebuilds again whenever the revision or the configured zone differs from
> `meter_rollup_state`. Until then a meter reads as "analysis being prepared" (`Pending`), never as "no data".
>
> **Cost is not a view either.** The cost engine (`CostReader`, §7.5) prices each bucket month by month, at the price
> valid on the 15th of each local month (D-36), so the bucket size never changes a total. There is no day-accurate
> proration mode (§14.3). A price change inside a reading interval longer than a month is reported, not guessed (A-16).
```sql
-- Daily normalized consumption per meter (local-tz buckets)
CREATE MATERIALIZED VIEW consumption_daily
@@ -390,6 +474,20 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36
> Design conclusion: 1000 meters × 50 years is comfortably within TimescaleDB on modest hardware **provided** dashboards read aggregates and raw retention is bounded. Don't let the UI scan `reading` for charts.
> **Deviation (D-57, a documented limitation):** raw retention is **not enforced**. `MeterVault__RawRetentionDays`
> (default 1095) is shown, but nothing deletes readings. Every recompute rebuilds a meter's consumption from the
> readings that remain, so dropping old readings would destroy analytical history. Bounded raw retention first needs a
> recompute that can start from stored consumption. `/admin/settings` shows "Not enforced" with that reason, and the
> meter page's Readings tab explains it.
>
> **History reads the rollups** (§5.4 deviation): `consumption_rollup_month` for month/year buckets and
> `consumption_rollup` for day/week buckets. `consumption` itself is read only for at most two partial edge days per
> range and by the meter page's Normalized data tab. No chart reads `reading`. It is read by the paged Readings tab
> (100 rows per page, keyset-ordered, date-filtered, D-50), by the manual-entry checks, and for freshness: the latest 20
> reading times per meter (D-18). That freshness query has no time bound yet (§13, report). `consumption` and the
> rollups are the analytical history. Performance was measured against a synthetic 1,000-meter × 10-year dataset
> (`docs/ANALYSIS_REPORT.md`, §13).
---
## 6. Ingestion
@@ -413,7 +511,14 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36
- Ship the four reference CSVs as built-in example imports and as test fixtures.
### 6.4 Secrets
Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path); the app resolves at runtime. Document this clearly.
Tokens/passwords are **never** stored in plaintext in the DB. Two storage forms satisfy this, chosen per connector in the admin UI:
- **By reference** — `ingestion_endpoint.config` names an env var / Docker secret path (`token_env`, `password_env`); the app resolves it at runtime.
- **Encrypted at rest** — the operator types the secret into the connector dialog and it is stored encrypted (`token_enc`, `password_enc`) under the ASP.NET Core data-protection key ring.
Exactly one form survives a save; switching clears the other. The encrypted form exists because reference-only forced a file edit plus a service restart to add a connector, which in practice led to tokens being pasted into the env-var *name* field. It keeps the guarantee that matters — a `pg_dump` or JSON export carries nothing usable — but note the trust boundary: the key ring is on disk, so it protects against leaked database content, not against an attacker who already has the host. That is the same boundary as an env var, which is equally readable from `/proc`.
The key ring must be persisted outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`), or a redeploy that replaces the content root will orphan every stored secret. MQTT *usernames* are not secrets and are stored as-is. JSON exports drop `*_enc` values: they are bound to the originating key ring and so are useless where an export would be restored — expect to re-enter secrets after a restore.
---
@@ -422,46 +527,391 @@ Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoin
### 7.1 Register → consumption
For `cumulative_counter`/`generation_counter`: for each new reading, `amount = value previous_value`. Persist to `consumption`. Cross a `meter_swap` as `(old_final prev) + (curr new_initial)`; a `counter_reset` starts a fresh baseline. Ignore/annotate negative deltas that lack an explaining event (flag as anomaly).
**Month attribution.** A reading is an instant, and the consumption between two readings accrued over the time between them. Booking the whole delta at the closing reading misfiles it whenever the interval crosses a month boundary: readings on 1 August and 16 September would show six weeks of use in September and none in August. So a plain increase whose interval crosses one or more **local** month boundaries (instance timezone, §10 — the months the charts bucket by) is divided at those boundaries in proportion to elapsed time. Each share is stamped inside its month — the closing reading keeps its own timestamp for the month it falls in, other shares take the last second of their month — and a divided interval's rows are marked `quality = estimated`: the meter recorded a total, not a shape. The parts always sum to the original.
Imported monthly tables keep the golden fixtures reconciling (§13). A row labelled "Mai 2026" carries the register at the *end* of May and May's consumption, but is stamped 00:00 UTC on 1 May so it files under its month. The importer flags such a row `reading.flags & month_label` — only it still knows whether the date cell named a month or a day, and a day-dated "01.08.2026" at the same midnight is an ordinary instant. A month label is read as the end of its month: readings are walked in that effective order by every register normalizer and by the checks that judge a new reading against its predecessor (so a sheet imported after live readings of the same month does not count the month twice, and a mid-month reading below the month's end value is not a decrease), consecutive rows span exactly their closing month and book unchanged, a skipped month is shared between the months the gap covers, and a live reading after the last imported row counts from the end of that row's month rather than claiming it a second time. A label is stamped inside the month it names — at its own timestamp where that lies in the local month, otherwise (zones behind UTC) at the month's local start. Swap and reset amounts are never divided: they are explicit corrections booked at the event. A swap the importer detected at a month row applies from the start of that local month, i.e. to the first reading in it, and a new register's recorded start value never counts above that reading. Should two rows still land on the same instant, the engine adds them into one estimated row rather than producing a duplicate key.
Every reader that buckets consumption by month or day (cost, trends, solar, consumables, flow, meter detail) buckets in the configured instance timezone — the same zone the division uses — never a hard-coded one, and starts and ends requested periods at local midnight. The zone id is normalised to its IANA form, and one unknown to .NET or PostgreSQL is reported at startup. Because consumption is derived, a change to these rules is applied to stored data at startup: `app_setting.normalization_revision` and `normalization_zone` record the rule revision and zone the stored series was built with, and every non-virtual meter is recomputed when either differs (the first run also flags month rows of earlier monthly imports, identified from each batch's stored mapping; a batch whose dates were auto-detected counts as monthly when all its rows sit on the 1st across at least two months, which is logged; if the flagging fails, nothing is rebuilt and the upgrade is retried at the next start). A meter whose rebuild fails is logged and listed in `normalization_pending`, retried at the next start, and never stops the application from starting.
> **Current (normalization revision 3, D-10, D-11, D-16):**
> - Every normalized row carries its source interval, so coverage and resolution can be recorded (§5.4).
> - A reading that closes exactly at a local midnight is booked in the day it closes, not the next one.
> - The startup rebuild also writes the rollup and coverage tables, and records each meter's `meter_rollup_state`.
> Virtual meters store no consumption: the migration purged their rows, and a meter that becomes virtual is purged
> on its next recompute.
> - A meter whose oldest consumption predates its oldest reading or event is skipped and logged, so its history is not
> truncated.
> - Month division still applies only to cumulative and generation counters. Tank, runtime, direct-delta and
> instant-rate intervals are booked whole. Where such an interval crosses a month edge, the months on either side are
> `Unresolved` and only coarser buckets are resolved (D-14, A-16).
### 7.2 Runtime → consumption (burner)
For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed.
### 7.3 Consumable/tank balance & forecast
`balance(t) = Σ deliveries(≤t) Σ consumption(≤t)`, reconciled to physical `tank_level` events when present (cm → litres via calibration). Forecast to empty from a trailing consumption rate (e.g. last-30-day L/day) → `Vorraussichtliches Ende`. Surface low/reorder thresholds.
> **Current (D-54, `TankLevels`):** the last dipstick ("Last dipstick: value on date") is kept apart from "Estimated
> now (incl. deliveries since)", which does not deduct use since the dipstick. For a period that ended before now,
> the Consumables page shows the contents at the period's end, never today's balance. The forecast is a labelled
> projection. It is hidden when the dipstick is older than 60 days, when too little time has passed, or when no use
> was measured. Deliveries before a tank's first level open a coverage gap (D-13).
### 7.4 Virtual meters
For `virtual`: evaluate `config.expression` (whitelisted, sandboxed — a small safe expression evaluator over referenced meters' consumption/generation series), e.g. `self_consumption = generation grid_feed_in`, `savings = self_consumption * unit_price`. Persist results to `consumption` (or compute on read — decide per §14). This is how PV self-consumption/savings and net figures are modelled without hardcoding.
> **Deviation (D-25 D-33, D-39, A-08, A-12, A-15, D-58):**
>
> - **Canonical definition in `meter.meta`:** `expression`, `referencedMeterIds` (always derived from the expression
> and rewritten on save), `resultKind`, `resultUnit` and `costRule`. The result kind is one of `consumption`,
> `generation`, `net` or `indicator`. Save writes the effective (inferred) kind, unit and cost rule, so readers never
> re-infer them (A-08). Topology links (`meter_link`) never define or change a calculation. `meter_source` rows of
> type `virtual` are not used for this.
> - **Formula:**
> - Grammar: `+ * /`, parentheses and numbers, parsed to an AST (`FormulaParser`), at most 2,000 characters and
> nesting depth 64.
> - References are `m<id>`. Any other identifier is an error; it is never read as 0.
> - The old string evaluator and `VirtualNormalizer` were removed.
> - **Validation** (`VirtualValidator`), on save and again on read for legacy data, covers:
> - syntax;
> - unknown or self references;
> - loops through nested virtual meters, reported with their path;
> - kinds and units: `+`/`` need the same unit and kind or a declared `net`; meter × or ÷ meter needs a declared
> unit and kind `indicator`. Indicators are non-additive, never totalled and never costed.
> - **Evaluated on read, never materialized** (`VirtualEvaluator`, through `AnalysisReader`):
> - Evaluation runs per bucket from the sources' rollups, in dependency order. Each physical source is read once,
> however deeply it is nested.
> - Coverage is the intersection of the sources' coverage, and the resolution is the coarsest among them.
> - Strict: a missing source bucket makes the result missing and names the source. An observed zero is a valid input.
> - A non-finite result (division by zero) is `Invalid` with the reason. So is a loop, which is reported with its
> dependency path.
> - A period total is the formula over the sources' totals across their joint coverage. For a linear formula without
> a constant this equals the sum of its buckets; any other formula (for example a ratio) is marked non-additive,
> and its total is the ratio of totals.
> - The result carries every source's series (the "source contributions" on the meter page).
> - Nothing is written to `consumption` for a virtual meter. This decides §14.1.
> - **Totals:** a virtual meter is an *analysis view* by default and is never added on top of the meters it reads.
> The meter's `totals` override (`auto|always|never`) can make it replace its sources in its type's totals and in the
> bill (D-23).
> - **Legacy definitions (D-28):** at startup (`VirtualDefinitionUpgrade`), an expression-less virtual meter whose
> same-type incoming links name sources of one unit and kind gets the equivalent explicit sum stored. The run is
> idempotent and logged. Anything ambiguous is flagged "needs configuration" and is never guessed. The seed writes
> Summe Solar as `m4 + m5`, generation, kWh, cost rule `none`.
> - **Prices are not part of expressions** (`savings = self_consumption * unit_price` is not supported). A virtual
> meter's cost follows its cost rule (§7.5): `sourceCosts` (pure sums: the sources' own metered costs), `ownQuantity`
> (linear formulas: the evaluated quantity at its unit price) or `none`. PV savings are computed by the Solar page
> (self-consumption × the grid unit price, month by month) or through `ownQuantity`.
> - Export/import carries `meter_link` and remaps meter ids inside definitions (D-32). Deleting a meter names the
> virtual meters that read it and asks for confirmation (D-33).
### 7.5 Cost
`cost(bucket) = Σ(consumption_amount × active_unit_price) + base_price(prorated) feed_in_credit bonus`. Prices resolved by date from `tariff` (time-ranged). Currency from `app_setting`. Provide monthly-price and day-accurate-proration modes (§5.4). Categories roll costs up per `cost_category`; add `manual_cost` for meter-less categories (pool).
> **Deviation: the bill (D-22, D-34 D-43, A-15 A-19, A-21 A-22, A-26 A-27).** One cost engine (`CostReader` →
> `BillRun` → the Core `CostCalculator`) prices the portfolio, an energy type, a meter or a category for one resolved
> period. Every page, the REST API and the CSV export use it. Before the rework, costs summed every meter; this is
> what the engine does now:
>
> - **What is billed (D-22, D-34):**
> - Per energy type, the `grid_import` meters are billed if the type has one. Otherwise its *use* meters are billed:
> the `total_load` meter, or else the consumption roots of the topology.
> - Generation meters are never billed.
> - The feed-in credit is the FeedIn price × the export of `grid_export` meters.
> - Runtime meters and virtual views are not billed.
> - Submeters (topology children) are breakdowns, never added. So the seeded Strom bill is Zähler Netz × price, as
> the sheet's `Kosten` is.
> - **Separately billed subsections (D-35, A-19):**
> - A containment child with its own meter-scoped unit price is billed at that price, and its quantity is taken out
> of its billed ancestor.
> - The same applies to a consumer linked directly below a billed grid meter.
> - Quantity totals do not change.
> - **Prices (D-36):**
> - The spreadsheet's monthly convention is kept: the price valid on the **15th of each local month**.
> - Every bucket is cut into its local months and each part priced at its month's price, so week, month and year
> buckets, and the period total, agree.
> - There is no day-accurate proration (§14.3).
> - A reading interval longer than a month (a tank dipped every few months, quarterly burner hours) leaves its months
> unknown. A longer bucket over such months is priced as a whole when every month in it has the same price;
> otherwise it is unavailable, with an attention item (A-16).
> - **Tariff applicability (D-37):**
> - A UnitPrice or FeedIn tariff applies only when its unit's denominator matches the meter's normalized unit.
> Known scales are converted (ct, per 100 L, per MWh).
> - A parsed unit or currency mismatch makes the cost "unavailable (unit)", and the explanation names what does not
> fit (A-28). An unparseable unit applies, with a warning.
> - BasePrice units are per day, per month (the default) or per year.
> - The tariff editor checks units on save. It states that **Bonus, Discount and Tax are stored but not applied**
> (D-57).
> - **Not priced vs price gap vs zero (D-38, A-26, A-27):**
> - A billed scope with no UnitPrice tariff at any date is **not priced (no tariff)**. That is an attention item, not
> a partial total.
> - A hole in a priced scope's tariff history makes those months a **price gap** (cost unavailable).
> - An explicit zero tariff is a **valid zero**. The tariff editor refuses to save a new tariff without a value, so a
> deep link cannot create a free period by accident.
> - A missing FeedIn price is reported only where a `grid_export` meter exists.
> - Months with no grid meter in service, while use was measured, are unavailable rather than free (A-17).
> - A bucket with nothing booked reads "No data", never "Priced" (A-26).
> - The quantity analysis stays visible whenever the cost is unavailable.
> - **Standing charges (D-40, A-18):**
> - A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service period
> (install or first data to retirement or now, regardless of reading gaps).
> - Type- and global-scoped charges are their own rows, **once per scope**, never copied onto each meter.
> - Meter-scoped fees stay on their meter. A fee on a meter that no bill line prices is its own row.
> - **Manual costs (D-41):**
> - A manual cost is booked in full, **once**, on its `PeriodStart` local day when that day is in the period and not
> after today. `PeriodEnd` is informational.
> - They count in the Overview, the Analysis page, categories and the export alike.
> - **Categories (D-42, A-22):**
> - A category's cost is the priced **non-overlapping cover** of its members, plus its manual costs.
> - The disjoint categories, *Uncategorized* and the standing-charge rows form the **composition**, which reconciles
> to the bill.
> - A category that overlaps another, or covers meters outside the bill, is an **overlapping view**. It is listed
> apart, never summed, and never drawn in the donut. The donut is drawn only when every slice is ≥ 0; otherwise
> signed bars are used.
> - A category whose members price nothing (calculated views, generation, runtime) says so rather than "no data".
> - **Virtual meters (D-39, A-15):** they are costed by their named rule:
> - `sourceCosts` (pure sums only): each physical source's own metered cost, once, with no scope-level standing
> charges.
> - `ownQuantity` (linear formulas without a constant): the evaluated quantity at its unit price.
> - `none`: every other formula, and generation sums (generation is never billed), which is why Summe Solar is not
> costed.
> - The rule is named next to every virtual cost. A virtual meter enters the bill only through the `always`
> override, and then replaces its sources.
> - The REST API reports a meter without a cost rule as `NotPriced` with the reason (A-21).
> - **Currency (D-43):** the configured `MeterVault__Currency` everywhere, through `Format.Money` / `InstanceCurrency`.
> A tariff in another currency is a unit mismatch; no conversion is made.
> - **Changes (D-07, A-23):** a cost change is stated only between complete figures, by one rule on every page
> (`OverviewComparison.Between`). If both totals are complete, it compares the totals. Otherwise it compares only the
> paired buckets that are complete on both sides, with the caption "over the part both periods cover".
---
## 8. Dashboard & UX
> **Deviation (analysis rework, brief §3–§8, D-46 D-55, D-58):** the panels below were rebuilt as one set of pages
> that share one period contract, one reader pair and one set of components. §8.0 describes what they share; the
> notes under §8.1 §8.7 say what each page now is.
### 8.0 Shared analysis contract (current)
**Pages and navigation (D-47, D-48):**
| Sidebar entry | Route | What it is |
|---|---|---|
| Overview | `/` | One selected period (default month to date): the cost with its composition, a card per energy type (quantities in their own units, cost with billing basis, change, freshness), the history chart, "What changed", attention items |
| Analysis | `/trends` (route kept) | Explore any scope: `portfolio`, energy `type`, cost `category`, one `meter`, or up to six `meters` side by side, by quantity or cost |
| Meters | `/meters` | All meters with period values, status, resolution, "data up to", how each counts, quick entry, filter by type |
| Energy types → *type* | `/energy/{id}` | Tabs `overview`, `history` (total or `view=meters`), `flow` (Sankey + table + Manage connections), `meters` |
| Specialized views | `/solar`, `/consumables` | Always listed; each shows a setup state when unsupported (no generation meter, no tank) |
| Data import | `/import`, `/import/wizard` | Import batches with revert; the CSV mapping wizard |
| Configuration | `/admin/*` | Energy type *definitions*, tariffs, cost categories, connectors, settings |
- The meter page `/meters/{id}` is the per-meter hub. Its tabs are `analysis`, `readings`, `normalized`, `events`,
`tariffs`, `sources`. A virtual meter shows `analysis`, `events`, `tariffs`, `calculation`: no Readings or
Normalized data, and Calculation instead of Sources.
- Old links still work: `tab=consumption` opens `normalized`, and on a virtual meter `sources` opens `calculation` and
`readings` opens `analysis`. Tabs are resolved by key and meter mode, never by index.
- One-shot `action=` links (reading, swap, reset, delivery, tank level, note, edit, source) open their dialog once and
are then dropped from the address.
- Breadcrumbs (Overview → type → meter) carry the period. Expanded sidebar groups persist in a cookie (`mv-nav`), and
the group of the current page is always open. If the energy types cannot be loaded, the nav shows an error with
Retry instead of dropping the group.
- The app-bar "Find a meter" (a text button from the md breakpoint, an icon below) opens a meter's Analysis tab with
the current period, next to the quick-entry action.
**Period and URL state (D-01 D-08, D-46, A-13, `AnalysisQuery`):**
| Key | Values |
|---|---|
| `scope`, `id`, `ids` | `portfolio`, `type`, `category`, `meter`, `meters` (`ids`, at most 6) |
| `metric` | `consumption`, `generation`, `export`, `runtime`, `net`, `cost`, `balance` |
| `period` | `mtd`, `last-month`, `ytd`, `prev-year`, `12m`, `24m`, `all`, `custom` (with `from`/`to`, local, inclusive) |
| `bucket` | `auto`, `day`, `week` (Monday), `month`, `year` |
| `compare` | `none`, `prev-period`, `prev-year`, `year:YYYY` |
- The URL is the state: reload, share and Back reproduce the page. Defaults are never written: the Overview uses
`mtd`, history pages `12m` (12 calendar buckets ending with the current partial month), and every page compares with
the previous year (A-13).
- Toolbar and tab changes replace the history entry; drill-downs push. An invalid token falls back to the default
with a notice.
- `all` spans the scope's available data, never a fixed century.
- `auto` picks one bucket, at most 400 points per series. A finer explicit bucket is refused with a coarser
suggestion.
- A period resolves once per request against a captured "now" in the configured zone (§10). Quantities, costs,
comparisons and the export use the same half-open bounds.
- Comparisons shift in calendar units. The change figure is measured only over the range both periods cover, and both
exact ranges are shown (D-07). The percentage is "not applicable" for a zero or negative baseline; the absolute
difference is always shown (D-08). Colours depend on the metric: more generation is good, more consumption is not.
- Projections are separate, labelled with their method, and suppressed where coverage is insufficient (D-09).
- Pages load through a `LoadSequencer`, so a superseded load never overwrites a later one. Panels show a
loading/refresh/error-with-Retry state.
- Other page-specific keys: the energy page's `tab` and `view`, the Overview's `chart`, the record tabs' `from`/`to`.
**Missing vs zero vs not priced (brief §4.3, D-14, D-38, A-24 A-28):**
- Every figure carries its bucket status (§5.4) and provenance (measured, manual, imported, estimated, derived,
opening balance), worded next to it (`FigureText`).
- A true zero is a number and a bar on the baseline.
- An unknown bucket is a gap in the chart, marked "", and "—" with its reason in the table: no data, only coarser
data, cannot be calculated, being prepared.
- Qualified values (partial, estimated) are marked "*".
- A period without data says "No data for this period", names the available dates and offers "Go to latest data". A
future range says it has not started yet.
- A chart never says "no data" when the reason is a coarser resolution (it names the resolution and offers the
interval that shows it) or a missing price (it names the cost status).
- Drill-downs only go where finer data exists. A monthly bucket never opens 31 unknown days. A physical meter's finest
bucket opens its Normalized data. A virtual meter's opens its own analysis over that bucket, whose source list links
on to the sources' records.
- Missing prices, stale sources, invalid calculations, rows dated after now and possible overlaps become attention
items, each with one targeted action (D-53). For example, "Add tariff" opens
`/admin/tariffs?scope=&id=&component=&from=&action=new` prefilled with the first uncovered month (D-52).
**Shared components (`Components/Shared/Analysis`):** `PageHeader`, `AnalysisBreadcrumbs`, `PeriodToolbar`,
`AnalysisChart` (ApexCharts; one axis per unit; signed values around a real zero line; follows the light/dark theme
in the current circuit), `AnalysisTable` (the accessible equivalent of every chart), `MetricCard`, `ChangeChip`,
`ValueStatus`, `EmptyPeriodState`, `PendingState`, `PanelError`, `LoadPanel`, `ProjectionNote`, `ComparisonSummary`,
`AttentionList`, `SeriesContributions`.
**CSV export (D-55):** `GET /export/analysis.csv` takes the same URL keys as the pages; every toolbar has "Export CSV".
It writes one row per bucket and series, with these columns:
- `series_id`, `series_name`, `kind`, `unit`;
- `bucket_start`, `bucket_end` (local ISO with offset; the end is exclusive, and a to-date bucket ends at now),
`timezone`;
- `value` (invariant, full precision; empty when unknown), `status`, `provenance`;
- `cost`, `cost_status`, `currency`, `comparison_value`.
A bucket with nothing booked is `Missing`, never `Available`. Invalid requests (a notice, too many buckets, an unknown
scope) get a 400 with a plain-text reason. The endpoint is a UI endpoint, like the pages, and needs no API key.
### 8.1 Overview
- KPI cards: **Today**, **This month**, **This year** cost — each with Δ (absolute + %) vs the previous comparable period and an ↑/↓ indicator.
- "Cost now" total across all categories.
> **Deviation (D-58, brief §7.1):** the Overview shows **one selected period** instead of Today/This month/This year
> cards. It has a period toolbar and defaults to month to date. The page shows:
>
> - the cost of the period, split into metered use, standing charges, manual costs and feed-in credit;
> - one card per energy type, with its measures in their own units (unlike quantities are never added), the cost with
> its billing basis, the change, freshness, and a link to that type with the same period;
> - the history chart with a metric selector (`chart=`), the previous-year overlay, a table toggle and drill-down;
> - "What changed", by category or by meter, with rows linking to the scoped Analysis page with the same dates;
> - the cost composition (§7.5): a donut only for non-negative disjoint slices, otherwise signed bars. Overlapping
> views are listed apart;
> - attention items, and "Latest month with data" with its month and basis (meters, manual costs or both, D-19).
>
> Changes are measured over the coverage both periods share (D-07). A period without data offers "Go to latest data";
> the page never switches to history on its own. Missing categories or tariffs are small setup notes, never a
> prerequisite for seeing quantities. The REST summary keeps its legacy month/year windows (D-45).
### 8.2 Cost breakdown / "what costs most"
- Stacked bar or donut by `cost_category` for a selectable period; ranked list (most → least).
- **Difference view** (explicitly requested): a table answering *"what cost more, what cost less this time"* — per category **and** per meter, **this month vs last month** and **this year vs last year**, columns `now | previous | Δ | Δ% | ↑/↓`, sorted by absolute impact.
> **Current:** both live on the Overview for the selected period and its comparison, not for fixed month/year windows.
> The composition is §7.5's. "What changed" lists categories or meters with current, previous, change and percentage
> where applicable, sorted by impact, plus a bill total row. A change is shown only between complete figures (A-23).
### 8.3 Trends
- Consumption and cost over time; **granularity toggle** day/week/month/year; per-meter or per-category; **previous-year overlay**.
> **Current:** this is the **Analysis** page (`/trends`, brief §7.4, nav "Analysis"). It offers:
>
> - Scope: all energy types, one energy type, a cost category, one meter, or a comparison of up to six meters. A
> seventh is refused with an explanation.
> - Metric: only what the scope supports. A category is always available by cost, and by quantity only when all its
> meters share one kind and unit; otherwise the page explains why and offers the alternatives.
> - The shared toolbar, with a calendar-year select (compare with any of the five years before), the chart (overlays
> up to three series; above that the comparison stays in the table), the table, drill-down and CSV export.
> - For per-type measures, total use and grid import side by side, never added. Portfolio cost is the same bill the
> Overview shows, with manual costs once.
### 8.4 PV / Solar panel
- Generation, self-consumption, grid feed/draw, **savings (Ersparnis)**, **autarky %**, **self-consumption %**. Time-filtered.
> **Current (D-54, `SolarService`):** one section per energy type that has generation. Nothing is inferred from names:
> meters are found by mode and by the effective roles `total_load`, `grid_import`, `grid_export` (A-07).
>
> - **Generation** is the type's generation measure, so a virtual sum such as Summe Solar is listed as a view and never
> added twice.
> - **Self-consumption** is total load grid import, or else generation grid export.
> - **Feed-in** is the grid export meter, or else generation self-consumption (labelled as calculated; batteries are
> not modelled).
> - **Site use** is the total load meter, or else self-consumption + grid import.
> - **Savings** are self-consumption × the grid unit price, month by month through the cost calculator.
> - Autarky % and self-consumption % are shown when the roles allow.
>
> Every figure shows its status and how it was obtained. Units come from the meters; mixed units give "cannot be
> calculated". A missing role gets a setup card with the candidate meters, which lead into the meter editor (no raw
> role tags). The page has no CSV export, because the export has no derived measures.
### 8.5 Oil / consumable panel
- Tank level (cm + L), balance vs capacity gauge, deliveries log, burner runtime, effective **L/h** (fixed/empirical), **forecast to empty**, monthly cost.
> **Current (D-54, `ConsumableService`):** "Now" and "Selected period" are separate parts.
>
> - **Now:** the last dipstick with its date (and cm reading), an estimate that includes the deliveries since then,
> the fill bar (only when the level is known), and the forecast as a labelled projection (§7.3).
> - **Selected period:** use from the analysis reader, the deliveries of the period only, burner runtime of the type's
> runtime meters, and the burn rate (fixed, or empirical when runtime is in hours).
> - A period that ended shows the contents at its end.
> - The cost stays unknown, never 0 €, when the tank has no tariff or its months cannot be placed (A-16).
### 8.6 Meter detail
- Raw readings, normalized consumption, source status (last-seen, last value), tariff timeline, events (swaps/deliveries/corrections), measured-vs-estimated markers.
> **Current (brief §7.2, D-47, D-50):** the header carries identity, the energy type, mode and retirement chips, and
> the actions: primary entry by mode (Add reading / Record tank level), the "Record event" menu and Edit. The tab bar
> sits directly below the header.
>
> - **Analysis** tab:
> - The quantity card in the normalized unit (D-20), with any projection shown separately inside it.
> - The cost card, with its rule named or "Not costed" plus the reason, and the cost change (A-23).
> - The previous-year overlay, a full chart, the table, drill-down and CSV export.
> - Events and tariff changes inside the range, listed as context under the chart.
> - A "Data quality and coverage" section: resolution, data range, freshness, opening balance with "Set install
> date", and rows recorded after now.
> - For virtual meters, the source contributions.
> - **Readings**, **Normalized data** and **Events** tabs:
> - Server-side paging, 100 rows per page, keyset-ordered, filtered by the page's `period`/`from`/`to`.
> - Their toolbar shows the whole range listed, and rows dated after now carry an "After now" mark (A-29).
> - The Readings tab explains that raw readings are the audit record and that raw retention is not enforced.
> - **Tariffs** lists meter, type and global tariffs with their effective end and the one that applies now, plus "Add
> tariff for this meter".
> - **Sources** links each source's connector to its editor (the connector detour keeps the typed draft).
> - A virtual meter's **Calculation** tab shows the status, the formula with meter names beside each `m<id>`, result
> kind and unit, cost rule, the meters read (also through nested calculations), and any problem with its dependency
> path.
> - The manual-entry dialog runs its own queries for the entered time, so its verdict never depends on a page of rows.
### 8.7 Admin / config
- CRUD for energy types, meters, sources, tariffs, cost categories, connectors; retention & locale/currency settings; import wizard; API keys.
> **Current (D-21, D-23, D-31, D-37, D-52, A-27, A-30):**
>
> - **Meter editor** (shared `MeterEditor`):
> - Roles use friendly names and one-line meanings, and are offered only for compatible modes. Saving a role moves it
> and names the meter that held it.
> - The totals override (Automatic / Always / Never) states its meaning and "In the totals now: …". A conflicting
> "Always" is refused, naming the other meter.
> - A virtual meter gets the calculation editor: Sum, Difference or Formula mode; sources picked by name, with unit,
> kind and dates; only valid cost rules offered, each with its reason.
> - A live preview uses the page's period (every preset, custom dates, all history) and shows per-source values and
> the incomplete months.
> - Saving a Sum can bring the incoming links in line with its sources, but links never change a calculation.
> - **Tariffs:** the deep link opens a prefilled dialog once, scoped to what can price that meter or type. Units are
> checked live, a new tariff needs a value, and Bonus/Discount/Tax are marked "not applied".
> - **Energy types** under Configuration edit the definitions ("Energy type definitions"); analysis lives under the
> Energy types nav group.
> - **Settings** is read-only. It shows the zone, the currency, raw retention ("Not enforced"), the normalization
> revision and zone, how many meters have current analysis data or are waiting for a rebuild, and calculated meters
> by status.
> - Flow topology is edited from the energy type's Flow tab ("Manage connections"): cycle-, type- and
> duplicate-checked, and it never touches a stored formula. Physical meters can also set their upstream meters in
> the editor.
> **Legacy monthly history:** imported data is monthly-granular. Offer per-import choice: keep native monthly buckets, or **linearly interpolate to daily** (energietracker-style) so old and new data render on the same axes. Interpolated points are marked `quality = interpolated`.
>
> **Current behaviour (D-14, D-57):** monthly data stays monthly and is never interpolated. A day or week bucket over
> it reads "only coarser data", the chart names the data's resolution and offers the interval that shows it, and a
> drill-down never opens days a monthly import cannot resolve (D-51).
---
@@ -483,17 +933,54 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
| `GET /api/v1/sources/status` | Connector/source health. |
| `GET /healthz` | Liveness/readiness (for Gatus). |
> **Current contract (D-45, A-16, A-21; pinned by `ApiContractTests`):** `/consumption`, `/cost` and
> `/dashboard/summary` keep every existing field, name and type. Their numbers now come from the analysis reader and
> the cost engine, so they match the pages. What changed is only added as new fields:
>
> | Endpoint | Behaviour | Added fields |
> |---|---|---|
> | `GET /api/v1/consumption?meter=&from=&to=` | Monthly rows as before. Instants with any offset are accepted (converted to UTC; an offset used to be a 500). Actuals stop at now. A month without data is absent, not a 0. A virtual meter is evaluated from its formula. | `status` (BucketStatus), `issue` (why a value is not plain), `kind`, `unit` (normalized) |
> | `GET /api/v1/cost?meter=&from=&to=` | `cost` stays numeric: 0 when nothing could be priced, with the reason beside it. The meter is priced by its rule (the bill line, a subsection at its unit price, a virtual meter by its cost rule). Generation and runtime meters, and meters that cannot be evaluated, are not costed. | `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the status of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted` (the reason), `missingPrices[]` (component, reason, scope, first/last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit` |
> | `GET /api/v1/dashboard/summary` | Keeps its legacy windows (calendar month and year to now against the whole previous ones) but prices the bill (§7.5). | `deltaPercentApplicable` per KPI (the percentage is 0 and not applicable for a zero or negative baseline), `latestMonth` `{period, basis}` |
>
> `/consumption` and `/cost` take one `meter` and answer by calendar month, as before the rework; the table's `scope`
> and `bucket` parameters are not implemented. The UI's analysis CSV (`GET /export/analysis.csv`, §8.0) is not part of
> `/api/v1` and needs no API key.
---
## 10. Non-functional
- **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
*Deviation (D-01 D-06, D-58):*
- **Zone:** the **configured** zone (`MeterVault__TimeZone`, normalized to its IANA id) is used everywhere:
normalization, rollups, readers, periods, the export. Berlin is never hard-coded, and a zone change rebuilds the
rollups.
- **Bounds:** a period resolves into a local inclusive date range for display and a **half-open** UTC range
`[from, to)` for queries. `to` is the local midnight after the end date, or the captured "now" for to-date
periods.
- **Calendar:** days start at local midnight, and a DST day really has 23 or 25 hours. Weeks start on Monday. Months
and years are local.
- **Clock:** "now" is read once per request from the registered `TimeProvider` (`InstanceClock` for pages).
Services never read the clock; tests freeze it.
- **After now:** rows whose interval ends after now are never counted as actuals; they are reported as "recorded
after now" (D-04).
- **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key.
- **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`.
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets (never in DB plaintext).
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets or encrypted at rest (never in DB plaintext) — see §6.4.
- **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions.
- **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability.
- **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded.
*Current:* dashboards read the rollup tables (§5.4). A request sends a constant handful of statements, whatever the
meter count. The point limit (400 per series) and the series limit (6 on a chart) are enforced before any SQL runs.
The brief's target is a 10-year monthly request for 100 meters in under 2 s; it was measured against a synthetic
1,000-meter × 10-year dataset (§13, `docs/ANALYSIS_REPORT.md`). Raw record tabs are paged (100 rows, keyset) and
date-filtered.
- **Accessibility & layout (brief §8):** every chart has a table equivalent; colour is never the only cue (words,
arrows, signed values, dashed overlays); visible focus rings; keyboard-reachable drill links; no page-wide overflow
at 360 px; charts follow the light/dark theme (cookie `mv-theme`) within the circuit. MudBlazor's own labels are
localized (`MeterVaultMudLocalizer`).
- **Currency:** `MeterVault__Currency` (default `EUR`) for every amount (D-43).
---
@@ -515,6 +1002,18 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
/docs architecture, setup, HA/Tasmota wiring, API, screenshots
```
> *Current layout:* the analysis rework added these folders.
> - `src/Core/Analysis`: pure rules for time and periods, coverage, rollups, quantities and units, totals and category
> cover, virtual formulas, and the cost calculator.
> - `src/Infrastructure/Analysis`: the reader, the catalog and the virtual upgrade.
> - `src/Infrastructure/Costing`: `CostReader` and `BillRun`.
> - `src/App/Analysis`: the URL contract and the chart, table and attention models.
> - `src/App/Components/Shared/Analysis`: the shared components.
> - Page folders under `src/App/Components/Pages`.
>
> The fixtures live in `sampledata/`. CI is Gitea Actions (`.gitea/workflows/`), publishing to the Gitea container
> registry. `CLAUDE.md` holds the maintained layout.
- **CI (GitHub Actions):** build → test (spin Timescale) → publish Docker image to **GHCR** (amd64; add arm64 if desired) on tag.
- **License:** pick before release — **MIT** (max adoption; matches energietracker/your prior assets) or **AGPL-3.0** (keeps hosted forks open). Default suggestion: **MIT**, unless keeping SaaS forks open-source matters to you.
- **Docs:** a "wire up HA/Tasmota" guide is the highest-leverage doc for adoption.
@@ -532,6 +1031,16 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
- **M6 — API & auth.** REST + OpenAPI; API keys; reverse-proxy trust. *Exit:* HA can push via `POST /readings`; Swagger published.
- **M7 — Release polish.** i18n (de/en); retention settings; JSON export/import; Unraid template; CI → GHCR; README + wiring guide. *Exit:* `docker compose up` from a clean host yields a working, documented instance.
> *After M7:* the dashboard/analysis rework was built in five phases:
> 1. shared semantics and fixtures;
> 2. virtual evaluation and migration;
> 3. history and navigation;
> 4. Overview and specialized pages;
> 5. integration, performance and documentation.
>
> It replaced M4's continuous aggregates and "monthly + prorated" cost view with rollup tables and the month-by-month
> bill (§5.4, §7.5), and M5's panels with the pages of §8.0. M7's "retention settings" remain display-only (D-57).
---
## 13. Testing strategy
@@ -541,21 +1050,89 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
- **Golden fixtures:** the four CSVs with expected monthly consumption/cost tables. A regression test asserts computed ≈ spreadsheet (define tolerance for rounding; the sheet rounds to cents / whole kWh).
- **Load smoke (optional):** synthetic 1000-meter × N-year generator to validate aggregate query latency and compression ratio.
> **Current (analysis rework, D-56):** `Core.Tests` has 1,733 tests and `Integration.Tests` 746, all passing at the end
> of the rework. The continuous-aggregate refresh test was replaced by
> `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums` (water Dec 2022 = 14 m³). `SchemaTests`
> pins that the aggregates and their jobs are gone and that the analysis tables cascade with their meter.
>
> - **Frozen clock, pure (Core):**
> - `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests`: local New Year, Berlin DST in spring
> and autumn (23 h and 25 h days, the repeated hour), 29 Feb, 31 March against February, shorter months, New York
> (a zone behind UTC).
> - Coverage, rollups, provenance and freshness: `Coverage*Tests`, `RollupBuilderTests`, `MatchedCoverageTests`,
> `ProvenanceRulesTests`, `FreshnessRulesTests`.
> - Totals and categories: `TotalsPolicyTests` (the seeded classification, D-22), `CategoryCoverTests`,
> `SeparatelyBilledSubmeterTests`.
> - Virtual formulas: `FormulaParserTests`, `VirtualValidatorTests`, `VirtualEvaluatorTests`, `DependencyGraphTests`,
> `LegacyVirtualDerivationTests`: A+B, AB, missing vs zero, nested, loop, division by zero.
> - Costing: `CostCalculator*Tests`, `CostingTariffBookTests`, `TariffUnitTests`.
> - Units and changes: `UnitsTests`, `ChangeTests`.
> - **Reader and costs (Testcontainers):**
> - `AnalysisReaderTests`: the worked examples, local days across DST and in New York, rows after now, new readings
> and corrections visible on the next read.
> - `AnalysisDataTests`: rollups written by diff, rows removed behind the tracker's back.
> - `CostReaderTests`: missing vs zero tariff, price gaps, bucket-independent totals, standing charges once per
> scope, manual costs once, virtual cost rules. `CostReviewFixTests` adds a category whose members price nothing.
> - `SeededBillTests`: the D-44 goldens. The yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022,
> 2025 and 2026; 2023 and 2024 are pinned to the documented 3.78 € / 0.46 € differences. It also pins the
> composition and a manual-cost-only instance.
> - `CostConsistencyTests`: the same cost change on four pages, and matching standing charges.
> - `CoverageOfFixturesTests`, and the unchanged reconciliation suites (electricity, water, oil, costs; Netz
> Einsparung through the new evaluator, D-29).
> - **Contracts and export:** `ApiContractTests` (fields and types of `/consumption`, `/cost`, `/dashboard/summary`,
> offsets, not-costed meters), `AnalysisExportEndpointTests`, `AnalysisCsvWriterTests`, and `ExportRoundTripTests`
> (links and virtual definitions remapped, a deleted meter's tariffs not restored).
> - **Pages without a browser:**
> - Loaders against a database: `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyTypePageTests`,
> `OverviewDataTests`, `SolarServiceTests`, `ConsumableServiceTests`, `MeterDraftPreviewTests`,
> `VirtualManagementTests`, `MeterDetailServiceTests` (keyset paging, half-open filters).
> - Pure UI models: `AnalysisQueryTests`, `AnalysisNavigationTests`, `AnalysisChartModelTests`,
> `AnalysisTableModelTests`, `AttentionItemsTests`, `LoadSequencerTests`, `MeterPageLogicTests`,
> `EnergyPageTests`, `OverviewLogicTests`, `MeterEditorLogicTests`, `TariffEditingTests`.
> - Server-rendered HTML in EN and DE: `DashboardRenderTests`, `OverviewPageTests`, `AnalysisComponentRenderTests`,
> `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
> - `StringResourceTests`, `EnumDisplayNameTests` and `FormatCultureTests` pin both languages and the currency.
> - **Browser checks:** interactive ApexCharts, browser history, theme switching and responsive layout were checked
> against the seeded instance. CDP scripts drove Chrome in EN/DE, light/dark, 1440/390/360 px. That acceptance walk
> is recorded in `docs/ANALYSIS_REPORT.md`. No bUnit or Playwright suite is in the repository.
> - **Performance** (`tests/Integration.Tests/Performance`, trait `Category=Performance`): skipped unless
> `METERVAULT_PERF=1`. `SyntheticLoadTests` loads a deterministic 1,000-meter × 10-year dataset (≈1.34 M readings;
> monthly, daily and hourly meters, tanks, roles, links, 20 virtual meters nested up to three levels) through the
> real pipeline. `ReaderTimingTests` times the reader and cost scenarios (the brief's target: 100 meters, 10 years
> monthly, under 2 s), counts SQL statements, records query plans, the startup rebuild and per-meter recompute
> cost. Results are in `docs/ANALYSIS_REPORT.md`.
---
## 14. Open questions & defaults
Pick the **default** and flag it if unsure; only ask when a question isn't listed here.
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.)
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`.
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration.
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed).
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.) *Decided (D-58):* computed on read everywhere, costs included; nothing is materialized.
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`. *Current (D-57):* native monthly only; interpolation is not offered.
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration. *Decided (D-36, A-16):* one price per local month, the one valid on the 15th (the sheet's convention); no proration mode. Every bucket is priced month by month, so bucket size never changes a total. A reading interval longer than a month is priced as a whole only when its months share one price; otherwise it is unavailable, with an attention item. Standing charges accrue per day (D-40).
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed). *Current:* the `instant_rate` normalizer integrates the rate over time (trapezoidal), and a gap longer than max(1 h, 10 × the median sample interval) opens a coverage gap (D-13). There is no rate-specific ingestion worker: rate values arrive through the ordinary sources like any other reading.
5. **Multi-user?** *Default:* single admin + reverse-proxy trust; full accounts post-v1.
6. **.NET version pin.** *Default:* current LTS at implementation time; keep `TargetFramework` in one place.
7. **License.** *Default:* MIT unless you want AGPL's copyleft on hosted forks.
8. **Name.** `MeterVault` is a placeholder — decide before first public tag.
The analysis rework settled these further questions. Each is recorded in `ANALYSIS_IMPLEMENTATION_NOTE.md`.
9. **Raw retention (§5.5).** *Decided (D-57):* not enforced until recompute can start from stored consumption. The
setting is shown as "Not enforced".
10. **What a type's total and bill count.** *Decided (D-22, D-34):* the non-overlapping topology roots, with
consumption and generation apart. The bill counts grid import where there is one, otherwise use. Submeters and
virtual views are never added; the `always` override lets a virtual meter replace its sources (D-23).
11. **Virtual costs.** *Decided (D-39, A-15):* a named cost rule. `sourceCosts` for pure sums, `ownQuantity` for
linear formulas, `none` otherwise and for generation sums. The default for new virtual meters is "analysis only"
in the totals.
12. **Default comparison.** *Decided (A-13):* the previous year, at the same elapsed point, measured over what both
periods cover (D-07).
13. **Default periods.** *Decided (D-02):* the Overview uses month to date. History pages use the last 12 months: 12
calendar buckets, the current one partial.
14. **Missing tariff.** *Decided (D-38):* "not priced", never 0. An explicit zero tariff is a valid zero.
---
## Appendix A — CSV import dialect (from the reference files)
@@ -576,7 +1153,7 @@ Pick the **default** and flag it if unsure; only ask when a question isn't liste
|------------|---------------|
| Zähler (Haus/Netz/Auto/Solar) | `meter` (`cumulative_`/`generation_counter`) |
| Verbrauch / Erzeugung | `consumption.amount` (kind 0/1) |
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions |
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions (quantities only: Solar Erzeugung = `m(Solar 1) + m(Solar 2)`, Netz Einsparung = `m(Haus) m(Netz)`). Ersparnis is a price × quantity, which expressions do not support (§7.4). It is the Solar page's savings, or a virtual meter's `ownQuantity` cost. |
| €/kWh, €/m³, €/100l | `tariff.unit_price` (time-ranged) |
| Grundpreis / Abschlag | `tariff.base_price` |
| Betriebststunden | `runtime_counter` meter |
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

+5 -3
View File
@@ -55,11 +55,13 @@ it as an MQTT source (above). No HA endpoint needed.
and a `HomeAssistant` source on the meter:
```json
{ "entityId": "sensor.house_power", "attribute": null, "pollSeconds": 60 }
{ "entityId": "sensor.house_power", "attribute": null, "pollMinutes": 60 }
```
Set `HA_TOKEN` (a long-lived access token) in the environment. Numeric state (or a named
`attribute`) is read every `pollSeconds`; `unavailable`/`unknown` states are skipped.
Set `HA_TOKEN` (a long-lived access token) in the environment, or type the token into the connector
dialog to have it encrypted at rest (SDD §6.4). Numeric state (or a named `attribute`) is read every
`pollMinutes` — default 60, because monthly totals and cost are identical whether a meter is sampled
hourly or per-second. `unavailable`/`unknown` states are skipped.
**C — HA pushes to the REST API.** POST to `/api/v1/readings` with an `X-Api-Key` header (see the
README). Good when HA should drive the cadence.
+570
View File
@@ -0,0 +1,570 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.App.Theme;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Infrastructure.Analysis;
using MudBlazor;
using MudBlazor.Utilities;
namespace MeterVault.App.Analysis;
/// <summary>How a chart series is drawn.</summary>
public enum ChartSeriesStyle
{
Bar,
Line,
}
/// <summary>Why a chart value has no number (brief §4.3): the chart words an empty plot by it, never as "no data" alone.</summary>
public enum ChartGap
{
/// <summary>The value is known.</summary>
None,
/// <summary>No data, a calculation that cannot be evaluated, data being prepared.</summary>
NoData,
/// <summary>The data exists only at a coarser resolution than the bucket (D-14 unresolved).</summary>
Unresolved,
/// <summary>A cost whose quantities are known but whose price is not (no tariff, a tariff gap, a unit mismatch, D-38).</summary>
NotPriced,
}
/// <summary>
/// One value as the chart draws it: the number (null for an unknown bucket, which is a gap — never a zero), whether it is
/// qualified (partial, estimated, not fully priced), and the words the tooltip adds to it.
/// </summary>
public sealed record ChartValue(double? Value, bool IsQualified, string? Note)
{
/// <summary>Why there is no number; <see cref="ChartGap.None"/> when there is one.</summary>
public ChartGap Gap { get; init; }
/// <summary>The status in words ("Only coarser data", "Not priced (no tariff)").</summary>
public string? Status { get; init; }
/// <summary>A quantity bucket (<see cref="FigureText.Of(BucketValue, Func{int, string?}?)"/>).</summary>
public static ChartValue Of(BucketValue value, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(value);
var status = FigureText.Of(value, meterName);
var gap = status.IsKnown ? ChartGap.None : value.Status == BucketStatus.Unresolved ? ChartGap.Unresolved : ChartGap.NoData;
return new ChartValue(status.IsKnown ? value.Value : null, status.IsQualified, status.IsQualified ? status.Full : null)
{
Gap = gap,
Status = status.Status,
};
}
/// <summary>A cost figure (<see cref="FigureText.Of(CostAmount)"/>).</summary>
public static ChartValue Of(CostAmount amount)
{
ArgumentNullException.ThrowIfNull(amount);
var status = FigureText.Of(amount);
var gap = status.IsKnown ? ChartGap.None
: amount.Status is CostStatus.NotPriced or CostStatus.PriceGap or CostStatus.UnitMismatch ? ChartGap.NotPriced
: amount.Availability == BucketStatus.Unresolved ? ChartGap.Unresolved
: ChartGap.NoData;
return new ChartValue(status.IsKnown ? amount.Cost : null, status.IsQualified, status.IsQualified ? status.Full : null)
{
Gap = gap,
Status = status.Status,
};
}
}
/// <summary>Why a chart has nothing to draw (<see cref="AnalysisChartPlan.EmptyReason"/>).</summary>
public enum ChartEmptyReason
{
/// <summary>Something can be drawn.</summary>
None,
/// <summary>No value is known: no data, or nothing that can be evaluated.</summary>
NoData,
/// <summary>The data is only resolved coarser than the buckets (a monthly import in days): a coarser interval shows it.</summary>
Unresolved,
/// <summary>The quantities are known but not priced: the cost is unavailable until a tariff covers it.</summary>
NotPriced,
}
/// <summary>
/// A series the analysis chart draws (D-49, brief §8): a stable key, a display name (user data is never translated), the
/// unit or currency its values are in, one value per bucket of the plan, bar or line, and whether it is the comparison
/// overlay of another series.
/// </summary>
/// <remarks>
/// A comparison overlay's values are paired with the current buckets by index (<see cref="BucketPair"/>, A-10): value i
/// belongs to the image of bucket i. It shares the colour of <see cref="BaseKey"/> and is drawn dashed (a line) or
/// faded (bars), so the pairing is readable without colour.
/// </remarks>
public sealed record AnalysisChartSeries
{
/// <summary>A quantity series.</summary>
/// <param name="key">A stable key (<see cref="SeriesKey.Id"/>, or any invariant token).</param>
/// <param name="name">The name shown in the legend and tooltip.</param>
/// <param name="unit">The normalized unit of every value (D-20).</param>
/// <param name="values">One value per bucket.</param>
public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList<BucketValue> values)
: this(key, name, unit, values, null)
{
}
/// <summary>A quantity series whose derived values name the meter they miss (<see cref="FigureText.Of(BucketValue, Func{int, string?}?)"/>).</summary>
/// <param name="key">A stable key.</param>
/// <param name="name">The name shown in the legend and tooltip.</param>
/// <param name="unit">The normalized unit of every value (D-20).</param>
/// <param name="values">One value per bucket.</param>
/// <param name="meterName">Names a meter id a value's dependency path ends at; "#id" without it.</param>
public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList<BucketValue> values, Func<int, string?>? meterName)
: this(key, name, unit, null, [.. (values ?? throw new ArgumentNullException(nameof(values))).Select(v => ChartValue.Of(v, meterName))])
{
}
private AnalysisChartSeries(string key, string name, string? unit, string? currency, IReadOnlyList<ChartValue> values)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(name);
Key = key;
Name = name;
Unit = unit;
Currency = currency;
Values = values;
}
public string Key { get; init; }
public string Name { get; init; }
/// <summary>The quantity unit; null for money.</summary>
public string? Unit { get; init; }
/// <summary>The ISO currency code when the values are money (D-43).</summary>
public string? Currency { get; init; }
public IReadOnlyList<ChartValue> Values { get; init; }
public ChartSeriesStyle Style { get; init; } = ChartSeriesStyle.Bar;
/// <summary>True for the comparison overlay of another series.</summary>
public bool IsComparison { get; init; }
/// <summary>For an overlay, the key of the series it compares; it takes that series' colour.</summary>
public string? BaseKey { get; init; }
/// <summary>True when the values are money.</summary>
public bool IsMoney => Currency is not null;
/// <summary>What the axis of this series is labelled with: the currency symbol for money, else the unit.</summary>
public string AxisUnit => Currency is { } currency ? Format.CurrencySymbol(currency) : Unit?.Trim() ?? string.Empty;
/// <summary>A cost series: one figure per bucket, in <paramref name="currency"/>.</summary>
public static AnalysisChartSeries ForCost(string key, string name, string currency, IReadOnlyList<CostAmount> amounts)
{
ArgumentNullException.ThrowIfNull(amounts);
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
return new AnalysisChartSeries(key, name, null, currency, [.. amounts.Select(ChartValue.Of)]);
}
/// <summary>
/// A reader series (a meter or a measure total). <paramref name="name"/> defaults to the meter's name, or for a
/// measure to the measure's wording.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="name">The legend name; the meter's name or the measure's wording by default.</param>
/// <param name="style">Bars by default.</param>
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
public static AnalysisChartSeries ForSeries(
AnalysisSeries series, string? name = null, ChartSeriesStyle style = ChartSeriesStyle.Bar, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(series);
return new AnalysisChartSeries(series.Key.Id, name ?? NameOf(series), series.Unit, series.Values, meterName) { Style = style };
}
/// <summary>
/// The comparison overlay of a reader series (<see cref="AnalysisSeries.Comparison"/>), paired with its buckets by
/// index; null when no comparison was read.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="name">The overlay's name, e.g. <see cref="ComparisonName"/>.</param>
/// <param name="style">Line by default: a dashed line over the bars.</param>
/// <param name="meterName">Names the meter a derived value misses; "#id" without it.</param>
public static AnalysisChartSeries? ComparisonOf(
AnalysisSeries series, string name, ChartSeriesStyle style = ChartSeriesStyle.Line, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(series);
return series.Comparison is { } comparison
? new AnalysisChartSeries(series.Key.Id + ":cmp", name, series.Unit, comparison.Values, meterName)
{
Style = style,
IsComparison = true,
BaseKey = series.Key.Id,
}
: null;
}
/// <summary>The comparison overlay of a cost series, priced in the paired buckets (<see cref="CostComparisonRequest"/>).</summary>
public static AnalysisChartSeries ComparisonForCost(
string baseKey, string name, string currency, IReadOnlyList<CostAmount> amounts, ChartSeriesStyle style = ChartSeriesStyle.Line) =>
ForCost(baseKey + ":cmp", name, currency, amounts) with { Style = style, IsComparison = true, BaseKey = baseKey };
/// <summary>"Haus (same period last year)": a series name with the comparison it shows.</summary>
public static string ComparisonName(string name, ComparisonRequest comparison)
{
ArgumentNullException.ThrowIfNull(comparison);
return name + " (" + comparison.Display() + ")";
}
/// <summary>The name of a reader series: the meter's name, or the measure's wording for a total.</summary>
public static string NameOf(AnalysisSeries series)
{
ArgumentNullException.ThrowIfNull(series);
return series.Name.Length > 0 || series.Key.Measure is not { } measure ? series.Name : measure.Display();
}
}
/// <summary>
/// The chart colours of one theme mode, taken from the MudBlazor palette (D-49): the series hues in a fixed order, a
/// muted hue for overlays without a base, and the text, grid and zero-line colours. Colour follows the series, in the
/// order the series are given, so a meter keeps its hue when others are added after it.
/// </summary>
/// <remarks>
/// The order — primary, secondary, info, then error, warning and success for a fourth to sixth meter — is the palette
/// order whose neighbours stay distinguishable under protan and deutan vision (checked with an OKLab ΔE validator: ≥ 9.5
/// in dark mode, ≥ 6.3 in light mode, where the legend and the table are the secondary encoding).
/// </remarks>
public sealed record ChartPalette(bool IsDark, IReadOnlyList<string> Series, string Muted, string Text, string Grid, string Baseline, string Surface)
{
/// <summary>The colours of the light or dark palette of <see cref="MeterVaultTheme"/>.</summary>
public static ChartPalette For(bool isDark)
{
Palette palette = isDark ? MeterVaultTheme.Instance.PaletteDark : MeterVaultTheme.Instance.PaletteLight;
return new ChartPalette(
isDark,
[Hex(palette.Primary), Hex(palette.Secondary), Hex(palette.Info), Hex(palette.Error), Hex(palette.Warning), Hex(palette.Success)],
palette.GrayDefault,
Rgba(palette.TextSecondary),
Rgba(palette.LinesDefault),
Rgba(palette.TextSecondary),
Hex(palette.Surface));
}
/// <summary>The hue of the <paramref name="index"/>-th series (0-based).</summary>
public string SeriesColor(int index) => Series[((index % Series.Count) + Series.Count) % Series.Count];
/// <summary><c>#RRGGBB</c>: the chart library does its own colour arithmetic and expects plain hex.</summary>
public static string Hex(MudColor color)
{
ArgumentNullException.ThrowIfNull(color);
return string.Create(CultureInfo.InvariantCulture, $"#{color.R:X2}{color.G:X2}{color.B:X2}");
}
/// <summary><c>rgba(r,g,b,a)</c> with the colour's own alpha.</summary>
public static string Rgba(MudColor color)
{
ArgumentNullException.ThrowIfNull(color);
return string.Create(CultureInfo.InvariantCulture, $"rgba({color.R},{color.G},{color.B},{Math.Round(color.APercentage, 3)})");
}
/// <summary>A <c>#RRGGBB</c> colour at <paramref name="alpha"/> as <c>rgba(…)</c>; anything else is returned unchanged.</summary>
public static string WithAlpha(string hex, double alpha)
{
ArgumentNullException.ThrowIfNull(hex);
if (hex.Length != 7 || hex[0] != '#'
|| !int.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb))
{
return hex;
}
var a = Math.Clamp(alpha, 0, 1);
return string.Create(CultureInfo.InvariantCulture, $"rgba({(rgb >> 16) & 0xFF},{(rgb >> 8) & 0xFF},{rgb & 0xFF},{Math.Round(a, 3)})");
}
}
/// <summary>
/// One point of a chart series, as the chart library receives it: the bucket's index and label, the value (null for a
/// gap), its fill (faded when qualified) and the tooltip text, formatted here with the reader's culture, unit and
/// currency and the status in words.
/// </summary>
public sealed record ChartPoint(int Index, string Label, decimal? Value, string? FillColor, string Tooltip, bool IsQualified);
/// <summary>One drawn series of a <see cref="ChartPanel"/>.</summary>
public sealed record ChartPanelSeries(
string Key,
string Name,
ChartSeriesStyle Style,
bool IsComparison,
string Color,
int StrokeWidth,
int DashSpace,
IReadOnlyList<ChartPoint> Points);
/// <summary>
/// One chart with one y-axis: the series of one unit or currency. Series of different units are never drawn against
/// two scales on one plot; they get a panel each.
/// </summary>
/// <param name="Unit">The axis unit ("kWh", "€"); empty when the values have none.</param>
/// <param name="HasNegative">A value is below zero: the zero line is drawn.</param>
/// <param name="HasPositive">A value is above zero.</param>
/// <param name="HasMarked">
/// A current (not comparison) point has a value that is qualified (partial, estimated, not fully priced): the note under
/// the chart explains <see cref="AnalysisChartPlan.Marker"/>.
/// </param>
/// <param name="HasValues">At least one point has a value.</param>
/// <param name="Labels">
/// The panel's axis labels: the bucket labels, with <see cref="AnalysisChartPlan.Marker"/> where one of the panel's own
/// current series has a qualified value and <see cref="AnalysisChartPlan.GapMarker"/> where one has none — a gap in the
/// cost panel does not mark the quantity panel's months.
/// </param>
/// <param name="HasGaps">A current point has no value: the note under the chart explains <see cref="AnalysisChartPlan.GapMarker"/>.</param>
public sealed record ChartPanel(
string Unit,
IReadOnlyList<ChartPanelSeries> Series,
bool HasNegative,
bool HasPositive,
bool HasMarked,
bool HasValues,
IReadOnlyList<string> Labels,
bool HasGaps = false);
/// <summary>
/// What the analysis chart draws (D-49): the bucket labels, which buckets are marked as qualified, and the panels —
/// computed without the chart library, so the rules are testable: unknown values stay gaps, qualified buckets are marked
/// in the label (not by colour alone), overlays pair by index, labels carry the year across years.
/// </summary>
/// <param name="Labels">One label per bucket, unmarked (each panel marks its own, <see cref="ChartPanel.Labels"/>).</param>
/// <param name="Marked">Per bucket: some current series is qualified there.</param>
/// <param name="Panels">One per unit, in the order the units first appear.</param>
public sealed record AnalysisChartPlan(IReadOnlyList<string> Labels, IReadOnlyList<bool> Marked, IReadOnlyList<ChartPanel> Panels)
{
/// <summary>The mark added to the label of a bucket with a qualified value; the note under the chart explains it.</summary>
public const string Marker = " *";
/// <summary>
/// The mark added to the label of a bucket without a value (no data, not priced, only coarser data): it is a gap, not
/// a zero — a true zero is drawn on the baseline — and the note under the chart says so.
/// </summary>
public const string GapMarker = " ";
/// <summary>The bar outline: a true zero is drawn as this line on the baseline, a gap draws nothing.</summary>
public const int BarStrokeWidth = 2;
/// <summary>Why nothing can be drawn; <see cref="ChartEmptyReason.None"/> when something can.</summary>
public ChartEmptyReason EmptyReason { get; init; }
/// <summary>For <see cref="ChartEmptyReason.NotPriced"/>: the cost's status in words ("Not priced (no tariff)").</summary>
public string? EmptyStatus { get; init; }
/// <summary>The fill alpha of a qualified bar.</summary>
public const double QualifiedAlpha = 0.45;
/// <summary>The fill alpha of a comparison bar.</summary>
public const double ComparisonAlpha = 0.4;
/// <summary>True when anything can be drawn.</summary>
public bool HasValues => Panels.Any(p => p.HasValues);
/// <summary>Plans the chart.</summary>
/// <param name="buckets">The buckets of the plan (<see cref="BucketPlan.Buckets"/>), oldest first.</param>
/// <param name="series">The series; values beyond the buckets are ignored, missing ones are gaps.</param>
/// <param name="palette">The theme's colours.</param>
/// <param name="pairs">The comparison buckets paired with <paramref name="buckets"/> (A-10), to name an overlay's own bucket in its tooltip.</param>
public static AnalysisChartPlan Build(
IReadOnlyList<AnalysisBucket> buckets,
IReadOnlyList<AnalysisChartSeries> series,
ChartPalette palette,
IReadOnlyList<BucketPair>? pairs = null)
{
ArgumentNullException.ThrowIfNull(buckets);
ArgumentNullException.ThrowIfNull(series);
ArgumentNullException.ThrowIfNull(palette);
var labels = BucketLabels(buckets);
var marked = new bool[buckets.Count];
foreach (var current in series.Where(s => !s.IsComparison))
{
for (var i = 0; i < buckets.Count; i++)
{
marked[i] |= ValueAt(current, i).IsQualified;
}
}
// Colour follows the series in the order given, never its rank; an overlay takes its base's colour.
var colours = new Dictionary<string, string>(StringComparer.Ordinal);
var next = 0;
foreach (var current in series.Where(s => !s.IsComparison))
{
if (!colours.ContainsKey(current.Key))
{
colours[current.Key] = palette.SeriesColor(next++);
}
}
var names = UniqueNames(series);
var panels = new List<ChartPanel>();
foreach (var group in series.Select((s, i) => (Series: s, Name: names[i])).GroupBy(x => x.Series.AxisUnit, StringComparer.Ordinal))
{
// A value that is known but qualified gets "*", a bucket without a value "": the reader tells a partial month
// from an empty one without colour, and a true zero carries no mark at all.
var shown = labels
.Select((label, i) =>
{
var current = group.Where(x => !x.Series.IsComparison).Select(x => ValueAt(x.Series, i)).ToList();
var mark = (current.Any(v => v.Value is not null && v.IsQualified) ? Marker : string.Empty)
+ (current.Any(v => v.Value is null) ? GapMarker : string.Empty);
return label + mark;
})
.ToList();
var drawn = new List<ChartPanelSeries>();
bool negative = false, positive = false, markedHere = false, gapsHere = false, values = false;
foreach (var (item, name) in group)
{
var colour = item.IsComparison
? item.BaseKey is { } baseKey && colours.TryGetValue(baseKey, out var baseColour) ? baseColour : palette.Muted
: colours[item.Key];
var points = new List<ChartPoint>(buckets.Count);
for (var i = 0; i < buckets.Count; i++)
{
var value = ValueAt(item, i);
var number = ToDecimal(value.Value);
negative |= number < 0;
positive |= number > 0;
values |= number is not null;
markedHere |= !item.IsComparison && value.IsQualified && number is not null;
gapsHere |= !item.IsComparison && number is null;
var pairLabel = item.IsComparison && pairs is not null && i < pairs.Count
? Format.BucketLabel(pairs[i].Comparison, includeYear: true)
: null;
points.Add(new ChartPoint(i, shown[i], number, FillOf(item, value, colour), TooltipOf(item, value, pairLabel), value.IsQualified));
}
// Bars are outlined in their colour, so a true zero is a line on the baseline — an actual point (brief §4.3)
// — while a gap draws nothing. An overlay's outline is thinner, like its fill is fainter.
var line = item.Style == ChartSeriesStyle.Line;
var stroke = line ? 2 : item.IsComparison ? 1 : BarStrokeWidth;
drawn.Add(new ChartPanelSeries(
item.Key, name, item.Style, item.IsComparison, colour, stroke, line && item.IsComparison ? 5 : 0, points));
}
panels.Add(new ChartPanel(group.Key, drawn, negative, positive, markedHere, values, shown, gapsHere));
}
var plan = new AnalysisChartPlan(labels, marked, panels);
if (plan.HasValues)
{
return plan;
}
// Nothing to draw: say why. A price that is missing is the reason when there is one — the quantities are there —
// then data that is only coarser than the buckets; otherwise there is no data.
var gaps = series.Where(s => !s.IsComparison)
.SelectMany(s => Enumerable.Range(0, buckets.Count).Select(i => ValueAt(s, i)))
.ToList();
var notPriced = gaps.FirstOrDefault(v => v.Gap == ChartGap.NotPriced);
var reason = notPriced is not null ? ChartEmptyReason.NotPriced
: gaps.Any(v => v.Gap == ChartGap.Unresolved) ? ChartEmptyReason.Unresolved
: ChartEmptyReason.NoData;
return plan with { EmptyReason = reason, EmptyStatus = notPriced?.Status };
}
/// <summary>
/// The axis label of each bucket (<see cref="Format.BucketLabel"/>), with the year across years
/// (<see cref="Format.SpansYears(IReadOnlyList{AnalysisBucket})"/>). Labels are the chart's categories, so they are
/// kept distinct: should two still collide, both get their year, and a remaining duplicate its position.
/// </summary>
public static IReadOnlyList<string> BucketLabels(IReadOnlyList<AnalysisBucket> buckets)
{
ArgumentNullException.ThrowIfNull(buckets);
var labels = buckets.Select(b => Format.BucketLabel(b, Format.SpansYears(buckets))).ToList();
if (labels.Distinct(StringComparer.Ordinal).Count() != labels.Count)
{
labels = [.. buckets.Select(b => Format.BucketLabel(b, includeYear: true))];
}
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < labels.Count; i++)
{
if (!seen.Add(labels[i]))
{
labels[i] = labels[i] + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")";
seen.Add(labels[i]);
}
}
return labels;
}
/// <summary>The value at a bucket; a series shorter than the plan is unknown there, never zero.</summary>
private static ChartValue ValueAt(AnalysisChartSeries series, int index) =>
index < series.Values.Count
? series.Values[index]
: new ChartValue(null, true, BucketStatus.Missing.Display()) { Gap = ChartGap.NoData, Status = BucketStatus.Missing.Display() };
private static decimal? ToDecimal(double? value) =>
value is { } number && double.IsFinite(number) && Math.Abs(number) < 7.9e27 ? (decimal)number : null;
private static string? FillOf(AnalysisChartSeries series, ChartValue value, string colour)
{
if (series.Style != ChartSeriesStyle.Bar)
{
return null;
}
if (series.IsComparison)
{
return ChartPalette.WithAlpha(colour, value.IsQualified ? ComparisonAlpha / 2 : ComparisonAlpha);
}
return value.IsQualified ? ChartPalette.WithAlpha(colour, QualifiedAlpha) : colour;
}
private static string TooltipOf(AnalysisChartSeries series, ChartValue value, string? pairLabel)
{
var text = series.IsMoney ? Format.Money(value.Value, series.Currency) : Format.Quantity(value.Value, series.Unit);
if (pairLabel is not null)
{
text = pairLabel + ": " + text;
}
return value.Note is { Length: > 0 } note ? text + " · " + note : text;
}
/// <summary>
/// Series names made distinct (the chart library keys series by name): two meters may share a name, which is user
/// data; the second gets its position.
/// </summary>
private static List<string> UniqueNames(IReadOnlyList<AnalysisChartSeries> series)
{
var names = new List<string>(series.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < series.Count; i++)
{
var name = string.IsNullOrWhiteSpace(series[i].Name) ? series[i].Key : series[i].Name;
if (!seen.Add(name))
{
name = name + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")";
seen.Add(name);
}
names.Add(name);
}
return names;
}
}
+168
View File
@@ -0,0 +1,168 @@
using System.Globalization;
using System.Text.Json;
using ApexCharts;
namespace MeterVault.App.Analysis;
/// <summary>
/// The chart library's options for one <see cref="ChartPanel"/> (D-49): a transparent background in the theme's mode,
/// straight lines that break at unknown buckets, a y-axis that reaches zero, a solid zero line when values are signed,
/// units or currency in the axis and tooltip formatters, no animation (so nothing moves for a reader who asked for
/// reduced motion) and no toolbar.
/// </summary>
/// <remarks>
/// On Blazor Server the library's .NET label formatters are unavailable, so formatting happens twice by design: the
/// tooltip text of every point is formatted in .NET (<see cref="ChartPoint.Tooltip"/>, carried as the point's
/// <c>extra</c>), and the axis uses a small JavaScript formatter with the reader's locale and the unit
/// (<see cref="ChartFormatters"/>).
/// </remarks>
public static class AnalysisChartOptions
{
/// <summary>Builds fresh options for <paramref name="panel"/>; the chart component re-keys its chart whenever they change.</summary>
/// <param name="panel">The panel.</param>
/// <param name="palette">The theme's colours.</param>
/// <param name="culture">The reader's culture, for the axis numbers.</param>
public static ApexChartOptions<ChartPoint> Build(ChartPanel panel, ChartPalette palette, CultureInfo culture)
{
ArgumentNullException.ThrowIfNull(panel);
ArgumentNullException.ThrowIfNull(palette);
ArgumentNullException.ThrowIfNull(culture);
var mode = palette.IsDark ? Mode.Dark : Mode.Light;
var discrete = new List<MarkersDiscrete>();
for (var s = 0; s < panel.Series.Count; s++)
{
var series = panel.Series[s];
if (series.Style != ChartSeriesStyle.Line)
{
continue;
}
// A qualified point on a line is a hollow square: its shape says "not a plain value", not only its colour.
foreach (var point in series.Points.Where(p => p.IsQualified && p.Value is not null))
{
discrete.Add(new MarkersDiscrete
{
SeriesIndex = s,
DataPointIndex = point.Index,
Shape = MarkerShape.Square,
Size = 5,
FillColor = palette.Surface,
StrokeColor = series.Color,
});
}
}
var axis = new YAxis
{
ForceNiceScale = true,
Labels = new YAxisLabels { Formatter = ChartFormatters.Axis(panel.Unit, culture) },
};
// A real zero baseline: an all-positive series is measured from zero, an all-negative one up to zero.
if (!panel.HasNegative && panel.HasPositive)
{
axis.Min = 0;
}
else if (panel.HasNegative && !panel.HasPositive)
{
axis.Max = 0;
}
return new ApexChartOptions<ChartPoint>
{
Chart = new Chart
{
Background = "transparent",
ForeColor = palette.Text,
Toolbar = new Toolbar { Show = false },
Zoom = new Zoom { Enabled = false },
Animations = new Animations { Enabled = false },
RedrawOnParentResize = true,
},
Theme = new ApexCharts.Theme { Mode = mode },
DataLabels = new DataLabels { Enabled = false },
Legend = new Legend { Position = LegendPosition.Top, HorizontalAlign = Align.Left },
Grid = new Grid { BorderColor = palette.Grid, StrokeDashArray = 0 },
Stroke = new Stroke { Curve = Curve.Straight },
Markers = new Markers
{
Size = panel.Series.Select(s => s.Style == ChartSeriesStyle.Line ? 3d : 0d).ToList(),
StrokeColors = palette.Surface,
StrokeWidth = 2,
Discrete = discrete,
Hover = new MarkersHover { SizeOffset = 2 },
},
PlotOptions = new PlotOptions { Bar = new PlotOptionsBar { ColumnWidth = "70%", BorderRadius = 2 } },
States = new States
{
Active = new StatesActive
{
AllowMultipleDataPointsSelection = false,
Filter = new StatesFilter { Type = StatesFilterType.none },
},
},
Tooltip = new Tooltip
{
Enabled = true,
Shared = true,
Intersect = false,
Theme = mode,
Y = new TooltipY { Formatter = ChartFormatters.Tooltip },
},
Xaxis = new XAxis
{
Labels = new XAxisLabels { Rotate = -45, HideOverlappingLabels = true, Trim = false },
Tooltip = new AxisTooltip { Enabled = false },
},
Yaxis = [axis],
Annotations = panel.HasNegative
? new Annotations
{
Yaxis =
[
new AnnotationsYAxis { Y = 0, BorderColor = palette.Baseline, BorderWidth = 1, StrokeDashArray = 0 },
],
}
: null,
};
}
}
/// <summary>The extra data a chart point carries into the browser: its tooltip text, formatted in .NET.</summary>
public sealed record ChartPointExtra(string Text);
/// <summary>
/// JavaScript formatter functions for the chart library (strings it evaluates). Everything interpolated into them —
/// locale, unit — is written as a JSON string literal, so a unit can never break out of its string.
/// </summary>
public static class ChartFormatters
{
/// <summary>
/// The tooltip value of a point: the text formatted in .NET (<see cref="ChartPointExtra"/>), which names the unit or
/// currency and the status in words; a plain number only if that is missing.
/// </summary>
public const string Tooltip =
"function (value, opts) { "
+ "var s = opts && opts.w && opts.w.config && opts.w.config.series ? opts.w.config.series[opts.seriesIndex] : null; "
+ "var p = s && s.data ? s.data[opts.dataPointIndex] : null; "
+ "if (p && p.extra && p.extra.text) { return p.extra.text; } "
+ "return value === null || value === undefined ? '—' : String(value); }";
/// <summary>
/// An axis label formatter: the number in the reader's locale (at most two decimals) and the unit or currency
/// symbol; blank for a missing value.
/// </summary>
public static string Axis(string? unit, CultureInfo culture)
{
ArgumentNullException.ThrowIfNull(culture);
var locale = string.IsNullOrEmpty(culture.Name) ? "en" : culture.Name;
var suffix = string.IsNullOrWhiteSpace(unit) ? string.Empty : " " + unit.Trim();
return "function (value) { if (value === null || value === undefined || !isFinite(value)) { return ''; } "
+ "return new Intl.NumberFormat(" + Literal(locale) + ", { maximumFractionDigits: 2 }).format(value) + " + Literal(suffix) + "; }";
}
/// <summary>A JavaScript string literal (JSON-escaped, HTML-sensitive characters included).</summary>
public static string Literal(string text) => JsonSerializer.Serialize(text ?? string.Empty);
}
+148
View File
@@ -0,0 +1,148 @@
using System.Globalization;
using System.Text;
namespace MeterVault.App.Analysis;
/// <summary>
/// One row of the analysis CSV (D-55): one series in one bucket.
/// </summary>
/// <param name="SeriesId">The stable series identity (<c>m12</c>, <c>t3:use:kWh</c>, <c>portfolio</c>, <c>c4</c>).</param>
/// <param name="SeriesName">The series' name: a meter's, a type's or a category's (user data), or a worded measure.</param>
/// <param name="Kind">What the value measures, as its invariant identifier (<c>Consumption</c>, <c>Cost</c>).</param>
/// <param name="Unit">The value's unit (normalized, D-20), or the currency code for a cost series.</param>
/// <param name="BucketStart">The bucket's first instant, in the instance zone's local time with its offset.</param>
/// <param name="BucketEnd">The bucket's end (exclusive): the next local midnight, or now for a bucket cut at now.</param>
/// <param name="TimeZone">The instance zone id the bounds are local to.</param>
/// <param name="Value">The value; null when it is unavailable (missing, unresolved, invalid, being prepared).</param>
/// <param name="Status">The value's availability (<c>Available</c>, <c>Partial</c>, …).</param>
/// <param name="Provenance">Where the value comes from, as flag identifiers joined by <c>|</c> (<c>Measured|Estimated</c>); empty when none.</param>
/// <param name="Cost">The cost in the bucket; null when not priced or not costed.</param>
/// <param name="CostStatus">The cost's price coverage (<c>Priced</c>, <c>NotPriced</c>, …); null when the series has no cost.</param>
/// <param name="Currency">The currency of <paramref name="Cost"/>; null when the series has no cost.</param>
/// <param name="ComparisonValue">The value in the paired comparison bucket (D-06); null without a comparison or when unavailable.</param>
public sealed record AnalysisCsvRow(
string SeriesId,
string SeriesName,
string Kind,
string Unit,
DateTimeOffset BucketStart,
DateTimeOffset BucketEnd,
string TimeZone,
double? Value,
string Status,
string Provenance,
double? Cost,
string? CostStatus,
string? Currency,
double? ComparisonValue);
/// <summary>
/// Writes the analysis table as CSV (D-55): RFC 4180 quoting, a header of invariant column names, invariant numbers at
/// full precision, ISO-8601 local bucket bounds with their offset, and empty cells for unavailable values — a spreadsheet
/// or a script reads the same figures the page shows, never a fabricated zero.
/// </summary>
/// <remarks>
/// <para>
/// Names and units are user data. A cell that starts with <c>=</c>, <c>+</c>, <c>-</c>, <c>@</c> or a control character
/// is prefixed with an apostrophe, so a spreadsheet shows it as text instead of running it as a formula; numbers are
/// written by this class and never need it.
/// </para>
/// <para>
/// <b>Chronological, unlike the pages (A-42).</b> The rows stay oldest first, in the plan's own order. The tables on
/// screen read newest first because a reader starts at the top; a CSV is not read, it is sorted, charted and
/// differenced, and every tool that does so — a spreadsheet's chart, a running total, a diff against last month's
/// file — expects time to run forwards. Reversing it here would only make every consumer sort it back.
/// </para>
/// </remarks>
public static class AnalysisCsvWriter
{
/// <summary>The header, in column order.</summary>
public static IReadOnlyList<string> Columns { get; } =
[
"series_id", "series_name", "kind", "unit", "bucket_start", "bucket_end", "timezone",
"value", "status", "provenance", "cost", "cost_status", "currency", "comparison_value",
];
private const string InstantFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
/// <summary>Writes the header and one line per row.</summary>
public static async Task WriteAsync(TextWriter writer, IEnumerable<AnalysisCsvRow> rows, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(rows);
await writer.WriteAsync(Line(Columns).AsMemory(), cancellationToken).ConfigureAwait(false);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteAsync(Line(Fields(row)).AsMemory(), cancellationToken).ConfigureAwait(false);
}
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>The whole CSV as a string (tests, small exports).</summary>
public static string Write(IEnumerable<AnalysisCsvRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
var builder = new StringBuilder();
builder.Append(Line(Columns));
foreach (var row in rows)
{
builder.Append(Line(Fields(row)));
}
return builder.ToString();
}
/// <summary>One CSV field: quoted when it holds a comma, a quote or a line break, with quotes doubled.</summary>
public static string Escape(string? field)
{
if (string.IsNullOrEmpty(field))
{
return string.Empty;
}
var needsQuotes = field.AsSpan().IndexOfAny(",\"\r\n") >= 0;
return needsQuotes ? "\"" + field.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : field;
}
/// <summary>A number at full precision in invariant form; empty when unknown or not finite.</summary>
public static string Number(double? value) =>
value is { } number && double.IsFinite(number) ? number.ToString("R", CultureInfo.InvariantCulture) : string.Empty;
/// <summary>An instant as local ISO-8601 with its offset (<c>2026-09-01T00:00:00+02:00</c>).</summary>
public static string Instant(DateTimeOffset value) => value.ToString(InstantFormat, CultureInfo.InvariantCulture);
/// <summary>User text made safe to open in a spreadsheet: a leading formula character becomes literal text.</summary>
public static string Text(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value[0] is '=' or '+' or '-' or '@' or '\t' or '\r' or '\n' ? "'" + value : value;
}
private static IEnumerable<string> Fields(AnalysisCsvRow row) =>
[
row.SeriesId,
Text(row.SeriesName),
row.Kind,
Text(row.Unit),
Instant(row.BucketStart),
Instant(row.BucketEnd),
row.TimeZone,
Number(row.Value),
row.Status,
row.Provenance,
Number(row.Cost),
row.CostStatus ?? string.Empty,
row.Currency ?? string.Empty,
Number(row.ComparisonValue),
];
private static string Line(IEnumerable<string> fields) => string.Join(',', fields.Select(Escape)) + "\r\n";
}
+113
View File
@@ -0,0 +1,113 @@
using MeterVault.Core.Analysis;
namespace MeterVault.App.Analysis;
/// <summary>
/// What a page shows when its URL does not say (D-02, D-46): the period preset, bucket size, comparison, metric and
/// scope. A default applies only to a key that is absent; <see cref="AnalysisQuery"/> never writes a key whose value
/// equals the target page's default, so links stay short and a page never rewrites its address on load.
/// </summary>
/// <remarks>
/// <para>
/// The Overview defaults to month to date and every history page — the meter Analysis tab, an energy type's History,
/// the Analysis page (<c>/trends</c>), Solar and Consumables — to the last 12 months (D-02). Both compare with the
/// previous year by default (amendment A-13): "this month so far against the same days last year" is the question the
/// Overview answers, and a seasonal utility compared with the months just before would read as a trend that is only
/// the season.
/// </para>
/// <para>
/// A null <see cref="Metric"/> means "the scope's natural metric": a meter's own quantity kind, a type's use, the
/// portfolio's cost. The page decides it; the URL only carries a metric somebody chose.
/// </para>
/// <para>
/// <see cref="Scope"/> is the scope a page is about when its URL names none: the portfolio on the Overview and the
/// Analysis page, the meter on a meter page (<see cref="ForScope"/>), the type on an energy type page. A route that
/// implies its scope therefore never writes it, and a link that carries the period onward never carries the scope.
/// </para>
/// </remarks>
public sealed record AnalysisDefaults
{
/// <exception cref="ArgumentException">
/// <paramref name="period"/> is <see cref="PeriodPreset.Custom"/>, which has no dates to default to, or
/// <paramref name="comparison"/> is a year comparison without its year.
/// </exception>
public AnalysisDefaults(
PeriodPreset period,
BucketSize bucket,
ComparisonRequest comparison,
AnalysisMetric? metric = null,
QueryScope? scope = null)
{
ArgumentNullException.ThrowIfNull(comparison);
if (period == PeriodPreset.Custom || !Enum.IsDefined(period))
{
throw new ArgumentException("A page default is a preset, never a custom range.", nameof(period));
}
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
{
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
}
Period = period;
Bucket = bucket;
Comparison = comparison;
Metric = metric;
Scope = scope ?? QueryScope.Portfolio;
}
/// <summary>The Overview (<c>/</c>): month to date, automatic buckets, compared with the previous year.</summary>
public static AnalysisDefaults Overview { get; } =
new(PeriodPreset.MonthToDate, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear));
/// <summary>
/// Every history view — the meter Analysis tab, an energy type's History, <c>/trends</c>, Solar, Consumables: the
/// last 12 months (12 calendar buckets ending with the current partial month), automatic buckets, compared with the
/// previous year.
/// </summary>
public static AnalysisDefaults History { get; } =
new(PeriodPreset.Last12Months, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear));
/// <summary>The CSV export (<c>/export/analysis.csv</c>): the history defaults, so a link written for it is explicit about everything else.</summary>
public static AnalysisDefaults Export => History;
public PeriodPreset Period { get; }
public BucketSize Bucket { get; }
public ComparisonRequest Comparison { get; }
/// <summary>The metric; null for the scope's natural one.</summary>
public AnalysisMetric? Metric { get; }
/// <summary>The scope the page is about when its URL names none.</summary>
public QueryScope Scope { get; }
/// <summary>These defaults on a page whose route implies <paramref name="scope"/> (a meter page, an energy type page).</summary>
public AnalysisDefaults ForScope(QueryScope scope) => new(Period, Bucket, Comparison, Metric, scope);
/// <summary>These defaults with another default metric.</summary>
public AnalysisDefaults WithMetric(AnalysisMetric? metric) => new(Period, Bucket, Comparison, metric, Scope);
/// <summary>
/// The defaults of the page at <paramref name="path"/> (base-relative or absolute path, query ignored): the Overview's
/// for <c>/</c>, the history defaults for everything else — for components outside a page (the meter search) that
/// carry the current page's period onward.
/// </summary>
public static AnalysisDefaults ForPath(string? path)
{
var text = path ?? string.Empty;
var cut = text.IndexOfAny(['?', '#']);
if (cut >= 0)
{
text = text[..cut];
}
if (Uri.TryCreate(text, UriKind.Absolute, out var absolute) && absolute.Scheme is "http" or "https")
{
text = absolute.AbsolutePath;
}
return text.Trim('/').Length == 0 ? Overview : History;
}
}
+270
View File
@@ -0,0 +1,270 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.Analysis;
/// <summary>The rows of an analysis export, or why the request cannot be answered (a 400 message).</summary>
/// <param name="Error">A short invariant message for an invalid request; null when there are rows.</param>
/// <param name="FileName">The download's file name.</param>
/// <param name="Rows">One row per bucket and series.</param>
public sealed record AnalysisExportResult(string? Error, string FileName, IReadOnlyList<AnalysisCsvRow> Rows)
{
public static AnalysisExportResult Invalid(string message) => new(message, string.Empty, []);
}
/// <summary>
/// Builds the analysis table the CSV export streams (D-55) from the same URL keys, the same
/// <see cref="AnalysisQuery"/> resolution and the same readers as the pages — so the file holds exactly the figures on
/// screen: quantities from <see cref="AnalysisReader"/>, costs from <see cref="CostReader"/>, comparisons paired by
/// bucket.
/// </summary>
/// <remarks>
/// <para>
/// <b>Series.</b> A meter or a meter selection exports each meter's own series (whatever the metric: a meter measures
/// what it measures), with its cost by the meter's rule. An energy type or the portfolio exports the per-type measures
/// of the metric (consumption: total use and grid import, never added), or every measure without one. The cost metric —
/// and a category, which is analysed by cost — exports one cost series per scope (per meter for a selection).
/// </para>
/// <para>
/// <b>Refused.</b> Any URL key that the query could not read (a notice), a category asked for a quantity, the tank
/// balance (not a bucketed series), too many meters or too many buckets, and an unknown meter, type or category — each
/// is a 400 with a short message, never a 500 and never a silently different export.
/// </para>
/// </remarks>
public sealed class AnalysisExport(
AnalysisReader reader,
CostReader costs,
AnalysisPeriods periods,
IDbContextFactory<MeterVaultDbContext> contextFactory,
TimeProvider time)
{
/// <summary>Reads what <paramref name="query"/> shows, as CSV rows.</summary>
public async Task<AnalysisExportResult> PrepareAsync(AnalysisQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Notices.Count > 0)
{
return AnalysisExportResult.Invalid(string.Join(" ", query.Notices.Select(n => n.Describe())));
}
if (query.Metric == AnalysisMetric.Balance)
{
return AnalysisExportResult.Invalid("The tank balance is not a bucketed series and cannot be exported; use metric=consumption.");
}
var byCost = query.Metric == AnalysisMetric.Cost || query.Scope.Kind == QueryScopeKind.Category;
if (query.Scope.Kind == QueryScopeKind.Category && query.Metric is { } metric && metric.IsQuantity())
{
return AnalysisExportResult.Invalid("A cost category is analysed by cost; use metric=cost.");
}
var period = await periods.ResolveAsync(query, time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
var names = await Names.LoadAsync(contextFactory, cancellationToken).ConfigureAwait(false);
var rows = byCost
? await CostRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false)
: await QuantityRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false);
return rows.Error is not null ? rows : rows with { FileName = FileName(query, period) };
}
private async Task<AnalysisExportResult> QuantityRowsAsync(
AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken)
{
if (query.Scope.Kind == QueryScopeKind.EnergyType && !names.Types.ContainsKey(query.Scope.Id!.Value))
{
return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown energy type: {query.Scope.Id}."));
}
if (query.Scope.MeterIds.FirstOrDefault(id => !names.Meters.ContainsKey(id)) is var missing and > 0)
{
return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown meter: {missing}."));
}
var request = query.ToAnalysisRequest(period)!;
var result = await reader.ReadAsync(request, cancellationToken).ConfigureAwait(false);
if (Refused(result.Refusal, result.Plan) is { } refusal)
{
return AnalysisExportResult.Invalid(refusal);
}
var buckets = result.Plan.Buckets;
var zone = reader.Zone;
var rows = new List<AnalysisCsvRow>();
if (query.Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters)
{
foreach (var series in result.Series)
{
var cost = await MeterCostAsync(series.MeterId!.Value, period, result.Plan, cancellationToken).ConfigureAwait(false);
rows.AddRange(Rows(series, series.Name, buckets, zone, cost));
}
return new AnalysisExportResult(null, string.Empty, rows);
}
var measures = query.Metric is { } metric ? AnalysisMetrics.MeasuresOf(metric) : null;
foreach (var series in result.Measures.Where(m => measures is null || (m.Key.Measure is { } measure && measures.Contains(measure))))
{
var typeName = series.EnergyTypeId is { } typeId ? names.Types.GetValueOrDefault(typeId, string.Empty) : string.Empty;
var name = series.Key.Measure is { } measure ? typeName + " · " + measure.Display() : typeName;
rows.AddRange(Rows(series, name, buckets, zone, cost: null));
}
return new AnalysisExportResult(null, string.Empty, rows);
}
private async Task<AnalysisExportResult> CostRowsAsync(
AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken)
{
var rows = new List<AnalysisCsvRow>();
BucketPlan? plan = null;
foreach (var request in query.ToCostRequests(period))
{
// Every scope of a selection is priced in the first one's buckets, so the rows line up.
var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false);
if (current.Refusal == CostRefusal.UnknownScope)
{
return AnalysisExportResult.Invalid("Unknown " + request.Scope.ToString().Replace(':', ' ') + ".");
}
if (Refused(current.Refusal == CostRefusal.TooManyPoints ? AnalysisRefusal.TooManyPoints : AnalysisRefusal.None, current.Plan) is { } refusal)
{
return AnalysisExportResult.Invalid(refusal);
}
plan ??= current.Plan;
IReadOnlyList<CostAmount>? previous = null;
if (query.Comparison.Kind != ComparisonKind.None)
{
var comparison = query.ToCostComparison(request with { Plan = current.Plan }, current.Plan);
if (comparison.Request is { } comparisonRequest)
{
previous = (await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false)).Buckets;
}
}
var (id, name) = CostSeries(request.Scope, names);
var buckets = current.Plan.Buckets;
for (var i = 0; i < buckets.Count && i < current.Buckets.Count; i++)
{
var amount = current.Buckets[i];
rows.Add(new AnalysisCsvRow(
id,
name,
nameof(QuantityKind.Cost),
current.Currency,
Local(buckets[i].From, reader.Zone),
Local(buckets[i].To, reader.Zone),
reader.Zone.Id,
amount.Cost,
// Nothing booked is unknown, never "Available" beside an empty value (§4.3, FigureText.IsNothingBooked).
(FigureText.IsNothingBooked(amount) ? BucketStatus.Missing : amount.Availability).ToString(),
string.Empty,
amount.Cost,
amount.Status.ToString(),
current.Currency,
previous is not null && i < previous.Count ? previous[i].Cost : null));
}
}
return new AnalysisExportResult(null, string.Empty, rows);
}
/// <summary>A meter's cost in the quantity buckets; null when the meter is not costed (generation, runtime, no rule).</summary>
private async Task<CostAnalysis?> MeterCostAsync(int meterId, ResolvedPeriod period, BucketPlan plan, CancellationToken cancellationToken)
{
var analysis = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meterId), period) { Plan = plan }, cancellationToken)
.ConfigureAwait(false);
return analysis.Refusal != CostRefusal.None || analysis.Meter is { Rule: MeterCostRule.None } ? null : analysis;
}
private static IEnumerable<AnalysisCsvRow> Rows(
AnalysisSeries series, string name, IReadOnlyList<AnalysisBucket> buckets, TimeZoneInfo zone, CostAnalysis? cost)
{
for (var i = 0; i < buckets.Count && i < series.Values.Count; i++)
{
var value = series.Values[i];
var amount = cost is not null && i < cost.Buckets.Count ? cost.Buckets[i] : null;
var previous = series.Comparison is { } comparison && i < comparison.Values.Count ? comparison.Values[i].Value : null;
yield return new AnalysisCsvRow(
series.Key.Id,
name,
series.Kind.ToString(),
series.Unit,
Local(buckets[i].From, zone),
Local(buckets[i].To, zone),
zone.Id,
value.Value,
value.Status.ToString(),
ProvenanceTokens(value.Provenance),
amount?.Cost,
amount?.Status.ToString(),
amount is null ? null : cost!.Currency,
previous);
}
}
/// <summary>The set flags as identifiers joined by <c>|</c> (<c>Measured|Estimated</c>); empty for none.</summary>
private static string ProvenanceTokens(Provenance provenance) =>
provenance == Provenance.None
? string.Empty
: string.Join('|', Enum.GetValues<Provenance>().Where(f => f != Provenance.None && provenance.HasFlag(f)));
private static (string Id, string Name) CostSeries(CostScope scope, Names names) => scope.Kind switch
{
CostScopeKind.EnergyType => (Token('t', scope.Id), names.Types.GetValueOrDefault(scope.Id!.Value, string.Empty)),
CostScopeKind.Meter => (Token('m', scope.Id), names.Meters.GetValueOrDefault(scope.Id!.Value, string.Empty)),
CostScopeKind.Category => (Token('c', scope.Id), names.Categories.GetValueOrDefault(scope.Id!.Value, string.Empty)),
_ => (QueryScope.Portfolio.Token, QueryScopeKind.Portfolio.Display()),
};
private static string Token(char prefix, int? id) => prefix + id!.Value.ToString(CultureInfo.InvariantCulture);
private static DateTimeOffset Local(DateTimeOffset instant, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(instant, zone);
/// <summary>The 400 message of a refused request; null when it was not refused.</summary>
private static string? Refused(AnalysisRefusal refusal, BucketPlan plan) => refusal switch
{
AnalysisRefusal.TooManySeries => string.Create(
CultureInfo.InvariantCulture, $"At most {AnalysisLimits.MaxSeries} meters can be exported side by side."),
AnalysisRefusal.TooManyPoints => string.Create(
CultureInfo.InvariantCulture,
$"bucket={AnalysisTokens.Format(plan.Size)} gives {plan.PointCount} buckets, more than {AnalysisLimits.MaxPoints}")
+ (plan.Suggested is { } suggested ? "; use bucket=" + AnalysisTokens.Format(suggested) + "." : "."),
_ => null,
};
/// <summary><c>metervault-meter-42-consumption-2025-10-01-2026-09-19.csv</c>.</summary>
private static string FileName(AnalysisQuery query, ResolvedPeriod period)
{
var scope = query.Scope.ToString().Replace(':', '-').Replace(',', '-');
var metric = query.Metric is { } m ? AnalysisMetrics.Format(m) : query.Scope.Kind == QueryScopeKind.Category ? "cost" : "quantity";
var last = period.HasNotStarted() ? period.LastDay : period.EffectiveLastDay();
var first = period.FirstDay <= last ? period.FirstDay : last;
return $"metervault-{scope}-{metric}-{AnalysisTokens.FormatDate(first)}-{AnalysisTokens.FormatDate(last)}.csv";
}
/// <summary>The names the rows carry: meters, energy types and categories (user data, as stored).</summary>
private sealed record Names(
IReadOnlyDictionary<int, string> Meters,
IReadOnlyDictionary<int, string> Types,
IReadOnlyDictionary<int, string> Categories)
{
public static async Task<Names> LoadAsync(IDbContextFactory<MeterVaultDbContext> factory, CancellationToken cancellationToken)
{
await using var db = await factory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name, cancellationToken).ConfigureAwait(false);
var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => (int)t.Id, t => t.DisplayName, cancellationToken).ConfigureAwait(false);
var categories = await db.CostCategories.AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken).ConfigureAwait(false);
return new Names(meters, types, categories);
}
}
}
@@ -0,0 +1,57 @@
using System.Text;
namespace MeterVault.App.Analysis;
/// <summary>
/// <c>GET /export/analysis.csv</c> (D-55): the analysis table of the URL keys the pages use — <c>scope</c>/<c>id</c>/
/// <c>ids</c>, <c>metric</c>, <c>period</c>, <c>from</c>, <c>to</c>, <c>bucket</c>, <c>compare</c> — as a CSV download.
/// Build links to it with <see cref="AnalysisLinks.Export"/>.
/// </summary>
/// <remarks>
/// A UI endpoint, not part of the versioned REST API: it serves the same reader a signed-in browser already sees and
/// needs no API key, like the pages themselves. Invalid input is a 400 with a short plain-text message; nothing a user
/// can type into the URL makes it a 500.
/// </remarks>
public static class AnalysisExportEndpoints
{
public static IEndpointRouteBuilder MapAnalysisExport(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapGet(AnalysisLinks.ExportPath, async (HttpContext http, AnalysisExport export, ILogger<AnalysisExport> logger, CancellationToken ct) =>
{
var query = AnalysisQuery.Parse(http.Request.Query, AnalysisDefaults.Export);
AnalysisExportResult prepared;
try
{
prepared = await export.PrepareAsync(query, ct);
}
catch (ArgumentException ex)
{
// The readers reject a combination they cannot answer with an ArgumentException; that is the request's
// fault, not the server's.
logger.LogWarning(ex, "Analysis export refused {Query}", query);
return Results.Text("This combination of keys cannot be exported.", "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest);
}
if (prepared.Error is { } error)
{
return Results.Text(error, "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest);
}
return Results.Stream(
async stream =>
{
// A byte-order mark, so spreadsheet programs read the umlauts of meter names as UTF-8.
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
await AnalysisCsvWriter.WriteAsync(writer, prepared.Rows, ct);
},
"text/csv; charset=utf-8",
prepared.FileName);
})
.ExcludeFromDescription();
return endpoints;
}
}
+117
View File
@@ -0,0 +1,117 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
namespace MeterVault.App.Analysis;
/// <summary>
/// What an analysis view charts (D-47 <c>metric=consumption|generation|export|runtime|net|cost|balance</c>): a
/// quantity kind, the cost, or a tank's balance. The URL token is the stable identifier; the visible label comes from
/// <see cref="Localization.DisplayNames"/>.
/// </summary>
public enum AnalysisMetric
{
Consumption,
Generation,
Export,
Runtime,
/// <summary>A signed virtual result (a difference of kinds, D-26).</summary>
Net,
/// <summary>The cost of the scope (D-34 D-43).</summary>
Cost,
/// <summary>A tank's level over time (consumables).</summary>
Balance,
}
/// <summary>URL tokens and meaning of <see cref="AnalysisMetric"/>.</summary>
public static class AnalysisMetrics
{
private static readonly (AnalysisMetric Value, string Token)[] Tokens =
[
(AnalysisMetric.Consumption, "consumption"),
(AnalysisMetric.Generation, "generation"),
(AnalysisMetric.Export, "export"),
(AnalysisMetric.Runtime, "runtime"),
(AnalysisMetric.Net, "net"),
(AnalysisMetric.Cost, "cost"),
(AnalysisMetric.Balance, "balance"),
];
/// <summary>The URL token of a metric (<c>consumption</c>, <c>cost</c>, …).</summary>
public static string Format(AnalysisMetric metric)
{
foreach (var (value, token) in Tokens)
{
if (value == metric)
{
return token;
}
}
throw new ArgumentOutOfRangeException(nameof(metric), metric, "No URL token for this metric.");
}
/// <summary>Parses a metric token, ignoring case and surrounding blanks; false for anything else.</summary>
public static bool TryParse(string? token, out AnalysisMetric metric)
{
metric = default;
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var text = token.Trim();
foreach (var (value, name) in Tokens)
{
if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
{
metric = value;
return true;
}
}
return false;
}
/// <summary>True for the quantity metrics (everything but cost and balance), which the analysis reader answers.</summary>
public static bool IsQuantity(this AnalysisMetric metric) => metric is not (AnalysisMetric.Cost or AnalysisMetric.Balance);
/// <summary>The quantity kind a quantity metric charts; null for cost and balance.</summary>
public static QuantityKind? QuantityKindOf(AnalysisMetric metric) => metric switch
{
AnalysisMetric.Consumption => QuantityKind.Consumption,
AnalysisMetric.Generation => QuantityKind.Generation,
AnalysisMetric.Export => QuantityKind.Export,
AnalysisMetric.Runtime => QuantityKind.Runtime,
AnalysisMetric.Net => QuantityKind.Net,
_ => null,
};
/// <summary>
/// The per-type measures (D-22) a quantity metric shows for an energy type or the portfolio: consumption is the
/// household use and, separately, the billed grid import (never added to each other); net has no measure, being a
/// virtual meter's own result.
/// </summary>
public static IReadOnlyList<TotalsMeasure> MeasuresOf(AnalysisMetric metric) => metric switch
{
AnalysisMetric.Consumption => [TotalsMeasure.Use, TotalsMeasure.GridImport],
AnalysisMetric.Generation => [TotalsMeasure.Generation],
AnalysisMetric.Export => [TotalsMeasure.Export],
AnalysisMetric.Runtime => [TotalsMeasure.Runtime],
_ => [],
};
/// <summary>The metric a quantity kind is charted under; <see cref="QuantityKind.Indicator"/> has none.</summary>
public static AnalysisMetric? MetricOf(QuantityKind kind) => kind switch
{
QuantityKind.Consumption => AnalysisMetric.Consumption,
QuantityKind.Generation => AnalysisMetric.Generation,
QuantityKind.Export => AnalysisMetric.Export,
QuantityKind.Runtime => AnalysisMetric.Runtime,
QuantityKind.Net => AnalysisMetric.Net,
QuantityKind.Cost => AnalysisMetric.Cost,
_ => null,
};
}
+189
View File
@@ -0,0 +1,189 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using Microsoft.AspNetCore.Components;
namespace MeterVault.App.Analysis;
/// <summary>One breadcrumb: its text (a name is user data) and its link; null for the current page.</summary>
public sealed record Crumb(string Text, string? Href);
/// <summary>
/// Where the analysis components lead (D-46, D-48, D-51, brief §4.3): replacing the page's analysis state from the
/// toolbar, drilling into a bucket, going to the latest data, and the breadcrumb trail — each keeping the period.
/// </summary>
public static class AnalysisNavigation
{
/// <summary>
/// Writes <paramref name="query"/> into the current page's address, replacing the history entry (D-46: toolbar and
/// tab changes replace, drill-downs push). Keys equal to the page defaults are removed; other keys (<c>tab</c>) stay.
/// </summary>
public static void Replace(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(navigation);
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
navigation.NavigateTo(navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults)), replace: true);
}
/// <summary>
/// The address of the current page showing <paramref name="query"/> (for a drill-down, which pushes a new history
/// entry: <c>Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults))</c>).
/// </summary>
public static string UriFor(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(navigation);
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
return navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults));
}
/// <summary>The local days a bucket stands for, both inclusive: a bucket cut at now stands for its whole unit (its <see cref="AnalysisBucket.NominalEndDay"/>).</summary>
public static (DateOnly First, DateOnly Last) DaysOf(AnalysisBucket bucket)
{
ArgumentNullException.ThrowIfNull(bucket);
var end = bucket.NominalEndDay ?? bucket.EndDay;
return (bucket.FirstDay, end > bucket.FirstDay ? end.AddDays(-1) : bucket.FirstDay);
}
/// <summary>
/// The finer bucket sizes a drill into <paramref name="size"/> may use, most useful first: a year opens its months,
/// a month its days (or weeks, when the data resolves weeks but not days), a week its days; a day has none.
/// </summary>
public static IReadOnlyList<BucketSize> FinerSizes(BucketSize size) => size switch
{
BucketSize.Year => [BucketSize.Month],
BucketSize.Month => [BucketSize.Day, BucketSize.Week],
BucketSize.Week => [BucketSize.Day],
_ => [],
};
/// <summary>
/// The drill-down of a chart bucket (D-51): the same scope, metric and comparison over the bucket's days, in the next
/// finer bucket the data supports. Null when there is none — a day, or data too coarse for anything finer (a monthly
/// import) — and the page opens the bucket's records instead (<see cref="NormalizedData"/>).
/// </summary>
/// <param name="query">The page's analysis state.</param>
/// <param name="bucket">The clicked bucket.</param>
/// <param name="coarsestResolution">
/// The coarsest resolution among the charted series (<see cref="Infrastructure.Analysis.AnalysisSeries.Resolution"/>);
/// null when unknown, which allows any finer size.
/// </param>
/// <remarks>
/// A named-year comparison (<c>year:2024</c>) needs a calendar year; drilling below a year turns it into the same
/// period a year earlier, which is what a year comparison of a month means.
/// </remarks>
public static AnalysisQuery? DrillInto(AnalysisQuery query, AnalysisBucket bucket, ResolutionClass? coarsestResolution = null)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(bucket);
var floor = coarsestResolution is { } resolution ? BucketPlanner.MinimumSizeFor(resolution) : BucketSize.Day;
BucketSize? finer = null;
foreach (var size in FinerSizes(bucket.Size))
{
if (size >= floor)
{
finer = size;
break;
}
}
var (first, last) = DaysOf(bucket);
if (finer is not { } next || !PeriodResolver.IsValidCustomRange(first, last))
{
return null;
}
var drilled = query.WithCustomRange(first, last).WithBucket(next);
var wholeYear = first is { Month: 1, Day: 1 } && last.Month == 12 && last.Day == 31 && first.Year == last.Year;
return query.Comparison.Kind == ComparisonKind.Year && !wholeYear
? drilled.WithComparison(new ComparisonRequest(ComparisonKind.PreviousYear))
: drilled;
}
/// <summary>The meter's Normalized data tab filtered to a bucket's days (D-50, D-51), keeping the rest of the analysis state.</summary>
public static string NormalizedData(int meterId, AnalysisQuery query, AnalysisBucket bucket)
{
ArgumentNullException.ThrowIfNull(query);
var (first, last) = DaysOf(bucket);
var target = PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : query;
return MeterLinks.Detail(meterId, MeterLinks.TabNormalized, null, target);
}
/// <summary>
/// "Go to latest data" (brief §4.3): a period of the same kind ending with the latest data — its month for a month
/// preset, its calendar year for a year preset, the 12 or 24 months up to it for those, a custom range of the same
/// length ending on the last available day. Null without availability.
/// </summary>
public static AnalysisQuery? LatestData(AnalysisQuery query, AvailableRange? availability)
{
ArgumentNullException.ThrowIfNull(query);
if (availability is null)
{
return null;
}
var lastDay = availability.LastDay;
var month = new DateOnly(lastDay.Year, lastDay.Month, 1);
var monthEnd = month.AddMonths(1).AddDays(-1);
var (first, last) = query.Period switch
{
PeriodPreset.MonthToDate or PeriodPreset.LastMonth => (month, monthEnd),
PeriodPreset.YearToDate or PeriodPreset.PreviousYear => (new DateOnly(lastDay.Year, 1, 1), new DateOnly(lastDay.Year, 12, 31)),
PeriodPreset.Last24Months => (month.AddMonths(-23), monthEnd),
PeriodPreset.Custom when query.From is { } from && query.To is { } to && to >= from =>
(lastDay.AddDays(from.DayNumber - to.DayNumber), lastDay),
_ => (month.AddMonths(-11), monthEnd),
};
if (first < PeriodResolver.MinSupportedDate)
{
first = PeriodResolver.MinSupportedDate;
}
return PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : null;
}
/// <summary>
/// The breadcrumb trail (D-48, brief §3.1): Overview → energy type → meter, each link carrying the period, bucket,
/// comparison and metric of <paramref name="query"/>; the last crumb is the current page and has no link.
/// </summary>
/// <param name="query">The current page's analysis state; null carries nothing.</param>
/// <param name="energyType">The energy type (id, name), when the page is inside one.</param>
/// <param name="meter">The meter (id, name), when the page is a meter's.</param>
/// <param name="current">A label for the current page below them (a tab, a specialized view); null when the last of the above is the page.</param>
public static IReadOnlyList<Crumb> Breadcrumbs(
AnalysisQuery? query,
(int Id, string Name)? energyType = null,
(int Id, string Name)? meter = null,
string? current = null)
{
var trail = new List<Crumb> { new(Strings.Nav_Overview, AnalysisLinks.Overview(query)) };
if (energyType is { } type)
{
trail.Add(new Crumb(type.Name, AnalysisLinks.EnergyType(type.Id, null, query)));
}
if (meter is { } m)
{
trail.Add(new Crumb(m.Name, MeterLinks.Analysis(m.Id, query)));
}
if (!string.IsNullOrWhiteSpace(current))
{
trail.Add(new Crumb(current, null));
}
else
{
trail[^1] = trail[^1] with { Href = null };
}
return trail;
}
}
+53
View File
@@ -0,0 +1,53 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>
/// Resolves an <see cref="AnalysisQuery"/> the way every page and the CSV export do (D-01, D-02, D-19): against the
/// captured now, in the zone the readers cut days in, and — for <c>all</c> only — over the scope's availability, which
/// is the one thing resolving has to read.
/// </summary>
/// <remarks>
/// Availability follows what the query shows: the cost scope's (billed meters plus manual costs) for the cost metric and
/// for a category, the quantity scope's otherwise (D-19). Nothing is read for any other preset.
/// </remarks>
public sealed class AnalysisPeriods(AnalysisReader reader, CostReader costs)
{
/// <summary>The zone periods are resolved in: the readers' (<c>MeterVault__TimeZone</c>).</summary>
public TimeZoneInfo Zone => reader.Zone;
/// <summary>Resolves <paramref name="query"/> as of <paramref name="now"/>.</summary>
public async Task<ResolvedPeriod> ResolveAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var availability = query.Period == PeriodPreset.AllHistory
? await AvailabilityAsync(query, now, cancellationToken).ConfigureAwait(false)
: null;
return query.Resolve(now, reader.Zone, availability);
}
/// <summary>What the query's scope has data for as of <paramref name="now"/>, capped at now (D-19); null without any.</summary>
public async Task<AvailableRange?> AvailabilityAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Metric == AnalysisMetric.Cost || query.Scope.ToAnalysisScope() is not { } scope)
{
var ranges = new List<AvailableRange?>();
foreach (var costScope in query.Scope.ToCostScopes())
{
var availability = await costs.GetAvailabilityAsync(costScope, now, cancellationToken).ConfigureAwait(false);
ranges.Add(availability.Range);
}
return AvailableRange.Union(ranges, costs.Zone);
}
var quantity = await reader.GetAvailabilityAsync(scope, now, cancellationToken).ConfigureAwait(false);
return quantity.Quantity;
}
}
+725
View File
@@ -0,0 +1,725 @@
using System.Globalization;
using System.Text;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace MeterVault.App.Analysis;
/// <summary>The URL keys of the analysis state (D-02, D-46, D-47). Stable invariant identifiers, never localized.</summary>
public static class AnalysisUrlKeys
{
public const string Scope = "scope";
public const string Id = "id";
public const string Ids = "ids";
public const string Metric = "metric";
public const string Period = "period";
public const string From = "from";
public const string To = "to";
public const string Bucket = "bucket";
public const string Compare = "compare";
/// <summary>Every analysis key, in the order links write them.</summary>
public static IReadOnlyList<string> All { get; } = [Scope, Id, Ids, Metric, Period, From, To, Bucket, Compare];
}
/// <summary>Which parts of an <see cref="AnalysisQuery"/> a URL is written with.</summary>
[Flags]
public enum AnalysisQueryParts
{
None = 0,
/// <summary><c>scope</c>, <c>id</c>, <c>ids</c>.</summary>
Scope = 1,
/// <summary><c>metric</c>.</summary>
Metric = 2,
/// <summary><c>period</c>, <c>from</c>, <c>to</c>.</summary>
Period = 4,
/// <summary><c>bucket</c>.</summary>
Bucket = 8,
/// <summary><c>compare</c>.</summary>
Comparison = 16,
/// <summary>What a link carries onward to another page (D-47): everything but the scope, which the target's route names.</summary>
Carry = Metric | Period | Bucket | Comparison,
All = Scope | Carry,
}
/// <summary>
/// The analysis state of a page, parsed from its URL (D-02, D-46, D-47, brief §4.1): period preset (or custom dates),
/// bucket size, comparison, metric and scope — immutable and compared by value, so a page reloads its analysis only
/// when this value changes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Reading.</b> <see cref="Parse(string, AnalysisDefaults)"/> reads the keys of <see cref="AnalysisUrlKeys"/>. A key
/// that is absent takes the page default (<see cref="AnalysisDefaults"/>); a key with an invalid value takes the default
/// too and adds a <see cref="Notices">notice</see> — a hand-edited or stale link never breaks the page (D-02). Tokens
/// are read case-insensitively; <c>previous-year</c> / <c>previous-period</c> are accepted for <c>prev-year</c> /
/// <c>prev-period</c>. <c>from</c>/<c>to</c> (yyyy-MM-dd, inclusive) make a custom range when <c>period</c> is
/// <c>custom</c> or absent, and are ignored beside another preset. Explicit meter selections keep at most
/// <see cref="AnalysisLimits.MaxSeries"/> meters.
/// </para>
/// <para>
/// <b>Writing.</b> <see cref="ToQueryParameters"/>, <see cref="ToNavigationParameters"/> and <see cref="AppendTo"/>
/// write the canonical tokens and omit every key equal to the <em>target</em> page's default; a custom range is written
/// as <c>from</c> and <c>to</c> alone. Links to another page carry <see cref="AnalysisQueryParts.Carry"/> (period,
/// bucket, comparison, metric), because the target's route names its scope.
/// </para>
/// <para>
/// <b>Resolving.</b> <see cref="Resolve"/> turns the preset into a <see cref="ResolvedPeriod"/> through
/// <see cref="PeriodResolver"/>, once per load, against a captured now and the instance zone; <c>all</c> spans the
/// scope's availability (D-19). <see cref="ToAnalysisRequest"/>, <see cref="ToCostRequests"/> and
/// <see cref="ToCostComparison"/> build the reader requests in one place, so pages and the CSV export ask identically.
/// </para>
/// <para>
/// <see cref="Notices"/> are not part of the value: two URLs that resolve to the same state are equal, whatever was
/// wrong with them. The <c>With…</c> helpers return a query without notices — a choice made in the toolbar is clean.
/// </para>
/// </remarks>
public sealed class AnalysisQuery : IEquatable<AnalysisQuery>
{
private AnalysisQuery(
PeriodPreset period,
DateOnly? from,
DateOnly? to,
BucketSize bucket,
ComparisonRequest comparison,
AnalysisMetric? metric,
QueryScope scope,
IReadOnlyList<AnalysisQueryNotice> notices)
{
Period = period;
From = from;
To = to;
Bucket = bucket;
Comparison = comparison;
Metric = metric;
Scope = scope;
Notices = notices;
}
/// <summary>The period preset; <see cref="PeriodPreset.Custom"/> with <see cref="From"/>/<see cref="To"/>.</summary>
public PeriodPreset Period { get; }
/// <summary>The first local day of a custom range (inclusive); null for a preset.</summary>
public DateOnly? From { get; }
/// <summary>The last local day of a custom range (inclusive); null for a preset.</summary>
public DateOnly? To { get; }
public BucketSize Bucket { get; }
public ComparisonRequest Comparison { get; }
/// <summary>The chosen metric; null for the scope's natural one (the page decides).</summary>
public AnalysisMetric? Metric { get; }
public QueryScope Scope { get; }
/// <summary>What in the URL was not used as written; not part of equality.</summary>
public IReadOnlyList<AnalysisQueryNotice> Notices { get; }
public bool IsCustom => Period == PeriodPreset.Custom;
/// <summary>The page defaults as a query: what a page shows with no analysis keys in its URL.</summary>
public static AnalysisQuery Default(AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(defaults);
return new AnalysisQuery(defaults.Period, null, null, defaults.Bucket, defaults.Comparison, defaults.Metric, defaults.Scope, []);
}
/// <summary>
/// Parses the analysis keys of a URL — absolute (<see cref="Microsoft.AspNetCore.Components.NavigationManager.Uri"/>),
/// base-relative, or a query string starting with <c>?</c>. Anything without a <c>?</c> has no keys.
/// </summary>
public static AnalysisQuery Parse(string? uriOrQuery, AnalysisDefaults defaults) =>
Parse(QueryHelpers.ParseQuery(QueryOf(uriOrQuery)), defaults);
/// <summary>Parses the analysis keys of <paramref name="uri"/>.</summary>
public static AnalysisQuery Parse(Uri uri, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(uri);
return Parse(uri.IsAbsoluteUri ? uri.Query : uri.OriginalString, defaults);
}
/// <summary>Parses the analysis keys of a query collection (<see cref="Microsoft.AspNetCore.Http.IQueryCollection"/>, a parsed query).</summary>
public static AnalysisQuery Parse(IEnumerable<KeyValuePair<string, StringValues>> query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
var keys = new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, values) in query)
{
if (key is not null)
{
keys[key] = keys.TryGetValue(key, out var existing) ? StringValues.Concat(existing, values) : values;
}
}
var reader = new Reader(keys);
var notices = new List<AnalysisQueryNotice>();
var (period, from, to) = ParsePeriod(reader, defaults, notices);
var bucket = ParseToken(reader, AnalysisUrlKeys.Bucket, defaults.Bucket, AnalysisTokens.TryParseBucket, AnalysisQueryNoticeKind.InvalidBucket, notices);
var comparison = ParseComparison(reader, defaults, notices);
var metric = ParseMetric(reader, defaults, notices);
var scope = ParseScope(reader, defaults, notices);
return new AnalysisQuery(period, from, to, bucket, comparison, metric, scope, notices);
}
/// <summary>This query with a preset period.</summary>
/// <exception cref="ArgumentException"><see cref="PeriodPreset.Custom"/>: use <see cref="WithCustomRange"/>.</exception>
public AnalysisQuery WithPeriod(PeriodPreset preset)
{
if (preset == PeriodPreset.Custom || !Enum.IsDefined(preset))
{
throw new ArgumentException("A custom period needs its dates; use WithCustomRange.", nameof(preset));
}
return new AnalysisQuery(preset, null, null, Bucket, Comparison, Metric, Scope, []);
}
/// <summary>This query with a custom range of local days, both inclusive.</summary>
/// <exception cref="ArgumentException">The range fails <see cref="PeriodResolver.IsValidCustomRange"/>: check it first (the toolbar applies a range only once it is valid).</exception>
public AnalysisQuery WithCustomRange(DateOnly first, DateOnly last)
{
if (!PeriodResolver.IsValidCustomRange(first, last))
{
throw new ArgumentException("A custom range needs a first and last day in order, within the supported dates.", nameof(first));
}
return new AnalysisQuery(PeriodPreset.Custom, first, last, Bucket, Comparison, Metric, Scope, []);
}
public AnalysisQuery WithBucket(BucketSize bucket) =>
Enum.IsDefined(bucket)
? new AnalysisQuery(Period, From, To, bucket, Comparison, Metric, Scope, [])
: throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Unknown bucket size.");
/// <exception cref="ArgumentException">A <see cref="ComparisonKind.Year"/> comparison without its year, which no URL can hold.</exception>
public AnalysisQuery WithComparison(ComparisonRequest comparison)
{
ArgumentNullException.ThrowIfNull(comparison);
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
{
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
}
return new AnalysisQuery(Period, From, To, Bucket, comparison, Metric, Scope, []);
}
/// <summary>This query with a metric; null for the scope's natural one.</summary>
public AnalysisQuery WithMetric(AnalysisMetric? metric) => new(Period, From, To, Bucket, Comparison, metric, Scope, []);
public AnalysisQuery WithScope(QueryScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
return new AnalysisQuery(Period, From, To, Bucket, Comparison, Metric, scope, []);
}
/// <summary>
/// The URL parameters of this query, in canonical order and tokens, leaving out every key equal to
/// <paramref name="defaults"/> (the target page's) and every part not in <paramref name="parts"/>.
/// </summary>
public IReadOnlyList<KeyValuePair<string, string>> ToQueryParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
{
ArgumentNullException.ThrowIfNull(defaults);
var list = new List<KeyValuePair<string, string>>(6);
if (parts.HasFlag(AnalysisQueryParts.Scope) && !Scope.Equals(defaults.Scope))
{
list.Add(new(AnalysisUrlKeys.Scope, Scope.Token));
if (Scope.Kind == QueryScopeKind.Meters)
{
list.Add(new(AnalysisUrlKeys.Ids, string.Join(',', Scope.MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture)))));
}
else if (Scope.Id is { } id)
{
list.Add(new(AnalysisUrlKeys.Id, id.ToString(CultureInfo.InvariantCulture)));
}
}
if (parts.HasFlag(AnalysisQueryParts.Metric) && Metric is { } metric && metric != defaults.Metric)
{
list.Add(new(AnalysisUrlKeys.Metric, AnalysisMetrics.Format(metric)));
}
if (parts.HasFlag(AnalysisQueryParts.Period))
{
if (IsCustom)
{
// from/to imply custom, so the preset key is left out.
list.Add(new(AnalysisUrlKeys.From, AnalysisTokens.FormatDate(From!.Value)));
list.Add(new(AnalysisUrlKeys.To, AnalysisTokens.FormatDate(To!.Value)));
}
else if (Period != defaults.Period)
{
list.Add(new(AnalysisUrlKeys.Period, AnalysisTokens.Format(Period)));
}
}
if (parts.HasFlag(AnalysisQueryParts.Bucket) && Bucket != defaults.Bucket)
{
list.Add(new(AnalysisUrlKeys.Bucket, AnalysisTokens.Format(Bucket)));
}
if (parts.HasFlag(AnalysisQueryParts.Comparison) && !Comparison.Equals(defaults.Comparison))
{
list.Add(new(AnalysisUrlKeys.Compare, AnalysisTokens.Format(Comparison)));
}
return list;
}
/// <summary>
/// Every analysis key of <paramref name="parts"/> for
/// <see cref="Microsoft.AspNetCore.Components.NavigationManager.GetUriWithQueryParameters(IReadOnlyDictionary{string, object?})"/>:
/// the value to write, or null to remove a key that equals the default — so updating the current page's URL keeps
/// its other keys (<c>tab</c>) and drops stale ones (<c>from</c>/<c>to</c> after leaving a custom range).
/// </summary>
public IReadOnlyDictionary<string, object?> ToNavigationParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var key in KeysOf(parts))
{
result[key] = null;
}
foreach (var (key, value) in ToQueryParameters(defaults, parts))
{
result[key] = value;
}
return result;
}
/// <summary>
/// <paramref name="url"/> with this query's parameters appended after its own (D-47: existing keys first), leaving
/// out what equals <paramref name="defaults"/> — the target page's. Links carry <see cref="AnalysisQueryParts.Carry"/>
/// by default: the target's route names its own scope.
/// </summary>
public string AppendTo(string url, AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.Carry)
{
ArgumentNullException.ThrowIfNull(url);
var parameters = ToQueryParameters(defaults, parts);
if (parameters.Count == 0)
{
return url;
}
var builder = new StringBuilder(url);
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
foreach (var (key, value) in parameters)
{
builder.Append(separator).Append(key).Append('=').Append(Escape(value));
separator = '&';
}
return builder.ToString();
}
/// <summary>
/// Resolves the period once, against the captured <paramref name="now"/> in the instance <paramref name="zone"/>
/// (D-01, D-03). <c>all</c> spans <paramref name="availability"/> (D-19) — the quantity or cost scope's, whichever
/// the page shows — and is the "no history" range without it.
/// </summary>
public ResolvedPeriod Resolve(DateTimeOffset now, TimeZoneInfo zone, AvailableRange? availability = null)
{
ArgumentNullException.ThrowIfNull(zone);
return Period switch
{
PeriodPreset.Custom => PeriodResolver.Resolve(PeriodPreset.Custom, From, To, now, zone),
PeriodPreset.AllHistory => PeriodResolver.Resolve(
PeriodPreset.AllHistory, null, null, now, zone, availability?.FirstDay, availability?.LastDay),
_ => PeriodResolver.Resolve(Period, null, null, now, zone),
};
}
/// <summary>
/// The quantity request of this query over <paramref name="period"/> (bucket and comparison included), or null for
/// a category scope, which is analysed by cost.
/// </summary>
/// <param name="period">The period from <see cref="Resolve"/>.</param>
/// <param name="includeMeterSeries">For a type or portfolio: also one series per meter ("individual meters").</param>
public AnalysisRequest? ToAnalysisRequest(ResolvedPeriod period, bool includeMeterSeries = false)
{
ArgumentNullException.ThrowIfNull(period);
return Scope.ToAnalysisScope() is { } scope
? new AnalysisRequest(scope, period) { Bucket = Bucket, Comparison = Comparison, IncludeMeterSeries = includeMeterSeries }
: null;
}
/// <summary>
/// The cost requests of this query over <paramref name="period"/>: one for the portfolio, a type, a meter or a
/// category, one per meter for a selection.
/// </summary>
/// <param name="period">The period from <see cref="Resolve"/>.</param>
/// <param name="plan">
/// Buckets to price in — a quantity result's <see cref="AnalysisResult.Plan"/>, or the first cost result's for the
/// rest of a selection — so cost and quantity share their buckets; null lets the cost reader plan from
/// <see cref="Bucket"/>.
/// </param>
/// <param name="includeCategories">For the portfolio: also the category composition (D-42).</param>
public IReadOnlyList<CostAnalysisRequest> ToCostRequests(ResolvedPeriod period, BucketPlan? plan = null, bool includeCategories = false)
{
ArgumentNullException.ThrowIfNull(period);
return
[
.. Scope.ToCostScopes().Select(scope => new CostAnalysisRequest(scope, period)
{
Bucket = Bucket,
Plan = plan,
IncludeCategories = includeCategories && scope.Kind == CostScopeKind.Portfolio,
}),
];
}
/// <summary>
/// The cost request for this query's comparison (D-06) of an already priced <paramref name="current"/> request: the
/// comparison period, priced in the images of the current buckets (<see cref="ComparisonResolver.PairBuckets"/>), so
/// bucket i of the result compares with bucket i of the current one. The cost reader has no comparison of its own;
/// quantities get theirs from the analysis reader (<see cref="AnalysisSeries.Comparison"/>).
/// </summary>
/// <param name="current">The current cost request (its scope and period).</param>
/// <param name="currentPlan">The plan the current result was priced in (<see cref="CostAnalysis.Plan"/>).</param>
public CostComparisonRequest ToCostComparison(CostAnalysisRequest current, BucketPlan currentPlan)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(currentPlan);
var resolution = ComparisonResolver.Resolve(current.Period, Comparison);
if (!resolution.IsApplicable)
{
return new CostComparisonRequest(resolution, null, []);
}
var pairs = ComparisonResolver.PairBuckets(current.Period, resolution.Period, currentPlan.Buckets);
var plan = new BucketPlan(currentPlan.Size, currentPlan.Size, [.. pairs.Select(p => p.Comparison)], pairs.Count, Refused: false, Suggested: null);
var request = new CostAnalysisRequest(current.Scope, resolution.Period.ToResolvedPeriod(current.Period))
{
Plan = plan,
MaxPoints = current.MaxPoints,
IncludeCategories = current.IncludeCategories,
};
return new CostComparisonRequest(resolution, request, pairs);
}
public bool Equals(AnalysisQuery? other) =>
other is not null
&& Period == other.Period
&& From == other.From
&& To == other.To
&& Bucket == other.Bucket
&& Comparison.Equals(other.Comparison)
&& Metric == other.Metric
&& Scope.Equals(other.Scope);
public override bool Equals(object? obj) => Equals(obj as AnalysisQuery);
public override int GetHashCode() => HashCode.Combine(Period, From, To, Bucket, Comparison, Metric, Scope);
public static bool operator ==(AnalysisQuery? left, AnalysisQuery? right) => left is null ? right is null : left.Equals(right);
public static bool operator !=(AnalysisQuery? left, AnalysisQuery? right) => !(left == right);
/// <summary>Every key written, defaults included — for logs.</summary>
public override string ToString()
{
var period = IsCustom
? AnalysisTokens.FormatDate(From!.Value) + ".." + AnalysisTokens.FormatDate(To!.Value)
: AnalysisTokens.Format(Period);
var metric = Metric is { } m ? AnalysisMetrics.Format(m) : "natural";
return string.Create(
CultureInfo.InvariantCulture,
$"{Scope} {metric} {period} {AnalysisTokens.Format(Bucket)} {AnalysisTokens.Format(Comparison)}");
}
private static (PeriodPreset Period, DateOnly? From, DateOnly? To) ParsePeriod(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
var periodToken = reader.First(AnalysisUrlKeys.Period);
var fromToken = reader.First(AnalysisUrlKeys.From);
var toToken = reader.First(AnalysisUrlKeys.To);
if (periodToken is not null)
{
if (!AnalysisTokens.TryParsePeriod(periodToken, out var preset))
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidPeriod, AnalysisUrlKeys.Period, periodToken));
return (defaults.Period, null, null);
}
// Beside another preset, from/to mean nothing and are ignored.
if (preset != PeriodPreset.Custom)
{
return (preset, null, null);
}
}
else if (fromToken is null && toToken is null)
{
return (defaults.Period, null, null);
}
if (AnalysisTokens.TryParseCustomRange(fromToken, toToken, out var first, out var last))
{
return (PeriodPreset.Custom, first, last);
}
notices.Add(new AnalysisQueryNotice(
AnalysisQueryNoticeKind.InvalidRange, AnalysisUrlKeys.From + "/" + AnalysisUrlKeys.To, (fromToken ?? string.Empty) + "/" + (toToken ?? string.Empty)));
return (defaults.Period, null, null);
}
private static ComparisonRequest ParseComparison(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
var token = reader.First(AnalysisUrlKeys.Compare);
if (token is null)
{
return defaults.Comparison;
}
if (AnalysisTokens.TryParseComparison(token, out var comparison))
{
return comparison;
}
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidComparison, AnalysisUrlKeys.Compare, token));
return defaults.Comparison;
}
private static AnalysisMetric? ParseMetric(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
var token = reader.First(AnalysisUrlKeys.Metric);
if (token is null)
{
return defaults.Metric;
}
if (AnalysisMetrics.TryParse(token, out var metric))
{
return metric;
}
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidMetric, AnalysisUrlKeys.Metric, token));
return defaults.Metric;
}
private delegate bool TokenParser<T>(string? token, out T value);
private static T ParseToken<T>(
Reader reader, string key, T fallback, TokenParser<T> parse, AnalysisQueryNoticeKind invalid, List<AnalysisQueryNotice> notices)
{
var token = reader.First(key);
if (token is null)
{
return fallback;
}
if (parse(token, out var value))
{
return value;
}
notices.Add(new AnalysisQueryNotice(invalid, key, token));
return fallback;
}
private static QueryScope ParseScope(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
// Without a scope key, ids mean nothing: the route (or the page default) names the scope.
var token = reader.First(AnalysisUrlKeys.Scope);
if (token is null)
{
return defaults.Scope;
}
if (!QueryScope.TryParseKind(token, out var kind))
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token));
return defaults.Scope;
}
switch (kind)
{
case QueryScopeKind.Portfolio:
return QueryScope.Portfolio;
case QueryScopeKind.Meters:
return ParseSelection(reader, token, notices) ?? defaults.Scope;
default:
var idToken = reader.First(AnalysisUrlKeys.Id);
if (TryParseId(idToken, out var id))
{
return kind switch
{
QueryScopeKind.EnergyType => QueryScope.ForEnergyType(id),
QueryScopeKind.Category => QueryScope.ForCategory(id),
_ => QueryScope.ForMeter(id),
};
}
notices.Add(idToken is null
? new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token)
: new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Id, idToken));
return defaults.Scope;
}
}
/// <summary>
/// <c>ids=3,5,9</c> (or repeated <c>ids</c>, or a single <c>id</c>): the valid ids in order, distinct, at most
/// <see cref="AnalysisLimits.MaxSeries"/>; null when none is valid.
/// </summary>
private static QueryScope? ParseSelection(Reader reader, string scopeToken, List<AnalysisQueryNotice> notices)
{
var tokens = reader.All(AnalysisUrlKeys.Ids)
.SelectMany(v => v.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
.ToList();
if (tokens.Count == 0 && reader.First(AnalysisUrlKeys.Id) is { } single)
{
tokens.Add(single.Trim());
}
var ids = new List<int>();
var invalid = new List<string>();
foreach (var item in tokens)
{
if (!TryParseId(item, out var id))
{
invalid.Add(item);
}
else if (!ids.Contains(id))
{
ids.Add(id);
}
}
if (invalid.Count > 0)
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Ids, string.Join(',', invalid)));
}
if (ids.Count > AnalysisLimits.MaxSeries)
{
notices.Add(new AnalysisQueryNotice(
AnalysisQueryNoticeKind.TooManyMeters, AnalysisUrlKeys.Ids, ids.Count.ToString(CultureInfo.InvariantCulture)));
ids = ids.Take(AnalysisLimits.MaxSeries).ToList();
}
if (ids.Count == 0)
{
if (invalid.Count == 0)
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, scopeToken));
}
return null;
}
return QueryScope.ForMeters(ids);
}
private static bool TryParseId(string? token, out int id)
{
id = 0;
return token is not null
&& int.TryParse(token.AsSpan().Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out id)
&& id > 0;
}
private static IEnumerable<string> KeysOf(AnalysisQueryParts parts)
{
if (parts.HasFlag(AnalysisQueryParts.Scope))
{
yield return AnalysisUrlKeys.Scope;
yield return AnalysisUrlKeys.Id;
yield return AnalysisUrlKeys.Ids;
}
if (parts.HasFlag(AnalysisQueryParts.Metric))
{
yield return AnalysisUrlKeys.Metric;
}
if (parts.HasFlag(AnalysisQueryParts.Period))
{
yield return AnalysisUrlKeys.Period;
yield return AnalysisUrlKeys.From;
yield return AnalysisUrlKeys.To;
}
if (parts.HasFlag(AnalysisQueryParts.Bucket))
{
yield return AnalysisUrlKeys.Bucket;
}
if (parts.HasFlag(AnalysisQueryParts.Comparison))
{
yield return AnalysisUrlKeys.Compare;
}
}
/// <summary>The query part of a URL (from its <c>?</c>, without a fragment), or empty.</summary>
private static string QueryOf(string? uriOrQuery)
{
if (string.IsNullOrEmpty(uriOrQuery))
{
return string.Empty;
}
var start = uriOrQuery.IndexOf('?', StringComparison.Ordinal);
if (start < 0)
{
return string.Empty;
}
var end = uriOrQuery.IndexOf('#', start);
return end < 0 ? uriOrQuery[start..] : uriOrQuery[start..end];
}
/// <summary>Escapes a value, keeping the <c>:</c> of <c>year:2025</c> and the commas of an id list readable.</summary>
private static string Escape(string value) =>
Uri.EscapeDataString(value).Replace("%3A", ":", StringComparison.Ordinal).Replace("%2C", ",", StringComparison.Ordinal);
/// <summary>Case-insensitive access to a parsed query: the first non-blank value of a key, or all of them.</summary>
private sealed class Reader(Dictionary<string, StringValues> keys)
{
public string? First(string key)
{
if (!keys.TryGetValue(key, out var values))
{
return null;
}
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
public IEnumerable<string> All(string key) =>
keys.TryGetValue(key, out var values) ? values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!) : [];
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Globalization;
namespace MeterVault.App.Analysis;
/// <summary>Why part of an analysis URL was not used as written (D-02: an invalid token falls back to the default with a notice).</summary>
public enum AnalysisQueryNoticeKind
{
/// <summary><c>period</c> is not a known preset.</summary>
InvalidPeriod,
/// <summary>A custom range whose <c>from</c>/<c>to</c> are missing, malformed, out of order or outside the supported dates.</summary>
InvalidRange,
/// <summary><c>bucket</c> is not a known size.</summary>
InvalidBucket,
/// <summary><c>compare</c> is not a known comparison.</summary>
InvalidComparison,
/// <summary><c>metric</c> is not a known metric.</summary>
InvalidMetric,
/// <summary><c>scope</c> is not a known scope, or names no usable id.</summary>
InvalidScope,
/// <summary>An <c>id</c>/<c>ids</c> entry is not a positive whole number.</summary>
InvalidId,
/// <summary>More meters than can be charted side by side were selected; the first ones are kept.</summary>
TooManyMeters,
}
/// <summary>
/// One part of an analysis URL that was not used as written: what was wrong, under which key, and the raw value. The
/// page shows it (localized through <see cref="Localization.DisplayNames"/>); the CSV export answers it with a 400.
/// </summary>
/// <param name="Kind">What was wrong.</param>
/// <param name="Key">The URL key (<c>period</c>, <c>ids</c>, …).</param>
/// <param name="Value">The raw value as it appeared (data, never shown untrusted as markup).</param>
public sealed record AnalysisQueryNotice(AnalysisQueryNoticeKind Kind, string Key, string? Value)
{
/// <summary>A short invariant English sentence, for logs and the export's 400 responses.</summary>
public string Describe() => Kind switch
{
AnalysisQueryNoticeKind.InvalidPeriod => Quote("Unknown period"),
AnalysisQueryNoticeKind.InvalidRange => Quote("Invalid custom range (from/to must be yyyy-MM-dd dates in order)"),
AnalysisQueryNoticeKind.InvalidBucket => Quote("Unknown bucket"),
AnalysisQueryNoticeKind.InvalidComparison => Quote("Unknown comparison"),
AnalysisQueryNoticeKind.InvalidMetric => Quote("Unknown metric"),
AnalysisQueryNoticeKind.InvalidScope => Quote("Unknown or incomplete scope"),
AnalysisQueryNoticeKind.InvalidId => Quote("Invalid id"),
AnalysisQueryNoticeKind.TooManyMeters => string.Create(
CultureInfo.InvariantCulture, $"At most {Infrastructure.Analysis.AnalysisLimits.MaxSeries} meters can be compared ('{Key}')."),
_ => Quote("Invalid value"),
};
private string Quote(string text) => string.Create(CultureInfo.InvariantCulture, $"{text}: {Key}='{Value}'.");
}
+338
View File
@@ -0,0 +1,338 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Infrastructure.Analysis;
namespace MeterVault.App.Analysis;
/// <summary>One formatted figure of a table: the number, its text (with unit or currency, "—" when unknown) and its status.</summary>
public sealed record TableFigure(double? Value, string Text, FigureStatus Status)
{
/// <summary>A quantity with its unit.</summary>
public static TableFigure Of(BucketValue value, string? unit, Func<int, string?>? meterName = null)
{
var status = FigureText.Of(value, meterName);
var number = status.IsKnown ? value.Value : null;
return new TableFigure(number, Format.Quantity(number, unit), status);
}
/// <summary>A cost in <paramref name="currency"/>.</summary>
public static TableFigure Of(CostAmount amount, string currency)
{
var status = FigureText.Of(amount);
var number = status.IsKnown ? amount.Cost : null;
return new TableFigure(number, Format.Money(number, currency), status);
}
}
/// <summary>
/// A series of the analysis table (brief §7.2): its values per bucket and in total, and optionally its cost and its
/// comparison — each formatted once, in the reader's culture, with its status in words.
/// </summary>
public sealed record AnalysisTableSeries
{
private AnalysisTableSeries(string key, string name, IReadOnlyList<TableFigure> values, TableFigure? total, bool isMoney)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(name);
Key = key;
Name = name;
Values = values;
Total = total;
IsMoney = isMoney;
}
public string Key { get; init; }
/// <summary>The column header (a meter's name is user data, never translated).</summary>
public string Name { get; init; }
/// <summary>One figure per bucket.</summary>
public IReadOnlyList<TableFigure> Values { get; init; }
/// <summary>The period total from the reader; null to show none (it is never added up here: a formula may not be additive).</summary>
public TableFigure? Total { get; init; }
/// <summary>True when the values are money, so no separate cost column applies.</summary>
public bool IsMoney { get; init; }
/// <summary>The cost per bucket, when priced (a meter's cost by its rule).</summary>
public IReadOnlyList<TableFigure>? Costs { get; init; }
public TableFigure? CostTotal { get; init; }
/// <summary>The comparison per paired bucket (A-10), when one was requested.</summary>
public IReadOnlyList<TableFigure>? Comparison { get; init; }
public TableFigure? ComparisonTotal { get; init; }
/// <summary>The change over the matched coverage (D-07) for the total row; computed from the totals when absent.</summary>
public Change? TotalChange { get; init; }
/// <summary>Formats the size of a change (with unit or currency).</summary>
public Func<double, string> FormatDifference { get; init; } = v => Format.Number(v, 2);
/// <summary>Whether a rise is good news (D-08).</summary>
public ChangePolarity Polarity { get; init; } = ChangePolarity.HigherIsWorse;
/// <summary>False when the buckets do not add up to the total (a ratio, a formula with a constant, D-27).</summary>
public bool IsAdditive { get; init; } = true;
/// <summary>A quantity series with its unit.</summary>
/// <param name="key">A stable key.</param>
/// <param name="name">The column header.</param>
/// <param name="unit">The unit of every value.</param>
/// <param name="values">One value per bucket.</param>
/// <param name="total">The period total; null to show none.</param>
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
public static AnalysisTableSeries ForValues(
string key, string name, string? unit, IReadOnlyList<BucketValue> values, BucketValue? total = null, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(values);
return new AnalysisTableSeries(
key, name, [.. values.Select(v => TableFigure.Of(v, unit, meterName))], total is null ? null : TableFigure.Of(total, unit, meterName), isMoney: false)
{
FormatDifference = v => Format.Quantity(v, unit),
};
}
/// <summary>A cost series (metric cost, a category): the values are money.</summary>
public static AnalysisTableSeries ForCosts(string key, string name, string currency, IReadOnlyList<CostAmount> amounts, CostAmount? total = null)
{
ArgumentNullException.ThrowIfNull(amounts);
return new AnalysisTableSeries(key, name, [.. amounts.Select(a => TableFigure.Of(a, currency))], total is null ? null : TableFigure.Of(total, currency), isMoney: true)
{
FormatDifference = v => Format.Money(v, currency),
};
}
/// <summary>
/// A reader series with its total, polarity, additivity and — when one was read — its comparison and the change over
/// the matched coverage.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="name">The column header; the meter's name or the measure's wording by default.</param>
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
public static AnalysisTableSeries ForSeries(AnalysisSeries series, string? name = null, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(series);
var table = ForValues(series.Key.Id, name ?? AnalysisChartSeries.NameOf(series), series.Unit, series.Values, series.Total, meterName) with
{
Polarity = ChangePolarities.For(series.Kind),
IsAdditive = series.IsAdditive,
};
return series.Comparison is { } comparison
? table.WithComparison(comparison.Values, comparison.Total, series.Unit, comparison.Change, meterName)
: table;
}
/// <summary>This series with a comparison in the same unit.</summary>
public AnalysisTableSeries WithComparison(
IReadOnlyList<BucketValue> values, BucketValue? total, string? unit, Change? totalChange = null, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(values);
return this with
{
Comparison = [.. values.Select(v => TableFigure.Of(v, unit, meterName))],
ComparisonTotal = total is null ? null : TableFigure.Of(total, unit, meterName),
TotalChange = totalChange,
};
}
/// <summary>This (money) series with its comparison priced in the paired buckets.</summary>
public AnalysisTableSeries WithComparisonCosts(IReadOnlyList<CostAmount> amounts, CostAmount? total, string currency)
{
ArgumentNullException.ThrowIfNull(amounts);
return this with
{
Comparison = [.. amounts.Select(a => TableFigure.Of(a, currency))],
ComparisonTotal = total is null ? null : TableFigure.Of(total, currency),
};
}
/// <summary>This quantity series with its cost per bucket (a meter's cost by its rule).</summary>
public AnalysisTableSeries WithCosts(IReadOnlyList<CostAmount> amounts, CostAmount? total, string currency)
{
ArgumentNullException.ThrowIfNull(amounts);
return this with
{
Costs = [.. amounts.Select(a => TableFigure.Of(a, currency))],
CostTotal = total is null ? null : TableFigure.Of(total, currency),
};
}
}
/// <summary>What a table column shows.</summary>
public enum AnalysisTableColumnKind
{
Value,
Status,
Cost,
CostStatus,
Comparison,
Change,
}
/// <summary>A column: its series, what it shows, its header, and (with several series) the series it belongs to.</summary>
public sealed record AnalysisTableColumn(string SeriesKey, AnalysisTableColumnKind Kind, string Header, string? SubHeader)
{
/// <summary>Numbers are right-aligned.</summary>
public bool IsNumeric => Kind is AnalysisTableColumnKind.Value or AnalysisTableColumnKind.Cost
or AnalysisTableColumnKind.Comparison or AnalysisTableColumnKind.Change;
}
/// <summary>A cell: its text, an optional second line (the reason, the compared bucket), a CSS class and whether it is unknown.</summary>
public sealed record AnalysisTableCell(string Text, string? Secondary = null, string? CssClass = null, bool IsUnknown = false);
/// <summary>A row: one bucket (or the total), its label and one cell per column.</summary>
public sealed record AnalysisTableRow(AnalysisBucket? Bucket, string Label, IReadOnlyList<AnalysisTableCell> Cells, bool IsTotal, bool IsQualified);
/// <summary>
/// The analysis table (brief §7.2) as data: one row per bucket and a total row, each series with its value, status in
/// words, optional cost and cost status, optional comparison and change. It is the chart's accessible alternative, so it
/// says in words what the chart only marks.
/// </summary>
/// <remarks>
/// <para>
/// A change is stated per bucket only where both figures are complete — a partial bucket against a whole one is not a
/// like-for-like change (D-07); the total row takes the reader's change over the matched coverage. Unknown values read
/// "—", never 0.
/// </para>
/// <para>
/// <b>Newest first (A-42).</b> The rows are the reverse of the plan: the latest bucket is the first row, and the total
/// row sits above them all. The chart stays chronological — it is read left to right — but a table is read top down,
/// and what a reader wants first is the period they are in. The plan itself is never reordered: <see cref="Build"/>
/// takes the buckets oldest first (as the reader returns them, paired with the comparison by index) and turns them
/// round once, here, so nothing that indexes the plan has to know.
/// </para>
/// </remarks>
public sealed record AnalysisTableModel(IReadOnlyList<AnalysisTableColumn> Columns, IReadOnlyList<AnalysisTableRow> Rows)
{
/// <summary>Builds the table: the total row first, then the buckets newest first (A-42).</summary>
/// <param name="buckets">The buckets, oldest first (the plan's own order).</param>
/// <param name="series">The series.</param>
/// <param name="pairs">The comparison buckets paired with <paramref name="buckets"/>, to name each row's compared bucket.</param>
/// <param name="includeTotal">Adds the total row.</param>
public static AnalysisTableModel Build(
IReadOnlyList<AnalysisBucket> buckets,
IReadOnlyList<AnalysisTableSeries> series,
IReadOnlyList<BucketPair>? pairs = null,
bool includeTotal = true)
{
ArgumentNullException.ThrowIfNull(buckets);
ArgumentNullException.ThrowIfNull(series);
var several = series.Count > 1;
var columns = new List<AnalysisTableColumn>();
foreach (var item in series)
{
var sub = several ? item.Name : null;
columns.Add(new(item.Key, AnalysisTableColumnKind.Value, item.Name.Length > 0 ? item.Name : Strings.Common_Value, null));
columns.Add(new(item.Key, AnalysisTableColumnKind.Status, Strings.Common_Status, sub));
if (item.Costs is not null)
{
columns.Add(new(item.Key, AnalysisTableColumnKind.Cost, Strings.AnalysisTable_Cost, sub));
columns.Add(new(item.Key, AnalysisTableColumnKind.CostStatus, Strings.AnalysisTable_PriceCoverage, sub));
}
if (item.Comparison is not null)
{
columns.Add(new(item.Key, AnalysisTableColumnKind.Comparison, Strings.AnalysisTable_Comparison, sub));
columns.Add(new(item.Key, AnalysisTableColumnKind.Change, Strings.AnalysisTable_Change, sub));
}
}
var labels = AnalysisChartPlan.BucketLabels(buckets);
var rows = new List<AnalysisTableRow>(buckets.Count + 1);
for (var i = 0; i < buckets.Count; i++)
{
var cells = new List<AnalysisTableCell>(columns.Count);
var qualified = false;
var pairLabel = pairs is not null && i < pairs.Count ? Format.BucketLabel(pairs[i].Comparison, includeYear: true) : null;
foreach (var item in series)
{
var value = At(item.Values, i);
qualified |= value.Status.IsQualified;
AddCells(cells, item, value, AtOrNull(item.Costs, i), AtOrNull(item.Comparison, i), pairLabel, null);
}
rows.Add(new AnalysisTableRow(buckets[i], labels[i], cells, IsTotal: false, qualified));
}
// A-42: the latest period is the first row. The pairing above is by index against the plan, so the order is
// turned round only once everything is built.
rows.Reverse();
if (includeTotal && buckets.Count > 0)
{
var cells = new List<AnalysisTableCell>(columns.Count);
var qualified = false;
foreach (var item in series)
{
var total = item.Total ?? Unknown;
qualified |= item.Total is not null && total.Status.IsQualified;
AddCells(cells, item, total, item.Costs is null ? null : item.CostTotal ?? Unknown, item.Comparison is null ? null : item.ComparisonTotal ?? Unknown, null, item.TotalChange);
}
// Above the rows, where a total belongs once they descend: it sums what follows it, not what precedes it.
rows.Insert(0, new AnalysisTableRow(null, Strings.AnalysisTable_Total, cells, IsTotal: true, qualified));
}
return new AnalysisTableModel(columns, rows);
}
/// <summary>Half a cent: a money difference that displays as zero is no change.</summary>
private const double MoneyTolerance = 0.005;
/// <summary>A figure nothing is known about.</summary>
private static TableFigure Unknown { get; } =
new(null, Format.Unknown, new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty));
private static TableFigure At(IReadOnlyList<TableFigure> figures, int index) => index < figures.Count ? figures[index] : Unknown;
private static TableFigure? AtOrNull(IReadOnlyList<TableFigure>? figures, int index) => figures is null ? null : At(figures, index);
private static void AddCells(
List<AnalysisTableCell> cells,
AnalysisTableSeries series,
TableFigure value,
TableFigure? cost,
TableFigure? comparison,
string? pairLabel,
Change? change)
{
cells.Add(new AnalysisTableCell(value.Text, null, value.Status.IsQualified ? "mv-qualified" : null, value.Value is null));
cells.Add(new AnalysisTableCell(value.Status.Summary, value.Status.Detail));
if (series.Costs is not null)
{
var figure = cost ?? Unknown;
cells.Add(new AnalysisTableCell(figure.Text, null, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null));
cells.Add(new AnalysisTableCell(figure.Status.Status, figure.Status.Detail));
}
if (series.Comparison is not null)
{
var figure = comparison ?? Unknown;
cells.Add(new AnalysisTableCell(figure.Text, pairLabel, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null));
var stated = change is { IsAvailable: true } ? change : ChangeOf(value, figure, series.IsMoney ? MoneyTolerance : Change.Tolerance);
var polarity = series.IsMoney ? ChangePolarities.ForCost(value.Value, figure.Value) : series.Polarity;
var tone = ChangeDisplay.Tone(stated, polarity);
cells.Add(new AnalysisTableCell(Format.ChangeText(stated, series.FormatDifference), null, ChangeDisplay.CssClass(tone), !stated.IsAvailable));
}
}
/// <summary>The change between two complete figures; unavailable when either is incomplete or unknown.</summary>
private static Change ChangeOf(TableFigure current, TableFigure previous, double tolerance) =>
current.Status.IsComplete && previous.Status.IsComplete
? Change.Between(current.Value, previous.Value, tolerance)
: Change.Unavailable;
}
+462
View File
@@ -0,0 +1,462 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>How urgent an attention item is. Shown with an icon and words, never colour alone.</summary>
public enum AttentionSeverity
{
/// <summary>Worth knowing (analysis being prepared, rows after now, a calculation to confirm).</summary>
Info,
/// <summary>A figure is incomplete until it is fixed (a missing price, a stale source).</summary>
Warning,
/// <summary>A figure cannot be computed until it is fixed (an invalid calculation).</summary>
Error,
}
/// <summary>One attention item (D-53): a localized one-liner and at most one targeted action.</summary>
/// <param name="Key">An invariant identity (kind, meter, text) — duplicates from the quantity and the cost reader collapse.</param>
/// <param name="Severity">How urgent it is.</param>
/// <param name="Text">The one-liner, in the reader's language; user data (names) as it is.</param>
/// <param name="ActionText">The action's label, or null when there is nothing to do here.</param>
/// <param name="ActionHref">Where the action leads.</param>
public sealed record AttentionItem(string Key, AttentionSeverity Severity, string Text, string? ActionText, string? ActionHref);
/// <summary>
/// The names attention items speak of: meters, energy types and cost categories by id (user data, never translated), with a neutral
/// fallback ("Meter #12") for an id nobody named.
/// </summary>
public sealed class AttentionNames
{
private readonly IReadOnlyDictionary<int, string> _meters;
private readonly IReadOnlyDictionary<int, string> _energyTypes;
private readonly IReadOnlyDictionary<int, string> _categories;
public AttentionNames(
IReadOnlyDictionary<int, string>? meters = null,
IReadOnlyDictionary<int, string>? energyTypes = null,
IReadOnlyDictionary<int, string>? categories = null)
{
_meters = meters ?? new Dictionary<int, string>();
_energyTypes = energyTypes ?? new Dictionary<int, string>();
_categories = categories ?? new Dictionary<int, string>();
}
/// <summary>
/// The meter names a quantity result and a cost result carry — series, classification, virtual sources at any depth,
/// priced lines — plus the given energy type names.
/// </summary>
public static AttentionNames From(AnalysisResult? result, CostAnalysis? costs = null, IReadOnlyDictionary<int, string>? energyTypes = null)
{
var meters = new Dictionary<int, string>();
if (result is not null)
{
foreach (var series in result.Series)
{
Add(meters, series.MeterId, series.Name);
AddContributions(meters, series.Contributions);
}
foreach (var entry in result.Classification)
{
Add(meters, entry.MeterId, entry.Name);
}
}
var categories = new Dictionary<int, string>();
if (costs is not null)
{
foreach (var line in costs.Lines)
{
Add(meters, line.MeterId, line.Name);
}
foreach (var category in (costs.Composition?.Categories ?? []).Concat(costs.Category is { } own ? [own] : []))
{
Add(categories, category.CategoryId, category.Name);
}
}
return new AttentionNames(meters, energyTypes, categories);
}
/// <summary>The meter's name, or "Meter #id".</summary>
public string Meter(int? id) =>
id is { } key && _meters.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: Loc.F(Strings.Attention_MeterFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
/// <summary>The energy type's name, or "Energy type #id".</summary>
public string EnergyType(int? id) =>
id is { } key && _energyTypes.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: Loc.F(Strings.Attention_EnergyTypeFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
/// <summary>The cost category's name, or "Category #id".</summary>
public string Category(int? id) =>
id is { } key && _categories.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: Loc.F(Strings.Attention_CategoryFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
/// <summary>A meter id's name for value details, or null when unknown.</summary>
public string? MeterOrNull(int id) => _meters.TryGetValue(id, out var name) ? name : null;
private static void AddContributions(Dictionary<int, string> meters, IReadOnlyList<SeriesContribution> contributions)
{
foreach (var contribution in contributions)
{
Add(meters, contribution.MeterId, contribution.Name);
AddContributions(meters, contribution.Nested);
}
}
private static void Add(Dictionary<int, string> meters, int? id, string? name)
{
if (id is { } key && !string.IsNullOrWhiteSpace(name))
{
meters.TryAdd(key, name);
}
}
}
/// <summary>
/// Turns the readers' attention codes (D-53) — <see cref="AnalysisProblem"/> and <see cref="CostAttention"/> — into
/// one-liners with one targeted action each: a missing price opens the tariff editor prefilled for its scope, component
/// and first uncovered month (D-52); a calculation to fix opens the meter's Calculation tab; a stale source its Sources
/// tab; rows after now its Normalized data around those days; a possible overlap the energy type's Meters tab; a
/// configuration conflict the meter editor. A kind this code does not know still gets its worded kind, without action.
/// </summary>
/// <remarks>
/// Items are ordered by severity (errors first), then as the readers reported them; items with the same text for the same
/// meter collapse (the cost reader repeats the quantity reader's problems).
/// </remarks>
public static class AttentionItems
{
/// <summary>Builds the items.</summary>
/// <param name="problems">The quantity reader's problems (<see cref="AnalysisResult.Problems"/>).</param>
/// <param name="costAttention">The cost reader's items (<see cref="CostAnalysis.Attention"/>) — and its <see cref="CostAnalysis.QuantityProblems"/> go into <paramref name="problems"/>.</param>
/// <param name="names">The names to speak of.</param>
/// <param name="query">The page's analysis state; links into meter pages carry its period.</param>
public static IReadOnlyList<AttentionItem> Build(
IEnumerable<AnalysisProblem>? problems,
IEnumerable<CostAttention>? costAttention,
AttentionNames names,
AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(names);
var items = new List<AttentionItem>();
foreach (var problem in problems ?? [])
{
if (problem is not null)
{
items.Add(ForProblem(problem, names, query));
}
}
foreach (var attention in costAttention ?? [])
{
if (attention is not null)
{
items.Add(ForCost(attention, names, query));
}
}
var seen = new HashSet<string>(StringComparer.Ordinal);
return
[
.. items
.Select((item, index) => (Item: item, Index: index))
.Where(x => seen.Add(x.Item.Key))
.OrderByDescending(x => x.Item.Severity)
.ThenBy(x => x.Index)
.Select(x => x.Item),
];
}
/// <summary>One quantity problem.</summary>
public static AttentionItem ForProblem(AnalysisProblem problem, AttentionNames names, AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(problem);
ArgumentNullException.ThrowIfNull(names);
var meter = names.Meter(problem.MeterId);
switch (problem.Kind)
{
case AnalysisProblemKind.AnalysisPending:
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_AnalysisPending, meter));
case AnalysisProblemKind.UnknownMeter:
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_UnknownMeter, meter));
case AnalysisProblemKind.InvalidDefinition:
// With the validator's finding the item says what is wrong (D-26), not only that something is.
var invalid = problem.Virtual is { } finding
? Loc.F(Strings.Attention_InvalidDefinitionBecause, meter, VirtualReason(finding, names))
: Loc.F(Strings.Attention_InvalidDefinition, meter);
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Error, invalid,
Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.MalformedDefinition:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_MalformedDefinition, meter),
Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.LegacyDefinition:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_LegacyDefinition, meter),
Strings.Attention_ConfirmCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.LegacyNeedsConfiguration:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_LegacyNeedsConfiguration, meter),
Strings.Attention_SetUpCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.RecordedAfterNow:
return RecordedAfterNow(problem, meter, query);
case AnalysisProblemKind.StaleSource:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_StaleSource, meter),
Strings.Attention_CheckSource, MeterLink(problem.MeterId, MeterLinks.TabSources, null, query));
case AnalysisProblemKind.TotalsProblem:
var other = problem.Totals?.OtherMeterId ?? (problem.MeterIds.Count > 0 ? problem.MeterIds[0] : null);
var reason = problem.Totals is { } totals ? TotalsReason(totals) : null;
var text = (other, reason) switch
{
(null, null) => Loc.F(Strings.Attention_TotalsProblem, meter),
(null, { } why) => Loc.F(Strings.Attention_TotalsProblemBecause, meter, why),
({ } id, null) => Loc.F(Strings.Attention_TotalsProblemWith, meter, names.Meter(id)),
({ } id, { } why) => Loc.F(Strings.Attention_TotalsProblemWithBecause, meter, names.Meter(id), why),
};
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Warning, text,
Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
case AnalysisProblemKind.PossibleOverlap:
return PossibleOverlap(problem, names, meter, query);
default:
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, problem.MeterId is null ? problem.Kind.Display() : meter + ": " + problem.Kind.Display());
}
}
/// <summary>One cost attention item.</summary>
public static AttentionItem ForCost(CostAttention attention, AttentionNames names, AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(attention);
ArgumentNullException.ThrowIfNull(names);
var meter = names.Meter(attention.MeterId);
var key = "cost:" + attention.Kind;
switch (attention.Kind)
{
case CostAttentionKind.MissingPrice when attention.Price is { } price:
return MissingPrice(price, names);
case CostAttentionKind.UnverifiedTariffUnit:
return Item(key, attention.MeterId, AttentionSeverity.Info, Strings.Attention_UnverifiedTariffUnit, Strings.Attention_OpenTariffs, TariffLinks.Path);
case CostAttentionKind.ManualCostAfterToday:
return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostAfterToday, attention.ManualCostIds.Count));
case CostAttentionKind.ManualCostCurrency:
return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostCurrency, attention.ManualCostIds.Count));
case CostAttentionKind.VirtualNotCosted:
return Item(
key, attention.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_VirtualNotCosted, meter),
Strings.Attention_EditCalculation, CalculationLink(attention.MeterId, query));
case CostAttentionKind.BillingConfiguration:
var billing = attention.Totals is { } problem
? Loc.F(Strings.Attention_BillingConfigurationBecause, meter, TotalsReason(problem, problem.OtherMeterId is { } otherId ? names.Meter(otherId) : null))
: Loc.F(Strings.Attention_BillingConfiguration, meter);
return Item(
key, attention.MeterId, AttentionSeverity.Warning, billing,
Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
case CostAttentionKind.PriceChangeInsideInterval when attention is { FirstMonth: { } first, LastMonth: { } last }:
return Item(
key, attention.MeterId, AttentionSeverity.Warning,
Loc.F(Strings.Attention_PriceChangeInsideInterval, meter, Format.MonthYear(first), Format.MonthYear(last)),
Strings.Attention_OpenTariffs, TariffLinks.Path);
case CostAttentionKind.BillingBasisGap when attention is { FirstMonth: { } first, LastMonth: { } last }:
return Item(
key, attention.MeterId, AttentionSeverity.Warning,
Loc.F(Strings.Attention_BillingBasisGap, meter, Format.MonthYear(first), Format.MonthYear(last)),
Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
case CostAttentionKind.CategoryPricesNothing:
// A category of calculated views or generation prices nothing (D-39, D-42): name the members and where
// to change the membership, never "no data yet" (A-22).
var members = attention.MeterIds.Count > 0 ? attention.MeterIds : attention.MeterId is { } only ? [only] : [];
return Item(
key + ":" + attention.CategoryId?.ToString(CultureInfo.InvariantCulture), attention.MeterId, AttentionSeverity.Info,
Loc.F(Strings.Attention_CategoryPricesNothing, names.Category(attention.CategoryId), string.Join(", ", members.Select(id => names.Meter(id)))),
Strings.Attention_EditCategories, CategoriesPath);
default:
return Item(key, attention.MeterId, AttentionSeverity.Info, attention.MeterId is null ? attention.Kind.Display() : meter + ": " + attention.Kind.Display());
}
}
/// <summary>
/// A price a figure needed and did not get (D-38): the scope it is missing for, the component and the first month
/// that lacks it, with the tariff deep link (D-52). A missing feed-in price is an optional credit.
/// </summary>
public static AttentionItem MissingPrice(MissingPrice price, AttentionNames names)
{
ArgumentNullException.ThrowIfNull(price);
ArgumentNullException.ThrowIfNull(names);
var scope = price.MeterId is { } meterId
? names.Meter(meterId)
: price.Scope switch
{
TariffScope.Meter => names.Meter(price.ScopeId),
TariffScope.EnergyType => names.EnergyType(price.ScopeId),
_ => Strings.Attention_AllEnergyTypes,
};
var component = price.Component.Display();
var month = Format.MonthYear(price.FirstMonth);
// A unit mismatch says what does not fit (D-37): the currency, a base price's period, or the meter's unit.
var (text, severity, action) = price.Reason switch
{
CostStatus.NotPriced => (Loc.F(Strings.Attention_PriceNotSetUp, scope, component), AttentionSeverity.Warning, Strings.Attention_AddTariff),
CostStatus.UnitMismatch when price.Issue == TariffUnitIssue.CurrencyMismatch =>
(Loc.F(Strings.Attention_PriceCurrencyMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
CostStatus.UnitMismatch when price.Component == TariffComponent.BasePrice =>
(Loc.F(Strings.Attention_BasePriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
CostStatus.UnitMismatch => (Loc.F(Strings.Attention_PriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
_ when price.IsCredit => (Loc.F(Strings.Attention_CreditMissing, scope, component, month), AttentionSeverity.Info, Strings.Attention_AddTariff),
_ => (Loc.F(Strings.Attention_PriceMissing, scope, component, month), AttentionSeverity.Warning, Strings.Attention_AddTariff),
};
var key = string.Create(
CultureInfo.InvariantCulture,
$"price:{price.Reason}:{price.Scope}:{price.ScopeId}:{price.MeterId}:{price.Component}:{price.FirstMonth:yyyy-MM}");
return new AttentionItem(key, severity, text, action, TariffLinks.For(price));
}
private static AttentionItem RecordedAfterNow(AnalysisProblem problem, string meter, AnalysisQuery? query)
{
if (problem.AfterNow is not { } block)
{
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_RecordedAfterNowPlain, meter));
}
var text = Loc.F(Strings.Attention_RecordedAfterNow, meter, Format.DateRange(block.FirstDay, block.LastDay));
string? link = null;
if (PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay))
{
var range = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithCustomRange(block.FirstDay, block.LastDay);
link = MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, range);
}
return Item(problem.Kind, block.MeterId, AttentionSeverity.Info, text, link is null ? null : Strings.Attention_ShowRows, link);
}
private static AttentionItem PossibleOverlap(AnalysisProblem problem, AttentionNames names, string meter, AnalysisQuery? query)
{
if (problem.Hint is not { } hint)
{
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_PossibleOverlapPlain, meter),
Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
}
var other = names.Meter(hint.OtherMeterId);
var text = hint.Kind switch
{
OverlapHintKind.NotLinkedBelowTotalLoad => Loc.F(Strings.Attention_AssumedBelowTotalLoad, meter, other),
OverlapHintKind.GridImportNotLinkedToTotalLoad => Loc.F(Strings.Attention_GridImportNotLinked, meter, other),
_ => meter + ": " + hint.Kind.Display(),
};
return Item(
problem.Kind, hint.MeterId, AttentionSeverity.Info, text,
Strings.Attention_ManageMeters, AnalysisLinks.EnergyType(hint.EnergyTypeId, AnalysisLinks.EnergyTabMeters, query));
}
/// <summary>
/// Why a calculation is invalid, in words (D-26): the finding's sentence, and in brackets what it is about — the
/// meters it names (the loop as a path), or the units and kinds that do not fit.
/// </summary>
public static string VirtualReason(VirtualProblem problem, AttentionNames names)
{
ArgumentNullException.ThrowIfNull(names);
return VirtualReasonText(problem, names);
}
/// <summary>
/// <see cref="VirtualReason(VirtualProblem, AttentionNames)"/> without the meters the finding names, for a page that
/// lists them itself as links beside the sentence (the meter's Calculation tab): the same words everywhere.
/// </summary>
public static string VirtualReasonWithoutMeters(VirtualProblem problem) => VirtualReasonText(problem, null);
private static string VirtualReasonText(VirtualProblem problem, AttentionNames? names)
{
ArgumentNullException.ThrowIfNull(problem);
var detail = problem.Kind switch
{
VirtualProblemKind.DependencyCycle when names is not null && problem.MeterIds.Count > 0 =>
string.Join(" → ", problem.MeterIds.Select(id => names.Meter(id))),
VirtualProblemKind.UnitMismatch or VirtualProblemKind.ResultUnitMismatch or VirtualProblemKind.IndicatorNeedsUnit
when problem.Values.Count > 0 => string.Join(", ", problem.Values),
VirtualProblemKind.KindMismatch or VirtualProblemKind.ResultKindMismatch or VirtualProblemKind.ResultKindRequired
or VirtualProblemKind.ResultKindUnsupported when problem.Values.Count > 0 => string.Join(", ", problem.Values.Select(KindWord)),
VirtualProblemKind.Syntax or VirtualProblemKind.NoReferences => null,
_ when names is not null && problem.MeterIds.Count > 0 => string.Join(", ", problem.MeterIds.Distinct().Select(id => names.Meter(id))),
_ => null,
};
var sentence = problem.Kind.Display();
return detail is null ? sentence : sentence + " (" + detail + ")";
}
/// <summary>What contradicts itself in the totals configuration (D-22, D-23), with the role it is about.</summary>
public static string TotalsReason(TotalsProblem problem, string? otherMeter = null)
{
ArgumentNullException.ThrowIfNull(problem);
var sentence = problem.Kind.Display();
var detail = problem.Role is { } role ? role.Display() : otherMeter;
return detail is null ? sentence : sentence + " (" + detail + ")";
}
/// <summary>A quantity kind the validator names by its token, in words ("mixed" too); anything else as it is.</summary>
private static string KindWord(string value) => MeterEditing.MeterEditorText.KindWord(value);
/// <summary>The cost category editor.</summary>
public const string CategoriesPath = "/admin/categories";
private static string? CalculationLink(int? meterId, AnalysisQuery? query) => MeterLink(meterId, MeterLinks.TabCalculation, null, query);
private static string? MeterLink(int? meterId, string tab, string? action, AnalysisQuery? query) =>
meterId is { } id ? MeterLinks.Detail(id, tab, action, query) : null;
private static AttentionItem Item(
AnalysisProblemKind kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null) =>
Item("problem:" + kind, meterId, severity, text, actionText, href);
private static AttentionItem Item(
string kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null)
{
var key = string.Create(CultureInfo.InvariantCulture, $"{kind}:{meterId}:{text}");
return new AttentionItem(key, severity, text, href is null ? null : actionText, href);
}
}
+106
View File
@@ -0,0 +1,106 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
namespace MeterVault.App.Analysis;
/// <summary>Which direction of a change is good news for a metric (D-08, brief §4.2).</summary>
public enum ChangePolarity
{
/// <summary>More is worse: consumption, runtime, cost.</summary>
HigherIsWorse,
/// <summary>More is better: generation, export.</summary>
HigherIsBetter,
/// <summary>Neither: a signed net result, an indicator, a tank level, a cost that is a credit.</summary>
Neutral,
}
/// <summary>How a change is coloured: good, bad or neither. Never shown by colour alone — the words and the arrow carry it.</summary>
public enum ChangeTone
{
Neutral,
Good,
Bad,
}
/// <summary>The polarity of a metric's changes (D-08: more consumption is not good, more generation is).</summary>
public static class ChangePolarities
{
/// <summary>The polarity of a quantity kind; net results and indicators are neutral.</summary>
public static ChangePolarity For(QuantityKind kind) => kind switch
{
QuantityKind.Consumption or QuantityKind.Runtime or QuantityKind.Cost => ChangePolarity.HigherIsWorse,
QuantityKind.Generation or QuantityKind.Export => ChangePolarity.HigherIsBetter,
_ => ChangePolarity.Neutral,
};
/// <summary>The polarity of a toolbar metric; net and tank level are neutral.</summary>
public static ChangePolarity For(AnalysisMetric metric) => metric switch
{
AnalysisMetric.Consumption or AnalysisMetric.Runtime or AnalysisMetric.Cost => ChangePolarity.HigherIsWorse,
AnalysisMetric.Generation or AnalysisMetric.Export => ChangePolarity.HigherIsBetter,
_ => ChangePolarity.Neutral,
};
/// <summary>
/// A cost rising is worse — unless either side is a credit (a negative cost, a feed-in larger than the charges):
/// then "more" and "less" have no settled meaning and the change is neutral.
/// </summary>
public static ChangePolarity ForCost(double? current, double? previous) =>
current < 0 || previous < 0 ? ChangePolarity.Neutral : ChangePolarity.HigherIsWorse;
}
/// <summary>
/// A change in words (D-08): the absolute difference always, the percentage where it applies ("percentage not
/// applicable" otherwise), and the direction as a word — so a change is readable without its colour.
/// </summary>
public static class ChangeDisplay
{
/// <summary>Good, bad or neutral for <paramref name="polarity"/>; neutral when unknown or unchanged.</summary>
public static ChangeTone Tone(Change change, ChangePolarity polarity)
{
ArgumentNullException.ThrowIfNull(change);
if (!change.IsAvailable || change.Direction == 0 || polarity == ChangePolarity.Neutral)
{
return ChangeTone.Neutral;
}
return (change.Direction > 0) == (polarity == ChangePolarity.HigherIsBetter) ? ChangeTone.Good : ChangeTone.Bad;
}
/// <summary>
/// "12 kWh more (+4.5 %)", "3.50 € less (-2.0 %)", "12 kWh more (percentage not applicable)", "No change", or
/// "No comparison" when either value is unknown.
/// </summary>
/// <param name="change">The change.</param>
/// <param name="formatMagnitude">Formats the size of the difference (a quantity with its unit, money).</param>
public static string Words(Change change, Func<double, string> formatMagnitude)
{
ArgumentNullException.ThrowIfNull(change);
ArgumentNullException.ThrowIfNull(formatMagnitude);
if (change.Absolute is not { } difference)
{
return Strings.Change_Unavailable;
}
if (change.Direction == 0)
{
return Strings.Change_None;
}
var magnitude = formatMagnitude(Math.Abs(difference));
var words = Loc.F(change.Direction > 0 ? Strings.Change_More : Strings.Change_Less, magnitude);
return words + " (" + Format.ChangePercent(change) + ")";
}
/// <summary>The CSS class colouring a tone with the theme's palette (<c>app.css</c>).</summary>
public static string CssClass(ChangeTone tone) => tone switch
{
ChangeTone.Good => "mv-change-good",
ChangeTone.Bad => "mv-change-bad",
_ => "mv-change-neutral",
};
}
+56
View File
@@ -0,0 +1,56 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.App.Analysis;
/// <summary>
/// How every page states a cost change (D-07, brief §10 Phase 4 exit): one rule — <see cref="OverviewComparison.Between"/>,
/// the totals when both periods are complete, else the paired buckets both sides have complete, else not comparable —
/// and one wording, so the Overview, an energy type, the Analysis page and a meter never disagree about the same scope and
/// period.
/// </summary>
public static class CostChanges
{
/// <summary>The change for a card: null without a comparison; unavailable ("No comparison") when not comparable.</summary>
public static Change? ForCard(CostChange change)
{
ArgumentNullException.ThrowIfNull(change);
return change.Basis == CostChangeBasis.NoComparison ? null : change.Change;
}
/// <summary>The change for a table's total row; null unless one is stated.</summary>
public static Change? ForTotalRow(CostChange change)
{
ArgumentNullException.ThrowIfNull(change);
return change.Change.IsAvailable ? change.Change : null;
}
/// <summary>Whether a rise is good news: neutral when either amount is a credit.</summary>
public static ChangePolarity Polarity(CostChange change)
{
ArgumentNullException.ThrowIfNull(change);
return ChangePolarities.ForCost(change.Current, change.Previous);
}
/// <summary>
/// The caption of a change: what it is compared with, and — when only part of the period could be matched — that it
/// is ("Same period last year · over the part both periods cover").
/// </summary>
public static string? Caption(AnalysisQuery query, CostChange change)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(change);
if (change.Basis == CostChangeBasis.NoComparison)
{
return null;
}
var compared = query.Comparison.Display();
return change.IsPartial ? compared + " · " + Strings.Overview_MatchedOnly : compared;
}
}
+19
View File
@@ -0,0 +1,19 @@
using MeterVault.Core.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>
/// How a cost figure is compared (D-06), from <see cref="AnalysisQuery.ToCostComparison"/>: the comparison as resolved,
/// the cost request that prices it in the paired buckets, and the pairs themselves.
/// </summary>
/// <param name="Resolution">The comparison period, or why there is none (a code the UI words).</param>
/// <param name="Request">The request to price the comparison with; null when the comparison does not apply.</param>
/// <param name="Pairs">Each current bucket with its image, by index (A-10); empty when the comparison does not apply.</param>
public sealed record CostComparisonRequest(
ComparisonResolution Resolution,
CostAnalysisRequest? Request,
IReadOnlyList<BucketPair> Pairs)
{
public bool IsApplicable => Request is not null;
}
+131
View File
@@ -0,0 +1,131 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
namespace MeterVault.App.Analysis;
/// <summary>
/// How one figure reads beside its number (brief §4.3, D-14): whether a number can be shown at all, whether it is
/// qualified — partial, estimated, an opening balance, only partly priced — and its status, detail and provenance in the
/// reader's words. Charts mark a qualified figure, tables and tooltips word it, so its meaning never rests on colour.
/// </summary>
/// <param name="IsKnown">A number can be shown (a partial total counts; a missing or invalid bucket does not).</param>
/// <param name="IsComplete">
/// The figure is complete: an available bucket, or a fully priced cost over available quantities. Only complete figures
/// are compared bucket by bucket (D-07); estimated provenance does not make a figure incomplete.
/// </param>
/// <param name="IsQualified">The figure is not a plain complete measured value: incomplete, estimated or an opening balance.</param>
/// <param name="Status">The status in words ("Complete", "Partial", "Not priced (no tariff)").</param>
/// <param name="Detail">Why, in words, or null.</param>
/// <param name="Provenance">Where the value comes from, in words ("Measured, Estimated"); empty when there is none.</param>
public sealed record FigureStatus(bool IsKnown, bool IsComplete, bool IsQualified, string Status, string? Detail, string Provenance)
{
/// <summary>Status and provenance in one line: "Partial · Measured".</summary>
public string Summary => string.IsNullOrEmpty(Provenance) ? Status : Status + " · " + Provenance;
/// <summary>The summary with its detail: "Partial · Measured — Data covers only part of this period".</summary>
public string Full => Detail is null ? Summary : Summary + " — " + Detail;
}
/// <summary>Words a <see cref="BucketValue"/> or a <see cref="CostAmount"/> (<see cref="FigureStatus"/>).</summary>
public static class FigureText
{
/// <summary>Provenance that qualifies a value even when its bucket is complete.</summary>
private const Provenance QualifyingProvenance = Provenance.Estimated | Provenance.OpeningBalance;
/// <summary>
/// A quantity bucket: its status and issue (<see cref="DisplayNames"/>), the issue's detail, and — for a value derived
/// from a dependency — the meter that caused it (the last id of <see cref="BucketValue.DependencyPath"/>).
/// </summary>
/// <param name="value">The value.</param>
/// <param name="meterName">Names a meter id for the dependency detail; "#id" without it.</param>
public static FigureStatus Of(BucketValue value, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(value);
var known = value.Value is { } number && double.IsFinite(number);
var complete = value.Status == BucketStatus.Available;
var qualified = !complete || (value.Provenance & QualifyingProvenance) != 0;
return new FigureStatus(known, complete, qualified, value.Status.Display(), DetailOf(value, meterName), value.Provenance.Display());
}
/// <summary>
/// A cost figure: its price coverage (<see cref="CostAmount.Status"/>), and as detail the availability of the
/// quantities behind it, components left unpriced and unchecked tariff units.
/// </summary>
public static FigureStatus Of(CostAmount amount)
{
ArgumentNullException.ThrowIfNull(amount);
// Nothing to bill and nothing missing (no line, no charge, no manual cost in the bucket): the engine keeps the
// figure unknown, so it reads "No data" — never "Priced" beside "—", and never complete (brief §4.3).
if (IsNothingBooked(amount))
{
return new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty);
}
var known = amount.Cost is { } cost && double.IsFinite(cost);
var complete = amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available;
var qualified = !complete || amount.Unverified;
// Prices cover the bucket but its quantity is unknown (no data, pending, unresolved): the cost is unknown
// because of the quantity, so that is its status — "Priced" beside "—" would read as a priced figure.
var unknownQuantity = !known && amount.Status == CostStatus.Priced && amount.Availability != BucketStatus.Available;
var details = new List<string>(3);
if (amount.Availability != BucketStatus.Available && !unknownQuantity)
{
details.Add(Loc.F(Strings.Figure_QuantityStatus, amount.Availability.Display()));
}
if (amount.IncludesNotPriced && amount.Status is CostStatus.Priced or CostStatus.Partial)
{
details.Add(Strings.Figure_SomeNotPriced);
}
if (amount.Unverified)
{
details.Add(Strings.Figure_UnverifiedUnit);
}
var status = unknownQuantity ? amount.Availability.Display() : amount.Status.Display();
return new FigureStatus(
known, complete, qualified, status, details.Count > 0 ? string.Join("; ", details) : null, string.Empty);
}
/// <summary>
/// True for a cost figure with nothing booked in it: no value, nothing unpriced, no quantity unavailable — the empty
/// figure of a bucket with no line, charge or manual cost (<see cref="CostAmount.Empty"/>). It is unknown, not a
/// priced zero; tables, charts and the CSV export word it as "No data" alike.
/// </summary>
public static bool IsNothingBooked(CostAmount amount)
{
ArgumentNullException.ThrowIfNull(amount);
return amount.Cost is null && amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available
&& amount.MissingPrices.Count == 0;
}
private static string? DetailOf(BucketValue value, Func<int, string?>? meterName)
{
if (value.Issue == ValueIssue.None)
{
return null;
}
var extras = new List<string>(2);
if (!string.IsNullOrWhiteSpace(value.IssueDetail))
{
extras.Add(value.IssueDetail.Trim());
}
if (value.DependencyPath is { Count: > 1 } path)
{
var culprit = path[^1];
extras.Add(meterName?.Invoke(culprit) is { Length: > 0 } name ? name : "#" + culprit.ToString(System.Globalization.CultureInfo.CurrentCulture));
}
var issue = value.Issue.Display();
return extras.Count == 0 ? issue : issue + " (" + string.Join(", ", extras) + ")";
}
}
+112
View File
@@ -0,0 +1,112 @@
using System.Globalization;
using System.Text;
namespace MeterVault.App.Analysis;
/// <summary>A piece of a virtual meter's formula: plain text, or a meter reference (<c>m12</c>) with its id.</summary>
public sealed record FormulaSegment(string Text, int? MeterId)
{
public bool IsMeter => MeterId is not null;
}
/// <summary>
/// A virtual meter's formula for display (brief §5.1): split into text and meter references so each <c>m&lt;id&gt;</c>
/// token can stand beside its meter's friendly name. The scan mirrors the formula lexer: an identifier is a letter or
/// underscore followed by letters, digits or underscores, a reference is exactly <c>m</c> and digits, and a number run
/// (digits and dots) is skipped whole, so the "3" of "1.3" is never read as part of a name.
/// </summary>
public static class FormulaText
{
/// <summary>Splits an expression into segments; empty for null or blank.</summary>
public static IReadOnlyList<FormulaSegment> Split(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return [];
}
var segments = new List<FormulaSegment>();
var text = new StringBuilder();
var pos = 0;
while (pos < expression.Length)
{
var c = expression[pos];
if (char.IsLetter(c) || c == '_')
{
var start = pos;
while (pos < expression.Length && (char.IsLetterOrDigit(expression[pos]) || expression[pos] == '_'))
{
pos++;
}
var token = expression[start..pos];
if (TryParseReference(token, out var id))
{
Flush(segments, text);
segments.Add(new FormulaSegment(token, id));
}
else
{
text.Append(token);
}
}
else if (char.IsAsciiDigit(c) || c == '.')
{
var start = pos;
while (pos < expression.Length && (char.IsAsciiDigit(expression[pos]) || expression[pos] == '.'))
{
pos++;
}
text.Append(expression, start, pos - start);
}
else
{
text.Append(c);
pos++;
}
}
Flush(segments, text);
return segments;
}
/// <summary>
/// The expression with each reference followed by its meter's name: <c>m5 (Solar 1) + m6 (Solar 2)</c>. A reference
/// nobody named stays as it is.
/// </summary>
public static string Annotate(string? expression, Func<int, string?> name)
{
ArgumentNullException.ThrowIfNull(name);
var builder = new StringBuilder();
foreach (var segment in Split(expression))
{
builder.Append(segment.Text);
if (segment.MeterId is { } id && name(id) is { Length: > 0 } meter)
{
builder.Append(" (").Append(meter).Append(')');
}
}
return builder.ToString();
}
private static bool TryParseReference(string token, out int id)
{
id = 0;
return token.Length >= 2
&& token[0] == 'm'
&& token.AsSpan(1).IndexOfAnyExceptInRange('0', '9') < 0
&& int.TryParse(token.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out id);
}
private static void Flush(List<FormulaSegment> segments, StringBuilder text)
{
if (text.Length > 0)
{
segments.Add(new FormulaSegment(text.ToString(), null));
text.Clear();
}
}
}
+218
View File
@@ -0,0 +1,218 @@
namespace MeterVault.App.Analysis;
/// <summary>One requested load: its generation and the token that cancels it when a newer load is requested.</summary>
public readonly record struct LoadTicket(long Generation, CancellationToken Token);
/// <summary>
/// Makes sure only the latest requested load is committed (brief §8, A13): <see cref="Next"/> cancels the load before it
/// and hands out a ticket; a load commits its result only while <see cref="IsCurrent"/> holds for its ticket. A delayed
/// first request can then never overwrite the scope or range the user selected after it — the old
/// <c>if (_loading) return;</c> guard dropped the newer request instead.
/// </summary>
/// <remarks>
/// <para>The page pattern (one sequencer per independently loading panel):</para>
/// <code>
/// private readonly LoadSequencer _loads = new();
/// private readonly LoadState&lt;AnalysisResult&gt; _result = new();
///
/// protected override async Task OnParametersSetAsync()
/// {
/// var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
/// if (query == _query) return; // an action drop or a tab change is not a new analysis (D-46)
/// _query = query;
/// await _loads.RunAsync(_result, async token =>
/// {
/// var period = await Periods.ResolveAsync(query, Clock.Now, token);
/// return await Reader.ReadAsync(query.ToAnalysisRequest(period)!, token);
/// }, Logger);
/// }
///
/// public void Dispose() => _loads.Dispose();
/// </code>
/// <para>
/// Render from <see cref="LoadState{T}"/>: <see cref="LoadState{T}.IsInitialLoad"/> → skeleton/progress;
/// <see cref="LoadState{T}.Value"/> with <see cref="LoadState{T}.IsRefreshing"/> → the previous result, dimmed, with a
/// thin progress bar (stale but visible); <see cref="LoadState{T}.Error"/> → a panel-level error with Retry (and, when a
/// value is kept, the note that it is from before); otherwise the value. Everything a panel shows — title, chart, table —
/// comes from the one committed value, so a new type's title never sits above the previous type's chart.
/// </para>
/// <para>
/// Initial loads stay in <c>OnInitialized</c>/<c>OnParametersSet</c> (D-46): the render tests read prerendered data.
/// </para>
/// </remarks>
public sealed class LoadSequencer : IDisposable
{
private readonly object _gate = new();
private CancellationTokenSource? _current;
private long _generation;
private bool _disposed;
/// <summary>The generation of the latest ticket; 0 before the first.</summary>
public long Generation
{
get
{
lock (_gate)
{
return _generation;
}
}
}
/// <summary>Starts a new load: cancels the previous one and returns the new ticket.</summary>
/// <exception cref="ObjectDisposedException">The sequencer (its component) is disposed.</exception>
public LoadTicket Next()
{
CancellationTokenSource? previous;
LoadTicket ticket;
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
previous = _current;
_current = new CancellationTokenSource();
_generation++;
ticket = new LoadTicket(_generation, _current.Token);
}
// Cancelled before it is disposed, so the token the superseded load holds stays cancelled (and usable).
previous?.Cancel();
previous?.Dispose();
return ticket;
}
/// <summary>True while <paramref name="ticket"/> is the latest load and has not been cancelled: only then may it commit.</summary>
public bool IsCurrent(LoadTicket ticket)
{
lock (_gate)
{
return !_disposed && ticket.Generation == _generation && !ticket.Token.IsCancellationRequested;
}
}
/// <summary>
/// Runs one load through <paramref name="state"/>: marks it loading, awaits <paramref name="load"/> with the ticket's
/// token, and commits the value or the error — only if no newer load was requested meanwhile. A superseded or
/// cancelled load changes nothing. Errors are logged and kept in the state for a panel-level Retry; they never
/// escape to the circuit.
/// </summary>
/// <returns>True when this load committed (a value or an error), false when it was superseded.</returns>
public async Task<bool> RunAsync<T>(LoadState<T> state, Func<CancellationToken, Task<T>> load, ILogger? logger = null)
where T : class
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(load);
var ticket = Next();
state.Begin(ticket.Generation);
try
{
var value = await load(ticket.Token);
if (!IsCurrent(ticket))
{
return false;
}
state.Commit(value);
return true;
}
catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested)
{
return false;
}
// A panel shows its own error with Retry: nothing a reader throws may end the circuit.
catch (Exception ex)
{
if (!IsCurrent(ticket))
{
return false;
}
logger?.LogError(ex, "Loading {Panel} failed", typeof(T).Name);
state.Fail(ex);
return true;
}
}
/// <summary>Cancels the load in flight; no ticket is current afterwards.</summary>
public void Dispose()
{
CancellationTokenSource? current;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
current = _current;
_current = null;
}
current?.Cancel();
current?.Dispose();
}
}
/// <summary>
/// What a panel shows while it loads (brief §8): the last committed value, whether a load is running, and the error of
/// the last load. The value is kept through a refresh and a failure, so a panel stays readable ("stale but visible")
/// instead of blanking.
/// </summary>
public sealed class LoadState<T>
where T : class
{
/// <summary>The last committed value; null before the first load finished.</summary>
public T? Value { get; private set; }
/// <summary>True while a load is running.</summary>
public bool IsLoading { get; private set; }
/// <summary>The error of the last committed load; null after a success.</summary>
public Exception? Error { get; private set; }
/// <summary>The generation of the running or last committed load.</summary>
public long Generation { get; private set; }
/// <summary>Nothing to show yet: the first load is running.</summary>
public bool IsInitialLoad => IsLoading && Value is null;
/// <summary>A value is shown while a newer one loads.</summary>
public bool IsRefreshing => IsLoading && Value is not null;
/// <summary>The value shown is not the answer to the current request: a newer load is running, or it failed.</summary>
public bool IsStale => Value is not null && (IsLoading || Error is not null);
/// <summary>A load is starting.</summary>
public void Begin(long generation)
{
Generation = generation;
IsLoading = true;
}
/// <summary>The load finished with <paramref name="value"/>.</summary>
public void Commit(T value)
{
Value = value;
Error = null;
IsLoading = false;
}
/// <summary>The load failed; the previous value, if any, stays visible.</summary>
public void Fail(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
Error = error;
IsLoading = false;
}
/// <summary>Forgets the value (e.g. when the page moved to a different entity whose old data must not show).</summary>
public void Clear()
{
Value = null;
Error = null;
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Globalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>What an analysis URL is about (D-47 <c>scope=portfolio|type|category|meter|meters</c>).</summary>
public enum QueryScopeKind
{
/// <summary>Everything: every energy type's totals and the whole bill.</summary>
Portfolio,
/// <summary>One energy type (<c>scope=type&amp;id=</c>).</summary>
EnergyType,
/// <summary>One cost category (<c>scope=category&amp;id=</c>); analysed by cost.</summary>
Category,
/// <summary>One meter, physical or virtual (<c>scope=meter&amp;id=</c>).</summary>
Meter,
/// <summary>An explicit selection of meters side by side (<c>scope=meters&amp;ids=3,5</c>, at most <see cref="AnalysisLimits.MaxSeries"/>).</summary>
Meters,
}
/// <summary>
/// The scope of an <see cref="AnalysisQuery"/>: a kind and the id(s) it names. Immutable, compared by value, and mapped
/// in one place onto the quantity reader's <see cref="AnalysisScope"/> and the cost reader's <see cref="CostScope"/>.
/// </summary>
public sealed class QueryScope : IEquatable<QueryScope>
{
private static readonly (QueryScopeKind Value, string Token)[] Tokens =
[
(QueryScopeKind.Portfolio, "portfolio"),
(QueryScopeKind.EnergyType, "type"),
(QueryScopeKind.Category, "category"),
(QueryScopeKind.Meter, "meter"),
(QueryScopeKind.Meters, "meters"),
];
private QueryScope(QueryScopeKind kind, int? id, IReadOnlyList<int> meterIds)
{
Kind = kind;
Id = id;
MeterIds = meterIds;
}
/// <summary>Every energy type and the whole bill.</summary>
public static QueryScope Portfolio { get; } = new(QueryScopeKind.Portfolio, null, []);
public QueryScopeKind Kind { get; }
/// <summary>The energy type, category or meter id; null for the portfolio and a meter selection.</summary>
public int? Id { get; }
/// <summary>The meters of a selection (distinct, in the order given); the one meter of a meter scope; empty otherwise.</summary>
public IReadOnlyList<int> MeterIds { get; }
/// <summary>The URL token of <see cref="Kind"/>.</summary>
public string Token => TokenOf(Kind);
public static QueryScope ForEnergyType(int energyTypeId) => new(QueryScopeKind.EnergyType, Positive(energyTypeId, nameof(energyTypeId)), []);
public static QueryScope ForCategory(int categoryId) => new(QueryScopeKind.Category, Positive(categoryId, nameof(categoryId)), []);
public static QueryScope ForMeter(int meterId)
{
Positive(meterId, nameof(meterId));
return new QueryScope(QueryScopeKind.Meter, meterId, [meterId]);
}
/// <summary>
/// An explicit selection. The ids are made distinct (first occurrence wins). More than
/// <see cref="AnalysisLimits.MaxSeries"/> are kept as given; the reader refuses them (<see cref="AnalysisRefusal.TooManySeries"/>),
/// and <see cref="AnalysisQuery.Parse(string, AnalysisDefaults)"/> caps them with a notice.
/// </summary>
/// <exception cref="ArgumentException">No id, or an id that is not positive.</exception>
public static QueryScope ForMeters(IEnumerable<int> meterIds)
{
ArgumentNullException.ThrowIfNull(meterIds);
List<int> ids = [.. meterIds.Distinct()];
if (ids.Count == 0)
{
throw new ArgumentException("A meter selection needs at least one meter.", nameof(meterIds));
}
if (ids.Any(id => id <= 0))
{
throw new ArgumentException("Meter ids are positive.", nameof(meterIds));
}
return new QueryScope(QueryScopeKind.Meters, null, ids);
}
/// <summary>The URL token of a scope kind.</summary>
public static string TokenOf(QueryScopeKind kind)
{
foreach (var (value, token) in Tokens)
{
if (value == kind)
{
return token;
}
}
throw new ArgumentOutOfRangeException(nameof(kind), kind, "No URL token for this scope.");
}
/// <summary>Parses a scope token (<c>portfolio</c>, <c>type</c>, …), ignoring case and surrounding blanks.</summary>
public static bool TryParseKind(string? token, out QueryScopeKind kind)
{
kind = default;
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var text = token.Trim();
foreach (var (value, name) in Tokens)
{
if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
{
kind = value;
return true;
}
}
return false;
}
/// <summary>
/// The quantity reader's scope: the portfolio, an energy type, a meter or a selection. Null for a category, which
/// the quantity reader does not know — a category is analysed by cost (brief §7.4).
/// </summary>
public AnalysisScope? ToAnalysisScope() => Kind switch
{
QueryScopeKind.Portfolio => AnalysisScope.Portfolio,
QueryScopeKind.EnergyType => AnalysisScope.ForEnergyType(Id!.Value),
QueryScopeKind.Meter => AnalysisScope.ForMeter(Id!.Value),
QueryScopeKind.Meters => AnalysisScope.ForMeters(MeterIds),
_ => null,
};
/// <summary>
/// The cost reader's scopes: one for the portfolio, a type, a meter or a category, and one per meter for a selection
/// (each meter is priced by its own rule, never as a sum, D-39).
/// </summary>
public IReadOnlyList<CostScope> ToCostScopes() => Kind switch
{
QueryScopeKind.Portfolio => [CostScope.Portfolio],
QueryScopeKind.EnergyType => [CostScope.ForEnergyType(Id!.Value)],
QueryScopeKind.Category => [CostScope.ForCategory(Id!.Value)],
QueryScopeKind.Meter => [CostScope.ForMeter(Id!.Value)],
_ => [.. MeterIds.Select(CostScope.ForMeter)],
};
public bool Equals(QueryScope? other) =>
other is not null && Kind == other.Kind && Id == other.Id && MeterIds.SequenceEqual(other.MeterIds);
public override bool Equals(object? obj) => Equals(obj as QueryScope);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(Kind);
hash.Add(Id);
foreach (var id in MeterIds)
{
hash.Add(id);
}
return hash.ToHashCode();
}
/// <summary><c>portfolio</c>, <c>type:3</c>, <c>meter:12</c>, <c>meters:3,5</c> — for logs and keys.</summary>
public override string ToString() => Kind switch
{
QueryScopeKind.Portfolio => Token,
QueryScopeKind.Meters => Token + ":" + string.Join(',', MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture))),
_ => Token + ":" + Id!.Value.ToString(CultureInfo.InvariantCulture),
};
private static int Positive(int id, string paramName) =>
id > 0 ? id : throw new ArgumentOutOfRangeException(paramName, id, "Ids are positive.");
}
+113
View File
@@ -0,0 +1,113 @@
using System.Globalization;
using MeterVault.App.Analysis;
namespace MeterVault.App;
/// <summary>
/// Addresses of the analysis pages — the Overview, an energy type, the Analysis page (<c>/trends</c>), Solar,
/// Consumables — and of the CSV export, each carrying the period of an <see cref="AnalysisQuery"/> so a drill-down or a
/// Back link never loses the dates (brief §3.1, D-47). Meter pages are <see cref="MeterLinks"/>'.
/// </summary>
/// <remarks>
/// Keys a route already had come first, the analysis keys after them (D-47). A key equal to the target page's default
/// is left out (D-02), so a link written from the Overview's month to date says <c>period=mtd</c> to a history page but
/// nothing to the Overview.
/// </remarks>
public static class AnalysisLinks
{
/// <summary>The Overview tab of an energy type page: quantities, cost, coverage, changes (brief §7.3).</summary>
public const string EnergyTabOverview = "overview";
/// <summary>The History tab: the shared chart and table.</summary>
public const string EnergyTabHistory = "history";
/// <summary>The Flow tab: the Sankey and its table.</summary>
public const string EnergyTabFlow = "flow";
/// <summary>The Meters tab: the type's meters with their period values.</summary>
public const string EnergyTabMeters = "meters";
/// <summary>The route of the CSV export (D-55).</summary>
public const string ExportPath = "/export/analysis.csv";
/// <summary>The energy type page's tabs, in order; the first is the default.</summary>
public static IReadOnlyList<string> EnergyTabs { get; } = [EnergyTabOverview, EnergyTabHistory, EnergyTabFlow, EnergyTabMeters];
/// <summary>The energy type tab a requested key opens: the key itself (any case), or Overview for anything else.</summary>
public static string ResolveEnergyTab(string? tab)
{
var key = tab?.Trim().ToLowerInvariant();
return key is not null && EnergyTabs.Contains(key, StringComparer.Ordinal) ? key : EnergyTabOverview;
}
/// <summary>The panel index of a requested energy type tab.</summary>
public static int EnergyTabIndex(string? tab)
{
var key = ResolveEnergyTab(tab);
for (var i = 0; i < EnergyTabs.Count; i++)
{
if (string.Equals(EnergyTabs[i], key, StringComparison.Ordinal))
{
return i;
}
}
return 0;
}
/// <summary>The Overview (<c>/</c>), carrying the period — the target of breadcrumbs and Back.</summary>
public static string Overview(AnalysisQuery? query = null) => Carry("/", query, AnalysisDefaults.Overview);
/// <summary>
/// An energy type page (<c>/energy/{id}</c>), optionally on a tab (<see cref="EnergyTabs"/>; the Overview tab is not
/// written), carrying the period.
/// </summary>
public static string EnergyType(int energyTypeId, string? tab = null, AnalysisQuery? query = null)
{
var url = "/energy/" + energyTypeId.ToString(CultureInfo.InvariantCulture);
if (!string.IsNullOrWhiteSpace(tab) && ResolveEnergyTab(tab) is var key && key != EnergyTabOverview)
{
url += "?tab=" + key;
}
return Carry(url, query, AnalysisDefaults.History);
}
/// <summary>
/// The Analysis page (<c>/trends</c>) for a scope and metric, carrying the rest of <paramref name="query"/>:
/// <c>/trends?scope=type&amp;id=3&amp;metric=cost&amp;period=ytd</c>. A null <paramref name="metric"/> keeps the
/// query's.
/// </summary>
public static string Analysis(QueryScope scope, AnalysisMetric? metric = null, AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(scope);
var target = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithScope(scope);
if (metric is not null)
{
target = target.WithMetric(metric);
}
return target.AppendTo("/trends", AnalysisDefaults.History, AnalysisQueryParts.All);
}
/// <summary>Solar (<c>/solar</c>), carrying the period.</summary>
public static string Solar(AnalysisQuery? query = null) => Carry("/solar", query, AnalysisDefaults.History);
/// <summary>Tanks &amp; consumables (<c>/consumables</c>), carrying the period.</summary>
public static string Consumables(AnalysisQuery? query = null) => Carry("/consumables", query, AnalysisDefaults.History);
/// <summary>
/// The CSV export of what <paramref name="query"/> shows (D-55): every key that differs from the export's defaults,
/// scope included — the export has no route to imply one.
/// </summary>
public static string Export(AnalysisQuery query)
{
ArgumentNullException.ThrowIfNull(query);
return query.AppendTo(ExportPath, AnalysisDefaults.Export, AnalysisQueryParts.All);
}
private static string Carry(string url, AnalysisQuery? query, AnalysisDefaults target) =>
query is null ? url : query.AppendTo(url, target);
}
+309
View File
@@ -0,0 +1,309 @@
using MeterVault.App.Analysis;
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.App.AnalysisPage;
/// <summary>
/// Reads what the Analysis page shows for one address (brief §7.4): quantities through the shared analysis reader and
/// costs through the cost reader — the same calls the meter and energy type pages, the Overview and the CSV export make
/// — and turns them into chart and table inputs once, inside the load. No figure is computed here: the page never adds
/// up series, never prices anything, never fills a gap.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quantities.</b> The portfolio and an energy type show the per-type measures of the metric (consumption: total use
/// and grid import, side by side, never added — D-22); a meter, a comparison and a category's meters each their own
/// series. One meter also shows its cost by its rule in the same buckets.
/// </para>
/// <para>
/// <b>Costs.</b> One cost series per cost scope: the whole bill with manual costs and standing charges (the figure the
/// Overview shows for the same range), a type's bill, a category (D-42) or a meter by its rule — each priced month by month,
/// so the bucket size never changes a total (D-36). A comparison is priced in the images of the current buckets (A-10).
/// The priced meters' quantities are read once more for their resolution, which decides how far a bucket can be drilled
/// into (D-51) — a monthly import has no finer detail to open.
/// </para>
/// </remarks>
public sealed class AnalysisPageLoader(AnalysisReader reader, CostReader costs, AnalysisPeriods periods)
{
/// <summary>More base series than this and the comparison stays in the table: overlays would crowd the chart.</summary>
public const int MaxOverlaidSeries = 3;
/// <summary>Reads the page for <paramref name="query"/> (the address as parsed) and its <paramref name="selection"/>.</summary>
/// <param name="query">The address's analysis state.</param>
/// <param name="selection">The address read against the options (<see cref="AnalysisSelection.Resolve"/>).</param>
/// <param name="options">The names of types and meters.</param>
/// <param name="now">The instant captured once for this load (D-01).</param>
/// <param name="cancellationToken">Cancels a superseded load.</param>
public async Task<AnalysisPageView> LoadAsync(
AnalysisQuery query, AnalysisSelection selection, AnalysisPageOptions options, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(selection);
ArgumentNullException.ThrowIfNull(options);
var read = selection.ReadQuery(query);
var kind = selection.IsCost ? AnalysisPageViewKind.Cost : AnalysisPageViewKind.Quantity;
if (selection.Refusal != AnalysisPageRefusal.None)
{
// Nothing to read; the dates still show in the toolbar.
return new AnalysisPageView(read, selection, read.Resolve(now, periods.Zone), kind);
}
var period = await periods.ResolveAsync(read, now, cancellationToken).ConfigureAwait(false);
return kind == AnalysisPageViewKind.Cost
? await CostAsync(read, selection, options, period, cancellationToken).ConfigureAwait(false)
: await QuantityAsync(read, selection, options, period, cancellationToken).ConfigureAwait(false);
}
private async Task<AnalysisPageView> QuantityAsync(
AnalysisQuery read, AnalysisSelection selection, AnalysisPageOptions options, ResolvedPeriod period, CancellationToken cancellationToken)
{
var view = new AnalysisPageView(read, selection, period, AnalysisPageViewKind.Quantity);
var result = await reader.ReadAsync(read.ToAnalysisRequest(period)!, cancellationToken).ConfigureAwait(false);
if (result.Refusal != AnalysisRefusal.None)
{
return view with { Plan = result.Plan, ReaderRefusal = result.Refusal };
}
List<AnalysisSeries> series;
Dictionary<string, string> names = new(StringComparer.Ordinal);
AvailableRange? availability;
if (selection.ShowsMeters)
{
series = [.. selection.SeriesMeterIds.Select(result.SeriesFor).OfType<AnalysisSeries>()];
foreach (var item in series)
{
names[item.Key.Id] = item.Name;
}
availability = AvailableRange.Union(series.Select(s => s.Availability), reader.Zone);
}
else
{
var measures = AnalysisMetrics.MeasuresOf(selection.Metric ?? AnalysisMetric.Consumption);
var typeOrder = options.Types.Select((t, i) => (t.Id, i)).ToDictionary(p => p.Id, p => p.i);
series =
[
.. result.Measures
.Where(m => m.Key.Measure is { } measure && measures.Contains(measure))
.Where(m => selection.EnergyTypeId is not { } typeId || m.EnergyTypeId == typeId)
.OrderBy(m => m.EnergyTypeId is { } id ? typeOrder.GetValueOrDefault(id, int.MaxValue) : int.MaxValue)
.ThenBy(m => m.Key.Measure),
];
foreach (var item in series)
{
names[item.Key.Id] = MeasureName(item, series, selection.Scope.Kind == QueryScopeKind.Portfolio, options);
}
availability = result.Availability.Quantity;
}
CostAnalysis? meterCost = null;
var meterCostChange = CostChange.NoComparison;
if (selection.Scope.Kind == QueryScopeKind.Meter && options.Meter(selection.Scope.Id) is { IsCostable: true } meter)
{
var priced = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter.Id), period) { Plan = result.Plan }, cancellationToken)
.ConfigureAwait(false);
meterCost = priced.Refusal == CostRefusal.None && priced.Meter is not { Rule: MeterCostRule.None } ? priced : null;
// Its change, as the meter's own page states it (D-07): priced in the paired buckets of the comparison.
if (meterCost is not null && read.Comparison.Kind != ComparisonKind.None
&& read.ToCostComparison(meterCost.Request, meterCost.Plan) is { Request: { } comparisonRequest } costComparison)
{
var previous = await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false);
meterCostChange = OverviewComparison.Between(meterCost.Buckets, meterCost.Total, previous.Buckets, previous.Total, costComparison.Pairs);
}
}
// A derived value names the meter it misses (a source without data, a loop) by its catalog name, as the attention
// list beside it does — never by its id.
string? MeterNameOf(int id) => options.Meter(id)?.Name;
var overlays = series.Count <= MaxOverlaidSeries;
List<AnalysisChartSeries> chart = [];
List<AnalysisTableSeries> table = [];
foreach (var item in series)
{
var name = names[item.Key.Id];
chart.Add(AnalysisChartSeries.ForSeries(item, name, meterName: MeterNameOf));
if (overlays && AnalysisChartSeries.ComparisonOf(item, AnalysisChartSeries.ComparisonName(name, read.Comparison), meterName: MeterNameOf) is { } overlay)
{
chart.Add(overlay);
}
var row = AnalysisTableSeries.ForSeries(item, name, MeterNameOf);
table.Add(meterCost is not null ? row.WithCosts(meterCost.Buckets, meterCost.Total, meterCost.Currency) : row);
}
List<AnalysisProblem> problems = [.. result.Problems];
if (meterCost is not null)
{
problems.AddRange(meterCost.QuantityProblems);
}
return view with
{
Plan = result.Plan,
Quantities = result,
Series = series,
SeriesNames = names,
MeterCost = meterCost,
MeterCostChange = meterCostChange,
Chart = chart,
Table = table,
Pairs = result.Comparison is { IsApplicable: true } comparison ? comparison.Buckets : null,
Comparison = result.Comparison?.Resolution,
Matched = series.Count == 1 ? series[0].Comparison?.Matched : null,
Resolution = Coarsest(series.Select(s => s.Resolution)),
Availability = availability,
Problems = problems,
CostAttention = meterCost?.Attention ?? [],
};
}
private async Task<AnalysisPageView> CostAsync(
AnalysisQuery read, AnalysisSelection selection, AnalysisPageOptions options, ResolvedPeriod period, CancellationToken cancellationToken)
{
var view = new AnalysisPageView(read, selection, period, AnalysisPageViewKind.Cost);
// A comparison prices only the meters that can have a cost (D-34); the others are named on the page.
var requests = read.ToCostRequests(period)
.Where(r => r.Scope.Kind != CostScopeKind.Meter || selection.Scope.Kind == QueryScopeKind.Meter || selection.SeriesMeterIds.Contains(r.Scope.Id!.Value))
.ToList();
BucketPlan? plan = null;
ComparisonResolution? resolution = null;
IReadOnlyList<BucketPair>? pairs = null;
var series = new List<AnalysisCostSeries>(requests.Count);
foreach (var request in requests)
{
var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false);
if (current.Refusal == CostRefusal.TooManyPoints)
{
return view with { Plan = current.Plan, ReaderRefusal = AnalysisRefusal.TooManyPoints };
}
if (current.Refusal == CostRefusal.UnknownScope)
{
continue;
}
plan ??= current.Plan;
CostAnalysis? previous = null;
IReadOnlyList<BucketPair> seriesPairs = [];
if (read.Comparison.Kind != ComparisonKind.None)
{
var comparison = read.ToCostComparison(request with { Plan = current.Plan }, current.Plan);
resolution ??= comparison.Resolution;
if (comparison.Request is { } comparisonRequest)
{
pairs ??= comparison.Pairs;
seriesPairs = comparison.Pairs;
previous = await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false);
}
}
var (key, name) = CostSeriesName(request.Scope, options);
series.Add(new AnalysisCostSeries(key, name, current, previous) { Pairs = seriesPairs });
}
plan ??= BucketPlanner.Plan(period, read.Bucket == BucketSize.Auto ? BucketSize.Month : read.Bucket);
var overlays = series.Count <= MaxOverlaidSeries;
List<AnalysisChartSeries> chart = [];
List<AnalysisTableSeries> table = [];
foreach (var item in series)
{
var currency = item.Current.Currency;
chart.Add(AnalysisChartSeries.ForCost(item.Key, item.Name, currency, item.Current.Buckets));
if (overlays && item.Comparison is { } previous)
{
chart.Add(AnalysisChartSeries.ComparisonForCost(
item.Key, AnalysisChartSeries.ComparisonName(item.Name, read.Comparison), currency, previous.Buckets));
}
var row = AnalysisTableSeries.ForCosts(item.Key, item.Name, currency, item.Current.Buckets, item.Current.Total);
table.Add(item.Comparison is { } compared
? row.WithComparisonCosts(compared.Buckets, compared.Total, currency) with { TotalChange = CostChanges.ForTotalRow(item.CostChange) }
: row);
}
// The priced meters' own data decides how fine a bucket can be opened (D-51): a monthly import has no days. A line
// without any tariff adds nothing to the figure, so its data does not hold the drill-down back.
var priced = series
.SelectMany(s => s.Current.Lines
.Where(l => l.Total.Status != Core.Analysis.Costing.CostStatus.NotPriced)
.Select(l => l.MeterId)
.Concat(s.Current.Meter is { } m ? [m.MeterId] : []))
.Distinct()
.ToList();
AnalysisResult? quantities = null;
if (priced.Count > 0)
{
quantities = await reader.ReadAsync(
new AnalysisRequest(AnalysisScope.ForMeters(priced), period) { Plan = plan, MaxSeries = int.MaxValue }, cancellationToken)
.ConfigureAwait(false);
}
return view with
{
Plan = plan,
Quantities = quantities,
Costs = series,
Chart = chart,
Table = table,
Pairs = pairs,
Comparison = resolution ?? (read.Comparison.Kind == ComparisonKind.None ? null : ComparisonResolver.Resolve(period, read.Comparison)),
Resolution = quantities is null ? null : Coarsest(quantities.Series.Select(s => s.Resolution)),
Availability = AvailableRange.Union(series.Select(s => s.Current.Availability.Range), costs.Zone),
Problems = [.. series.SelectMany(s => s.Current.QuantityProblems)],
CostAttention = [.. series.SelectMany(s => s.Current.Attention)],
};
}
/// <summary>The coarsest of the given resolutions; null when none is known.</summary>
private static ResolutionClass? Coarsest(IEnumerable<ResolutionClass?> resolutions)
{
ResolutionClass? coarsest = null;
foreach (var resolution in resolutions)
{
if (resolution is { } value && (coarsest is null || value > coarsest))
{
coarsest = value;
}
}
return coarsest;
}
/// <summary>
/// A measure's name: its wording ("Total use"), prefixed with its energy type on the portfolio ("Strom · Total use"),
/// and with its unit when the same measure appears in two units.
/// </summary>
private static string MeasureName(AnalysisSeries series, IReadOnlyList<AnalysisSeries> all, bool withType, AnalysisPageOptions options)
{
var name = AnalysisChartSeries.NameOf(series);
if (withType && series.EnergyTypeId is { } typeId)
{
name = options.TypeName(typeId) + " · " + name;
}
var twins = all.Count(s => s.EnergyTypeId == series.EnergyTypeId && s.Key.Measure == series.Key.Measure);
return twins > 1 ? name + " (" + series.Unit + ")" : name;
}
private static (string Key, string Name) CostSeriesName(CostScope scope, AnalysisPageOptions options) => scope.Kind switch
{
CostScopeKind.EnergyType => ("cost:" + scope, options.TypeName(scope.Id!.Value)),
CostScopeKind.Meter => ("cost:" + scope, options.MeterName(scope.Id!.Value)),
CostScopeKind.Category => ("cost:" + scope, options.Category(scope.Id)?.Name ?? scope.ToString()),
_ => ("cost:portfolio", Strings.Analysis_TotalCost),
};
}
+194
View File
@@ -0,0 +1,194 @@
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.AnalysisPage;
/// <summary>
/// One meter the Analysis page can pick (brief §7.4): its name (user data, never translated), its energy type, whether it
/// is calculated, what it measures in which normalized unit (D-20), and whether it can have a cost of its own (D-34,
/// D-39).
/// </summary>
/// <param name="Id">The meter.</param>
/// <param name="Name">Its name.</param>
/// <param name="EnergyTypeId">Its energy type.</param>
/// <param name="IsVirtual">A calculated (virtual) meter.</param>
/// <param name="Kind">What its values measure.</param>
/// <param name="Unit">The normalized unit of its values.</param>
/// <param name="IsCostable">
/// True when a cost can be formed for it: a physical consumption or export meter (a bill line or a view at its price), or
/// a virtual meter whose cost rule is not <c>none</c>. Generation and runtime are never billed.
/// </param>
/// <param name="IsRetired">Retired: it keeps its history (D-24), and the pickers say so.</param>
public sealed record AnalysisPageMeter(
int Id, string Name, int EnergyTypeId, bool IsVirtual, QuantityKind Kind, string Unit, bool IsCostable, bool IsRetired = false)
{
/// <summary>The metric the meter's own series is charted under; null for an indicator, which has none.</summary>
public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind);
}
/// <summary>An energy type and the quantity metrics its totals report (D-22: a measure per metric).</summary>
/// <param name="Id">The energy type.</param>
/// <param name="Name">Its display name (user data).</param>
/// <param name="QuantityMetrics">The quantity metrics its measures cover, in <see cref="AnalysisPageOptions.QuantityOrder"/>.</param>
public sealed record AnalysisPageType(int Id, string Name, IReadOnlyList<AnalysisMetric> QuantityMetrics);
/// <summary>A cost category and its meters (meter members plus the meters of its energy-type members, D-42).</summary>
/// <param name="Id">The category.</param>
/// <param name="Name">Its name (user data).</param>
/// <param name="MeterIds">Its meters, ascending, each once.</param>
public sealed record AnalysisPageCategory(int Id, string Name, IReadOnlyList<int> MeterIds);
/// <summary>
/// What the Analysis page can offer: the energy types with the metrics they support, the cost categories with their
/// meters, and every meter with its quantity — the input of <see cref="AnalysisSelection.Resolve"/>, loaded once per page
/// from the same catalog the reader classifies with, so the choices never promise a measure the reader does not have.
/// </summary>
public sealed class AnalysisPageOptions
{
private readonly Dictionary<int, AnalysisPageType> _types;
private readonly Dictionary<int, AnalysisPageCategory> _categories;
private readonly Dictionary<int, AnalysisPageMeter> _meters;
public AnalysisPageOptions(
IEnumerable<AnalysisPageType> types, IEnumerable<AnalysisPageCategory> categories, IEnumerable<AnalysisPageMeter> meters)
{
ArgumentNullException.ThrowIfNull(types);
ArgumentNullException.ThrowIfNull(categories);
ArgumentNullException.ThrowIfNull(meters);
Types = [.. types];
Categories = [.. categories];
// Grouped by energy type in the types' order, then by name: the order of the pickers.
var typeOrder = Types.Select((t, i) => (t.Id, i)).ToDictionary(p => p.Id, p => p.i);
Meters =
[
.. meters
.OrderBy(m => typeOrder.GetValueOrDefault(m.EnergyTypeId, int.MaxValue))
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(m => m.Id),
];
_types = Types.ToDictionary(t => t.Id);
_categories = Categories.ToDictionary(c => c.Id);
_meters = Meters.ToDictionary(m => m.Id);
}
/// <summary>The quantity metrics in the order every list offers them.</summary>
public static IReadOnlyList<AnalysisMetric> QuantityOrder { get; } =
[AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Export, AnalysisMetric.Runtime, AnalysisMetric.Net];
public static AnalysisPageOptions Empty { get; } = new([], [], []);
/// <summary>The energy types, in their stored order.</summary>
public IReadOnlyList<AnalysisPageType> Types { get; }
/// <summary>The cost categories, in their sort order.</summary>
public IReadOnlyList<AnalysisPageCategory> Categories { get; }
/// <summary>Every meter, grouped by energy type, then by name.</summary>
public IReadOnlyList<AnalysisPageMeter> Meters { get; }
public AnalysisPageType? Type(int? id) => id is { } key ? _types.GetValueOrDefault(key) : null;
public AnalysisPageCategory? Category(int? id) => id is { } key ? _categories.GetValueOrDefault(key) : null;
public AnalysisPageMeter? Meter(int? id) => id is { } key ? _meters.GetValueOrDefault(key) : null;
/// <summary>The energy type's name; "#id" when it is unknown.</summary>
public string TypeName(int id) => Type(id)?.Name ?? "#" + id.ToString(System.Globalization.CultureInfo.CurrentCulture);
/// <summary>The meter's name; "#id" when it is unknown.</summary>
public string MeterName(int id) => Meter(id)?.Name ?? "#" + id.ToString(System.Globalization.CultureInfo.CurrentCulture);
/// <summary>Meter names by id, for attention items and contributions.</summary>
public IReadOnlyDictionary<int, string> MeterNames => _meters.ToDictionary(p => p.Key, p => p.Value.Name);
/// <summary>Energy type names by id.</summary>
public IReadOnlyDictionary<int, string> TypeNames => _types.ToDictionary(p => p.Key, p => p.Value.Name);
/// <summary>The quantity metric of a per-type measure (D-22): household use and grid import are both consumption.</summary>
public static AnalysisMetric MetricOf(TotalsMeasure measure) => measure switch
{
TotalsMeasure.Use or TotalsMeasure.GridImport => AnalysisMetric.Consumption,
TotalsMeasure.Generation => AnalysisMetric.Generation,
TotalsMeasure.Export => AnalysisMetric.Export,
_ => AnalysisMetric.Runtime,
};
/// <summary>
/// The options from loaded rows: the reader's catalog (normalized quantities, effective virtual definitions, the
/// totals classification), the energy types and the categories with their members.
/// </summary>
public static AnalysisPageOptions Build(AnalysisCatalog catalog, IEnumerable<EnergyType> types, IEnumerable<CostCategory> categories)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(types);
ArgumentNullException.ThrowIfNull(categories);
var typeList = types.OrderBy(t => t.Id).ToList();
var pageTypes = typeList.Select(t =>
{
var measures = catalog.Totals.ForType(t.Id).Measures.Where(g => g.MeterIds.Count > 0).Select(g => MetricOf(g.Measure)).ToHashSet();
return new AnalysisPageType(t.Id, t.DisplayName, [.. QuantityOrder.Where(measures.Contains)]);
});
var meters = catalog.Meters.Values.Select(m => new AnalysisPageMeter(
m.Id,
m.Name,
m.EnergyTypeId,
m.IsVirtual,
m.Quantity.Kind,
m.Quantity.Unit,
IsCostable(m),
m.Meter.RetiredAt is not null));
var metersByType = catalog.Meters.Values.GroupBy(m => m.EnergyTypeId).ToDictionary(g => g.Key, g => g.Select(m => m.Id).ToList());
var pageCategories = categories
.OrderBy(c => c.Sort)
.ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(c => new AnalysisPageCategory(
c.Id,
c.Name,
[
.. c.Members
.SelectMany(member => member.MeterId is { } meterId
? [meterId]
: member.EnergyTypeId is { } typeId ? metersByType.GetValueOrDefault(typeId) ?? [] : (IEnumerable<int>)[])
.Where(catalog.Meters.ContainsKey)
.Distinct()
.Order(),
]));
return new AnalysisPageOptions(pageTypes, pageCategories, meters);
}
/// <summary>Loads the options: the reader's catalog, the energy types and the categories with their members.</summary>
public static async Task<AnalysisPageOptions> LoadAsync(
IDbContextFactory<MeterVaultDbContext> contextFactory, AnalysisReader reader, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(contextFactory);
ArgumentNullException.ThrowIfNull(reader);
var catalog = await reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false);
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var types = await db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
var categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).ToListAsync(cancellationToken).ConfigureAwait(false);
return Build(catalog, types, categories);
}
/// <summary>
/// Whether a meter can have a cost of its own: physical consumption and export (D-34), a virtual meter with a cost rule
/// (D-39) — never generation or runtime, which are not billed, and never an indicator.
/// </summary>
private static bool IsCostable(AnalysisMeter meter) => meter.IsVirtual
? meter.CostRule != VirtualCostRule.None && meter.Quantity.Kind is QuantityKind.Consumption or QuantityKind.Net
: meter.Quantity.Kind is QuantityKind.Consumption or QuantityKind.Export;
}
+125
View File
@@ -0,0 +1,125 @@
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.App.AnalysisPage;
/// <summary>What the Analysis page charts: quantities from the analysis reader, or costs from the cost reader.</summary>
public enum AnalysisPageViewKind
{
Quantity,
Cost,
}
/// <summary>
/// One cost series of the page: a cost scope (the portfolio, a type, a category or a meter), its figure priced by the cost
/// reader, and — when a comparison applies — the same scope priced in the paired buckets (D-06, A-10).
/// </summary>
/// <param name="Key">A stable key for the chart and table.</param>
/// <param name="Name">The series name (user data for a type, category or meter).</param>
/// <param name="Current">The priced period.</param>
/// <param name="Comparison">The comparison priced in the images of the current buckets; null when none applies.</param>
public sealed record AnalysisCostSeries(string Key, string Name, CostAnalysis Current, CostAnalysis? Comparison)
{
/// <summary>The comparison's buckets paired with the current ones (A-10); empty without a comparison.</summary>
public IReadOnlyList<BucketPair> Pairs { get; init; } = [];
/// <summary>
/// The change of the period's cost against the comparison, by the rule every page states it with (D-07,
/// <see cref="OverviewComparison.Between"/>): the totals when both are complete, else the paired buckets complete on
/// both sides, else not comparable.
/// </summary>
public CostChange CostChange => Comparison is { } previous && Pairs.Count > 0
? OverviewComparison.Between(Current.Buckets, Current.Total, previous.Buckets, previous.Total, Pairs)
: CostChange.NoComparison;
}
/// <summary>
/// Everything the Analysis page shows for one address, committed at once (brief §8): the selection, the resolved period
/// and buckets, the series with their chart and table inputs (formatted in the request's culture), the comparison, the
/// availability behind the empty states, and the attention items. Nothing here is computed by the page itself — the
/// quantities are the analysis reader's and the costs the cost reader's, so the page agrees with every other view.
/// </summary>
public sealed record AnalysisPageView(AnalysisQuery Query, AnalysisSelection Selection, ResolvedPeriod Period, AnalysisPageViewKind Kind)
{
/// <summary>The buckets; null when nothing was read (a refused selection).</summary>
public BucketPlan? Plan { get; init; }
/// <summary>The quantity result (the quantity view), or the priced meters' quantities read for their resolution (the cost view).</summary>
public AnalysisResult? Quantities { get; init; }
/// <summary>The quantity series shown, in the order charted.</summary>
public IReadOnlyList<AnalysisSeries> Series { get; init; } = [];
/// <summary>Display names of <see cref="Series"/>, by series key.</summary>
public IReadOnlyDictionary<string, string> SeriesNames { get; init; } = new Dictionary<string, string>();
/// <summary>The cost series shown (the cost view).</summary>
public IReadOnlyList<AnalysisCostSeries> Costs { get; init; } = [];
/// <summary>For one meter's quantity: its cost by its rule, in the same buckets (null when it is not costed).</summary>
public CostAnalysis? MeterCost { get; init; }
/// <summary>The change of <see cref="MeterCost"/> against the comparison, by the rule every page uses (D-07).</summary>
public CostChange MeterCostChange { get; init; } = CostChange.NoComparison;
public IReadOnlyList<AnalysisChartSeries> Chart { get; init; } = [];
public IReadOnlyList<AnalysisTableSeries> Table { get; init; } = [];
/// <summary>The comparison buckets paired with the plan's (A-10); null without a comparison.</summary>
public IReadOnlyList<BucketPair>? Pairs { get; init; }
/// <summary>How the comparison was resolved, or why there is none.</summary>
public ComparisonResolution? Comparison { get; init; }
/// <summary>The coverage both periods share, for a single quantity series (D-07).</summary>
public MatchedCoverageResult? Matched { get; init; }
/// <summary>The coarsest resolution among the charted data: how far a bucket can be drilled into (D-51).</summary>
public ResolutionClass? Resolution { get; init; }
/// <summary>What the view's scope has data for (D-19), for "No data for this period" and "Go to latest data".</summary>
public AvailableRange? Availability { get; init; }
/// <summary>The readers refused the request before reading (too many meters or buckets).</summary>
public AnalysisRefusal ReaderRefusal { get; init; }
public IReadOnlyList<AnalysisProblem> Problems { get; init; } = [];
public IReadOnlyList<CostAttention> CostAttention { get; init; } = [];
/// <summary>The whole range lies after now (D-04).</summary>
public bool NotYetOccurred => Period.HasNotStarted();
/// <summary>True when nothing was shown for the address: the selection or the readers refused it (an explanation instead).</summary>
public bool IsRefused => Selection.Refusal != AnalysisPageRefusal.None || ReaderRefusal != AnalysisRefusal.None;
/// <summary>Every series is being rebuilt (D-16): "analysis being prepared", never "no data".</summary>
public bool IsPending => Kind == AnalysisPageViewKind.Quantity
? Series.Count > 0 && Series.All(s => s.IsPending)
: Costs.Count > 0 && Costs.All(c => c.Current.Total.Availability == BucketStatus.Pending);
/// <summary>
/// Nothing is known in the period: no series, every quantity total missing, or no cost figure with a value, nothing that
/// needs a price and no quantity that is only unresolved. A known zero is data; a missing price is not "no data" (the
/// cost cards say "not priced"), nor is a quantity the buckets cannot resolve (the cards name its state), nor a
/// category whose members price nothing — the attention list says why (A-22), and "nothing recorded yet" would be false.
/// </summary>
public bool HasNoData => Kind == AnalysisPageViewKind.Quantity
? Series.Count == 0 || Series.All(s => s.Total.Status == BucketStatus.Missing)
: Costs.Count == 0
|| (Costs.All(c => c.Current.Total is { Cost: null, Status: Core.Analysis.Costing.CostStatus.Priced, Availability: BucketStatus.Missing or BucketStatus.Available })
&& !CostAttention.Any(a => a.Kind == CostAttentionKind.CategoryPricesNothing));
/// <summary>The name of a quantity series as the page shows it.</summary>
public string NameOf(AnalysisSeries series)
{
ArgumentNullException.ThrowIfNull(series);
return SeriesNames.TryGetValue(series.Key.Id, out var name) ? name : AnalysisChartSeries.NameOf(series);
}
}
+334
View File
@@ -0,0 +1,334 @@
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Infrastructure.Analysis;
namespace MeterVault.App.AnalysisPage;
/// <summary>Why the Analysis page cannot show what its address asks for, and explains instead (brief §7.4).</summary>
public enum AnalysisPageRefusal
{
None,
/// <summary>The energy type, category or meter does not exist (any more).</summary>
UnknownScope,
/// <summary>More meters than can be compared side by side (<see cref="AnalysisLimits.MaxSeries"/>); never cut silently.</summary>
TooManyMeters,
/// <summary>A category asked for a quantity while its meters measure different kinds or units.</summary>
CategoryMixed,
/// <summary>A category asked for a quantity has no meters (its costs are manual costs only).</summary>
CategoryWithoutMeters,
/// <summary>A category asked for a quantity has more meters than can be charted side by side.</summary>
CategoryTooManyMeters,
}
/// <summary>What the page did differently from the address, and says so.</summary>
public enum AnalysisPageNoticeKind
{
/// <summary>The metric does not apply to the selection; its natural metric is shown instead.</summary>
MetricNotAvailable,
/// <summary>Some selected meters do not exist and were left out.</summary>
UnknownMetersLeftOut,
}
/// <summary>A page notice with the metric it is about (for <see cref="AnalysisPageNoticeKind.MetricNotAvailable"/>).</summary>
public sealed record AnalysisPageNotice(AnalysisPageNoticeKind Kind, AnalysisMetric? Requested = null, AnalysisMetric? Shown = null);
/// <summary>Meters of one kind and unit, e.g. the consumption meters of a mixed category, in kWh.</summary>
public sealed record AnalysisMeterGroup(QuantityKind Kind, string Unit, IReadOnlyList<int> MeterIds)
{
public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind);
}
/// <summary>
/// The Analysis page's reading of its address against what exists (brief §7.4, D-47): the scope and its name, the metrics
/// the scope supports and the one shown, the meters shown as series, and — when the address asks for something that
/// cannot be shown as one quantity — the reason in <see cref="Refusal"/>. Pure: the page, the CSV link and the tests read
/// the same answer.
/// </summary>
/// <remarks>
/// <para>
/// <b>Metrics.</b> The portfolio and an energy type offer the quantity metrics of their per-type measures (D-22) and the
/// cost; a meter its own quantity and — when it can be costed — its cost; a comparison the metrics of its meters and the
/// cost. A cost category is analysed by cost, and by a quantity only when all its meters measure one kind in one unit.
/// A metric the scope does not support falls back to the scope's natural one with a notice (D-02), except a category
/// quantity, which is explained, never silently turned into a cost.
/// </para>
/// <para>
/// <b>Series.</b> A comparison shows the meters that measure the chosen metric (a meter measures what it measures) and
/// names the others; its cost shows the meters that can have one. A category's quantity is its meters side by side —
/// each from the shared reader, never added up, because members may overlap (D-22).
/// </para>
/// </remarks>
public sealed record AnalysisSelection
{
private AnalysisSelection(QueryScope scope, AnalysisMetric? metric)
{
Scope = scope;
Metric = metric;
}
/// <summary>The scope shown (unknown meters of a comparison left out).</summary>
public QueryScope Scope { get; private init; }
/// <summary>The metric shown; null for a meter's own quantity when it has no metric (an indicator).</summary>
public AnalysisMetric? Metric { get; private init; }
/// <summary>The metrics the scope supports, in the order the selector lists them.</summary>
public IReadOnlyList<AnalysisMetric> Metrics { get; private init; } = [];
/// <summary>What the scope shows without a <c>metric</c> key.</summary>
public AnalysisMetric? NaturalMetric { get; private init; }
/// <summary>The meters shown as series (meter, comparison, category quantity); empty for measure and whole-scope cost views.</summary>
public IReadOnlyList<int> SeriesMeterIds { get; private init; } = [];
/// <summary>Selected meters not shown for this metric (another kind, or no cost of their own).</summary>
public IReadOnlyList<int> HiddenMeterIds { get; private init; } = [];
/// <summary>For <see cref="AnalysisPageRefusal.CategoryMixed"/>: the category's meters by kind and unit.</summary>
public IReadOnlyList<AnalysisMeterGroup> Groups { get; private init; } = [];
public AnalysisPageRefusal Refusal { get; private init; }
public IReadOnlyList<AnalysisPageNotice> Notices { get; private init; } = [];
/// <summary>The scope's name (a type, category or meter — user data); null for the portfolio and a comparison.</summary>
public string? ScopeName { get; private init; }
/// <summary>The energy type the scope belongs to (a type, or a meter's type).</summary>
public int? EnergyTypeId { get; private init; }
/// <summary>True when the metric shown is the cost.</summary>
public bool IsCost => Metric == AnalysisMetric.Cost;
/// <summary>True when the values shown are meters' own series (a meter, a comparison, a category's meters).</summary>
public bool ShowsMeters => !IsCost && Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters or QueryScopeKind.Category;
/// <summary>
/// The query the readers are asked with: the metric shown written out, and for a view of meters their explicit
/// selection — so resolving <c>all</c> (D-19), reading and the CSV export all see the same scope.
/// </summary>
public AnalysisQuery ReadQuery(AnalysisQuery query)
{
ArgumentNullException.ThrowIfNull(query);
var read = query.WithMetric(Metric);
return ShowsMeters && Scope.Kind != QueryScopeKind.Meter && SeriesMeterIds.Count > 0
? read.WithScope(QueryScope.ForMeters(SeriesMeterIds))
: read.WithScope(Scope);
}
/// <summary>
/// <paramref name="query"/> as the page shows it: the scope shown and the metric shown, with no <c>metric</c> key
/// when it is the scope's natural one — the state the selectors and the drill-downs build on.
/// </summary>
public AnalysisQuery Shown(AnalysisQuery query)
{
ArgumentNullException.ThrowIfNull(query);
return query.WithScope(Scope).WithMetric(Metric == NaturalMetric ? null : Metric);
}
/// <summary>Reads <paramref name="query"/> against <paramref name="options"/>.</summary>
public static AnalysisSelection Resolve(AnalysisQuery query, AnalysisPageOptions options)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(options);
return query.Scope.Kind switch
{
QueryScopeKind.EnergyType => ForType(query, options),
QueryScopeKind.Category => ForCategory(query, options),
QueryScopeKind.Meter => ForMeter(query, options),
QueryScopeKind.Meters => ForMeters(query, options),
_ => ForPortfolio(query, options),
};
}
/// <summary>The metric a scope's natural choice is: consumption when offered, else the first quantity, else the cost.</summary>
private static AnalysisMetric NaturalOf(IReadOnlyList<AnalysisMetric> quantities) =>
quantities.Contains(AnalysisMetric.Consumption) ? AnalysisMetric.Consumption : quantities.Count > 0 ? quantities[0] : AnalysisMetric.Cost;
private static AnalysisSelection ForPortfolio(AnalysisQuery query, AnalysisPageOptions options)
{
var quantities = options.Types.SelectMany(t => t.QuantityMetrics).ToHashSet();
List<AnalysisMetric> metrics = [AnalysisMetric.Cost, .. AnalysisPageOptions.QuantityOrder.Where(quantities.Contains)];
return WithMetric(new AnalysisSelection(QueryScope.Portfolio, null) { Metrics = metrics, NaturalMetric = AnalysisMetric.Cost }, query.Metric);
}
private static AnalysisSelection ForType(AnalysisQuery query, AnalysisPageOptions options)
{
if (options.Type(query.Scope.Id) is not { } type)
{
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
}
List<AnalysisMetric> metrics = [.. type.QuantityMetrics, AnalysisMetric.Cost];
var selection = new AnalysisSelection(query.Scope, null)
{
Metrics = metrics,
NaturalMetric = NaturalOf(type.QuantityMetrics),
ScopeName = type.Name,
EnergyTypeId = type.Id,
};
return WithMetric(selection, query.Metric);
}
private static AnalysisSelection ForCategory(AnalysisQuery query, AnalysisPageOptions options)
{
if (options.Category(query.Scope.Id) is not { } category)
{
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
}
var members = category.MeterIds.Select(id => options.Meter(id)).OfType<AnalysisPageMeter>().ToList();
var groups = GroupsOf(members);
// A quantity only when every meter measures one kind in one unit (brief §7.4).
var single = groups.Count == 1 && groups[0].Metric is { } only ? only : (AnalysisMetric?)null;
List<AnalysisMetric> metrics = single is { } metric ? [AnalysisMetric.Cost, metric] : [AnalysisMetric.Cost];
var selection = new AnalysisSelection(query.Scope, AnalysisMetric.Cost)
{
Metrics = metrics,
NaturalMetric = AnalysisMetric.Cost,
ScopeName = category.Name,
Groups = groups,
};
if (query.Metric is not { } requested || requested == AnalysisMetric.Cost)
{
return selection;
}
if (!requested.IsQuantity() || (single is { } supported && supported != requested))
{
// A metric its meters do not measure (or the tank balance): the cost, with a notice.
return selection with { Notices = [new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, requested, AnalysisMetric.Cost)] };
}
// The quantity asked for cannot be one: explained, never silently shown as the cost.
var refusal = members.Count == 0 ? AnalysisPageRefusal.CategoryWithoutMeters
: single is null ? AnalysisPageRefusal.CategoryMixed
: members.Count > AnalysisLimits.MaxSeries ? AnalysisPageRefusal.CategoryTooManyMeters
: AnalysisPageRefusal.None;
return selection with
{
Metric = requested,
SeriesMeterIds = refusal == AnalysisPageRefusal.None ? [.. members.Select(m => m.Id)] : [],
Refusal = refusal,
};
}
private static AnalysisSelection ForMeter(AnalysisQuery query, AnalysisPageOptions options)
{
if (options.Meter(query.Scope.Id) is not { } meter)
{
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
}
List<AnalysisMetric> metrics = [];
if (meter.Metric is { } own)
{
metrics.Add(own);
}
if (meter.IsCostable)
{
metrics.Add(AnalysisMetric.Cost);
}
var selection = new AnalysisSelection(query.Scope, null)
{
Metrics = metrics,
NaturalMetric = meter.Metric,
SeriesMeterIds = [meter.Id],
ScopeName = meter.Name,
EnergyTypeId = meter.EnergyTypeId,
};
return WithMetric(selection, query.Metric);
}
private static AnalysisSelection ForMeters(AnalysisQuery query, AnalysisPageOptions options)
{
var requested = query.Scope.MeterIds;
if (requested.Count > AnalysisLimits.MaxSeries)
{
return Refused(query.Scope, AnalysisPageRefusal.TooManyMeters);
}
var meters = requested.Select(id => options.Meter(id)).OfType<AnalysisPageMeter>().ToList();
if (meters.Count == 0)
{
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
}
List<AnalysisPageNotice> notices = meters.Count < requested.Count ? [new AnalysisPageNotice(AnalysisPageNoticeKind.UnknownMetersLeftOut)] : [];
var scope = QueryScope.ForMeters(meters.Select(m => m.Id));
List<AnalysisMetric> metrics =
[
.. meters.Select(m => m.Metric).OfType<AnalysisMetric>().Distinct().OrderBy(IndexOf),
];
if (meters.Any(m => m.IsCostable))
{
metrics.Add(AnalysisMetric.Cost);
}
var selection = WithMetric(
new AnalysisSelection(scope, null) { Metrics = metrics, NaturalMetric = meters[0].Metric, Notices = notices },
query.Metric);
// A meter measures what it measures: the metric picks which of the selected meters are compared.
var shown = selection.IsCost
? meters.Where(m => m.IsCostable).ToList()
: meters.Where(m => m.Metric == selection.Metric).ToList();
return selection with
{
SeriesMeterIds = [.. shown.Select(m => m.Id)],
HiddenMeterIds = [.. meters.Except(shown).Select(m => m.Id)],
};
}
/// <summary>The metric shown for a requested one: the request when supported, else the natural metric with a notice.</summary>
private static AnalysisSelection WithMetric(AnalysisSelection selection, AnalysisMetric? requested)
{
if (requested is not { } metric || metric == selection.NaturalMetric)
{
return selection with { Metric = selection.NaturalMetric };
}
if (selection.Metrics.Contains(metric))
{
return selection with { Metric = metric };
}
return selection with
{
Metric = selection.NaturalMetric,
Notices = [.. selection.Notices, new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, metric, selection.NaturalMetric)],
};
}
private static AnalysisSelection Refused(QueryScope scope, AnalysisPageRefusal refusal) =>
new(scope, null) { Refusal = refusal };
private static List<AnalysisMeterGroup> GroupsOf(IEnumerable<AnalysisPageMeter> meters) =>
[
.. meters
.GroupBy(m => (m.Kind, Unit: Units.Normalize(m.Unit)))
.Select(g => new AnalysisMeterGroup(g.Key.Kind, g.Key.Unit, [.. g.Select(m => m.Id)])),
];
private static int IndexOf(AnalysisMetric metric)
{
var index = AnalysisPageOptions.QuantityOrder.ToList().IndexOf(metric);
return index < 0 ? int.MaxValue : index;
}
}
+102 -9
View File
@@ -1,9 +1,12 @@
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using MeterVault.Infrastructure.Update;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.Api;
@@ -19,11 +22,40 @@ public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffCompo
public sealed record IngestResult(int Written, int Updated, int Rejected, int Ignored);
/// <summary>
/// A price a cost figure needed and did not get (D-38), as <c>/api/v1/cost</c> reports it in <c>missingPrices</c>:
/// what to price, why it is missing, where the price belongs and from which month to which.
/// </summary>
public sealed record ApiMissingPrice(
TariffComponent Component,
CostStatus Reason,
TariffScope Scope,
int? ScopeId,
int? MeterId,
DateOnly FirstMonth,
DateOnly LastMonth,
int? TariffId,
TariffUnitIssue Issue,
bool IsCredit)
{
public static ApiMissingPrice Of(MissingPrice price)
{
ArgumentNullException.ThrowIfNull(price);
return new ApiMissingPrice(
price.Component, price.Reason, price.Scope, price.ScopeId, price.MeterId, price.FirstMonth, price.LastMonth,
price.TariffId, price.Issue, price.IsCredit);
}
}
/// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary>
public static class ApiEndpoints
{
private const int MaxReadingsPerRequest = 5000;
/// <summary>Header confirming an update request was made on purpose rather than by a foreign page.</summary>
public const string UpdateRequestHeader = "X-MeterVault-Update";
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
@@ -36,20 +68,57 @@ public static class ApiEndpoints
}
int written = 0, updated = 0, rejected = 0, ignored = 0;
var touched = new HashSet<int>();
foreach (var r in readings)
{
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
// Normalize once per meter after the batch, not per reading: a recompute rewrites the
// meter's whole consumption series, so doing it inside the loop is quadratic.
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
{
case IngestionOutcome.Written: written++; break;
case IngestionOutcome.Updated: updated++; break;
case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
case IngestionOutcome.RejectedDecrease: rejected++; break;
default: ignored++; break; // unknown meter
}
}
foreach (var meterId in touched)
{
await ingestion.RenormalizeMeterAsync(meterId, ct);
}
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) =>
{
if (runner.Availability is not UpdateAvailability.Allowed)
{
return Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: runner.Availability switch
{
UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.",
_ => "This install has no in-place update mechanism (containers are replaced, not updated).",
});
}
// Not authentication — the operator opted out of that. This only stops a *different site*
// driving the endpoint through the browser of someone on this network: a plain HTML form
// cannot set a custom header, and a cross-origin fetch that tries is stopped by the
// preflight, which nothing here answers. Costs a deliberate caller one flag.
if (!http.Request.Headers.ContainsKey(UpdateRequestHeader))
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
title: $"Send the {UpdateRequestHeader} header to confirm this is a deliberate request.");
}
var launch = await runner.LaunchAsync(ct);
return launch.Started
? Results.Accepted(value: new { message = launch.Message })
: Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: launch.Message);
}).WithSummary($"Start an in-place update (LXC only; requires MeterVault__AllowInAppUpdate and the {UpdateRequestHeader} header).");
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.Meters.AsNoTracking()
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
@@ -60,19 +129,43 @@ public static class ApiEndpoints
.Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() })
.ToListAsync(ct)));
// /consumption, /cost and /dashboard/summary are contracts other systems read (D-45, ApiContractTests): every field
// keeps its name and type, and what the analysis rework adds arrives as new fields. The numbers follow the new
// engine — actuals stop at now, virtual meters are evaluated, costs are the bill's — which the release notes list.
api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to,
CostService cost, CancellationToken ct) =>
{
var buckets = await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct);
return Results.Ok(buckets.Select(b => new { b.Period, b.Consumption, b.Generation }));
}).WithSummary("Normalized monthly consumption/generation for a meter.");
// Npgsql accepts only UTC instants for timestamptz; a caller's local offset must not be a 500.
var buckets = await cost.GetMeterCostsAsync(meter, from.ToUniversalTime(), to.ToUniversalTime(), CostBucket.Month, ct);
// Months that exist only for a cost (a standing charge through a reading gap) are no consumption rows.
return Results.Ok(buckets.Where(b => b.HasQuantity).Select(b => new { b.Period, b.Consumption, b.Generation, b.Status, b.Issue, b.Kind, b.Unit }));
}).WithSummary("Normalized monthly consumption/generation for a meter (virtual meters evaluated), with each month's status.");
api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to,
CostService cost, CancellationToken ct) =>
Results.Ok(await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct)));
{
var buckets = await cost.GetMeterCostsAsync(meter, from.ToUniversalTime(), to.ToUniversalTime(), CostBucket.Month, ct);
return Results.Ok(buckets.Select(b => new
{
b.Period,
b.Consumption,
b.Generation,
b.Cost,
b.CostStatus,
b.CostAvailability,
b.CostRule,
b.NotCosted,
MissingPrices = b.MissingPrices.Select(ApiMissingPrice.Of),
b.Status,
b.Issue,
b.Kind,
b.Unit,
}));
}).WithSummary("Monthly cost of a meter by its cost rule (costRule; notCosted says why a meter has none), with its price coverage (costStatus, missingPrices) and whether the cost is known (costAvailability).");
api.MapGet("/dashboard/summary", async (DashboardService dashboard, CancellationToken ct) =>
Results.Ok(await dashboard.GetSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow), ct)));
api.MapGet("/dashboard/summary", async (DashboardService dashboard, TimeProvider time, CancellationToken ct) =>
Results.Ok(await dashboard.GetSummaryAsync(time.GetUtcNow(), ct)))
.WithSummary("Overview KPIs: this calendar month and year to now against the whole previous ones (legacy windows), priced as the bill.");
api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
NormalizationService normalization, CancellationToken ct) =>
+1 -1
View File
@@ -5,7 +5,7 @@ namespace MeterVault.App.Api;
/// <summary>
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
/// When no keys are configured the API is open — intended only for local development.
/// With no keys configured the API is closed, unless <c>AllowAnonymousApi</c> opts into an open one.
/// </summary>
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
{
+53
View File
@@ -0,0 +1,53 @@
using Microsoft.JSInterop;
namespace MeterVault.App;
/// <summary>
/// UI preferences kept in cookies (D-48, D-49) so the server renders them right from the first byte: the theme and the
/// expanded navigation groups. <c>App.razor</c> reads them on every full request and hands them to the interactive
/// root; the circuit writes them back through the tiny script helper in <c>wwwroot/metervault.js</c>.
/// </summary>
/// <remarks>
/// A cookie rather than local storage because the server needs the value while prerendering: a theme stored only in
/// the browser would flash dark-then-light on every reload and reset on the language switch, which is a full reload.
/// The cookies are plain preferences (not HttpOnly, SameSite=Lax, one year, path /), written only by the script, whose
/// whitelist accepts nothing but these two names.
/// </remarks>
public static class BrowserPreferences
{
/// <summary>The theme cookie: <c>dark</c> or <c>light</c>.</summary>
public const string ThemeCookie = "mv-theme";
/// <summary>The expanded navigation groups (<see cref="NavGroups"/>).</summary>
public const string NavCookie = "mv-nav";
/// <summary>
/// Writes a preference cookie from the circuit. A missing or disconnected browser (prerender, a closed tab) only
/// loses the preference, never the circuit.
/// </summary>
public static async Task SaveAsync(IJSRuntime js, string name, string value)
{
ArgumentNullException.ThrowIfNull(js);
try
{
await js.InvokeAsync<bool>("meterVault.setPreference", name, value);
}
catch (JSDisconnectedException)
{
// The browser went away; there is nobody to remember the preference for.
}
catch (JSException)
{
// The helper script failed to load (blocked, stale cache): the preference lasts for this circuit only.
}
catch (InvalidOperationException)
{
// Prerendering: no browser yet. Preferences are only changed interactively, so this is a programming slip.
}
catch (TaskCanceledException)
{
// The call timed out or the circuit is shutting down.
}
}
}
+50 -5
View File
@@ -1,5 +1,9 @@
<!DOCTYPE html>
<html lang="en">
@using System.Globalization
@using Microsoft.AspNetCore.Localization
@using MeterVault.App.Theme
<!DOCTYPE html>
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
<head>
<meta charset="utf-8" />
@@ -14,12 +18,53 @@
</head>
<body>
<Routes @rendermode="InteractiveServer" />
@* The theme and the expanded navigation groups come from cookies read here, on the server, and ride into the
interactive root as parameters: they survive prerender -> circuit, so a reload or the language switch (a full
reload) renders the chosen mode from the first byte instead of flashing the default (D-48, D-49). *@
<Routes @rendermode="InteractiveServer" DarkMode="@_darkMode" NavGroups="@_navGroups" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_content/Blazor-ApexCharts/js/apex-charts.min.js"></script>
<script src="_content/Blazor-ApexCharts/js/blazor-apex-charts.min.js"></script>
@* Blazor-ApexCharts 6.x imports its own ES modules (js/apexcharts.esm.js, js/blazor-apexcharts.js) when a chart
first renders; the old apex-charts.min.js / blazor-apex-charts.min.js bundles no longer ship, so they are not
referenced. *@
<script src="@Assets["metervault.js"]"></script>
</body>
</html>
@code {
private bool _darkMode = ThemeState.DefaultIsDark;
private string? _navGroups;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
protected override void OnInitialized()
{
if (HttpContext is null)
{
return;
}
_darkMode = ThemeState.Parse(HttpContext.Request.Cookies[BrowserPreferences.ThemeCookie]) ?? ThemeState.DefaultIsDark;
_navGroups = HttpContext.Request.Cookies[BrowserPreferences.NavCookie];
// Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the
// culture cookie on the very first visit. Without this the language picker would be the only
// thing that ever writes the cookie, so a reader whose browser asked for German would be served
// German until the moment they touched the picker — and the picker would open showing English.
HttpContext.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(
new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)),
new CookieOptions
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddYears(1),
SameSite = SameSiteMode.Lax,
HttpOnly = true,
IsEssential = true,
});
}
}
+77 -8
View File
@@ -1,7 +1,14 @@
@inherits LayoutComponentBase
@implements IDisposable
@using System.Globalization
@using MeterVault.App.Theme
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ThemeState Theme
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
@* The mode lives in the scoped ThemeState (D-49): App.razor seeds it from the cookie, so prerender, reload and the
language switch keep it, and charts observe the same value. *@
<MudThemeProvider Theme="MeterVaultTheme.Instance" IsDarkMode="Theme.IsDark" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
@@ -9,13 +16,34 @@
<MudLayout>
<MudAppBar Elevation="1" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="@S.Layout_ToggleNavigation" />
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
<MudText Typo="Typo.h6">MeterVault</MudText>
<MudSpacer />
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")">
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
@* Search is the fastest way to a meter from anywhere: labelled where there is room (md and up), an icon with
an accessible name below. *@
<MudButton Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Search"
OnClick="OpenMeterSearchAsync" Class="d-none d-md-inline-flex mr-1">@S.Layout_FindMeter</MudButton>
<MudTooltip Text="@S.Layout_FindMeter" RootClass="d-md-none">
<MudIconButton Icon="@Icons.Material.Filled.Search" Color="Color.Inherit" OnClick="OpenMeterSearchAsync"
aria-label="@S.Layout_FindMeter" />
</MudTooltip>
<MudTooltip Text="@S.Layout_Language">
<MudMenu Icon="@Icons.Material.Filled.Translate" Color="Color.Inherit"
AriaLabel="@S.Layout_Language" Dense="true">
@foreach (var culture in Loc.SupportedCultures)
{
<MudMenuItem OnClick="@(() => SwitchCulture(culture))"
Icon="@(culture == _current ? Icons.Material.Filled.Check : null)"
IconColor="Color.Primary">
@Loc.DisplayName(culture)
</MudMenuItem>
}
</MudMenu>
</MudTooltip>
<MudTooltip Text="@ThemeToggleLabel">
<MudIconButton Icon="@(Theme.IsDark ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
Color="Color.Inherit" OnClick="Theme.ToggleAsync" aria-label="@ThemeToggleLabel" />
</MudTooltip>
</MudAppBar>
@@ -31,12 +59,53 @@
</MudLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
@S.Layout_UnhandledError
<a href="." class="reload">@S.Layout_Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
private bool _drawerOpen = true;
private bool _darkMode = true;
private string _current = Loc.SupportedCultures[0];
/// <summary>What the theme button does: switch to the other mode.</summary>
private string ThemeToggleLabel => Theme.IsDark ? S.Layout_LightMode : S.Layout_DarkMode;
protected override void OnInitialized()
{
Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current);
Theme.Changed += OnThemeChanged;
}
private void OnThemeChanged() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Theme.Changed -= OnThemeChanged;
private async Task OpenMeterSearchAsync() =>
await DialogService.ShowAsync<MeterSearchDialog>(S.Layout_FindMeter, new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true,
CloseOnEscapeKey = true,
Position = DialogPosition.TopCenter,
});
// A circuit is stuck with the culture it was opened under, so changing language is a real
// navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the
// reload comes back translated. Returning to the current path keeps the reader in place, and
// the theme cookie keeps the mode.
private void SwitchCulture(string culture)
{
if (culture == _current)
{
return;
}
var here = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
Navigation.NavigateTo(
$"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri={Uri.EscapeDataString(here)}",
forceLoad: true);
}
}
+157 -22
View File
@@ -1,48 +1,181 @@
@implements IDisposable
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject NavigationManager Navigation
@inject NavState NavState
@inject IJSRuntime JS
@inject ILogger<NavMenu> Logger
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Domain
@* One stable structure (D-48, brief §3.1): the analysis entries first, the per-type analysis and the specialized views
as groups, then data import and the configuration pages. "Energy types" here opens a type's analysis; the entry of
the same name under Configuration edits the definitions. Expanded groups persist in a cookie, and the group holding
the current page is always open. *@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">@S.Nav_Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Analysis</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
@foreach (var type in _energyTypes)
{
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
}
<MudNavGroup Title="@S.Nav_EnergyTypes" Icon="@Icons.Material.Filled.Layers"
Expanded="@IsExpanded(NavGroups.EnergyTypes)" ExpandedChanged="@(v => SetExpandedAsync(NavGroups.EnergyTypes, v))">
@if (_typesFailed)
{
@* A database hiccup keeps the group and says so, with a way to try again — never a silently shorter menu. *@
<MudText Typo="Typo.caption" Color="Color.Error" Class="d-block px-4 py-1" role="status">@S.Nav_EnergyTypesError</MudText>
<MudNavLink OnClick="RetryEnergyTypesAsync" Icon="@Icons.Material.Filled.Refresh">@S.Common_Retry</MudNavLink>
}
else if (_energyTypes is { Count: 0 })
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block px-4 py-1">@S.Nav_NoEnergyTypes</MudText>
}
else if (_energyTypes is not null)
{
@foreach (var type in _energyTypes)
{
<MudNavLink Href="@AnalysisLinks.EnergyType(type.Id)" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
}
}
</MudNavGroup>
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
<MudDivider Class="my-2" />
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
@* Always listed; without the meters a view needs, it says what is missing instead of disappearing. What counts is
the meters' configured mode and tank, never their names. *@
<MudNavGroup Title="@S.Nav_SpecializedViews" Icon="@Icons.Material.Filled.ViewQuilt"
Expanded="@IsExpanded(NavGroups.SpecializedViews)" ExpandedChanged="@(v => SetExpandedAsync(NavGroups.SpecializedViews, v))">
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">
@S.Nav_Solar
@if (_setup is { HasGeneration: false })
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block">@S.Nav_SolarSetup</MudText>
}
</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">
@S.Nav_Consumables
@if (_setup is { HasTank: false })
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block">@S.Nav_ConsumablesSetup</MudText>
}
</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
<MudNavGroup Title="@S.Nav_Configuration" Icon="@Icons.Material.Filled.Settings"
Expanded="@IsExpanded(NavGroups.Configuration)" ExpandedChanged="@(v => SetExpandedAsync(NavGroups.Configuration, v))">
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">@S.Nav_Connectors</MudNavLink>
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">@S.Nav_Settings</MudNavLink>
</MudNavGroup>
</MudNavMenu>
@code {
private List<EnergyType> _energyTypes = [];
private List<EnergyType>? _energyTypes;
private bool _typesFailed;
private ViewSetup? _setup;
private HashSet<string> _expanded = new(StringComparer.Ordinal);
protected override async Task OnInitializedAsync()
{
_expanded = new HashSet<string>((IEnumerable<string>?)NavState.SavedGroups ?? NavGroups.DefaultExpanded, StringComparer.Ordinal);
OpenGroupOf(Navigation.Uri);
Navigation.LocationChanged += OnLocationChanged;
NavState.EnergyTypesChanged += OnEnergyTypesChanged;
NavState.MetersChanged += OnMetersChanged;
await LoadEnergyTypesAsync();
await LoadSetupAsync();
}
private bool IsExpanded(string group) => _expanded.Contains(group);
/// <summary>A group folded or opened by the user: remembered for the next visit.</summary>
private async Task SetExpandedAsync(string group, bool expanded)
{
var changed = expanded ? _expanded.Add(group) : _expanded.Remove(group);
if (changed)
{
await BrowserPreferences.SaveAsync(JS, BrowserPreferences.NavCookie, NavGroups.Format(_expanded));
}
}
/// <summary>Opens the group holding the page at <paramref name="uri"/>; never folds one (that is the user's call).</summary>
private bool OpenGroupOf(string uri) =>
NavGroups.GroupFor(Navigation.ToBaseRelativePath(uri)) is { } group && _expanded.Add(group);
private async Task LoadEnergyTypesAsync()
{
try
{
await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
_typesFailed = false;
}
catch (Exception)
catch (Exception ex)
{
// Nav must never break the layout — a DB hiccup just hides the per-type links.
_energyTypes = [];
// The layout must never break over the menu: keep the group, say it failed, offer Retry — and log why.
Logger.LogError(ex, "Loading the energy types for the navigation failed");
_energyTypes = null;
_typesFailed = true;
}
}
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
/// <summary>Whether the specialized views have what they need: a generation counter for Solar, a tank for Consumables.</summary>
private async Task LoadSetupAsync()
{
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var hasGeneration = await db.Meters.AnyAsync(m => m.Mode == MeterMode.GenerationCounter);
var hasTank = await db.Tanks.AnyAsync(t => db.Meters.Any(m => m.Id == t.MeterId && m.Mode == MeterMode.ConsumableBalance));
_setup = new ViewSetup(hasGeneration, hasTank);
}
catch (Exception ex)
{
// Unknown is shown as nothing: the entries stay, without a setup hint.
Logger.LogError(ex, "Checking the specialized views' setup for the navigation failed");
_setup = null;
}
}
private async Task RetryEnergyTypesAsync()
{
await LoadEnergyTypesAsync();
StateHasChanged();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
if (OpenGroupOf(e.Location))
{
_ = InvokeAsync(StateHasChanged);
}
}
private void OnEnergyTypesChanged() =>
_ = InvokeAsync(async () =>
{
await LoadEnergyTypesAsync();
StateHasChanged();
});
private void OnMetersChanged() =>
_ = InvokeAsync(async () =>
{
await LoadSetupAsync();
StateHasChanged();
});
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
NavState.EnergyTypesChanged -= OnEnergyTypesChanged;
NavState.MetersChanged -= OnMetersChanged;
}
// Map the energy type's stored icon name to a Material icon; fall back to a bolt.
private static string TypeIcon(string? icon) => icon switch
{
"bolt" => Icons.Material.Filled.Bolt,
@@ -52,4 +185,6 @@
"thermostat" => Icons.Material.Filled.Thermostat,
_ => Icons.Material.Filled.Bolt,
};
private sealed record ViewSetup(bool HasGeneration, bool HasTank);
}
@@ -7,25 +7,25 @@
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
@S.Reconnect_Rejoining
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
@S.Reconnect_RetryCountdownPrefix <span id="components-seconds-to-next-attempt"></span> @S.Reconnect_RetryCountdownSuffix
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
@S.Reconnect_RejoinFailed<br />@S.Reconnect_RetryOrReload
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
@S.Reconnect_Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
@S.Reconnect_Paused
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
@S.Reconnect_ResumeFailed<br />@S.Reconnect_RetryOrReload
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
@S.Reconnect_Resume
</button>
</div>
</dialog>
+33 -33
View File
@@ -5,12 +5,12 @@
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Cost categories</PageTitle>
<PageTitle>MeterVault — @S.Nav_CostCategories</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Cost categories</MudText>
<MudText Typo="Typo.h4">@S.Nav_CostCategories</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add category
@S.Categories_AddCategory
</MudButton>
</div>
@@ -22,22 +22,22 @@ else
{
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Members</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
<MudTh>@S.Common_Name</MudTh>
<MudTh>@S.Categories_Sort</MudTh>
<MudTh>@S.Categories_Members</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
<MudTd DataLabel="@S.Common_Name">
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
{
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
}
@context.Name
</MudTd>
<MudTd DataLabel="Sort">@context.Sort</MudTd>
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudTd DataLabel="@S.Categories_Sort">@context.Sort</MudTd>
<MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
@@ -47,20 +47,20 @@ else
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
@if (_working.Id != 0)
{
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Categories_Members</MudText>
@if (_members.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Categories_NoMembers</MudText>
}
else
{
@@ -77,30 +77,30 @@ else
</MudList>
}
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
<MudSelect T="int?" @bind-Value="_addMeterId" Label="@S.Categories_AddMeter" Dense="true" Style="min-width:180px">
@foreach (var meter in _meters)
{
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
}
</MudSelect>
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">@S.Categories_Add</MudButton>
<MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" Dense="true" Style="min-width:180px">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
}
</MudSelect>
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">@S.Categories_Add</MudButton>
</div>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Categories_SaveFirst</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
<MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@@ -129,12 +129,12 @@ else
{
var meters = c.Members.Count(m => m.MeterId is not null);
var types = c.Members.Count(m => m.EnergyTypeId is not null);
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
return meters + types == 0 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types);
}
private string MemberLabel(CostCategoryMember m) =>
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
: m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}")
: "—";
private void OpenEdit(CostCategory? category)
@@ -158,7 +158,7 @@ else
{
if (string.IsNullOrWhiteSpace(_working.Name))
{
Snackbar.Add("Name is required.", Severity.Warning);
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
return;
}
@@ -169,7 +169,7 @@ else
db.CostCategories.Add(category);
await db.SaveChangesAsync();
// Re-open on the new category so members can be added.
Snackbar.Add("Saved. Add members below.", Severity.Success);
Snackbar.Add(S.Categories_SavedAddMembers, Severity.Success);
await LoadAsync();
OpenEdit(_categories!.First(c => c.Id == category.Id));
return;
@@ -181,7 +181,7 @@ else
existing.Sort = _working.Sort;
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add("Saved.", Severity.Success);
Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync();
}
@@ -235,8 +235,8 @@ else
private async Task DeleteAsync(CostCategory category)
{
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle,
Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
{
return;
}
@@ -244,7 +244,7 @@ else
await using var db = await DbFactory.CreateDbContextAsync();
// Members cascade with the category; manual_cost.category_id is SetNull.
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
Snackbar.Add("Deleted.", Severity.Success);
Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync();
}
+379 -54
View File
@@ -1,24 +1,33 @@
@page "/admin/connectors"
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
<PageTitle>MeterVault — Connectors</PageTitle>
<PageTitle>MeterVault — @S.Nav_Connectors</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Connectors</MudText>
<MudText Typo="Typo.h4">@S.Nav_Connectors</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add connector
@S.Connectors_AddConnector
</MudButton>
</div>
@if (_returnTo is { } back)
{
@* Arrived from a meter's source dialog: the way back is always in view, saved or not. *@
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sensors">
@Loc.F(S.Connectors_ForMeter, back.MeterName) <MudLink Href="@MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType)">@Loc.F(S.Connectors_BackToMeter, back.MeterName)</MudLink>
</MudAlert>
}
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
(or Docker secret) resolved at runtime.
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
</MudAlert>
@if (_endpoints is null)
@@ -29,20 +38,43 @@ else
{
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
<MudTh>Enabled</MudTh>
<MudTh>Last status</MudTh>
<MudTh>Last seen</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
<MudTh>@S.Common_Name</MudTh>
<MudTh>@S.Common_Type</MudTh>
<MudTh>@S.Common_Enabled</MudTh>
<MudTh>@S.Connectors_UsedBy</MudTh>
<MudTh>@S.Connectors_LastStatus</MudTh>
<MudTh>@S.Common_LastSeen</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Type">@context.Type</MudTd>
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
<MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
<MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
<MudTd DataLabel="@S.Connectors_UsedBy">
@if (_usage.TryGetValue(context.Id, out var users))
{
@foreach (var user in users.Take(UsersShown))
{
<MudLink Href="@MeterLinks.Detail(user.MeterId, MeterLinks.TabSources)" Class="mr-2">@user.MeterName</MudLink>
}
@if (users.Count > UsersShown)
{
<MudText Typo="Typo.caption" Inline="true">@Loc.F(S.Connectors_UsedByMore, users.Count - UsersShown)</MudText>
}
@if (!context.IsEnabled)
{
@* Workers skip disabled connectors, so every source on it has stopped. *@
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@S.Connectors_DisabledInUse</MudText>
}
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Connectors_Unused</MudText>
}
</MudTd>
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
@@ -50,34 +82,49 @@ else
</MudTable>
@if (_endpoints.Count == 0)
{
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
<MudAlert Severity="Severity.Normal" Class="mt-4">@S.Connectors_Empty</MudAlert>
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name))</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="@S.Common_Type" Class="mb-2">
@foreach (var type in Enum.GetValues<EndpointType>())
{
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
<MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
@if (_working.Type == EndpointType.HomeAssistant)
{
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
<MudTextField @bind-Value="_working.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
@if (_working.UseDirectToken)
{
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
@S.Connectors_EncryptedHint
</MudText>
}
else
{
<MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
@S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
</MudText>
}
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="@S.Connectors_WebSocketPush" Color="Color.Primary" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
@S.Connectors_WebSocketHint
</MudText>
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
<MudTextField @bind-Value="_working.TestEntityId" Label="@S.Connectors_TestEntityId" Class="mb-2" />
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
Test connection
@S.Connectors_TestConnection
</MudButton>
@if (_testing)
{
@@ -85,28 +132,65 @@ else
}
@if (_testResult is not null)
{
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@TestText(_testResult)</MudAlert>
}
}
else
{
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Port" Label="@S.Connectors_Port" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="@S.Connectors_Tls" Color="Color.Primary" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
@if (_working.UseDirectCredentials)
{
<MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
@S.Connectors_EncryptedHint
</MudText>
}
else
{
<MudTextField @bind-Value="_working.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
<MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
}
<MudTextField @bind-Value="_working.ExtraTopics" Label="@S.Connectors_ExtraTopics" Class="mb-2" />
}
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@code {
/// <summary>Opens a new connector of this <see cref="EndpointType"/> once the page is interactive.</summary>
[SupplyParameterFromQuery(Name = "new")]
public string? NewParam { get; set; }
/// <summary>Opens this connector for editing once the page is interactive.</summary>
[SupplyParameterFromQuery(Name = "edit")]
public int? EditParam { get; set; }
/// <summary>The meter whose source dialog sent the user here; see <see cref="MeterLinks.NewConnector"/>.</summary>
[SupplyParameterFromQuery(Name = "meter")]
public int? MeterParam { get; set; }
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
public int? SourceParam { get; set; }
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
public string? SourceTypeParam { get; set; }
private const int UsersShown = 3;
private List<IngestionEndpoint>? _endpoints;
private Dictionary<int, List<ConnectorUser>> _usage = [];
private ReturnTarget? _returnTo;
private (EndpointType? New, int? Edit)? _pendingOpen;
private bool _droppingOpen;
private bool _editOpen;
private bool _testing;
private HaTestResult? _testResult;
@@ -115,10 +199,97 @@ else
protected override Task OnInitializedAsync() => LoadAsync();
protected override async Task OnParametersSetAsync()
{
if (MeterParam != _returnTo?.MeterId)
{
_returnTo = null;
if (MeterParam is { } meterId)
{
// Resolved against the database, so the way back names a meter that exists — and only
// ever leads to a meter page, whatever the query string says.
await using var db = await DbFactory.CreateDbContextAsync();
var name = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => m.Name).FirstOrDefaultAsync();
if (name is not null)
{
_returnTo = new ReturnTarget(meterId, name, SourceParam, null);
}
}
}
if (_returnTo is not null)
{
_returnTo = _returnTo with
{
SourceId = SourceParam,
SourceType = Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
};
}
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
{
_pendingOpen = (Enum.TryParse<EndpointType>(NewParam, ignoreCase: true, out var type) ? type : null, EditParam);
}
else
{
_droppingOpen = false;
}
}
/// <summary>
/// Opens a deep-linked connector dialog. The request is dropped from the address first and the dialog
/// opened once that navigation has come back — the order the meter page uses, for the same reason: a
/// circuit's first location change dismisses any dialog already open.
/// </summary>
protected override void OnAfterRender(bool firstRender)
{
if (_pendingOpen is not { } open || _endpoints is null)
{
return;
}
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
{
if (!_droppingOpen)
{
_droppingOpen = true;
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
{
["new"] = null,
["edit"] = null,
}), replace: true);
}
return;
}
_pendingOpen = null;
if (open.Edit is { } editId && _endpoints.FirstOrDefault(e => e.Id == editId) is { } endpoint)
{
OpenEdit(endpoint);
}
else if (open.New is { } newType)
{
OpenEdit(null);
_working.Type = newType;
}
StateHasChanged();
}
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
var users = await db.MeterSources.AsNoTracking()
.Where(s => s.EndpointId != null)
.Select(s => new { EndpointId = s.EndpointId!.Value, s.MeterId, MeterName = s.Meter!.Name })
.ToListAsync();
_usage = users
.GroupBy(u => u.EndpointId)
.ToDictionary(
g => g.Key,
g => g.DistinctBy(u => u.MeterId).OrderBy(u => u.MeterName).Select(u => new ConnectorUser(u.MeterId, u.MeterName)).ToList());
}
private void OpenEdit(IngestionEndpoint? endpoint)
@@ -135,6 +306,12 @@ else
{
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket,
// Carry the ciphertext through untouched and never send the secret to the browser:
// the field stays blank and only a typed value replaces what is stored.
TokenEnc = ha.TokenEnc,
UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc),
// The host the stored token was saved against; a stored token is never sent anywhere else.
SavedBaseUrl = ha.BaseUrl,
};
}
else
@@ -145,6 +322,9 @@ else
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
Username = mqtt.Username, PasswordEnc = mqtt.PasswordEnc,
UseDirectCredentials =
!string.IsNullOrWhiteSpace(mqtt.Username) || !string.IsNullOrWhiteSpace(mqtt.PasswordEnc),
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
};
}
@@ -157,7 +337,45 @@ else
_testResult = null;
try
{
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId);
// Test what the connector would actually use — including a token typed but not yet
// saved, so a bad token is caught before it is stored.
if (_working.UseDirectToken)
{
if (!string.IsNullOrWhiteSpace(_working.Token))
{
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.Token, _working.TestEntityId);
}
else if (!SameOrigin(_working.BaseUrl, _working.SavedBaseUrl))
{
// Storing the token encrypted means the UI can decrypt something the operator
// can no longer read. Sending it to a Base URL edited in this dialog would turn
// "Test connection" into an exfiltration primitive — point it at any host and the
// token arrives as a Bearer header. A stored secret only ever goes to the origin
// it was saved for; testing elsewhere means typing the token again.
_testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged);
}
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
{
_testResult = await HaTester.TestAsync(_working.BaseUrl, stored, _working.TestEntityId);
}
else
{
_testResult = new HaTestResult(false, S.Connectors_TestNoToken);
}
}
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
{
_testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
}
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
{
_testResult = new HaTestResult(false,
Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
}
else
{
_testResult = await HaTester.TestAsync(_working.BaseUrl, envToken, _working.TestEntityId);
}
}
finally
{
@@ -169,42 +387,76 @@ else
{
if (string.IsNullOrWhiteSpace(_working.Name))
{
Snackbar.Add("Name is required.", Severity.Warning);
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
return;
}
if (_working.Type == EndpointType.HomeAssistant
&& _working.UseDirectToken
&& string.IsNullOrWhiteSpace(_working.Token)
&& !_working.HasStoredToken)
{
Snackbar.Add(S.Connectors_TokenRequired, Severity.Warning);
return;
}
var config = _working.Type == EndpointType.HomeAssistant
? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv), UseWebSocket = _working.UseWebSocket }.ToJson()
? new HaEndpointConfig
{
BaseUrl = Trim(_working.BaseUrl),
UseWebSocket = _working.UseWebSocket,
// Exactly one storage form survives a save: switching modes clears the other, so a
// stale token cannot linger and silently win at resolution time.
TokenEnv = _working.UseDirectToken ? null : Trim(_working.TokenEnv),
TokenEnc = _working.UseDirectToken ? ProtectOrKeep(_working.Token, _working.TokenEnc) : null,
}.ToJson()
: new EndpointConfig
{
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
Port = _working.Port,
Tls = _working.Tls,
UsernameEnv = Trim(_working.UsernameEnv),
PasswordEnv = Trim(_working.PasswordEnv),
UsernameEnv = _working.UseDirectCredentials ? null : Trim(_working.UsernameEnv),
PasswordEnv = _working.UseDirectCredentials ? null : Trim(_working.PasswordEnv),
Username = _working.UseDirectCredentials ? Trim(_working.Username) : null,
PasswordEnc = _working.UseDirectCredentials
? ProtectOrKeep(_working.Password, _working.PasswordEnc)
: null,
ExtraTopics = SplitTopics(_working.ExtraTopics),
}.ToJson();
await using var db = await DbFactory.CreateDbContextAsync();
IngestionEndpoint saved;
if (_working.Id == 0)
{
db.IngestionEndpoints.Add(new IngestionEndpoint
saved = new IngestionEndpoint
{
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
});
};
db.IngestionEndpoints.Add(saved);
}
else
{
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
existing.Type = _working.Type;
existing.Name = _working.Name.Trim();
existing.Config = config;
existing.IsEnabled = _working.IsEnabled;
saved = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
saved.Type = _working.Type;
saved.Name = _working.Name.Trim();
saved.Config = config;
saved.IsEnabled = _working.IsEnabled;
}
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add("Saved.", Severity.Success);
Snackbar.Add(S.Common_Saved, Severity.Success);
// Back to the source that was waiting for this connector, with it picked — when it can serve that
// source. Anything else (disabled, or of another kind) keeps the user here, the way back in view.
if (_returnTo is { } back
&& saved.IsEnabled
&& (back.SourceType is not { } sourceType || SourceRouting.Serves(saved.Type, sourceType)))
{
Nav.NavigateTo(MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType, saved.Id));
return;
}
await LoadAsync();
}
@@ -212,22 +464,51 @@ else
{
await using var db = await DbFactory.CreateDbContextAsync();
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : "";
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
// Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
// rather than falling back to any broker. Say so plainly.
var note = sourceCount > 0
? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
: "";
if (!await Confirm.DeleteAsync(
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
{
return;
}
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
Snackbar.Add("Deleted.", Severity.Success);
Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync();
}
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>
/// Whether two URLs address the same host. Compares scheme, host and port rather than the raw
/// string, so a trailing slash or a path tweak does not force the token to be re-typed. Fails
/// closed: anything unparsable counts as a different origin.
/// </summary>
private static bool SameOrigin(string? a, string? b) =>
Uri.TryCreate(a, UriKind.Absolute, out var left)
&& Uri.TryCreate(b, UriKind.Absolute, out var right)
&& string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase)
&& left.Port == right.Port;
/// <summary>
/// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank.
/// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared".
/// </summary>
private string? ProtectOrKeep(string? typed, string? existingCiphertext) =>
string.IsNullOrWhiteSpace(typed) ? existingCiphertext : Secrets.Protect(typed);
private static IReadOnlyList<string> SplitTopics(string? csv) =>
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
private sealed record ConnectorUser(int MeterId, string MeterName);
/// <summary>The meter source a user came here to set up a connector for.</summary>
private sealed record ReturnTarget(int MeterId, string MeterName, int? SourceId, SourceType? SourceType);
private sealed class EditModel
{
public int Id { get; set; }
@@ -241,6 +522,20 @@ else
public bool UseWebSocket { get; set; }
public string? TestEntityId { get; set; }
/// <summary>True to store the token here (encrypted); false to name an env var.</summary>
public bool UseDirectToken { get; set; }
/// <summary>Typed token. Always blank on load — a stored secret is never sent to the browser.</summary>
public string? Token { get; set; }
/// <summary>Stored ciphertext, round-tripped so leaving <see cref="Token"/> blank keeps it.</summary>
public string? TokenEnc { get; set; }
/// <summary>Base URL as saved, so an edited one can be told from the token's own host.</summary>
public string? SavedBaseUrl { get; set; }
public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc);
// MQTT broker
public string? Host { get; set; } = "localhost";
public int Port { get; set; } = 1883;
@@ -248,5 +543,35 @@ else
public string? UsernameEnv { get; set; }
public string? PasswordEnv { get; set; }
public string? ExtraTopics { get; set; }
public bool UseDirectCredentials { get; set; }
/// <summary>Username entered directly — not a secret, so shown when editing.</summary>
public string? Username { get; set; }
public string? Password { get; set; }
public string? PasswordEnc { get; set; }
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
}
// HaTestResult.Message stays English for the log; the reader gets the verdict in their own
// language. Anything the connector page decided for itself (Precondition) already carries its
// own localized wording, and HA's own diagnostics — an HTTP status, an exception — are passed
// through untranslated because they are not ours to reword.
private static string TestText(HaTestResult result) => result.Outcome switch
{
HaTestOutcome.Connected => S.Connectors_TestConnected,
HaTestOutcome.ConnectedWithValue => Loc.F(
S.Connectors_TestConnectedValue,
result.EntityId ?? string.Empty,
result.SampleValue is { } value ? Format.Number(value, 2) : string.Empty),
HaTestOutcome.BaseUrlMissing => S.Connectors_TestBaseUrlRequired,
HaTestOutcome.TokenMissing => S.Connectors_TestTokenMissing,
HaTestOutcome.HttpError => Loc.F(S.Connectors_TestHttpError, result.Detail ?? string.Empty),
HaTestOutcome.NoNumericState => Loc.F(S.Connectors_TestNoNumericState, result.EntityId ?? string.Empty),
HaTestOutcome.RequestFailed => Loc.F(S.Connectors_TestFailed, result.Detail ?? string.Empty),
_ => result.Message,
};
}
@@ -2,17 +2,21 @@
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavState NavState
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Energy types</PageTitle>
<PageTitle>MeterVault — @S.EnergyTypes_Title</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Energy types</MudText>
@* Configuration, not analysis: the menu's "Energy types" group opens each type's analysis, this page edits what a type
is. The title and the line below say which one the reader is on. *@
<div class="d-flex align-center justify-space-between mb-1 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4" HtmlTag="h1">@S.EnergyTypes_Title</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add energy type
@S.EnergyTypes_Add
</MudButton>
</div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">@S.EnergyTypes_Description</MudText>
@if (_types is null)
{
@@ -22,24 +26,24 @@ else
{
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Key</MudTh>
<MudTh>Display name</MudTh>
<MudTh>Base unit</MudTh>
<MudTh>Default mode</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
<MudTh>@S.EnergyTypes_Key</MudTh>
<MudTh>@S.EnergyTypes_DisplayName</MudTh>
<MudTh>@S.EnergyTypes_BaseUnit</MudTh>
<MudTh>@S.EnergyTypes_DefaultMode</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Key">@context.Key</MudTd>
<MudTd DataLabel="Display name">
<MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
<MudTd DataLabel="@S.EnergyTypes_DisplayName">
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
{
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
}
@context.DisplayName
</MudTd>
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
<MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
@@ -49,24 +53,24 @@ else
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
<MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.DisplayName" Label="@S.EnergyTypes_DisplayName" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.BaseUnit" Label="@S.EnergyTypes_BaseUnitLabel" Required="true" Class="mb-2" />
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>())
{
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
<MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@@ -105,14 +109,14 @@ else
{
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
{
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
Snackbar.Add(S.EnergyTypes_RequiredFields, Severity.Warning);
return;
}
await using var db = await DbFactory.CreateDbContextAsync();
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
{
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
Snackbar.Add(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), Severity.Error);
return;
}
@@ -141,7 +145,8 @@ else
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add("Saved.", Severity.Success);
Snackbar.Add(S.Common_Saved, Severity.Success);
NavState.NotifyEnergyTypesChanged();
await LoadAsync();
}
@@ -151,21 +156,20 @@ else
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
if (meterCount > 0)
{
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
Snackbar.Add(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error);
return;
}
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName)))
{
return;
}
var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id);
if (target is not null)
// Its type-scoped prices go with it: tariff.scope_id has no foreign key.
if (await MeterVault.Infrastructure.Persistence.EntityDeletion.DeleteEnergyTypeAsync(db, type.Id))
{
db.EnergyTypes.Remove(target);
await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success);
Snackbar.Add(S.Common_Deleted, Severity.Success);
NavState.NotifyEnergyTypesChanged();
}
await LoadAsync();
+158 -24
View File
@@ -1,30 +1,43 @@
@page "/admin/settings"
@using System.Text.Json
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Analysis
@using MeterVault.Infrastructure.Normalization
@using MeterVault.Infrastructure.Persistence
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@inject IDbContextFactory<MeterVaultDbContext> DbFactory
@inject AnalysisReader Reader
@inject InstanceCurrency Currency
@inject ILogger<Settings> Logger
@using MudBlazor
<PageTitle>MeterVault — Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
<PageHeader Title="@S.Nav_Settings" Class="mb-2" />
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
These are the <b>effective</b> settings the running instance is using. They are configured via environment
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
@S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
(<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
</MudAlert>
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Locale &amp; time</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@Reader.Zone.Id</code></td></tr>
<tr><td>@S.Settings_Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
<tr><td>@S.Common_Currency</td><td style="text-align:right"><code>@Currency.Code</code> (@Currency.Symbol)</td></tr>
<tr>
<td>@S.Settings_RawRetention</td>
<td style="text-align:right">
@* D-57: every recompute rebuilds a meter from its readings, so deleting old ones would erase history. *@
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@S.Settings_RetentionNotEnforced</MudChip>
</td>
</tr>
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-2">@Loc.F(S.Settings_RetentionReason, _o.RawRetentionDays)</MudText>
<MudText Typo="Typo.caption" Class="mv-muted mt-2">
@S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
</MudText>
</MudPaper>
@@ -32,7 +45,7 @@
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Access &amp; ingestion</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AccessAndIngestion</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr>
@@ -40,33 +53,154 @@
<td style="text-align:right">
@if (_o.ApiKeys.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Success">@Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count)</MudChip>
}
else if (_o.AllowAnonymousApi)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.Settings_ApiOpen</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Settings_ApiClosed</MudChip>
}
</td>
</tr>
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
<tr><td>@S.Settings_ReverseProxyTrust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off)</td></tr>
<tr><td>@S.Settings_LiveIngestionWorkers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off)</td></tr>
<tr><td>@S.Settings_SeedReferenceData</td><td style="text-align:right">@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off)</td></tr>
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
<MudText Typo="Typo.caption" Class="mv-muted mt-2">
@S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
@S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AnalysisData</MudText>
@if (_analysis is { } state)
{
<MudSimpleTable Dense="true">
<tbody>
<tr><td>@S.Settings_NormalizationRevision</td><td style="text-align:right">@RevisionText(state)</td></tr>
<tr><td>@S.Settings_NormalizationZone</td><td style="text-align:right">@ZoneText(state)</td></tr>
<tr>
<td>@S.Settings_MetersCurrent</td>
<td style="text-align:right">
@Loc.F(S.Settings_MetersCurrentValue, state.CurrentMeters, state.PhysicalMeters)
@if (state.PhysicalMeters > state.CurrentMeters)
{
<span class="mv-muted"> · @Loc.F(S.Settings_MetersPending, state.PhysicalMeters - state.CurrentMeters)</span>
}
</td>
</tr>
@if (state.Retry.Count > 0)
{
<tr><td>@S.Settings_RebuildRetry</td><td style="text-align:right">@string.Join(", ", state.Retry)</td></tr>
}
<tr><td>@S.Settings_VirtualMeters</td><td style="text-align:right">@VirtualText(state)</td></tr>
</tbody>
</MudSimpleTable>
}
else if (_analysisFailed)
{
<MudAlert Severity="Severity.Warning" Dense="true">@S.Settings_AnalysisUnavailable</MudAlert>
}
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-2">@S.Settings_AnalysisHelp</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@code {
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
private AnalysisState? _analysis;
private bool _analysisFailed;
protected override void OnInitialized() => _o = Options.Value;
/// <summary>What the analysis data is built with (D-16) and how the calculated meters stand (D-26, D-28).</summary>
private sealed record AnalysisState(
int? Revision,
string? Zone,
int PhysicalMeters,
int CurrentMeters,
IReadOnlyList<string> Retry,
IReadOnlyList<(VirtualMeterStatus Status, int Count)> Virtual);
protected override async Task OnInitializedAsync()
{
_o = Options.Value;
try
{
_analysis = await LoadAnalysisStateAsync();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The settings themselves are configuration and always show; only this read-out depends on the database.
Logger.LogWarning(ex, "Could not read the analysis data state");
_analysisFailed = true;
}
}
private async Task<AnalysisState> LoadAnalysisStateAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var settings = await db.AppSettings.AsNoTracking()
.Where(s => s.Key == NormalizationUpgrade.SettingKey || s.Key == NormalizationUpgrade.ZoneSettingKey || s.Key == NormalizationUpgrade.PendingSettingKey)
.ToDictionaryAsync(s => s.Key, s => s.Value);
var catalog = await Reader.LoadCatalogAsync();
var physical = catalog.Meters.Values.Where(m => !m.IsVirtual).ToList();
var retryIds = Read<int[]>(settings, NormalizationUpgrade.PendingSettingKey) ?? [];
var virtualStates = catalog.Meters.Values
.Where(m => m.IsVirtual)
.GroupBy(m => m.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration)
.OrderBy(g => g.Key)
.Select(g => (g.Key, g.Count()))
.ToList();
return new AnalysisState(
Read<int?>(settings, NormalizationUpgrade.SettingKey),
Read<string>(settings, NormalizationUpgrade.ZoneSettingKey),
physical.Count,
physical.Count(m => !m.IsPending),
[.. retryIds.Select(id => catalog.Find(id)?.Name ?? $"#{id}")],
virtualStates);
}
private static T? Read<T>(IReadOnlyDictionary<string, string> settings, string key)
{
if (!settings.TryGetValue(key, out var json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json);
}
catch (JsonException)
{
return default;
}
}
private static string RevisionText(AnalysisState state) => state.Revision switch
{
null => S.Settings_RevisionNone,
var revision when revision < NormalizationUpgrade.CurrentRevision =>
Loc.F(S.Settings_RevisionOutdated, revision, NormalizationUpgrade.CurrentRevision),
var revision => Loc.F(S.Settings_RevisionValue, revision),
};
private string ZoneText(AnalysisState state) => state.Zone switch
{
null => S.Settings_RevisionNone,
var zone when !string.Equals(zone, Reader.Zone.Id, StringComparison.Ordinal) => Loc.F(S.Settings_ZoneDiffers, zone),
var zone => zone,
};
private static string VirtualText(AnalysisState state) => state.Virtual.Count == 0
? S.Settings_None
: string.Join(" · ", state.Virtual.Select(v => $"{v.Status.Display()}: {v.Count}"));
}
+328 -72
View File
@@ -1,18 +1,37 @@
@page "/admin/tariffs"
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@using MeterVault.App.TariffEditing
@using MeterVault.Core.Analysis.Quantities
@using MeterVault.Infrastructure.Analysis
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject AnalysisReader Reader
@inject InstanceCurrency Currency
@inject InstanceClock Clock
@inject NavigationManager Nav
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject ILogger<Tariffs> Logger
<PageTitle>MeterVault — Tariffs</PageTitle>
<PageHeader Title="@S.Nav_Tariffs" Description="@S.Tariffs_Description" Class="mb-1">
<Actions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
@S.Tariffs_AddTariff
</MudButton>
</Actions>
</PageHeader>
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-3">@S.Tariffs_NotAppliedNote</MudText>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Tariffs</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add tariff
</MudButton>
</div>
@if (_link.HasScope)
{
@* D-52: a link from a missing-cost explanation scopes the list to what can price that meter or type. *@
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
<div class="d-flex flex-wrap align-center" style="gap:0.25rem 1rem">
<span>@FilterText</span>
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="ShowAll">@S.Tariffs_ShowAll</MudButton>
</div>
</MudAlert>
}
@if (_tariffs is null)
{
@@ -20,49 +39,70 @@
}
else
{
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
var shown = _tariffs.Where(t => _link.Lists(t, MeterEnergyType)).ToList();
<MudTable Items="shown" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Scope</MudTh>
<MudTh>Component</MudTh>
<MudTh>Value</MudTh>
<MudTh>Unit</MudTh>
<MudTh>Valid from</MudTh>
<MudTh>Valid to</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
<MudTh>@S.Common_Scope</MudTh>
<MudTh>@S.Tariffs_Component</MudTh>
<MudTh>@S.Common_Value</MudTh>
<MudTh>@S.Common_Unit</MudTh>
<MudTh>@S.Tariffs_ValidFrom</MudTh>
<MudTh>@S.Tariffs_ValidTo</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
<MudTd DataLabel="Component">@context.Component</MudTd>
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd>
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
<MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
<MudTd DataLabel="@S.Tariffs_Component">
@context.Component.Display()
@if (IsNotApplied(context.Component))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="ml-1">@S.Tariffs_NotAppliedChip</MudChip>
}
</MudTd>
<MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="@S.Common_Unit">
@context.Unit
@if (RowIssue(context) is { } issue)
{
<MudTooltip Text="@issue.Text">
<MudIcon Icon="@(issue.IsError ? Icons.Material.Filled.ErrorOutline : Icons.Material.Filled.WarningAmber)"
Color="@(issue.IsError ? Color.Error : Color.Warning)" Size="Size.Small" Class="ml-1"
Style="vertical-align:middle" aria-label="@issue.Text" />
</MudTooltip>
}
</MudTd>
<MudTd DataLabel="@S.Tariffs_ValidFrom" Style="white-space:nowrap">@Format.Date(context.ValidFrom)</MudTd>
<MudTd DataLabel="@S.Tariffs_ValidTo" Style="white-space:nowrap">@(_effectiveEnds.GetValueOrDefault(context.Id) is { } until ? Format.Date(until) : S.Tariffs_OpenEnded)</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" aria-label="@S.Tariffs_EditTariff" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Tariffs_DeleteTitle" />
</MudTd>
</RowTemplate>
</MudTable>
@if (_tariffs.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
}
else if (shown.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_FilterEmpty</MudAlert>
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
<MudSelect T="TariffScope" Value="_working.ScopeType" ValueChanged="SetScope" Label="@S.Common_Scope" Class="mb-2">
@foreach (var scope in Enum.GetValues<TariffScope>())
{
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
<MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
}
</MudSelect>
@if (_working.ScopeType == TariffScope.EnergyType)
{
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
<MudSelect T="int?" Value="_working.ScopeId" ValueChanged="SetScopeId" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
@@ -71,93 +111,302 @@ else
}
else if (_working.ScopeType == TariffScope.Meter)
{
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
<MudSelect T="int?" Value="_working.ScopeId" ValueChanged="SetScopeId" Label="@S.Common_Meter" Class="mb-2">
@foreach (var m in _meters)
{
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
}
</MudSelect>
}
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
<MudSelect T="TariffComponent" Value="_working.Component" ValueChanged="SetComponent" Label="@S.Tariffs_Component" Class="mb-2">
@foreach (var component in Enum.GetValues<TariffComponent>())
{
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
<MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
}
</MudSelect>
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
@* D-38: a new tariff starts without a value, and saving needs one; a typed 0 is a deliberate free price, said so. *@
<MudNumericField T="double?" @bind-Value="_working.Value" Label="@S.Common_Value" Format="0.####" Class="mb-2"
Required="true" RequiredError="@S.Tariffs_ValueRequired" Immediate="true"
HelperText="@(_working.Value is null ? null : TariffValue.Note(ValueVerdict))" />
<MudTextField T="string" Value="_working.Unit" ValueChanged="SetUnit" Label="@S.Tariffs_UnitLabel" Required="true"
Immediate="true" DebounceInterval="300" Class="mb-1" />
@* D-37: how the unit was read and whether it fits what this tariff would price, before it is saved. *@
<div class="mb-3" aria-live="polite">
@foreach (var (isError, text) in TariffUnitCheck.Describe(Verdict, Currency.Code))
{
<MudText Typo="Typo.caption" Color="@(isError ? Color.Error : Color.Default)" Class="@(isError ? "d-block" : "d-block mv-muted")">@text</MudText>
}
</div>
<MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<Tariff>? _tariffs;
private IReadOnlyDictionary<int, DateOnly?> _effectiveEnds = new Dictionary<int, DateOnly?>();
private List<EnergyType> _energyTypes = [];
private List<Meter> _meters = [];
private AnalysisCatalog? _catalog;
private bool _editOpen;
private EditModel _working = new();
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private TariffDeepLink _link = TariffDeepLink.None;
/// <summary>A new-tariff request from the link, held until the action has been dropped from the address.</summary>
private TariffDeepLink? _pendingNew;
private bool _droppingAction;
[SupplyParameterFromQuery(Name = TariffLinks.ParamScope)]
public string? ScopeParam { get; set; }
[SupplyParameterFromQuery(Name = TariffLinks.ParamId)]
public string? IdParam { get; set; }
[SupplyParameterFromQuery(Name = TariffLinks.ParamComponent)]
public string? ComponentParam { get; set; }
[SupplyParameterFromQuery(Name = TariffLinks.ParamFrom)]
public string? FromParam { get; set; }
/// <summary>A dialog to open once the page is interactive (<c>new</c>); dropped from the address when consumed.</summary>
[SupplyParameterFromQuery(Name = TariffLinks.ParamAction)]
public string? Action { get; set; }
protected override Task OnInitializedAsync() => LoadAsync();
protected override void OnParametersSet()
{
_link = TariffDeepLink.Parse(ScopeParam, IdParam, ComponentParam, FromParam, Action);
if (_link.OpenNew)
{
_pendingNew = _link;
}
else if (string.IsNullOrEmpty(Action))
{
_droppingAction = false;
}
}
/// <summary>
/// Opens the deep-linked new-tariff dialog once the page is interactive (D-52). As on a meter's page, the action is
/// dropped from the address first and the dialog opened when that navigation has come back: a circuit's first
/// location change would otherwise dismiss the dialog, and a reload must not reopen it. The scope stays in the
/// address, so the list stays scoped.
/// </summary>
protected override void OnAfterRender(bool firstRender)
{
if (_pendingNew is not { } request || _tariffs is null)
{
return;
}
if (!string.IsNullOrEmpty(Action))
{
if (!_droppingAction)
{
_droppingAction = true;
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
{
[TariffLinks.ParamAction] = null,
[TariffLinks.ParamComponent] = null,
[TariffLinks.ParamFrom] = null,
}), replace: true);
}
return;
}
_pendingNew = null;
OpenNew(request);
StateHasChanged();
}
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
// By component, then the newest validity first (A-42): a price history is read from the price that applies now.
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenByDescending(t => t.ValidFrom).ToListAsync();
_effectiveEnds = TariffValidity.EffectiveEnds(
_tariffs.Select(t => new TariffSpan(t.Id, t.ScopeType, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo)));
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
try
{
_catalog = await Reader.LoadCatalogAsync();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Without it the unit is still parsed; only the check against the scope's units is skipped.
Logger.LogWarning(ex, "Could not load the meter catalog for the tariff unit check");
_catalog = null;
}
}
private string ScopeLabel(Tariff t) => t.ScopeType switch
{
TariffScope.Global => "Global",
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
TariffScope.Global => S.Tariffs_ScopeGlobal,
TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"),
_ => t.ScopeType.ToString(),
};
private string FilterText => _link.Scope switch
{
TariffScope.Meter => Loc.F(S.Tariffs_FilterMeter, _meters.FirstOrDefault(m => m.Id == _link.ScopeId)?.Name ?? $"#{_link.ScopeId}"),
TariffScope.EnergyType => Loc.F(S.Tariffs_FilterType, _energyTypes.FirstOrDefault(t => t.Id == _link.ScopeId)?.DisplayName ?? $"#{_link.ScopeId}"),
_ => S.Tariffs_FilterGlobal,
};
private int? MeterEnergyType(int meterId) => _meters.FirstOrDefault(m => m.Id == meterId)?.EnergyTypeId;
private static bool IsNotApplied(TariffComponent component) =>
component is TariffComponent.Bonus or TariffComponent.Discount or TariffComponent.Tax;
private void ShowAll() => Nav.NavigateTo(TariffLinks.Path);
/// <summary>What a tariff of this scope and component would price, by unit (D-20, D-34).</summary>
private IReadOnlyList<TariffUnitTarget> TargetsFor(TariffScope scope, int? scopeId, TariffComponent component) =>
_catalog is null
? []
: TariffUnitCheck.TargetsFor(_catalog, scope, scopeId, component, id =>
_energyTypes.FirstOrDefault(t => t.Id == id) is { } type ? (type.DisplayName, type.BaseUnit) : null);
private TariffUnitVerdict Verdict => TariffUnitCheck.Check(
_working.Unit, _working.Component, TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component), Currency.Code);
/// <summary>A stored tariff whose unit would be refused or warned about now, with the first line that says why.</summary>
private (bool IsError, string Text)? RowIssue(Tariff tariff)
{
var verdict = TariffUnitCheck.Check(tariff.Unit, tariff.Component, TargetsFor(tariff.ScopeType, tariff.ScopeId, tariff.Component), Currency.Code);
if (verdict.Kind is TariffUnitVerdictKind.Fits or TariffUnitVerdictKind.NotApplied)
{
return null;
}
var lines = TariffUnitCheck.Describe(verdict, Currency.Code);
var reason = lines.FirstOrDefault(l => l.IsError);
return reason.Text is null ? (false, lines[^1].Text) : (true, reason.Text);
}
private void OpenEdit(Tariff? tariff)
{
_working = tariff is null
? new EditModel { ValidFrom = DateTime.Today }
: new EditModel
{
Id = tariff.Id,
ScopeType = tariff.ScopeType,
ScopeId = tariff.ScopeId,
Component = tariff.Component,
Value = tariff.Value,
Unit = tariff.Unit,
Currency = tariff.Currency,
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
Notes = tariff.Notes,
};
if (tariff is null)
{
OpenNew(TariffDeepLink.None with { Scope = _link.Scope, ScopeId = _link.ScopeId });
return;
}
_working = new EditModel
{
Id = tariff.Id,
ScopeType = tariff.ScopeType,
ScopeId = tariff.ScopeId,
Component = tariff.Component,
Value = tariff.Value,
Unit = tariff.Unit,
UnitTouched = true,
Currency = tariff.Currency,
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
Notes = tariff.Notes,
};
_editOpen = true;
}
/// <summary>A new tariff, prefilled from a link (D-52): its scope, component and first month, and a unit that fits.</summary>
private void OpenNew(TariffDeepLink request)
{
_working = new EditModel
{
ScopeType = request.Scope ?? TariffScope.EnergyType,
ScopeId = request.Scope == TariffScope.Global ? null : request.ScopeId,
Component = request.Component ?? TariffComponent.UnitPrice,
Currency = Currency.Code,
ValidFrom = (request.From ?? Clock.Today).ToDateTime(TimeOnly.MinValue),
};
SuggestUnit();
_editOpen = true;
}
/// <summary>A unit nobody typed follows the scope and component: currency per the priced unit, or per month.</summary>
private void SuggestUnit()
{
if (_working.UnitTouched)
{
return;
}
var target = TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component).FirstOrDefault();
_working.Unit = TariffUnit.Suggest(_working.Component, target?.Unit ?? (_working.Component is TariffComponent.UnitPrice or TariffComponent.FeedIn ? "kWh" : null), Currency.Code);
}
private void SetScope(TariffScope scope)
{
_working.ScopeType = scope;
_working.ScopeId = null;
SuggestUnit();
}
private void SetScopeId(int? id)
{
_working.ScopeId = id;
SuggestUnit();
}
private void SetComponent(TariffComponent component)
{
_working.Component = component;
SuggestUnit();
}
private void SetUnit(string? unit)
{
_working.Unit = unit ?? "";
_working.UnitTouched = true;
}
/// <summary>What the value field holds, for the save guard and the note under it (D-38).</summary>
private TariffValueVerdict ValueVerdict => TariffValue.Check(_working.Value, _working.Component);
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
{
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
Snackbar.Add(S.Tariffs_UnitAndValidFromRequired, Severity.Warning);
return;
}
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
{
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
Snackbar.Add(S.Tariffs_ScopeTargetRequired, Severity.Warning);
return;
}
// D-38: an untouched value is not a price. Without this, the missing-price deep link would save a free period.
if (ValueVerdict.BlocksSave())
{
Snackbar.Add(S.Tariffs_ValueRequired, Severity.Warning);
return;
}
// D-37: a unit that is read and does not fit would leave the cost "unavailable (unit)"; refuse it here instead.
if (Verdict.Blocks)
{
Snackbar.Add(S.Tariffs_UnitBlocked, Severity.Warning);
return;
}
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
var value = _working.Value!.Value;
await using var db = await DbFactory.CreateDbContextAsync();
if (_working.Id == 0)
@@ -167,9 +416,9 @@ else
ScopeType = _working.ScopeType,
ScopeId = scopeId,
Component = _working.Component,
Value = _working.Value,
Value = value,
Unit = _working.Unit.Trim(),
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim(),
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
@@ -181,9 +430,9 @@ else
existing.ScopeType = _working.ScopeType;
existing.ScopeId = scopeId;
existing.Component = _working.Component;
existing.Value = _working.Value;
existing.Value = value;
existing.Unit = _working.Unit.Trim();
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim();
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
@@ -191,13 +440,14 @@ else
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add("Saved.", Severity.Success);
Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync();
}
private async Task DeleteAsync(Tariff tariff)
{
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle,
Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), Format.Number(tariff.Value, 4), tariff.Unit)))
{
return;
}
@@ -208,7 +458,7 @@ else
{
db.Tariffs.Remove(target);
await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success);
Snackbar.Add(S.Common_Deleted, Severity.Success);
}
await LoadAsync();
@@ -220,8 +470,14 @@ else
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
public int? ScopeId { get; set; }
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
public double Value { get; set; }
/// <summary>The price; null until one is typed (a new tariff never defaults to a free 0, D-38).</summary>
public double? Value { get; set; }
public string Unit { get; set; } = "EUR/kWh";
/// <summary>True once the user typed a unit (or it is a stored tariff's): it is no longer re-suggested.</summary>
public bool UnitTouched { get; set; }
public string Currency { get; set; } = "EUR";
public DateTime? ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
@@ -0,0 +1,152 @@
@using MeterVault.App.AnalysisPage
@using MeterVault.Core.Analysis
@using MeterVault.Core.Analysis.Costing
@using MeterVault.Infrastructure.Analysis
@inject InstanceCurrency Currency
@* The period figures of the Analysis page (brief §7.4, D-08): one card per series with its total — the status in words
when there is no number, never a zero — and its change against the comparison over the dates both periods cover. A cost
card says what it is made of (metered use, standing charges, manual costs, feed-in credit), so manual costs are visibly
counted once; a meter's cost names its rule (D-39). *@
<MudGrid Spacing="2" Class="mb-3">
@if (View.Kind == AnalysisPageViewKind.Quantity)
{
@foreach (var series in View.Series)
{
<MudItem xs="12" sm="6" md="4" lg="3">
<MetricCard Title="@View.NameOf(series)" Value="series.Total" Unit="@series.Unit"
Change="series.Comparison?.Change" Polarity="ChangePolarities.For(series.Kind)"
ChangeCaption="@ChangeCaption" Caption="@CaptionOf(series)"
Href="@HrefOf(series)" LinkText="@LinkTextOf(series)" />
</MudItem>
}
@if (View.MeterCost is { } meterCost)
{
<MudItem xs="12" sm="6" md="4" lg="3">
<MetricCard Title="@AnalysisMetric.Cost.Display()" Cost="meterCost.Total" Currency="@meterCost.Currency"
Caption="@RuleOf(meterCost)"
Change="CostChanges.ForCard(View.MeterCostChange)" Polarity="CostChanges.Polarity(View.MeterCostChange)"
ChangeCaption="@CostChanges.Caption(Shown, View.MeterCostChange)" />
</MudItem>
}
}
else
{
@foreach (var cost in View.Costs)
{
<MudItem xs="12" sm="6" md="4" lg="3">
<MetricCard Title="@cost.Name" Cost="cost.Current.Total" Currency="@cost.Current.Currency"
Change="CostChanges.ForCard(cost.CostChange)" Polarity="CostChanges.Polarity(cost.CostChange)"
ChangeCaption="@CostChanges.Caption(Shown, cost.CostChange)" Caption="@CaptionOf(cost)"
Href="@HrefOf(cost)" LinkText="@(HrefOf(cost) is null ? null : S.Analysis_OpenMeter)" />
</MudItem>
}
}
</MudGrid>
@code {
[Parameter, EditorRequired]
public AnalysisPageView View { get; set; } = null!;
[Parameter, EditorRequired]
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
[Parameter, EditorRequired]
public AnalysisQuery Shown { get; set; } = null!;
private string? ChangeCaption => View.Comparison?.IsApplicable == true ? Shown.Comparison.Display() : null;
/// <summary>A meter: its energy type and what it measures; a measure: the meters counted in it (D-22).</summary>
private string? CaptionOf(AnalysisSeries series)
{
if (series.MeterId is { } meterId)
{
var type = series.EnergyTypeId is { } typeId ? Options.TypeName(typeId) : null;
var what = series.Kind.Display();
var calculated = Options.Meter(meterId) is { IsVirtual: true } ? " · " + S.Analysis_Calculated : string.Empty;
return (type is null ? what : type + " · " + what) + calculated;
}
return series.MemberIds.Count > 0
? Loc.F(S.Analysis_Counted, string.Join(", ", series.MemberIds.Select(Options.MeterName)))
: null;
}
/// <summary>A meter's page, or — on the portfolio — the energy type in this page.</summary>
private string? HrefOf(AnalysisSeries series)
{
if (series.MeterId is { } meterId)
{
return View.Selection.Scope.Kind == QueryScopeKind.Meter ? null : MeterLinks.Analysis(meterId, Shown);
}
return View.Selection.Scope.Kind == QueryScopeKind.Portfolio && series.EnergyTypeId is { } typeId
? AnalysisLinks.Analysis(QueryScope.ForEnergyType(typeId), null, Shown)
: null;
}
private string? LinkTextOf(AnalysisSeries series) =>
series.MeterId is not null ? S.Analysis_OpenMeter
: series.EnergyTypeId is { } typeId ? Loc.F(S.Analysis_AnalyseScope, Options.TypeName(typeId))
: null;
/// <summary>What a cost is made of, and for a type its billing basis, for a meter its rule.</summary>
private string? CaptionOf(AnalysisCostSeries cost)
{
var current = cost.Current;
if (current.Meter is { } meter)
{
return RuleOf(current);
}
var parts = PartsOf(current.Total);
if (current.Request.Scope.Kind == CostScopeKind.EnergyType && current.EnergyTypes.FirstOrDefault() is { } type)
{
parts = type.Basis.Display() + (parts is null ? string.Empty : " · " + parts);
}
return parts;
}
private string? HrefOf(AnalysisCostSeries cost) =>
cost.Current.Request.Scope is { Kind: CostScopeKind.Meter, Id: { } meterId } && View.Selection.Scope.Kind != QueryScopeKind.Meter
? MeterLinks.Analysis(meterId, Shown)
: null;
/// <summary>"Metered use 1.234,00 € · Standing charges 96,00 € · Manual costs 800,00 €" — only the parts there are.</summary>
private string? PartsOf(CostAmount total)
{
List<string> parts = [];
if (total.Usage is { } usage)
{
parts.Add(Loc.F(S.Analysis_PartUsage, Currency.Format(usage)));
}
if (total.StandingCharge is { } standing)
{
parts.Add(Loc.F(S.Analysis_PartStanding, Currency.Format(standing)));
}
if (total.Manual is { } manual)
{
parts.Add(Loc.F(S.Analysis_PartManual, Currency.Format(manual)));
}
if (total.FeedInCredit is { } credit)
{
parts.Add(Loc.F(S.Analysis_PartCredit, Currency.Format(credit)));
}
return parts.Count > 0 ? string.Join(" · ", parts) : null;
}
/// <summary>How a meter's cost is formed (D-39), or why it has none.</summary>
private static string? RuleOf(Infrastructure.Costing.CostAnalysis cost) => cost.Meter switch
{
{ Rule: Infrastructure.Costing.MeterCostRule.None } meter => Loc.F(S.Analysis_NotCosted, meter.NotCosted.Display()),
{ } meter => Loc.F(S.Analysis_CostRule, meter.Rule.Display()),
_ => null,
};
}
@@ -0,0 +1,84 @@
@using MeterVault.App.AnalysisPage
@using MeterVault.Infrastructure.Analysis
@* Why the Analysis page shows no figures for its address, and what would work (brief §7.4, §4.3): a scope that no longer
exists, more meters than can be compared, or a cost category whose meters cannot be one quantity — named per kind and
unit, with the comparison of each group and the category's cost one click away. Never a silently different view. *@
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3" role="status">
@switch (Selection.Refusal)
{
case AnalysisPageRefusal.UnknownScope:
<div>@S.Analysis_RefusalUnknownScope</div>
<div class="mv-analysis-refusal__actions">
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="() => Show(Shown.WithScope(QueryScope.Portfolio).WithMetric(null))">
@S.Analysis_ShowAll
</MudButton>
</div>
break;
case AnalysisPageRefusal.TooManyMeters:
<div>@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)</div>
<div class="mv-analysis-refusal__actions">
<MudButton Variant="Variant.Outlined" Size="Size.Small"
OnClick="() => Show(Shown.WithScope(QueryScope.ForMeters(Selection.Scope.MeterIds.Take(AnalysisLimits.MaxSeries))))">
@Loc.F(S.Analysis_CompareFirst, AnalysisLimits.MaxSeries)
</MudButton>
</div>
break;
case AnalysisPageRefusal.CategoryMixed:
<div>@Loc.F(S.Analysis_RefusalCategoryMixed, Selection.ScopeName ?? string.Empty)</div>
<ul class="mv-analysis-refusal__groups">
@foreach (var group in Selection.Groups)
{
<li>
<span>@Loc.F(S.Analysis_Group, group.Kind.Display(), group.Unit, string.Join(", ", group.MeterIds.Select(Options.MeterName)))</span>
@if (group.MeterIds.Count <= AnalysisLimits.MaxSeries)
{
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="() => Show(Shown.WithScope(QueryScope.ForMeters(group.MeterIds)).WithMetric(group.Metric))">
@S.Analysis_CompareGroup
</MudButton>
}
</li>
}
</ul>
<div class="mv-analysis-refusal__actions">@ShowCost</div>
break;
case AnalysisPageRefusal.CategoryWithoutMeters:
<div>@Loc.F(S.Analysis_RefusalCategoryWithoutMeters, Selection.ScopeName ?? string.Empty)</div>
<div class="mv-analysis-refusal__actions">@ShowCost</div>
break;
case AnalysisPageRefusal.CategoryTooManyMeters:
<div>@Loc.F(S.Analysis_RefusalCategoryTooManyMeters, Selection.ScopeName ?? string.Empty, CategoryMeterCount, AnalysisLimits.MaxSeries)</div>
<div class="mv-analysis-refusal__actions">@ShowCost</div>
break;
}
</MudAlert>
@code {
[Parameter, EditorRequired]
public AnalysisSelection Selection { get; set; } = null!;
[Parameter, EditorRequired]
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
[Parameter, EditorRequired]
public AnalysisQuery Shown { get; set; } = null!;
/// <summary>Shows the chosen alternative (replacing the address).</summary>
[Parameter]
public EventCallback<AnalysisQuery> OnShow { get; set; }
private int CategoryMeterCount => Options.Category(Selection.Scope.Id)?.MeterIds.Count ?? 0;
private RenderFragment ShowCost => __builder =>
{
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="() => Show(Shown.WithMetric(null))">@S.Analysis_ShowCost</MudButton>
};
private Task Show(AnalysisQuery query) => OnShow.InvokeAsync(query);
}
@@ -0,0 +1,3 @@
.mv-analysis-refusal__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.mv-analysis-refusal__groups { margin: 8px 0 0 0; padding-left: 1.25rem; }
.mv-analysis-refusal__groups li { overflow-wrap: anywhere; }
@@ -0,0 +1,61 @@
@using MeterVault.App.AnalysisPage
@using MeterVault.Core.Analysis.Totals
@using MeterVault.Infrastructure.Analysis
@* What the figures of the Analysis page are — and are not (brief §6.1, D-22, D-42): a category's meters are shown side by
side and never added; a comparison names the selected meters it does not show for this measure; total use and grid
import are side by side, never summed; an overlapping category is a view on the bill, not a slice of it. *@
@if (_notes.Count > 0)
{
<ul class="mv-analysis-notes mb-3">
@foreach (var note in _notes)
{
<li>
<MudIcon Icon="@Icons.Material.Outlined.Info" Size="Size.Small" aria-hidden="true" />
<span>@note</span>
</li>
}
</ul>
}
@code {
[Parameter, EditorRequired]
public AnalysisPageView View { get; set; } = null!;
[Parameter, EditorRequired]
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
private List<string> _notes = [];
protected override void OnParametersSet()
{
var selection = View.Selection;
_notes = [];
if (selection.ShowsMeters && selection.Scope.Kind == QueryScopeKind.Category && View.Series.Count > 1)
{
_notes.Add(S.Analysis_CategorySideBySide);
}
if (selection.HiddenMeterIds.Count > 0)
{
var names = string.Join(", ", selection.HiddenMeterIds.Select(Options.MeterName));
_notes.Add(selection.IsCost
? Loc.F(S.Analysis_NotShownNoCost, names)
: Loc.F(S.Analysis_NotShownForMetric, selection.Metric?.Display() ?? string.Empty, names));
}
// Total use and grid import answer different questions: side by side, never added (D-22).
if (!selection.ShowsMeters && View.Kind == AnalysisPageViewKind.Quantity
&& View.Series.Any(s => s.Key.Measure == TotalsMeasure.Use) && View.Series.Any(s => s.Key.Measure == TotalsMeasure.GridImport))
{
_notes.Add(S.Analysis_UseAndGridApart);
}
if (View.Costs.FirstOrDefault()?.Current.Category is { IsOverlappingView: true })
{
_notes.Add(S.Analysis_OverlappingView);
}
}
}
@@ -0,0 +1,3 @@
.mv-analysis-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.mv-analysis-notes li { display: flex; align-items: flex-start; gap: 6px; font-size: 0.875rem; color: var(--mud-palette-text-secondary); }
.mv-analysis-notes li span { min-width: 0; overflow-wrap: anywhere; }
@@ -0,0 +1,172 @@
@using MeterVault.App.AnalysisPage
@using MeterVault.Core.Analysis
@using MeterVault.Core.Analysis.Costing
@using MeterVault.Infrastructure.Analysis
@inject NavigationManager Nav
@* Everything the Analysis page shows for one committed load (brief §7.4, §4.3): an explanation instead of figures when the
selection cannot be one quantity, the attention items, the empty and pending states, the period figures with their
change, which dates are compared, the chart (a click drills into a bucket, D-51) and the table with its drill-down
links. Everything comes from the one view, so a title never sits above another selection's chart. *@
@if (View.Selection.Refusal != AnalysisPageRefusal.None)
{
<AnalysisExplanation Selection="View.Selection" Options="Options" Shown="Shown" OnShow="OnShow" />
}
else if (View.ReaderRefusal == AnalysisRefusal.TooManySeries)
{
<MudAlert Severity="Severity.Warning" Class="mb-3">@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)</MudAlert>
}
else if (View.ReaderRefusal == AnalysisRefusal.TooManyPoints)
{
@* The toolbar says which interval would work and offers it. *@
<MudText Typo="Typo.body2" Class="mv-muted">@S.Analysis_ChooseCoarserBucket</MudText>
}
else
{
<AttentionList Problems="View.Problems" CostAttention="View.CostAttention" Names="_names" Query="Shown" MaxItems="5" Class="mb-3" />
@if (View.NotYetOccurred)
{
<EmptyPeriodState NotYetOccurred="true" />
}
else if (View.IsPending)
{
<PendingState OnRefresh="OnRefresh" />
}
else if (View.HasNoData)
{
<EmptyPeriodState Availability="View.Availability" LatestHref="@LatestHref">
@if (View.Availability is null)
{
@* Nothing at all yet: where data comes from. With older data, the dates and "Go to latest data" say it. *@
<MudText Typo="Typo.body2" Class="mv-muted mt-1">
@S.Analysis_NoDataYetHint
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
</MudText>
}
</EmptyPeriodState>
}
else
{
<AnalysisCards View="View" Options="Options" Shown="Shown" />
<ComparisonSummary Period="View.Period" Resolution="View.Comparison" Matched="View.Matched" Class="mb-3" />
<AnalysisNotes View="View" Options="Options" />
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
<MudText Typo="Typo.subtitle1" Class="mb-2">@ChartTitle</MudText>
<AnalysisChart Buckets="View.Plan!.Buckets" Series="View.Chart" ComparisonPairs="View.Pairs" Title="@ChartTitle"
OnBucketClick="_onBucketClick" Resolution="View.Resolution" OnUseBucket="size => OnShow.InvokeAsync(Shown.WithBucket(size))" />
@if (HiddenOverlays)
{
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.Analysis_ComparisonInTable, AnalysisPageLoader.MaxOverlaidSeries)</MudText>
}
</MudPaper>
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
<MudText Typo="Typo.subtitle1" Class="mb-2">@S.Analysis_TableTitle</MudText>
<AnalysisTable Buckets="View.Plan!.Buckets" Series="View.Table" ComparisonPairs="View.Pairs" DrillHref="_drillHref"
Caption="@Loc.F(S.Analysis_TableCaption, ChartTitle)" />
</MudPaper>
@if (View.Series is [{ Basis: SeriesBasis.Virtual or SeriesBasis.LegacyVirtual } only])
{
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
<SeriesContributions Series="only" Query="Shown" MeterName="@(id => Options.Meter(id)?.Name)"
UnitOfSource="@(id => Options.Meter(id)?.Unit)" />
</MudPaper>
}
}
}
@code {
[Parameter, EditorRequired]
public AnalysisPageView View { get; set; } = null!;
[Parameter, EditorRequired]
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
/// <summary>The address as shown when the view was read: drill-downs and links build on it.</summary>
[Parameter, EditorRequired]
public AnalysisQuery Shown { get; set; } = null!;
/// <summary>The page defaults, which an address leaves out.</summary>
[Parameter, EditorRequired]
public AnalysisDefaults Defaults { get; set; } = null!;
/// <summary>Shows another state (an action of an explanation), replacing the address.</summary>
[Parameter]
public EventCallback<AnalysisQuery> OnShow { get; set; }
/// <summary>Reads the same state again (analysis being prepared: check whether it is ready).</summary>
[Parameter]
public EventCallback OnRefresh { get; set; }
private AttentionNames _names = new();
private EventCallback<AnalysisBucket> _onBucketClick;
private Func<AnalysisBucket, string?>? _drillHref;
protected override void OnParametersSet()
{
_names = new AttentionNames(Options.MeterNames, Options.TypeNames, Options.Categories.ToDictionary(c => c.Id, c => c.Name));
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): a
// comparison of monthly imports has nothing finer to open.
var drills = View.Plan is { } plan && plan.Buckets.Any(b => DrillHref(b) is not null);
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, DrillAsync) : default;
_drillHref = drills ? DrillHref : null;
}
/// <summary>"Consumption — Strom", "Cost — All energy types".</summary>
private string ChartTitle
{
get
{
var selection = View.Selection;
var what = selection.Metric?.Display() ?? View.Series.FirstOrDefault()?.Kind.Display() ?? string.Empty;
var scope = selection.ScopeName ?? selection.Scope.Kind.Display();
return what.Length == 0 ? scope : what + " — " + scope;
}
}
private bool HiddenOverlays =>
View.Pairs is not null && (View.Kind == AnalysisPageViewKind.Cost ? View.Costs.Count : View.Series.Count) > AnalysisPageLoader.MaxOverlaidSeries;
private string? LatestHref =>
AnalysisNavigation.LatestData(Shown, View.Availability) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null;
/// <summary>
/// Where a bucket leads (D-51): the same view over the bucket in the next finer size the data resolves; for one meter
/// whose data is too coarse for that, its records of the bucket; otherwise nowhere.
/// </summary>
private string? DrillHref(AnalysisBucket bucket)
{
if (AnalysisNavigation.DrillInto(Shown, bucket, View.Resolution) is { } next)
{
return AnalysisNavigation.UriFor(Nav, next, Defaults);
}
if (View.Selection.Scope.Kind == QueryScopeKind.Meter && View.Selection.Scope.Id is { } meterId)
{
var (first, last) = AnalysisNavigation.DaysOf(bucket);
return Options.Meter(meterId) is { IsVirtual: true }
? MeterLinks.Analysis(meterId, PeriodResolver.IsValidCustomRange(first, last) ? Shown.WithCustomRange(first, last) : Shown)
: AnalysisNavigation.NormalizedData(meterId, Shown, bucket);
}
return null;
}
private Task DrillAsync(AnalysisBucket bucket)
{
// A drill-down is a new history entry (D-46): Back returns to the coarser view.
if (DrillHref(bucket) is { } href)
{
Nav.NavigateTo(href);
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,20 @@
@* A group heading inside a MudSelect's list (the meters of one energy type). MudSelect renders its items twice — once in
a hidden "shadow" pass that only registers them, once in the dropdown — and a plain MudListSubheader would show up in
the page from the first pass. This one renders only in the dropdown. *@
@if (!HideContent)
{
<MudListSubheader Class="mv-picker-group">@ChildContent</MudListSubheader>
}
@code {
/// <summary>
/// True in MudSelect's hidden registration pass, where nothing may be drawn: MudSelect cascades it by this name to its
/// items (MudSelectItem.HideContent).
/// </summary>
[CascadingParameter(Name = "HideContent")]
public bool HideContent { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,268 @@
@using MeterVault.App.AnalysisPage
@using MeterVault.Infrastructure.Analysis
@* What the Analysis page analyses (brief §7.4, D-47): everything, an energy type, a cost category, one meter or up to
six meters side by side — and which measure of it. Meters are picked by name, grouped by energy type, with calculated
and retired meters marked. Nothing here navigates: every choice raises QueryChanged, and the page writes it into its
address (replace), so reload, Back and a shared link restore it. A seventh meter is refused with an explanation,
never dropped silently. *@
<div class="mv-scope" role="group" aria-label="@S.Analysis_ScopeGroup">
<div class="mv-scope__field">
<MudSelect T="QueryScopeKind" Value="Selection.Scope.Kind" ValueChanged="OnKindChanged" Label="@S.Analysis_ScopeLabel"
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" ToStringFunc="@(k => k.Display())">
@foreach (var kind in Kinds)
{
<MudSelectItem T="QueryScopeKind" Value="kind" Disabled="@(!IsOffered(kind))">@kind.Display()</MudSelectItem>
}
</MudSelect>
</div>
@switch (Selection.Scope.Kind)
{
case QueryScopeKind.EnergyType:
<div class="mv-scope__field">
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForEnergyType(id))"
Label="@QueryScopeKind.EnergyType.Display()" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
ToStringFunc="@(id => Options.Type(id)?.Name ?? string.Empty)">
@foreach (var type in Options.Types)
{
<MudSelectItem T="int" Value="type.Id">@type.Name</MudSelectItem>
}
</MudSelect>
</div>
break;
case QueryScopeKind.Category:
<div class="mv-scope__field">
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForCategory(id))"
Label="@QueryScopeKind.Category.Display()" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
ToStringFunc="@(id => Options.Category(id)?.Name ?? string.Empty)">
@foreach (var category in Options.Categories)
{
<MudSelectItem T="int" Value="category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
</div>
break;
case QueryScopeKind.Meter:
<div class="mv-scope__field mv-scope__field--wide">
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForMeter(id))"
Label="@S.Common_Meter" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
ToStringFunc="@(id => MeterLabel(id))" MaxHeight="420">
@MeterItems
</MudSelect>
</div>
break;
case QueryScopeKind.Meters:
<div class="mv-scope__field mv-scope__field--wide">
<MudSelect @key="_pickerGeneration" T="int" MultiSelection="true" SelectedValues="_meters" SelectedValuesChanged="OnMetersChanged"
Label="@Loc.F(S.Analysis_MetersLabel, AnalysisLimits.MaxSeries)" Variant="Variant.Outlined"
Margin="Margin.Dense" Dense="true" MaxHeight="420"
MultiSelectionTextFunc="@(ids => MetersText(ids))"
Error="@(_meterHint is not null)" ErrorText="@_meterHint">
@MeterItems
</MudSelect>
</div>
break;
}
@if (Selection.Metric is { } shown && (Selection.Metrics.Count > 1 || !Selection.Metrics.Contains(shown)))
{
<div class="mv-scope__field">
<MudSelect T="AnalysisMetric" Value="shown" ValueChanged="OnMetricChanged" Label="@S.Toolbar_Metric"
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" ToStringFunc="@(m => m.Display())">
@* A category quantity that cannot be shown is still what the address asks for: listed, so the way back to
the cost is one choice away. *@
@foreach (var metric in Selection.Metrics.Contains(shown) ? Selection.Metrics : [shown, .. Selection.Metrics])
{
<MudSelectItem T="AnalysisMetric" Value="metric">@metric.Display()</MudSelectItem>
}
</MudSelect>
</div>
}
</div>
@code {
/// <summary>The meters, energy types and categories to choose from.</summary>
[Parameter, EditorRequired]
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
/// <summary>The page's current reading of its address.</summary>
[Parameter, EditorRequired]
public AnalysisSelection Selection { get; set; } = null!;
/// <summary>The address as shown (<see cref="AnalysisSelection.Shown"/>): every choice starts from it.</summary>
[Parameter, EditorRequired]
public AnalysisQuery Query { get; set; } = null!;
/// <summary>Raised with the new state; the page writes it into its address (replace).</summary>
[Parameter]
public EventCallback<AnalysisQuery> QueryChanged { get; set; }
private static readonly IReadOnlyList<QueryScopeKind> Kinds =
[QueryScopeKind.Portfolio, QueryScopeKind.EnergyType, QueryScopeKind.Category, QueryScopeKind.Meter, QueryScopeKind.Meters];
private IReadOnlyCollection<int> _meters = [];
private AnalysisQuery? _synced;
private string? _meterHint;
/// <summary>Re-creates the multi-select after a refused pick, so it drops the box it ticked on its own.</summary>
private int _pickerGeneration;
protected override void OnParametersSet()
{
// The multi-select follows the address; a new address also clears the reason for a refused pick.
if (Query != _synced)
{
_synced = Query;
_meters = Selection.Scope.Kind == QueryScopeKind.Meters ? [.. Selection.Scope.MeterIds] : [];
_meterHint = null;
}
}
private bool IsOffered(QueryScopeKind kind) => kind switch
{
QueryScopeKind.EnergyType => Options.Types.Count > 0,
QueryScopeKind.Category => Options.Categories.Count > 0,
QueryScopeKind.Meter or QueryScopeKind.Meters => Options.Meters.Count > 0,
_ => true,
};
/// <summary>The meters grouped by energy type, calculated and retired ones marked.</summary>
private RenderFragment MeterItems => __builder =>
{
foreach (var group in Options.Meters.GroupBy(m => m.EnergyTypeId))
{
<PickerGroupHeader>@Options.TypeName(group.Key)</PickerGroupHeader>
foreach (var meter in group)
{
<MudSelectItem T="int" Value="meter.Id">@MeterLabel(meter.Id)</MudSelectItem>
}
}
};
private string MeterLabel(int id)
{
if (Options.Meter(id) is not { } meter)
{
return Options.MeterName(id);
}
var label = meter.IsVirtual ? Loc.F(S.Analysis_CalculatedMeter, meter.Name) : meter.Name;
return meter.IsRetired ? Loc.F(S.Analysis_RetiredMeter, label) : label;
}
private string MetersText(IReadOnlyList<string?> ids) =>
string.Join(", ", ids.Select(text =>
int.TryParse(text, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var id) ? MeterLabel(id) : text));
private async Task OnKindChanged(QueryScopeKind kind)
{
if (kind == Selection.Scope.Kind)
{
return;
}
var current = Selection.Scope;
var meterIds = current.MeterIds.Where(id => Options.Meter(id) is not null).ToList();
var typeId = Selection.EnergyTypeId ?? meterIds.Select(id => Options.Meter(id)!.EnergyTypeId).Cast<int?>().FirstOrDefault();
QueryScope? next = kind switch
{
QueryScopeKind.Portfolio => QueryScope.Portfolio,
QueryScopeKind.EnergyType => (typeId ?? Options.Types.FirstOrDefault()?.Id) is { } type ? QueryScope.ForEnergyType(type) : null,
QueryScopeKind.Category => Options.Categories.FirstOrDefault() is { } category ? QueryScope.ForCategory(category.Id) : null,
QueryScopeKind.Meter => FirstMeter(meterIds, typeId) is { } meter ? QueryScope.ForMeter(meter) : null,
_ => MetersFor(meterIds, typeId) is { Count: > 0 } ids ? QueryScope.ForMeters(ids) : null,
};
if (next is not null)
{
await ChangeScope(next);
}
}
/// <summary>The meter a switch to one meter starts with: the one shown, else the type's first, else the first.</summary>
private int? FirstMeter(IReadOnlyList<int> meterIds, int? typeId) =>
meterIds.Count > 0 ? meterIds[0]
: (Options.Meters.FirstOrDefault(m => m.EnergyTypeId == typeId && !m.IsVirtual && !m.IsRetired)
?? Options.Meters.FirstOrDefault(m => !m.IsVirtual && !m.IsRetired)
?? Options.Meters.FirstOrDefault())?.Id;
/// <summary>
/// The meters a switch to a comparison starts with: those shown, else the energy type's meters of the measure shown
/// (when they fit), else one meter to add others to.
/// </summary>
private List<int> MetersFor(IReadOnlyList<int> meterIds, int? typeId)
{
if (meterIds.Count > 0)
{
return [.. meterIds];
}
var ofType = Options.Meters
.Where(m => m.EnergyTypeId == typeId && !m.IsRetired && (Selection.IsCost ? m.IsCostable : m.Metric == Selection.Metric))
.Select(m => m.Id)
.ToList();
if (ofType.Count is > 0 and <= AnalysisLimits.MaxSeries)
{
return ofType;
}
return FirstMeter([], typeId) is { } first ? [first] : [];
}
private async Task ChangeScope(QueryScope scope)
{
if (scope.Equals(Selection.Scope))
{
return;
}
// The measure goes along when the new scope has it; otherwise the new scope's natural one.
var next = Query.WithScope(scope);
if (next.Metric is { } metric && !AnalysisSelection.Resolve(next, Options).Metrics.Contains(metric))
{
next = next.WithMetric(null);
}
await QueryChanged.InvokeAsync(next);
}
private async Task OnMetersChanged(IReadOnlyCollection<int> values)
{
var chosen = values.ToList();
if (chosen.Count > AnalysisLimits.MaxSeries)
{
// Refused with the reason; the selection stays as it was.
_meterHint = Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries);
_meters = [.. Selection.Scope.MeterIds];
_pickerGeneration++;
return;
}
if (chosen.Count == 0)
{
_meterHint = S.Analysis_AtLeastOneMeter;
_meters = [.. Selection.Scope.MeterIds];
_pickerGeneration++;
return;
}
_meterHint = null;
_meters = chosen;
// Keep the order in which meters were picked: the ones already shown first.
var ordered = Selection.Scope.MeterIds.Where(chosen.Contains).Concat(chosen.Where(id => !Selection.Scope.MeterIds.Contains(id))).ToList();
await ChangeScope(QueryScope.ForMeters(ordered));
}
private async Task OnMetricChanged(AnalysisMetric metric)
{
if (metric != Selection.Metric)
{
await QueryChanged.InvokeAsync(Query.WithMetric(metric == Selection.NaturalMetric ? null : metric));
}
}
}
@@ -0,0 +1,7 @@
/* The "what" row of the Analysis page: wraps like the period toolbar, one field per row on a phone. */
.mv-scope { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 12px; }
.mv-scope__field { flex: 1 1 180px; min-width: 160px; max-width: 280px; }
.mv-scope__field--wide { flex: 2 1 280px; max-width: 560px; }
@media (max-width: 599.98px) {
.mv-scope__field, .mv-scope__field--wide { flex: 1 1 100%; min-width: 0; max-width: none; }
}
+101 -160
View File
@@ -1,185 +1,126 @@
@page "/consumables"
@using MeterVault.App.Components.Pages.Specialized
@using MeterVault.Core.Analysis
@implements IDisposable
@inject NavigationManager Nav
@inject InstanceClock Clock
@inject ConsumableService ConsumablesSvc
@using MudBlazor
@inject ILogger<Consumables> Logger
<PageTitle>MeterVault — Consumables</PageTitle>
@* Tanks & consumables (brief §7.5, D-54): the shared header, toolbar and missing-data semantics around every tank's
specialised measures. Each tank keeps its state now — the last dipstick as measured, the contents estimated from it,
the forecast as a projection — apart from the selected period, which has its own usage, deliveries, burner runtime,
cost and, for a period that is over, the contents at its end. *@
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Oil / consumables</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
<PageHeader Title="@S.Nav_Consumables" Description="@S.Consumables_Description">
<Breadcrumbs>
<AnalysisBreadcrumbs Query="_query" Current="@S.Nav_Consumables" />
</Breadcrumbs>
</PageHeader>
@if (_items is null)
@if (_query is not null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
<PeriodToolbar Query="_query" Period="_state.Value?.Analysis.Period" Plan="_state.Value?.Analysis.Plan" Defaults="Defaults"
QueryChanged="OnQueryChanged" ExportHref="@_state.Value?.ExportHref" Class="mb-4" />
}
else if (_items.Count == 0)
{
<MudAlert Severity="Severity.Info">
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
data from <MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
@foreach (var item in _items)
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view">
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised — but it must not just
be missing from the page, with nothing saying where it went. *@
@foreach (var meter in view.Analysis.Unconfigured)
{
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
<MudText Typo="Typo.h5">
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
</MudText>
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
{
<text> · @Format.Number(cm, 0) cm</text>
}
@if (item.LevelAsOf is { } asOf)
{
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
}
</MudText>
</MudItem>
<MudItem xs="12" md="8">
<MudGrid>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
<MudText Typo="Typo.subtitle1">
@if (item.FixedRate is { } fr)
{
<text>@Format.Number(fr, 2) @item.Unit/h</text>
}
else if (item.EffectiveRate is { } er)
{
<text>@Format.Number(er, 2) @item.Unit/h</text>
}
else
{
<text>—</text>
}
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
<MudText Typo="Typo.subtitle1">
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
@if (item.AveragePerDay is { } apd)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
(@Format.Number(apd, 1) @item.Unit/day)
</MudText>
}
</MudText>
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
</MudItem>
<MudItem xs="12" md="5">
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
@if (item.Deliveries.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
}
else
{
<div style="max-height:260px; overflow-y:auto">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
</thead>
<tbody>
@foreach (var delivery in item.Deliveries)
{
<tr>
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
}
</MudItem>
</MudGrid>
</MudPaper>
<MudAlert Severity="Severity.Warning" Class="mb-3">
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.Consumables_SetUpTank</MudButton>
</MudAlert>
}
}
@if (view.Analysis.Tanks.Count == 0 && view.Analysis.Unconfigured.Count == 0)
{
<div class="mv-empty" role="status">
<MudIcon Icon="@Icons.Material.Outlined.PropaneTank" Class="mv-empty__icon" aria-hidden="true" />
<div class="mv-empty__body">
<MudText Typo="Typo.subtitle1">@S.Consumables_NoTanksTitle</MudText>
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.Consumables_NoTanksHelp, MeterMode.ConsumableBalance.Display())</MudText>
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/meters">@S.Nav_Meters</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" Href="/import">@S.Nav_Import</MudButton>
</div>
</div>
</div>
}
else if (!view.Analysis.IsRefused)
{
@foreach (var tank in view.Tanks)
{
<TankSection @key="tank.Tank.MeterId" View="tank" Query="view.Query" Period="view.Analysis.Period"
Problems="ProblemsOf(view.Analysis, tank.Tank)" Currency="@view.Analysis.Currency" OnRefresh="RetryAsync" />
}
}
</LoadPanel>
@code {
private int _months = 60;
private bool _loading;
private IReadOnlyList<ConsumableSummary>? _items;
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History;
protected override Task OnInitializedAsync() => LoadAsync();
private readonly LoadSequencer _loads = new();
private readonly LoadState<ConsumablesPageView> _state = new();
private AnalysisQuery? _query;
private async Task OnRangeChanged(int months)
/// <summary>One committed result: the query it answers, the read model, every tank's series and the export link.</summary>
private sealed record ConsumablesPageView(AnalysisQuery Query, ConsumableAnalysis Analysis, IReadOnlyList<TankView> Tanks, string? ExportHref);
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync);
private async Task ReloadIfChangedAsync()
{
_months = months;
await LoadAsync();
}
private async Task LoadAsync()
{
if (_loading)
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
if (query == _query)
{
return;
}
_loading = true;
_items = null;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = asOf.AddMonths(-_months);
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
}
finally
{
_loading = false;
}
_query = query;
await LoadAsync(query);
StateHasChanged();
}
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query);
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
{
var points = item.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
.ToList();
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
// "Now" is read once per load (D-01); "all" spans what the tanks have data for (D-19).
var now = Clock.Now;
var availability = query.Period == PeriodPreset.AllHistory ? await ConsumablesSvc.GetAvailabilityAsync(now, token) : null;
var period = query.Resolve(now, ConsumablesSvc.Zone, availability);
var analysis = await ConsumablesSvc.GetAsync(new ConsumableRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token);
var tanks = analysis.Tanks.Select(t => TankView.Build(t, analysis.Quantities, query, analysis.Currency)).ToList();
// The CSV export of what the tables show: every tank's usage (D-55), within the chart's series limit.
var ids = analysis.Tanks.Select(t => t.MeterId).ToList();
var export = ids.Count is > 0 and <= MeterVault.Infrastructure.Analysis.AnalysisLimits.MaxSeries
? AnalysisLinks.Export(query.WithScope(QueryScope.ForMeters(ids)).WithMetric(AnalysisMetric.Consumption))
: null;
return new ConsumablesPageView(query, analysis, tanks, export);
}, Logger);
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
/// <summary>The reader's problems about one tank and the burners it feeds.</summary>
private static IReadOnlyList<MeterVault.Infrastructure.Analysis.AnalysisProblem> ProblemsOf(ConsumableAnalysis analysis, TankAnalysis tank)
{
var ids = tank.Runtime.Select(r => r.MeterId).OfType<int>().Append(tank.MeterId).ToHashSet();
var problems = (analysis.Quantities?.Problems ?? []).Concat(tank.Cost?.QuantityProblems ?? []);
return [.. problems.Where(p => p.MeterId is { } id ? ids.Contains(id) : p.MeterIds.Any(ids.Contains))];
}
private static Color FillColor(double fraction) => fraction switch
public void Dispose()
{
< 0.15 => Color.Error,
< 0.30 => Color.Warning,
_ => Color.Success,
};
Nav.LocationChanged -= OnLocationChanged;
_loads.Dispose();
}
}
+234 -84
View File
@@ -1,101 +1,251 @@
@page "/"
@using MeterVault.App.Components.Pages.Overview
@using MeterVault.Core.Analysis
@using MeterVault.Core.Analysis.Coverage
@using Microsoft.AspNetCore.Components.Routing
@inject NavigationManager Nav
@inject InstanceClock Clock
@inject AnalysisPeriods Periods
@inject DashboardService Dash
@inject ILogger<Dashboard> Logger
@implements IDisposable
<PageTitle>MeterVault — Overview</PageTitle>
@* The Overview (brief §7.1): what happened in the chosen period, what changed, and where to look — the portfolio's
quantities per energy type in their own units and the bill for the same resolved period and buckets, with the
comparison over the coverage both periods share. The toolbar's range applies to every panel; a period without data
says so and offers the latest data as its own period instead of silently showing it (D-19). The update banner sits in
the header, apart from the analytical status. *@
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
<PageHeader Title="@S.Nav_Overview" Description="@S.Overview_Description">
<UpdateBanner />
</PageHeader>
@if (_summary is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudGrid>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
<DeltaChip Kpi="_summary.Month" />
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
<DeltaChip Kpi="_summary.Year" />
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
</MudPaper>
</MudItem>
<div class="mv-ov">
<PeriodToolbar Query="_query!" Period="_state.Value?.Period" Plan="_state.Value?.Data.Plan" Defaults="Defaults"
QueryChanged="OnQueryChanged" ExportHref="@ExportHref" />
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
@if (_breakdown is { Count: > 0 })
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view" Class="mt-2">
@if (!view.Data.IsRefused)
{
<OverviewCoverage Data="view.Data" />
}
@if (view.Data.IsRefused)
{
@* The toolbar explains the refused bucket size and offers a coarser one; nothing was read. *@
}
else if (view.Data.IsPending)
{
<PendingState OnRefresh="RetryAsync" Class="mb-4" />
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names" Query="view.Query" />
}
else if (view.Data.NotYetOccurred || view.Data.HasNoData)
{
<EmptyPeriodState NotYetOccurred="view.Data.NotYetOccurred" Availability="view.Data.Availability"
LatestHref="@LatestHref(view)" Class="mb-4">
@if (view.Data.Availability is null)
{
<CategoryDonut Slices="_breakdown" />
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<tbody>
@foreach (var slice in _breakdown)
{
<tr>
<td>@slice.Name</td>
<td style="text-align:right">@Format.Euro(slice.Cost)</td>
</tr>
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.body2" Class="mv-muted mt-1">
@S.Dashboard_SetupNoMeters
<MudLink Href="/meters" Typo="Typo.body2">@S.Nav_Meters</MudLink> ·
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
</thead>
<tbody>
@foreach (var row in _difference)
</EmptyPeriodState>
@if (view.Data.Types.Count > 0)
{
<nav class="mv-ov-typelinks mb-4" aria-label="@S.Nav_EnergyTypes">
<span class="mv-muted">@S.Overview_TypesInPeriod</span>
@foreach (var type in view.Data.Types)
{
<MudLink Href="@AnalysisLinks.EnergyType(type.Type.Id, null, view.Query)" Typo="Typo.body2">@type.Type.Name</MudLink>
}
</nav>
}
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names" Query="view.Query" MaxItems="5" />
}
else
{
<MudGrid Spacing="3">
<MudItem xs="12" sm="6" lg="3">
<div class="mv-ov-lead">
<OverviewCostCard Data="view.Data" Query="view.Query" />
@if (HasAttention(view))
{
<tr>
<td>@row.Name</td>
<td style="text-align:right">@Format.Euro(row.Current)</td>
<td style="text-align:right">@Format.Euro(row.Previous)</td>
<td style="text-align:right" class="@(row.Delta >= 0 ? "mv-up" : "mv-down")">
@Format.DirectionIcon(Math.Sign(row.Delta)) @Format.Euro(Math.Abs(row.Delta))
</td>
</tr>
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-attention">
@if (view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs)
{
<div class="mb-3">
<h2 class="mud-typography mud-typography-subtitle2 mb-1">@S.Overview_SetupTitle</h2>
<MudText Typo="Typo.body2">
@if (view.Data.Setup.FirstGap == CostSetupGap.NoMeters)
{
@S.Dashboard_SetupNoMeters
<MudLink Href="/meters" Typo="Typo.body2">@S.Nav_Meters</MudLink><span> · </span>
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
}
else
{
@S.Dashboard_SetupNoTariffs
<MudLink Href="/admin/tariffs" Typo="Typo.body2">@S.Nav_Tariffs</MudLink>
}
</MudText>
</div>
}
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names"
Query="view.Query" MaxItems="4" />
</MudPaper>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
}
</div>
</MudItem>
@foreach (var type in view.Data.Types)
{
<MudItem xs="12" sm="6" lg="3" @key="type.Type.Id">
<OverviewTypeCard Figures="type" Query="view.Query" Currency="@view.Currency" Zone="view.Period.Zone" />
</MudItem>
}
</MudGrid>
@if (TypesWithoutMeters(view) is { Count: > 0 } empty)
{
<p class="mv-ov-typelinks mt-2">
<span class="mv-muted">@S.Overview_TypesWithoutMeters</span>
@foreach (var type in empty)
{
<MudLink Href="@AnalysisLinks.EnergyType(type.Id, null, view.Query)" Typo="Typo.body2">@type.Name</MudLink>
}
</p>
}
@* The ranges compared; the matched stretch only when less than the whole periods is compared (D-07). *@
<ComparisonSummary Period="view.Period" Resolution="view.Data.Comparison"
Matched="@(view.Data.CostChange.IsPartial || view.Data.CostChange.Basis == CostChangeBasis.NotComparable ? view.Data.CostChange.Matched : null)"
Subject="@S.AnalysisTable_Cost" Class="mt-3" />
<MudGrid Spacing="3" Class="mt-1">
<MudItem xs="12">
<OverviewHistory View="view" ChartKey="@_chartKey" ChartKeyChanged="OnChartKeyChanged" OnDrill="Drill" />
</MudItem>
<MudItem xs="12" lg="7">
<OverviewChanges View="view" />
</MudItem>
<MudItem xs="12" lg="5">
<OverviewComposition View="view" />
</MudItem>
</MudGrid>
}
</LoadPanel>
</div>
@code {
private DashboardSummary? _summary;
private IReadOnlyList<CategorySlice> _breakdown = [];
private IReadOnlyList<DifferenceRow> _difference = [];
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.Overview;
protected override async Task OnInitializedAsync()
private readonly LoadSequencer _loads = new();
private readonly LoadState<OverviewView> _state = new();
private AnalysisQuery? _query;
private AnalysisQuery? _requested;
private string? _chartKey;
private string? ExportHref
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
_summary = await Dash.GetSummaryAsync(asOf);
get
{
if (_query is null)
{
return null;
}
var yearStart = new DateOnly(asOf.Year, 1, 1);
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
var option = _state.Value?.Option(_chartKey);
var scoped = _query.WithScope(option?.Scope ?? QueryScope.Portfolio).WithMetric(option?.Metric ?? AnalysisMetric.Cost);
return AnalysisLinks.Export(scoped);
}
}
protected override void OnInitialized()
{
_query = AnalysisQuery.Parse(Nav.Uri, Defaults);
_chartKey = OverviewView.ChartKeyOf(Nav.Uri);
Nav.LocationChanged += OnLocationChanged;
}
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
{
_chartKey = OverviewView.ChartKeyOf(Nav.Uri);
StateHasChanged();
await ReloadIfChangedAsync();
StateHasChanged();
});
/// <summary>Loads when the analysis state changed; a chart selection alone is not a new analysis (D-46).</summary>
private async Task ReloadIfChangedAsync()
{
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
if (query == _requested)
{
return;
}
_requested = query;
_query = query;
await LoadAsync(query);
}
private Task RetryAsync() => LoadAsync(_query!);
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
{
// "Now" once per load (D-01); the whole page answers this one period.
var now = Clock.Now;
var period = query.Period == PeriodPreset.AllHistory
? query.Resolve(now, Periods.Zone, await AllHistoryAsync(query, now, token))
: await Periods.ResolveAsync(query, now, token);
var data = await Dash.GetOverviewAsync(period, query.Bucket, query.Comparison, token);
return OverviewView.Build(query, data);
}, Logger);
/// <summary>All history on the Overview spans both what the meters measured and the bill (manual costs included, D-19).</summary>
private async Task<AvailableRange?> AllHistoryAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken token)
{
var quantities = await Periods.AvailabilityAsync(query.WithMetric(null), now, token);
var costs = await Periods.AvailabilityAsync(query.WithMetric(AnalysisMetric.Cost), now, token);
return AvailableRange.Union([quantities, costs], Periods.Zone);
}
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
/// <summary>The chart selection goes into the address (replace): a reload or a shared link shows the same (D-46).</summary>
private void OnChartKeyChanged(string key)
{
_chartKey = key == OverviewView.CostKey ? null : key;
Nav.NavigateTo(Nav.GetUriWithQueryParameter(OverviewView.ChartParameter, _chartKey), replace: true);
}
/// <summary>A clicked bucket opens on the Overview itself, one size finer (D-51) — a drill-down pushes.</summary>
private void Drill((AnalysisBucket Bucket, ResolutionClass? Resolution) click)
{
if (_query is not null && AnalysisNavigation.DrillInto(_query, click.Bucket, click.Resolution) is { } next)
{
Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults));
}
}
/// <summary>
/// "Go to latest data": a period of the same kind ending with the latest data — for month to date the latest month
/// with data — opened as its own period on the Overview (brief §4.3). Nothing moves by itself.
/// </summary>
private static string? LatestHref(OverviewView view) =>
AnalysisNavigation.LatestData(view.Query, view.Data.Availability) is { } target ? AnalysisLinks.Overview(target) : null;
private static bool HasAttention(OverviewView view) =>
view.AttentionCount > 0 || view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs;
private static List<OverviewEnergyType> TypesWithoutMeters(OverviewView view) => [.. view.Data.EnergyTypes.Where(t => !t.HasMeters)];
public void Dispose()
{
Nav.LocationChanged -= OnLocationChanged;
_loads.Dispose();
}
}
@@ -0,0 +1,120 @@
/* The Overview's panels (Components/Pages/Overview): one look for their heads and footers. Palette variables only, so
light and dark mode both work; everything wraps down to 360px and wide tables scroll inside their own box. */
.mv-ov ::deep .mv-ov-panel {
min-width: 0;
}
.mv-ov ::deep .mv-ov-panel__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
margin-bottom: 8px;
}
.mv-ov ::deep .mv-ov-panel__title {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.mv-ov ::deep .mv-ov-panel__controls {
flex: 0 1 320px;
min-width: 200px;
}
.mv-ov ::deep .mv-ov-panel__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px 16px;
margin-top: 4px;
}
.mv-ov ::deep .mv-ov-typelinks {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 12px;
font-size: 0.875rem;
}
/* The first column: the period's cost, and under it what needs attention, together as tall as the type cards. */
.mv-ov ::deep .mv-ov-lead {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.mv-ov ::deep .mv-ov-lead .mv-metric {
height: auto;
}
.mv-ov ::deep .mv-ov-lead .mv-metric:only-child {
height: 100%;
}
.mv-ov ::deep .mv-ov-attention {
flex: 1 1 auto;
min-width: 0;
}
.mv-ov ::deep .mv-ov-card {
height: 100%;
}
/* In the narrow first column: the icon beside the words, the action under them. */
.mv-ov ::deep .mv-ov-attention .mv-attention__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 8px;
align-items: start;
}
.mv-ov ::deep .mv-ov-attention .mv-attention__icon {
margin-top: 2px;
}
.mv-ov ::deep .mv-ov-attention .mv-attention__action {
grid-column: 2;
}
/* The change table and the composition: names and status words wrap, amounts do not. The tone classes win over the
table cell's own text colour; the total row is set apart. */
.mv-ov ::deep .mv-ov-amount {
white-space: nowrap;
}
.mv-ov ::deep .mv-ov-changes th.mv-num,
.mv-ov ::deep .mv-ov-changes td.mv-num,
.mv-ov ::deep .mv-ov-composition th.mv-num,
.mv-ov ::deep .mv-ov-composition td.mv-num {
text-align: right;
}
.mv-ov ::deep .mv-ov-changes td.mv-change-good { color: var(--mud-palette-success); }
.mv-ov ::deep .mv-ov-changes td.mv-change-bad { color: var(--mud-palette-error); }
.mv-ov ::deep .mv-ov-changes td.mv-change-neutral { color: var(--mud-palette-text-secondary); }
.mv-ov ::deep .mv-ov-total > td {
font-weight: 600;
border-top: 2px solid var(--mud-palette-lines-default);
}
@media (min-width: 600px) {
.mv-ov ::deep .mv-ov-changes td.mv-num,
.mv-ov ::deep .mv-ov-composition td.mv-num {
white-space: nowrap;
}
}
@media (max-width: 599.98px) {
.mv-ov ::deep .mv-ov-changes .mud-table-row + .mud-table-row > td:first-child,
.mv-ov ::deep .mv-ov-composition .mud-table-row + .mud-table-row > td:first-child {
border-top: 1px solid var(--mud-palette-lines-default);
}
}
@@ -0,0 +1,108 @@
@using MeterVault.App.Energy
@using MeterVault.Core.Analysis
@* The energy type's Flow (brief §7.3, D-30): the Sankey as a topology tool, fed with the canonical period totals — a
virtual sum drawn from its calculation inputs (marked calculated), a meter below several others split in proportion
(marked estimated) — and its table equivalent: every ribbon with what it is, every meter with its signed value or its
status in words, and why a meter is not drawn. "Manage connections" edits the topology. No topology never stops the
analysis: the other tabs do not depend on it. *@
@if (Analysis.Flow is { } flow)
{
<div class="mv-energy-flow">
<div class="mv-energy-flow__head">
<div class="mv-energy-flow__intro">
<MudText Typo="Typo.h6">@S.EnergyView_Flow</MudText>
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.EnergyView_FlowCaption, flow.Unit)</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Hub"
OnClick="() => ManageConnections.InvokeAsync()">@S.EnergyView_ManageConnections</MudButton>
</div>
@if (!flow.HasChain)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">@S.EnergyView_NoConnections</MudAlert>
}
else
{
<SankeyChart Nodes="flow.Nodes" Links="flow.Links" Unit="@flow.Unit" />
<ul class="mv-energy-flow__legend" aria-label="@S.EnergyView_Legend">
<li><span class="mv-swatch mv-swatch--measured" aria-hidden="true"></span>@S.EnergyView_LegendMeasured</li>
<li><span class="mv-swatch mv-swatch--calculated" aria-hidden="true"></span>@S.EnergyView_LegendCalculated</li>
<li><span class="mv-swatch mv-swatch--estimated" aria-hidden="true"></span>@S.EnergyView_LegendEstimated</li>
<li><span class="mv-swatch mv-swatch--other" aria-hidden="true"></span>@S.EnergyView_LegendOther</li>
</ul>
}
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.EnergyView_FlowTable</MudText>
<MudGrid Spacing="3">
@if (flow.Links.Count > 0)
{
<MudItem xs="12" lg="7">
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_Connections" tabindex="0">
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
<caption class="mv-sr-only">@S.EnergyView_Connections</caption>
<thead>
<tr>
<th scope="col">@S.EnergyView_ColFrom</th>
<th scope="col">@S.EnergyView_ColTo</th>
<th scope="col" class="mv-num">@S.Common_Amount</th>
<th scope="col">@S.Common_Type</th>
</tr>
</thead>
<tbody>
@foreach (var link in flow.Links.OrderBy(l => FlowText.NodeName(flow, l.From), StringComparer.CurrentCultureIgnoreCase).ThenByDescending(l => l.Value))
{
<tr>
<td>@FlowText.NodeName(flow, link.From)</td>
<td>@FlowText.NodeName(flow, link.To)</td>
<td class="mv-num">@Format.Quantity(link.Value, flow.Unit)</td>
<td class="mv-energy-flow__kind">@FlowText.EdgeKind(flow, link)</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
</MudItem>
}
<MudItem xs="12" lg="@(flow.Links.Count > 0 ? 5 : 12)">
<div class="mv-table-scroll" role="region" aria-label="@S.Common_Meters" tabindex="0">
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
<caption class="mv-sr-only">@S.Common_Meters</caption>
<thead>
<tr>
<th scope="col">@S.Common_Meter</th>
<th scope="col" class="mv-num">@S.Meters_ColValue</th>
<th scope="col">@S.EnergyView_ColDiagram</th>
</tr>
</thead>
<tbody>
@foreach (var meter in flow.Meters.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase))
{
<tr>
<td><MudLink Href="@MeterLinks.Analysis(meter.MeterId, Query)" Typo="Typo.body2">@meter.Name</MudLink></td>
<td class="mv-num @(meter.Value is null ? "mv-unknown" : null)">@FlowText.MeterValue(meter)</td>
<td class="mv-energy-flow__kind">@(FlowText.NotDrawnReason(flow, meter) ?? S.EnergyView_InDiagram)</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
</MudItem>
</MudGrid>
</div>
}
@code {
/// <summary>The page's committed value.</summary>
[Parameter, EditorRequired]
public EnergyAnalysis Analysis { get; set; } = null!;
/// <summary>The page's analysis state (meter links carry its period).</summary>
[Parameter, EditorRequired]
public AnalysisQuery Query { get; set; } = null!;
/// <summary>Opens the page's "Manage connections" dialog.</summary>
[Parameter]
public EventCallback ManageConnections { get; set; }
}
@@ -0,0 +1,62 @@
.mv-energy-flow__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 8px 16px;
margin-bottom: 12px;
}
.mv-energy-flow__intro {
flex: 1 1 320px;
min-width: 0;
}
.mv-energy-flow__legend {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin: 8px 0 0 0;
padding: 0;
font-size: 0.8125rem;
color: var(--mud-palette-text-secondary);
}
.mv-energy-flow__legend li {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mv-swatch {
display: inline-block;
width: 22px;
height: 10px;
border-radius: 2px;
flex: none;
}
.mv-swatch--measured {
background: var(--mud-palette-text-secondary);
opacity: 0.6;
}
.mv-swatch--calculated {
border: 1.5px dashed var(--mud-palette-text-secondary);
background: transparent;
}
.mv-swatch--estimated {
border: 1.5px dotted var(--mud-palette-text-primary);
background: var(--mud-palette-action-disabled-background);
}
.mv-swatch--other {
background: #78909C;
}
.mv-energy-flow ::deep td.mv-energy-flow__kind {
white-space: normal;
min-width: 24ch;
}
@@ -0,0 +1,215 @@
@using MeterVault.App.Energy
@using MeterVault.Core.Analysis
@using MeterVault.Infrastructure.Analysis
@inject NavigationManager Nav
@* The energy type's History (brief §7.3): the shared chart and table for the chosen metric, over the page's period and
interval, compared with the chosen period (a calendar year for a year). "Total" charts the type's measures — never a
breakdown on top of its parent or a calculated view on top of its sources (D-22); "Individual meters" charts the
meters side by side and says how each one counts, so their bars are not read as adding up. Signed values stay signed.
A bucket opens its finer detail (D-51). *@
<div class="mv-energy-history">
<div class="mv-energy-history__views">
<MudToggleGroup T="string" Value="@View" ValueChanged="OnViewChangedAsync" SelectionMode="SelectionMode.SingleSelection"
Outlined="true" Color="Color.Primary" Size="Size.Small" aria-label="@S.EnergyView_ViewLabel"
Class="mv-energy-history__toggle">
<MudToggleItem T="string" Value="@EnergyPageKeys.ViewTotal" Text="@S.EnergyView_ViewTotal" />
<MudToggleItem T="string" Value="@EnergyPageKeys.ViewMeters" Text="@S.EnergyView_ViewMeters" />
</MudToggleGroup>
<MudText Typo="Typo.caption" Class="mv-muted">
@(_view?.IsIndividual == true ? S.EnergyView_ViewMetersHelp : S.EnergyView_ViewTotalHelp)
</MudText>
</div>
@if (_view is null || _quantities is null)
{
@* Nothing read yet: the page shows its own loading state. *@
}
else if (_view.IsEmpty)
{
<MudAlert Severity="Severity.Info" Dense="true">@EmptyText()</MudAlert>
}
else if (_view.Main is { IsPending: true })
{
<PendingState OnRefresh="OnRefresh" />
}
else if (_quantities.NotYetOccurred || NoData())
{
<EmptyPeriodState NotYetOccurred="_quantities.NotYetOccurred" Availability="Availability()" LatestHref="@LatestHref()" />
}
else
{
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched"
Subject="@MainName()" Class="mb-3" />
<AnalysisChart Buckets="_buckets" Series="_view.Chart" ComparisonPairs="_pairs" Title="@_title" OnBucketClick="_onBucketClick"
Resolution="_view.Coarsest" OnUseBucket="UseBucket" />
@if (_view.IsIndividual)
{
@if (_view.Hidden > 0)
{
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
@Loc.F(S.EnergyView_MoreMeters, _view.Shown.Count, _view.Shown.Count + _view.Hidden)
<MudLink Href="@AnalysisLinks.Analysis(QueryScope.ForEnergyType(Analysis.EnergyTypeId), _view.Metric, Query)" Typo="Typo.body2">@S.Nav_Analysis</MudLink>
</MudText>
}
@if (_view.Memberships.Count > 0)
{
<section class="mv-energy-history__counts" aria-labelledby="@_countsId">
<MudText Typo="Typo.subtitle2" id="@_countsId">@S.EnergyView_HowCounted</MudText>
<ul>
@foreach (var (series, membership) in _view.Memberships)
{
<li>
<MudIcon Icon="@MeterListRows.MembershipIcon(membership)" Size="Size.Small" aria-hidden="true" Class="mv-muted" />
<span>
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)" Typo="Typo.body2">@series.Name</MudLink>:
@membership.Label@(string.IsNullOrEmpty(membership.Detail) ? null : " — " + membership.Detail)
</span>
</li>
}
</ul>
</section>
}
}
else if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost)
{
<AttentionList CostAttention="cost.Attention" Problems="cost.QuantityProblems" Names="Analysis.Names" Query="Query"
MaxItems="3" Class="mt-3" />
}
<div class="mt-4">
<AnalysisTable Buckets="_buckets" Series="_view.Table" ComparisonPairs="_pairs" Caption="@_title" DrillHref="_drillHref" />
</div>
}
</div>
@code {
/// <summary>The page's committed value.</summary>
[Parameter, EditorRequired]
public EnergyAnalysis Analysis { get; set; } = null!;
/// <summary>The page's analysis state.</summary>
[Parameter, EditorRequired]
public AnalysisQuery Query { get; set; } = null!;
/// <summary>The page's defaults (drill-downs and "latest data" keep the page's other keys).</summary>
[Parameter, EditorRequired]
public AnalysisDefaults Defaults { get; set; } = null!;
/// <summary>The metric shown (<see cref="EnergyMetrics.Effective"/>).</summary>
[Parameter]
public AnalysisMetric Metric { get; set; }
/// <summary>The view key (<see cref="EnergyPageKeys.ViewTotal"/> or <see cref="EnergyPageKeys.ViewMeters"/>).</summary>
[Parameter]
public string View { get; set; } = EnergyPageKeys.ViewTotal;
/// <summary>The view was switched; the page writes it into its address.</summary>
[Parameter]
public EventCallback<string> ViewChanged { get; set; }
/// <summary>Loads again (analysis being prepared).</summary>
[Parameter]
public EventCallback OnRefresh { get; set; }
private readonly string _countsId = "mv-counts-" + Guid.NewGuid().ToString("N")[..8];
private object? _builtFrom;
private EnergyHistoryView? _view;
private AnalysisResult? _quantities;
private IReadOnlyList<AnalysisBucket> _buckets = [];
private IReadOnlyList<BucketPair>? _pairs;
private string _title = string.Empty;
private EventCallback<AnalysisBucket> _onBucketClick;
private Func<AnalysisBucket, string?>? _drillHref;
protected override void OnParametersSet()
{
// Rebuilt only for a new value, metric, view or comparison: the chart re-keys on a new list.
var source = (Analysis, Metric, View, Query.Comparison);
if (Equals(_builtFrom, source))
{
return;
}
_builtFrom = source;
_quantities = Analysis.Quantities;
_view = EnergyHistoryView.Build(Analysis, Metric, View == EnergyPageKeys.ViewMeters, Query.Comparison);
if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost)
{
_buckets = cost.Plan.Buckets;
_pairs = Analysis.CostComparison?.Pairs is { Count: > 0 } pairs ? pairs : null;
}
else
{
_buckets = _quantities?.Plan.Buckets ?? [];
_pairs = _quantities?.Comparison?.Buckets;
}
var typeName = Analysis.Type?.Name ?? string.Empty;
_title = Loc.F(S.EnergyView_ChartTitle, Metric.Display(), typeName);
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): monthly
// data has no days to open, and a click that does nothing is a dead end.
var drills = _buckets.Any(b => DrillHref(b) is not null);
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, DrillAsync) : default;
_drillHref = drills ? DrillHref : null;
}
/// <summary>The buckets are finer than the data: open the interval that shows it (replacing the address, D-46).</summary>
private void UseBucket(BucketSize size) => AnalysisNavigation.Replace(Nav, Query.WithBucket(size), Defaults);
private async Task OnViewChangedAsync(string? view) =>
await ViewChanged.InvokeAsync(EnergyPageKeys.ResolveView(view));
private string EmptyText()
{
if (_view!.Metric == AnalysisMetric.Cost)
{
return S.EnergyView_CostPerMeterNote;
}
return _view.IsIndividual
? Loc.F(S.EnergyView_NoMetersForMetric, _view.Metric.Display())
: Loc.F(S.EnergyView_NoTotalForMetric, _view.Metric.Display());
}
private bool NoData()
{
if (_view!.Metric == AnalysisMetric.Cost)
{
return false;
}
var shown = _view.IsIndividual ? _view.Shown : EnergyMetrics.MeasuresOf(_quantities, _view.Metric);
return _view.HasNoData(shown);
}
private MeterVault.Core.Analysis.Coverage.AvailableRange? Availability() =>
_view?.Main?.Availability ?? _quantities?.Availability.Quantity;
/// <summary>
/// The name of the series the comparison line speaks of, when the view charts several whose matched coverage
/// may differ (A-41); null when it charts one and the line cannot be misread.
/// </summary>
private string? MainName() => _view is { Table.Count: > 1 } view ? view.Table[0].Name : null;
private ComparisonResolution? ComparisonResolution() =>
_view?.Metric == AnalysisMetric.Cost ? Analysis.CostComparison?.Resolution : _quantities?.Comparison?.Resolution;
private string? LatestHref() =>
AnalysisNavigation.LatestData(Query, Availability()) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null;
private string? DrillHref(AnalysisBucket bucket) =>
AnalysisNavigation.DrillInto(Query, bucket, _view?.Coarsest) is { } next ? AnalysisNavigation.UriFor(Nav, next, Defaults) : null;
/// <summary>A chart bucket opens its finer detail (D-51), pushing a history entry so Back returns here.</summary>
private void DrillAsync(AnalysisBucket bucket)
{
if (DrillHref(bucket) is { } href)
{
Nav.NavigateTo(href);
}
}
}
@@ -0,0 +1,32 @@
.mv-energy-history__views {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 12px;
margin-bottom: 12px;
}
.mv-energy-history__views ::deep .mv-energy-history__toggle {
flex: none;
}
.mv-energy-history__counts {
margin-top: 12px;
}
.mv-energy-history__counts ul {
list-style: none;
margin: 4px 0 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.875rem;
}
.mv-energy-history__counts li {
display: flex;
align-items: flex-start;
gap: 6px;
overflow-wrap: anywhere;
}
@@ -0,0 +1,385 @@
@using MeterVault.App.Energy
@using MeterVault.Core.Analysis
@using MeterVault.Core.Analysis.Coverage
@using MeterVault.Core.Analysis.Totals
@using MeterVault.Infrastructure.Analysis
@inject NavigationManager Nav
@* The energy type's Overview (brief §7.3): one card per measure — use, grid import, export, generation, runtime, each
with its own unit and status, never added to each other (D-22) — the type's bill with its basis and standing charge
(D-34, D-40), the comparison over matched coverage (D-07), a compact trend, the data's coverage, and the meters that
changed most. *@
@if (_quantities is not null)
{
<div class="mv-energy-overview">
<AttentionList Problems="Analysis.Problems" CostAttention="Analysis.Cost?.Attention" Names="Analysis.Names" Query="Query"
MaxItems="4" Class="mb-4" />
@if (_pending)
{
<PendingState OnRefresh="OnRefresh" Class="mb-4" />
}
@if (_measures.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4">
@S.EnergyView_NoMeasures
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)">@S.Nav_Meters</MudLink>
</MudAlert>
}
else if (_quantities.NotYetOccurred || _noData)
{
<EmptyPeriodState NotYetOccurred="_quantities.NotYetOccurred" Availability="_quantities.Availability.Quantity"
LatestHref="@LatestHref" Class="mb-4" />
}
else
{
<MudGrid Spacing="3" Class="mb-1">
@foreach (var series in _measures)
{
<MudItem xs="12" sm="6" lg="3">
<MetricCard Title="@EnergyHistoryView.MeasureName(series, _measures)" Value="series.Total" Unit="@series.Unit"
Change="@ChangeOf(series)" Polarity="ChangePolarities.For(series.Kind)" ChangeCaption="@_comparisonCaption"
Caption="@MembersCaption(series)" Href="@HistoryHref(AnalysisMetrics.MetricOf(series.Kind))"
LinkText="@S.EnergyView_OpenHistory" />
</MudItem>
}
@if (Analysis.Cost is { } cost && Analysis.Metrics.Contains(AnalysisMetric.Cost))
{
<MudItem xs="12" sm="6" lg="3">
<MetricCard Title="@S.AnalysisTable_Cost" Cost="cost.Total" Currency="@cost.Currency" Caption="@_costBasis"
Change="_costChange" Polarity="_costPolarity" ChangeCaption="@_costCaption"
Href="@HistoryHref(AnalysisMetric.Cost)" LinkText="@S.EnergyView_OpenHistory">
@foreach (var line in _costLines)
{
<MudText Typo="Typo.caption" Class="mv-muted d-block">@line</MudText>
}
</MetricCard>
</MudItem>
}
</MudGrid>
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched"
Subject="@_comparisonSubject" Class="mt-3 mb-4" />
<MudGrid Spacing="3">
<MudItem xs="12" md="8">
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-energy-panel">
<div class="mv-energy-panel__head">
<MudText Typo="Typo.h6" Class="mv-energy-panel__title">@_trendTitle</MudText>
<MudLink Href="@HistoryHref(_main is null ? null : AnalysisMetrics.MetricOf(_main.Kind))" Typo="Typo.body2">@S.EnergyView_OpenHistory</MudLink>
</div>
<AnalysisChart Buckets="_quantities.Plan.Buckets" Series="_trend" ComparisonPairs="_quantities.Comparison?.Buckets"
Title="@_trendTitle" Height="240" Resolution="_main?.Resolution" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-energy-panel">
<MudText Typo="Typo.h6" Class="mb-2">@S.EnergyView_Coverage</MudText>
@if (_quantities.Availability.Quantity is { } available)
{
<MudText Typo="Typo.body2">@Loc.F(S.Empty_AvailableRange, Format.Date(available.FirstDay), Format.Date(available.LastDay))</MudText>
}
else
{
<MudText Typo="Typo.body2">@S.Empty_NoDataYet</MudText>
}
<dl class="mv-energy-coverage">
@foreach (var series in _measures)
{
var status = FigureText.Of(series.Total, Analysis.Names.MeterOrNull);
<dt>@EnergyHistoryView.MeasureName(series, _measures)</dt>
<dd>
@status.Summary
@if (series.Resolution is { } resolution)
{
<span> · @resolution.Display()</span>
}
@if (series.Freshness.State != FreshnessState.NoData)
{
<span> · @series.Freshness.State.Display()</span>
}
@if (status.IsQualified && status.Detail is { } detail)
{
<div class="mv-cell-secondary">@detail</div>
}
@* Its own dates whenever they are not the scope's: a measure counted by a tank dipped
once a year must not be read against the range of the burner beside it (A-41). *@
@if (OwnRange(series) is { } own)
{
<div class="mv-cell-secondary">@Loc.F(S.Empty_AvailableRange, Format.Date(own.FirstDay), Format.Date(own.LastDay))</div>
}
</dd>
}
</dl>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Outlined="true" Elevation="0" Class="pa-4">
@* A-43: the ranked changes, then every other meter that has data in either period — with its values and
the status that says why there is no change. A meter with data is never silently left out. *@
<MudText Typo="Typo.h6">@S.EnergyView_LargestChanges</MudText>
@if (_quantities.Comparison is not { IsApplicable: true })
{
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparison</MudText>
}
else if (_changes.IsEmpty)
{
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparableMeters</MudText>
}
else
{
@if (_changes.Ranked.Count > 0)
{
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
}
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_LargestChanges" tabindex="0">
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
<thead>
<tr>
<th scope="col">@S.Common_Meter</th>
<th scope="col" class="mv-num">@S.EnergyView_ColCurrent</th>
<th scope="col" class="mv-num">@S.AnalysisTable_Comparison</th>
<th scope="col">@S.AnalysisTable_Change</th>
</tr>
</thead>
<tbody>
@foreach (var change in _changes.Ranked)
{
var series = change.Series;
<tr>
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
<td class="mv-num">
@Format.Quantity(change.Current, series.Unit)
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)</div>
</td>
<td class="mv-num">
@Format.Quantity(change.Previous, series.Unit)
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Comparison!.FirstDay, change.Matched.Comparison.LastDay)</div>
</td>
<td>
<ChangeChip Change="change.Change" Polarity="ChangePolarities.For(series.Kind)"
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
</td>
</tr>
}
</tbody>
@if (_changes.Rest.Count > 0)
{
<tbody>
@if (_changes.Ranked.Count > 0)
{
<tr class="mv-row-group">
<th scope="colgroup" colspan="4">
@S.EnergyView_ChangesRest
<div class="mv-cell-secondary">@S.EnergyView_ChangesRestNote</div>
</th>
</tr>
}
@foreach (var row in _changes.Rest)
{
var series = row.Series;
<tr>
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
@FigureCell(row.Current, series)
@FigureCell(row.Comparison, series)
<td>
<ChangeChip Change="row.Change" Polarity="ChangePolarities.For(series.Kind)"
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
@if (row.SharesNoDays)
{
<div class="mv-cell-secondary">@S.Comparison_NotComparable</div>
}
</td>
</tr>
}
</tbody>
}
</MudSimpleTable>
</div>
@if (_changes.Hidden > 0)
{
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
@Loc.F(S.EnergyView_MoreMeters, _changes.Shown, _changes.Shown + _changes.Hidden)
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)" Typo="Typo.body2">@S.Nav_Meters</MudLink>
</MudText>
}
}
</MudPaper>
</MudItem>
</MudGrid>
}
</div>
}
@code {
/// <summary>The page's committed value.</summary>
[Parameter, EditorRequired]
public EnergyAnalysis Analysis { get; set; } = null!;
/// <summary>The page's analysis state (links carry its period).</summary>
[Parameter, EditorRequired]
public AnalysisQuery Query { get; set; } = null!;
/// <summary>The page's defaults, for "go to latest data" on the page itself.</summary>
[Parameter, EditorRequired]
public AnalysisDefaults Defaults { get; set; } = null!;
/// <summary>Loads again (analysis being prepared).</summary>
[Parameter]
public EventCallback OnRefresh { get; set; }
private const int MaxChanges = 6;
private EnergyAnalysis? _builtFor;
private AnalysisResult? _quantities;
private List<AnalysisSeries> _measures = [];
private AnalysisSeries? _main;
private IReadOnlyList<AnalysisChartSeries> _trend = [];
private MeterChangeList _changes = MeterChangeList.None;
private string _trendTitle = string.Empty;
private string? _comparisonCaption;
private string? _comparisonSubject;
private string? _costBasis;
private List<string> _costLines = [];
private Change? _costChange;
private string? _costCaption;
private ChangePolarity _costPolarity = ChangePolarity.HigherIsWorse;
private bool _pending;
private bool _noData;
protected override void OnParametersSet()
{
if (ReferenceEquals(_builtFor, Analysis))
{
return;
}
// Built once per committed value (the chart re-keys on a new list): every text in the reader's culture.
_builtFor = Analysis;
_quantities = Analysis.Quantities;
if (_quantities is null)
{
return;
}
_measures = [.. _quantities.Measures.OrderBy(s => s.Key.Measure).ThenBy(s => s.Unit, StringComparer.Ordinal)];
_main = _measures.FirstOrDefault(s => s.Key.Measure == TotalsMeasure.Use) ?? _measures.FirstOrDefault();
_pending = _measures.Any(s => s.IsPending);
_noData = _measures.Count > 0 && _measures.All(s => s.Total.Status == BucketStatus.Missing && !s.IsPending);
_comparisonCaption = _quantities.Comparison is { IsApplicable: true } ? Query.Comparison.Display() : null;
_comparisonSubject = _measures.Count > 1 && _main is { } named ? EnergyHistoryView.MeasureName(named, _measures) : null;
if (_main is { } main)
{
var name = EnergyHistoryView.MeasureName(main, _measures);
_trendTitle = Loc.F(S.EnergyView_TrendOf, name);
List<AnalysisChartSeries> trend = [AnalysisChartSeries.ForSeries(main, name)];
if (AnalysisChartSeries.ComparisonOf(main, AnalysisChartSeries.ComparisonName(name, Query.Comparison)) is { } overlay)
{
trend.Add(overlay);
}
_trend = trend;
}
else
{
_trendTitle = S.EnergyView_Trend;
_trend = [];
}
_changes = MeterChanges.Of(_quantities.Series, MaxChanges);
BuildCost();
}
/// <summary>A measure's own availability when it differs from the scope's (A-41); null when they are the same.</summary>
private AvailableRange? OwnRange(AnalysisSeries series) =>
series.Availability is { } own && (_quantities?.Availability.Quantity is not { } scope || own.FirstDay != scope.FirstDay || own.LastDay != scope.LastDay)
? own
: null;
private void BuildCost()
{
_costBasis = null;
_costLines = [];
_costChange = null;
_costCaption = null;
if (Analysis.Cost is not { } cost)
{
return;
}
var figure = cost.EnergyTypes.FirstOrDefault(t => t.EnergyTypeId == Analysis.EnergyTypeId);
var billed = cost.Lines.Where(l => l.Kind != BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList();
if (figure is { } type)
{
_costBasis = billed.Count > 0 ? Loc.F(S.EnergyView_CostBasis, type.Basis.Display(), string.Join(", ", billed)) : type.Basis.Display();
}
var credited = cost.Lines.Where(l => l.Kind == BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList();
if (credited.Count > 0)
{
_costLines.Add(Loc.F(S.EnergyView_CostCredit, string.Join(", ", credited)));
}
// Every standing charge of the bill (D-40) — the type's rows and the meter fees on its lines — as the Overview and
// the Analysis page show it for the same scope.
if (Analysis.StandingCharge is { } standing && standing != 0)
{
_costLines.Add(Loc.F(S.EnergyView_StandingCharge, Format.Money(standing, cost.Currency)));
}
if (cost.ManualCosts.Bookings.Count > 0)
{
_costLines.Add(Loc.F(S.EnergyView_ManualCosts, Format.Money(cost.ManualCosts.Total.Cost, cost.Currency)));
}
// The change over what both bills cover completely, by the rule every page uses (D-07).
var change = Analysis.CostChange;
_costChange = CostChanges.ForCard(change);
_costPolarity = CostChanges.Polarity(change);
_costCaption = CostChanges.Caption(Query, change);
}
private Change? ChangeOf(AnalysisSeries series) =>
_quantities?.Comparison is { IsApplicable: true } ? series.Comparison?.Change ?? Change.Unavailable : null;
/// <summary>The meter's name, linked to its own analysis over the same period, and — when it is not counted in the totals — why.</summary>
private RenderFragment MeterCell(AnalysisSeries series) => __builder =>
{
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
{
<div class="mv-cell-secondary">@entry.Class.Display()</div>
}
};
/// <summary>
/// A total of a meter that is not ranked: its number, or the status in words instead of a fabricated 0 (§4.3). A
/// value that is not plain keeps its status beside it — that is the reason there is no change.
/// </summary>
private RenderFragment FigureCell(BucketValue value, AnalysisSeries series) => __builder =>
{
var status = FigureText.Of(value, Analysis.Names.MeterOrNull);
<td class="@(status.IsKnown ? "mv-num" : "mv-num mv-unknown")">
@(status.IsKnown ? Format.Quantity(value.Value, series.Unit) : Format.Unknown)
@if (!status.IsComplete)
{
<div class="mv-cell-secondary">@status.Summary</div>
}
</td>
};
private string? MembersCaption(AnalysisSeries series) =>
series.MemberIds.Count == 0 ? null : Loc.F(S.EnergyView_CountedMeters, string.Join(", ", series.MemberIds.Select(Analysis.MeterName)));
private string HistoryHref(AnalysisMetric? metric) =>
AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabHistory, metric is { } m ? Query.WithMetric(m) : Query);
private string? LatestHref =>
AnalysisNavigation.LatestData(Query, _quantities?.Availability.Quantity) is { } latest
? AnalysisNavigation.UriFor(Nav, latest, Defaults)
: null;
}
@@ -0,0 +1,31 @@
.mv-energy-overview ::deep .mv-energy-panel {
height: 100%;
}
.mv-energy-panel__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 12px;
margin-bottom: 8px;
}
.mv-energy-coverage {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2px;
margin: 12px 0 0 0;
font-size: 0.875rem;
}
.mv-energy-coverage dt {
font-weight: 500;
margin-top: 6px;
}
.mv-energy-coverage dd {
margin: 0;
color: var(--mud-palette-text-secondary);
overflow-wrap: anywhere;
}
@@ -0,0 +1,302 @@
@using MeterVault.App.Energy
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject ILogger<ManageConnectionsDialog> Logger
@* "Manage connections" (brief §3.2, D-30): an energy type's flow topology with clear source → destination names, where
a connection is added or removed on the spot. The rules (MeterLinkRules) refuse a meter into itself, a duplicate, a
link across types, a loop — the verdict names it before anything is saved — and any change to the incoming
connections of a virtual meter still calculated from them. A connection is topology only: it never writes or changes
a calculated meter's formula (D-25); one that mirrors a formula input says so. *@
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_options">
<TitleContent>
<MudText Typo="Typo.h6">@Loc.F(S.EnergyView_ConnectionsTitle, EnergyTypeName)</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.EnergyView_ConnectionsIntro</MudText>
@if (_error)
{
<PanelError HasStaleValue="false" OnRetry="LoadAsync" Class="mb-2" />
}
else if (_topology is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" aria-label="@S.Refresh_Updating" />
}
else
{
<MudText Typo="Typo.subtitle2" Class="mb-1">@S.EnergyView_Connections</MudText>
@if (_topology.Links.Count == 0)
{
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.EnergyView_ConnectionsEmpty</MudText>
}
else
{
<ul class="mv-connections">
@foreach (var link in _topology.Links)
{
var from = Name(link.FromMeterId);
var to = Name(link.ToMeterId);
var removal = _topology.CheckRemove(link);
<li class="mv-connections__item">
<div class="mv-connections__names">
<span class="mv-connections__meter">@from</span>
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" Size="Size.Small" aria-hidden="true" />
<span class="mv-sr-only">@S.EnergyView_FlowsInto</span>
<span class="mv-connections__meter">@to</span>
</div>
<div class="mv-connections__note">
@if (!removal.IsAllowed)
{
<span>@FlowText.Refusal(removal, Name, link.ToMeterId)</span>
<MudLink Href="@MeterLinks.Detail(link.ToMeterId, MeterLinks.TabCalculation, null)" Typo="Typo.caption">@S.EnergyView_EditCalculation</MudLink>
}
else if (_topology.MirrorsCalculation(link))
{
<span>@Loc.F(S.EnergyView_MirrorsCalculation, to, from)</span>
}
</div>
<MudIconButton Icon="@Icons.Material.Filled.LinkOff" Size="Size.Small" Color="Color.Error"
Disabled="@(_busy || !removal.IsAllowed)" OnClick="() => RemoveAsync(link)"
aria-label="@Loc.F(S.EnergyView_RemoveConnection, from, to)"
title="@Loc.F(S.EnergyView_RemoveConnection, from, to)" Class="mv-connections__remove" />
</li>
}
</ul>
}
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">@S.EnergyView_AddConnection</MudText>
@if (_topology.Meters.Count < 2)
{
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_ConnectionsNeedTwo</MudText>
}
else
{
<div class="mv-connections__add">
<div class="mv-connections__select">
<MudSelect T="int" Value="_from" ValueChanged="OnFromChanged" Label="@S.EnergyView_From" HelperText="@S.EnergyView_FromHelp"
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true">
<MudSelectItem T="int" Value="0">@S.EnergyView_ChooseMeter</MudSelectItem>
@foreach (var meter in _topology.Meters)
{
<MudSelectItem T="int" Value="meter.Id">@Label(meter)</MudSelectItem>
}
</MudSelect>
</div>
<div class="mv-connections__select">
<MudSelect T="int" Value="_to" ValueChanged="OnToChanged" Label="@S.EnergyView_To" HelperText="@S.EnergyView_ToHelp"
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true">
<MudSelectItem T="int" Value="0">@S.EnergyView_ChooseMeter</MudSelectItem>
@foreach (var meter in _topology.Meters)
{
<MudSelectItem T="int" Value="meter.Id">@Label(meter)</MudSelectItem>
}
</MudSelect>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddLink"
Disabled="@(_busy || _verdict is not { IsAllowed: true })" OnClick="AddAsync"
Class="mv-connections__button">@S.EnergyView_AddConnectionAction</MudButton>
</div>
@* A fixed slot, so the verdict appearing does not move the button under the pointer. *@
<div class="mv-connections__verdict" role="status" aria-live="polite">
@if (_verdict is { IsAllowed: false } refused)
{
<MudIcon Icon="@Icons.Material.Outlined.Block" Size="Size.Small" aria-hidden="true" />
<span>@FlowText.Refusal(refused, Name, _to)</span>
@if (refused.Refusal == MeterLinkRefusal.CalculatedFromLinks)
{
<MudLink Href="@MeterLinks.Detail(_to, MeterLinks.TabCalculation, null)" Typo="Typo.caption">@S.EnergyView_EditCalculation</MudLink>
}
}
else if (_verdict is { IsAllowed: true })
{
<span>@Loc.F(S.EnergyView_ConnectionPreview, Name(_from), Name(_to))</span>
}
</div>
}
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseAsync">@S.EnergyView_Done</MudButton>
</DialogActions>
</MudDialog>
@code {
/// <summary>The energy type whose connections are edited.</summary>
[Parameter, EditorRequired]
public int EnergyTypeId { get; set; }
/// <summary>Its name, for the title (user data).</summary>
[Parameter]
public string EnergyTypeName { get; set; } = string.Empty;
/// <summary>Raised when the dialog closes after at least one change, so the page reads the analysis again.</summary>
[Parameter]
public EventCallback Changed { get; set; }
private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true };
private MeterLinkService? _service;
private MeterLinkTopology? _topology;
private MeterLinkCheck? _verdict;
private bool _open;
private bool _busy;
private bool _error;
private bool _changed;
private int _from;
private int _to;
private MeterLinkService Service => _service ??= new MeterLinkService(DbFactory);
/// <summary>Opens the dialog and reads the type's connections afresh.</summary>
public async Task OpenAsync()
{
_open = true;
_changed = false;
_from = 0;
_to = 0;
_verdict = null;
await LoadAsync();
}
private async Task LoadAsync()
{
_error = false;
_topology = null;
StateHasChanged();
try
{
_topology = await Service.GetAsync(EnergyTypeId);
Check();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogError(ex, "Loading the connections of energy type {EnergyTypeId} failed", EnergyTypeId);
_error = true;
}
// The dialog renders through the dialog provider: say so when the list arrives, whoever awaited it.
StateHasChanged();
}
private string Name(int meterId)
{
if (_topology?.Find(meterId) is not { } meter)
{
return MeterMembership.FallbackName(meterId);
}
return meter.EnergyTypeId == EnergyTypeId ? meter.Name : Loc.F(S.EnergyView_OtherTypeMeter, meter.Name);
}
/// <summary>The meter's name, marked when it is calculated or retired — the two things that change what a link means.</summary>
private static string Label(MeterLinkMeter meter)
{
var marks = new List<string>(2);
if (meter.IsVirtual)
{
marks.Add(meter.Mode.Display());
}
if (!meter.IsActive)
{
marks.Add(S.Meters_Retired);
}
return marks.Count == 0 ? meter.Name : meter.Name + " (" + string.Join(", ", marks) + ")";
}
private void OnFromChanged(int id)
{
_from = id;
Check();
}
private void OnToChanged(int id)
{
_to = id;
Check();
}
private void Check() =>
_verdict = _topology is not null && _from != 0 && _to != 0 ? _topology.CheckAdd(_from, _to) : null;
private async Task AddAsync()
{
await RunAsync(async () =>
{
var result = await Service.AddAsync(_from, _to);
if (result.IsAllowed)
{
Snackbar.Add(Loc.F(S.EnergyView_ConnectionAdded, Name(_from), Name(_to)), Severity.Success);
_from = 0;
_to = 0;
}
else
{
Snackbar.Add(FlowText.Refusal(result, Name, _to), Severity.Warning);
}
return result.IsAllowed;
});
}
private async Task RemoveAsync(MeterLinkEntry link)
{
var from = Name(link.FromMeterId);
var to = Name(link.ToMeterId);
await RunAsync(async () =>
{
var result = await Service.RemoveAsync(link.LinkId);
Snackbar.Add(
result.IsAllowed ? Loc.F(S.EnergyView_ConnectionRemoved, from, to) : FlowText.Refusal(result, Name, link.ToMeterId),
result.IsAllowed ? Severity.Success : Severity.Warning);
return result.IsAllowed;
});
}
/// <summary>One change at a time; the list is read again afterwards, so it always shows what is stored.</summary>
private async Task RunAsync(Func<Task<bool>> change)
{
if (_busy)
{
return;
}
_busy = true;
try
{
_changed |= await change();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogError(ex, "Changing a connection of energy type {EnergyTypeId} failed", EnergyTypeId);
Snackbar.Add(S.EnergyView_ConnectionFailed, Severity.Error);
}
finally
{
_busy = false;
}
await LoadAsync();
}
private async Task OnVisibleChanged(bool visible)
{
if (!visible)
{
await CloseAsync();
}
}
private async Task CloseAsync()
{
_open = false;
if (_changed)
{
_changed = false;
await Changed.InvokeAsync();
}
}
}
@@ -0,0 +1,75 @@
.mv-connections {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.mv-connections__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: "names remove" "note remove";
align-items: center;
gap: 0 8px;
padding: 6px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.mv-connections__names {
grid-area: names;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 6px;
min-width: 0;
}
.mv-connections__meter {
overflow-wrap: anywhere;
}
.mv-connections__note {
grid-area: note;
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
font-size: 0.75rem;
color: var(--mud-palette-text-secondary);
}
.mv-connections__note:empty {
display: none;
}
.mv-connections__item ::deep .mv-connections__remove {
grid-area: remove;
}
.mv-connections__add {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
}
.mv-connections__select {
flex: 1 1 200px;
min-width: 0;
}
.mv-connections__add ::deep .mv-connections__button {
align-self: center;
min-height: 40px;
}
.mv-connections__verdict {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
min-height: 3em;
margin-top: 4px;
font-size: 0.875rem;
color: var(--mud-palette-text-secondary);
}
+241 -146
View File
@@ -1,172 +1,267 @@
@page "/energy/{Id:int}"
@inject FlowService Flow
@inject MeterVault.Infrastructure.Costing.CostService Costs
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using MeterVault.App.Energy
@using MeterVault.App.Components.Pages.Energy
@using MeterVault.App.Components.Shared.MeterLists
@using MeterVault.Core.Analysis
@using MeterVault.Infrastructure.Analysis
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@inject NavigationManager Nav
@inject InstanceClock Clock
@inject AnalysisPeriods Periods
@inject AnalysisReader Reader
@inject CostReader Costs
@inject FlowService Flow
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ILogger<EnergyView> Logger
@implements IDisposable
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
@* One energy type (brief §7.3): its user-defined name as the title, one period toolbar for every tab, and the tabs
Overview | History | Flow | Meters by key (tab=…, written with replace). The type is read once per period, bucket and
comparison — its measures and every meter's own series, its bill, the comparison bill and the flow of those same
totals — so a tab switch, the History view or the metric never reloads anything (D-46). *@
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
<PageHeader Title="@Title" Description="@S.EnergyView_Description">
<Breadcrumbs>
<AnalysisBreadcrumbs Query="_query" EnergyTypeId="Id" EnergyTypeName="@Title" />
</Breadcrumbs>
<Actions>
@if (_state.Value is { Type: not null } header)
{
@if (header.HasGeneration)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.WbSunny"
Href="@AnalysisLinks.Solar(_query)">@S.Nav_Solar</MudButton>
}
@if (header.HasTank)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.PropaneTank"
Href="@AnalysisLinks.Consumables(_query)">@S.Nav_Consumables</MudButton>
}
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Settings"
Href="/admin/energy-types">@S.EnergyView_EditDefinition</MudButton>
}
</Actions>
</PageHeader>
@if (_query is not null)
{
<PeriodToolbar Query="_query" Period="_state.Value?.Period" Plan="ToolbarPlan" Defaults="Defaults" QueryChanged="OnQueryChanged"
ShowBucket="ShowsBuckets" ShowComparison="ShowsBuckets"
Metrics="@(_tab == AnalysisLinks.EnergyTabHistory ? _state.Value?.Metrics : null)" NaturalMetric="NaturalMetric"
ExportHref="@ExportHref" Class="mb-3" />
}
<div class="mv-energy-page">
<LoadPanel State="_state" OnRetry="Retry" Context="analysis" PlaceholderHeight="320">
@if (analysis.Type is null)
{
<MudAlert Severity="Severity.Warning">@S.EnergyView_NotFound</MudAlert>
}
else if (analysis.Meters.Count == 0)
{
<div class="mv-empty" role="status">
<MudIcon Icon="@Icons.Material.Outlined.Speed" Class="mv-empty__icon" aria-hidden="true" />
<div class="mv-empty__body">
<MudText Typo="Typo.subtitle1">@S.EnergyView_NoMeters</MudText>
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_NoMetersHelp</MudText>
<div class="d-flex flex-wrap gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" Href="/meters"
StartIcon="@Icons.Material.Filled.Add">@S.Meters_AddMeter</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/import"
StartIcon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudButton>
</div>
</div>
</div>
}
else
{
<MudTabs ActivePanelIndex="AnalysisLinks.EnergyTabIndex(_tab)" ActivePanelIndexChanged="OnTabChanged"
Elevation="0" Rounded="true" Border="false" TabPanelsClass="pt-4" Class="mv-energy-tabs">
<MudTabPanel Text="@S.EnergyView_TabOverview">
<EnergyOverviewTab Analysis="analysis" Query="_query!" Defaults="Defaults" OnRefresh="Retry" />
</MudTabPanel>
<MudTabPanel Text="@S.EnergyView_TabHistory">
<EnergyHistoryTab Analysis="analysis" Query="_query!" Defaults="Defaults" Metric="EffectiveMetric(analysis)"
View="@_view" ViewChanged="OnViewChanged" OnRefresh="Retry" />
</MudTabPanel>
<MudTabPanel Text="@S.EnergyView_TabFlow">
<EnergyFlowTab Analysis="analysis" Query="_query!" ManageConnections="OpenConnectionsAsync" />
</MudTabPanel>
<MudTabPanel Text="@Loc.F(S.EnergyView_TabMeters, analysis.Meters.Count)">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 mb-2">
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_MetersHelp</MudText>
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.Hub"
OnClick="OpenConnectionsAsync">@S.EnergyView_ManageConnections</MudButton>
</div>
<MeterList Rows="MeterRows(analysis)" Query="_query" />
</MudTabPanel>
</MudTabs>
}
</LoadPanel>
</div>
@if (_graph is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (!_graph.HasData)
{
<MudAlert Severity="Severity.Info">
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
reference data from <MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
<MudGrid Class="mb-2">
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
<MudText Typo="Typo.h5">@_meters.Count</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
@if (_graph.HasChain)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
</MudText>
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
</MudAlert>
@if (_graph.Nodes.Count > 0)
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
<tbody>
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
{
<tr>
<td>@node.Label</td>
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudPaper>
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
<tbody>
@foreach (var meter in _meters)
{
<tr>
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
<td>@meter.Mode</td>
<td>@UpstreamLabel(meter.Id)</td>
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
<ManageConnectionsDialog @ref="_connections" EnergyTypeId="Id" EnergyTypeName="@Title" Changed="OnConnectionsChanged" />
@code {
[Parameter]
public int Id { get; set; }
private int _months = 60;
private bool _loading;
private FlowGraph? _graph;
private double _cost;
private List<Meter> _meters = [];
private Dictionary<int, List<string>> _downstream = [];
private readonly LoadSequencer _loads = new();
private readonly LoadState<EnergyAnalysis> _state = new();
private AnalysisQuery? _query;
private (int Id, AnalysisQuery Query)? _loaded;
private string _tab = AnalysisLinks.EnergyTabOverview;
private string _view = EnergyPageKeys.ViewTotal;
private ManageConnectionsDialog? _connections;
private EnergyAnalysis? _rowsFor;
private IReadOnlyList<MeterListRow> _rows = [];
protected override Task OnParametersSetAsync() => LoadAsync();
/// <summary>The History defaults (last 12 months, automatic, previous year) on a page whose route names the type.</summary>
private AnalysisDefaults Defaults => AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(Math.Max(1, Id)));
private async Task OnRangeChanged(int months)
private string Title => _state.Value is { EnergyTypeId: var shown, Type: { } type } && shown == Id ? type.Name : S.EnergyView_EnergyFallback;
/// <summary>The interval and the comparison change only the Overview's and the History's figures.</summary>
private bool ShowsBuckets => _tab is AnalysisLinks.EnergyTabOverview or AnalysisLinks.EnergyTabHistory;
private BucketPlan? ToolbarPlan => _state.Value is { } value ? value.RefusedPlan ?? value.Quantities?.Plan : null;
private AnalysisMetric? NaturalMetric => _state.Value is { Metrics.Count: > 0 } value ? value.Metrics[0] : null;
/// <summary>
/// The History's CSV (D-55): the type's measures of the metric, or — in the individual view — the meters it charts.
/// </summary>
private string? ExportHref
{
_months = months;
await LoadAsync();
get
{
if (_tab != AnalysisLinks.EnergyTabHistory || _query is null || Id <= 0)
{
return null;
}
var metric = _state.Value is { } value ? EffectiveMetric(value) : _query.Metric;
var scope = QueryScope.ForEnergyType(Id);
if (_view == EnergyPageKeys.ViewMeters && metric is { } m && m.IsQuantity()
&& EnergyMetrics.MetersOf(_state.Value?.Quantities, m).Take(AnalysisLimits.MaxSeries).Select(s => s.MeterId!.Value).ToList() is { Count: > 0 } shown)
{
scope = QueryScope.ForMeters(shown);
}
return AnalysisLinks.Export(_query.WithScope(scope).WithMetric(metric));
}
}
private async Task LoadAsync()
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
protected override Task OnParametersSetAsync() => SyncAsync();
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
{
if (_loading)
// Only this page's own address: a link away fires this too, just before the page goes.
if (!IsThisPage(e.Location))
{
return;
}
_loading = true;
_graph = null;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
var to = asOf.AddMonths(1);
var typeId = (short)Id;
await SyncAsync();
StateHasChanged();
});
_graph = await Flow.GetFlowAsync(typeId, from, to);
await using var db = await DbFactory.CreateDbContextAsync();
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
var links = await db.MeterLinks.AsNoTracking()
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
.ToListAsync();
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
_downstream = links
.GroupBy(l => l.FromMeterId)
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
double cost = 0;
foreach (var meter in _meters)
{
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
}
_cost = cost;
}
finally
{
_loading = false;
}
private bool IsThisPage(string uri)
{
var path = Nav.ToBaseRelativePath(uri);
var end = path.IndexOfAny(['?', '#']);
path = (end >= 0 ? path[..end] : path).TrimEnd('/');
return string.Equals(path, "energy/" + Id.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
}
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
/// <summary>Reads the address: the tab and view always, the analysis only when period, bucket or comparison changed.</summary>
private async Task SyncAsync()
{
(_tab, _view) = EnergyPageKeys.Parse(Nav.Uri);
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
_query = query;
private string UpstreamLabel(int meterId) =>
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
var key = (Id, LoadKey(query));
if (_loaded == key)
{
return;
}
if (_loaded?.Id != Id)
{
// Another type: its figures must not show under this title, not even dimmed.
_state.Clear();
}
_loaded = key;
await LoadAsync(query);
}
/// <summary>What the read depends on: the metric only picks what History charts, the scope is the route's.</summary>
private AnalysisQuery LoadKey(AnalysisQuery query) => EnergyAnalysisLoader.LoadKey(query, Id);
private Task Retry() => _query is null ? Task.CompletedTask : LoadAsync(_query);
private Task LoadAsync(AnalysisQuery query)
{
var id = Id;
return _loads.RunAsync(_state, token => ReadAsync(id, query, token), Logger);
}
private Task<EnergyAnalysis> ReadAsync(int id, AnalysisQuery query, CancellationToken token) =>
new EnergyAnalysisLoader(DbFactory, Periods, Reader, Costs, Flow).LoadAsync(id, query, Clock.Now, token);
/// <summary>The metric History charts: the address's when the type has it, else the type's first (consumption first).</summary>
private AnalysisMetric EffectiveMetric(EnergyAnalysis analysis) => EnergyMetrics.Effective(_query?.Metric, analysis.Metrics);
private IReadOnlyList<MeterListRow> MeterRows(EnergyAnalysis analysis)
{
// Built once per committed value: the list keeps its search and filter across renders.
if (!ReferenceEquals(_rowsFor, analysis))
{
_rowsFor = analysis;
_rows = MeterListRows.Build(analysis.Meters, analysis.Quantities);
}
return _rows;
}
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
/// <summary>A tab click writes <c>tab=</c> (replace, D-46); the Overview is the default and is not written.</summary>
private void OnTabChanged(int index)
{
var tab = index >= 0 && index < AnalysisLinks.EnergyTabs.Count ? AnalysisLinks.EnergyTabs[index] : AnalysisLinks.EnergyTabOverview;
if (tab == _tab)
{
return;
}
_tab = tab;
Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.Tab, tab == AnalysisLinks.EnergyTabOverview ? null : tab), replace: true);
}
private void OnViewChanged(string view)
{
if (view == _view)
{
return;
}
_view = view;
Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.View, view == EnergyPageKeys.ViewTotal ? null : view), replace: true);
}
private Task OpenConnectionsAsync() => _connections?.OpenAsync() ?? Task.CompletedTask;
/// <summary>The topology changed: the measures, the bill and the flow may classify differently, so read again.</summary>
private Task OnConnectionsChanged() => Retry();
public void Dispose()
{
Nav.LocationChanged -= OnLocationChanged;
_loads.Dispose();
}
}
@@ -0,0 +1,10 @@
/* Four tabs fit a phone: plain case and tighter padding below 600px, so none of them is cut off or scrolled away. */
@media (max-width: 599.98px) {
.mv-energy-page ::deep .mv-energy-tabs .mud-tab {
/* MudTabs sets the tab's minimum width inline. */
min-width: 0 !important;
padding: 6px 8px;
text-transform: none;
letter-spacing: normal;
}
}
+10 -10
View File
@@ -1,27 +1,27 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<PageTitle>@S.Error_PageTitle</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h1 class="text-danger">@S.Error_Heading</h1>
<h2 class="text-danger">@S.Error_Message</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
<strong>@S.Error_RequestId</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<h3>@S.Error_DevelopmentMode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
@* "Development" and ASPNETCORE_ENVIRONMENT are literal environment names — emphasised, never translated. *@
@((MarkupString)Loc.F(S.Error_DevelopmentSwap, "<strong>Development</strong>"))
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
<strong>@S.Error_DevelopmentWarning</strong>
@S.Error_DevelopmentWarningDetail
@((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
</p>
@code{
+115 -36
View File
@@ -9,21 +9,20 @@
@using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Import</PageTitle>
<PageTitle>MeterVault — @S.Nav_Import</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Import</MudText>
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6">Reference dataset</MudText>
<MudText Typo="Typo.h6">@S.Import_ReferenceDataset</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
starter dataset with meters, tariffs and categories.
@S.Import_ReferenceDatasetHelp
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
@(_referenceLoaded ? "Loaded" : "Load reference data")
@(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
</MudButton>
@if (_loadingReference)
{
@@ -35,22 +34,21 @@
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<div class="d-flex align-center justify-space-between">
<MudText Typo="Typo.h6">Your own CSV</MudText>
<MudText Typo="Typo.h6">@S.Import_YourOwnCsv</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard</MudButton>
</div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a
revertible import. Or dry-run against one of the built-in reference profiles below.
@S.Import_YourOwnCsvHelp
</MudText>
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
<MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
<MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
<MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
<MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
<MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
</MudSelect>
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
Dry-run a reference sheet
@S.Import_DryRunReferenceSheet
</MudButton>
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
</MudPaper>
@@ -60,20 +58,20 @@
{
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Preview</MudText>
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
<MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
<MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
<MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
<MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
</div>
@if (_preview.Warnings.Count > 0)
{
<MudExpansionPanels Class="mt-3">
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _preview.Warnings.Count)">
@foreach (var warning in _preview.Warnings.Take(50))
{
<MudText Typo="Typo.body2">@warning</MudText>
<MudText Typo="Typo.body2">@warning.Display()</MudText>
}
</MudExpansionPanel>
</MudExpansionPanels>
@@ -84,38 +82,57 @@
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.Import_RecentImports</MudText>
@if (_batches.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Import_NoImportsYet</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th>@S.Import_ColumnWritesTo</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
<tbody>
@foreach (var batch in _batches)
{
<tr>
<td>@batch.Id</td>
<td>@(batch.SourceName ?? "—")</td>
<td>
@* What the batch wrote to, so an import can be checked where it landed — and a
revert weighed against what it will take away. *@
@if (_targets.TryGetValue(batch.Id, out var targets))
{
@foreach (var meter in targets.Meters)
{
<MudLink Href="@MeterLinks.Detail(meter.Id)" Class="mr-2">@meter.Name</MudLink>
}
@foreach (var category in targets.Categories)
{
<MudLink Href="/admin/categories" Class="mr-2" Color="Color.Secondary">@category</MudLink>
}
}
else
{
<span>—</span>
}
</td>
<td style="text-align:right">@batch.RowCount</td>
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
<td>
@if (batch.RevertedAt is not null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Import_StatusReverted</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Success">@S.Import_StatusActive</MudChip>
}
</td>
<td style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
StartIcon="@Icons.Material.Filled.Undo"
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
OnClick="@(() => RevertAsync(batch))">@S.Import_Revert</MudButton>
</td>
</tr>
}
@@ -132,6 +149,7 @@
private string _profileName = "Strom";
private StagedImport? _preview;
private List<ImportBatch> _batches = [];
private Dictionary<int, BatchTargets> _targets = [];
private int? _reverting;
protected override async Task OnInitializedAsync()
@@ -147,13 +165,74 @@
.OrderByDescending(b => b.Id)
.Take(25)
.ToListAsync();
_targets = await LoadTargetsAsync(db, [.. _batches.Where(b => b.RevertedAt is null).Select(b => b.Id)]);
}
/// <summary>
/// The meters and cost categories each batch wrote rows for. Read from the rows themselves rather
/// than the stored mapping, which the reference import does not have and which cannot say what a
/// partly failed or reverted batch actually left behind.
/// </summary>
private static async Task<Dictionary<int, BatchTargets>> LoadTargetsAsync(MeterVaultDbContext db, List<int> batchIds)
{
if (batchIds.Count == 0)
{
return [];
}
var readingMeters = await db.Readings.AsNoTracking()
.Where(r => r.ImportBatchId != null && batchIds.Contains(r.ImportBatchId.Value))
.Select(r => new { Batch = r.ImportBatchId!.Value, r.MeterId })
.Distinct()
.ToListAsync();
var eventMeters = await db.MeterEvents.AsNoTracking()
.Where(e => e.ImportBatchId != null && batchIds.Contains(e.ImportBatchId.Value))
.Select(e => new { Batch = e.ImportBatchId!.Value, e.MeterId })
.Distinct()
.ToListAsync();
var costs = await db.ManualCosts.AsNoTracking()
.Where(c => c.ImportBatchId != null && batchIds.Contains(c.ImportBatchId.Value))
.Select(c => new { Batch = c.ImportBatchId!.Value, c.MeterId, c.CategoryId })
.Distinct()
.ToListAsync();
var meterPairs = readingMeters.Select(r => (r.Batch, MeterId: (int?)r.MeterId))
.Concat(eventMeters.Select(e => (e.Batch, MeterId: (int?)e.MeterId)))
.Concat(costs.Select(c => (c.Batch, c.MeterId)))
.Where(p => p.MeterId is not null)
.Select(p => (p.Batch, MeterId: p.MeterId!.Value))
.Distinct()
.ToList();
var categoryPairs = costs.Where(c => c.CategoryId is not null).Select(c => (c.Batch, CategoryId: c.CategoryId!.Value)).Distinct().ToList();
var meterIds = meterPairs.Select(p => p.MeterId).Distinct().ToList();
var categoryIds = categoryPairs.Select(p => p.CategoryId).Distinct().ToList();
var meterNames = await db.Meters.AsNoTracking().Where(m => meterIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => m.Name);
var categoryNames = await db.CostCategories.AsNoTracking().Where(c => categoryIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.Name);
return batchIds
.Select(id => (Id: id, Targets: new BatchTargets(
[.. meterPairs.Where(p => p.Batch == id && meterNames.ContainsKey(p.MeterId))
.Select(p => new MeterTarget(p.MeterId, meterNames[p.MeterId]))
.OrderBy(m => m.Name, StringComparer.CurrentCulture)],
[.. categoryPairs.Where(p => p.Batch == id && categoryNames.ContainsKey(p.CategoryId))
.Select(p => categoryNames[p.CategoryId])
.Order(StringComparer.CurrentCulture)])))
.Where(t => t.Targets.Meters.Count > 0 || t.Targets.Categories.Count > 0)
.ToDictionary(t => t.Id, t => t.Targets);
}
private sealed record MeterTarget(int Id, string Name);
private sealed record BatchTargets(IReadOnlyList<MeterTarget> Meters, IReadOnlyList<string> Categories);
private async Task RevertAsync(ImportBatch batch)
{
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?",
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?",
"Revert"))
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
S.Import_Revert))
{
return;
}
@@ -162,12 +241,12 @@
try
{
await ImportService.RevertAsync(batch.Id);
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success);
Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success);
await LoadBatchesAsync();
}
catch (Exception ex)
{
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error);
Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
}
finally
{
@@ -183,12 +262,12 @@
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await ReferenceImporter.LoadAsync(dir);
_referenceLoaded = true;
Snackbar.Add("Reference data loaded.", Severity.Success);
Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
await LoadBatchesAsync();
}
catch (Exception ex)
{
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
}
finally
{
+79 -57
View File
@@ -10,29 +10,28 @@
@using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Import wizard</PageTitle>
<PageTitle>MeterVault — @S.ImportWizard_Title</PageTitle>
<div class="d-flex align-center mb-4" style="gap:1rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" />
<MudText Typo="Typo.h4">Import wizard</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
<MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
</div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
Upload any CSV, map its columns to your meters and categories, preview what would be staged, then
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes,
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
@S.ImportWizard_IntroLead
<code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
Choose CSV
@S.ImportWizard_ChooseCsv
</MudButton>
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
@if (_fileName is not null)
{
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText>
<MudText><b>@_fileName</b> — @Loc.F(S.ImportWizard_FileSummary, _rows.Count, _colCount)</MudText>
}
</div>
</MudPaper>
@@ -40,10 +39,10 @@
@if (_colCount > 0)
{
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step1Title</MudText>
<MudGrid>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true">
<MudSelect T="int" @bind-Value="_dateColumn" Label="@S.ImportWizard_DateColumn" Dense="true">
@foreach (var i in Enumerable.Range(0, _colCount))
{
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
@@ -51,27 +50,27 @@
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true">
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem>
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="@S.ImportWizard_DateFormat" Dense="true">
<MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="6" sm="6" md="2">
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" />
<MudNumericField T="int" @bind-Value="_headerRow" Label="@S.ImportWizard_HeaderRow" Min="0" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="6" sm="6" md="2">
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" />
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="@S.ImportWizard_FirstDataRow" Min="0" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" md="2" Class="d-flex flex-column">
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="Skip zero rows" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
</MudItem>
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step2Title</MudText>
<div style="overflow-x:auto">
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
<thead>
@@ -79,7 +78,7 @@
@foreach (var i in Enumerable.Range(0, _colCount))
{
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
Col @i@(i == _dateColumn ? " 📅" : "")
@Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "")
</th>
}
</tr>
@@ -98,23 +97,23 @@
</MudSimpleTable>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Faded rows are before the first data row. The 📅 column supplies the date.
@S.ImportWizard_PreviewCaption
</MudText>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step3Title</MudText>
<div style="overflow-x:auto">
<MudSimpleTable Dense="true" Style="min-width:100%">
<thead>
<tr><th>Column</th><th>Sample</th><th style="min-width:160px">Role</th><th style="min-width:220px">Target</th><th style="min-width:120px">Unit</th></tr>
<tr><th>@S.ImportWizard_HeaderColumn</th><th>@S.ImportWizard_HeaderSample</th><th style="min-width:160px">@S.ImportWizard_HeaderRole</th><th style="min-width:220px">@S.Common_Target</th><th style="min-width:120px">@S.Common_Unit</th></tr>
</thead>
<tbody>
@foreach (var i in Enumerable.Range(0, _colCount))
{
<tr>
<td>
<b>Col @i</b>
<b>@Loc.F(S.ImportWizard_ColumnN, i)</b>
@if (!string.IsNullOrWhiteSpace(Header(i)))
{
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
@@ -125,7 +124,7 @@
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
@foreach (var role in Enum.GetValues<MappingRole>())
{
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem>
<MudSelectItem T="MappingRole" Value="role">@role.Display()</MudSelectItem>
}
</MudSelect>
</td>
@@ -133,7 +132,7 @@
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
{
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
Placeholder="Select meter" Clearable="true">
Placeholder="@S.ImportWizard_SelectMeter" Clearable="true">
@foreach (var m in _meters)
{
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
@@ -143,7 +142,7 @@
else if (_columns[i].Role == MappingRole.ManualCost)
{
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
Placeholder="Select category" Clearable="true">
Placeholder="@S.ImportWizard_SelectCategory" Clearable="true">
@foreach (var c in _categories)
{
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
@@ -154,7 +153,7 @@
<td>
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
{
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="e.g. kWh" Margin="Margin.Dense" />
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="@S.ImportWizard_UnitPlaceholder" Margin="Margin.Dense" />
}
</td>
</tr>
@@ -176,12 +175,12 @@
<MudPaper Class="pa-4 mb-4" Elevation="2">
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
<MudText Typo="Typo.h6">4. Preview &amp; commit</MudText>
<MudText Typo="Typo.h6">@S.ImportWizard_Step4Title</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
OnClick="Preview">Dry-run preview</MudButton>
OnClick="Preview">@S.ImportWizard_DryRunButton</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
Commit import
@S.ImportWizard_CommitButton
</MudButton>
@if (_committing)
{
@@ -192,24 +191,24 @@
@if (_staged is not null)
{
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText>
<MudText>Events: <b>@_staged.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText>
<MudText>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
<MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
<MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
<MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
</div>
@if (_staged.TotalRows == 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
Nothing staged. Check the first-data-row, date column and column mappings above.
@S.ImportWizard_NothingStaged
</MudAlert>
}
@if (_staged.Warnings.Count > 0)
{
<MudExpansionPanels Class="mt-3">
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")">
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _staged.Warnings.Count)">
@foreach (var warning in _staged.Warnings.Take(100))
{
<MudText Typo="Typo.body2">@warning</MudText>
<MudText Typo="Typo.body2">@warning.Display()</MudText>
}
</MudExpansionPanel>
</MudExpansionPanels>
@@ -243,6 +242,7 @@
private List<Meter> _meters = [];
private List<CostCategory> _categories = [];
private StagedImport? _staged;
private string? _stagedMapping;
private List<string> _validationErrors = [];
private bool _committing;
@@ -265,6 +265,7 @@
_columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray();
_dateColumn = 0;
_staged = null;
_stagedMapping = null;
_validationErrors = [];
await using var db = await DbFactory.CreateDbContextAsync();
@@ -283,8 +284,23 @@
using var reader = new StringReader(_csvText ?? string.Empty);
_staged = CsvImporter.Stage(BuildProfile(), reader);
_stagedMapping = BuildMappingJson();
}
/// <summary>
/// The mapping as persisted on the batch for provenance, and — compared against the mapping the
/// preview was staged under — the check that the two still agree.
/// </summary>
private string BuildMappingJson() => JsonSerializer.Serialize(new
{
dateColumn = _dateColumn,
dateKind = _dateKind.ToString(),
firstDataRow = _firstDataRow,
columns = _columns
.Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit })
.Where(c => c.role != nameof(MappingRole.Ignore)),
});
private async Task CommitAsync()
{
if (_staged is null || _staged.TotalRows == 0)
@@ -300,26 +316,29 @@
return;
}
// The staged rows were built from the mapping as it stood at preview time. Editing a target
// afterwards leaves them pointing at the old meter while the batch would record the new
// mapping — wrong data, with a provenance record that contradicts it and no error to notice.
// Nothing here can re-derive the rows, so refuse rather than write either version.
var mappingJson = BuildMappingJson();
if (!string.Equals(mappingJson, _stagedMapping, StringComparison.Ordinal))
{
_staged = null;
_stagedMapping = null;
_validationErrors = [S.ImportWizard_MappingChanged];
return;
}
_committing = true;
try
{
var mappingJson = JsonSerializer.Serialize(new
{
dateColumn = _dateColumn,
dateKind = _dateKind.ToString(),
firstDataRow = _firstDataRow,
columns = _columns
.Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit })
.Where(c => c.role != nameof(MappingRole.Ignore)),
});
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success);
Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success);
Nav.NavigateTo("/import");
}
catch (Exception ex)
{
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error);
Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error);
}
finally
{
@@ -332,7 +351,7 @@
var errors = new List<string>();
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
{
errors.Add("Map at least one column to a role other than Ignore.");
errors.Add(S.ImportWizard_ValidateNoMappedColumn);
}
for (var i = 0; i < _columns.Length; i++)
@@ -340,12 +359,12 @@
var c = _columns[i];
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
{
errors.Add($"Col {i} ({c.Role}) needs a target meter.");
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display()));
}
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
{
errors.Add($"Col {i} (ManualCost) needs a target category.");
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i));
}
}
@@ -359,9 +378,10 @@
foreach (var group in duplicateTargets)
{
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}";
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}"));
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter.");
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name
?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index)));
errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName));
}
return errors;
@@ -411,6 +431,8 @@
private string ColLabel(int col)
{
var header = Header(col);
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}";
return string.IsNullOrWhiteSpace(header)
? Loc.F(S.ImportWizard_ColumnN, col)
: Loc.F(S.ImportWizard_ColumnWithHeader, col, header);
}
}
+487 -427
View File
@@ -1,488 +1,548 @@
@page "/meters/{Id:int}"
@using MeterVault.App.Components.Pages.MeterPage
@using MeterVault.App.MeterDetails
@using MeterVault.Core.Analysis
@inject MeterDetailService Details
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject AnalysisPeriods Periods
@inject MeterVault.Infrastructure.Analysis.AnalysisReader Reader
@inject CostReader Costs
@inject InstanceClock Clock
@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
@inject ILogger<MeterDetail> Logger
@implements IDisposable
<PageTitle>MeterVault — Meter</PageTitle>
@* The per-meter hub (brief §7.2, SDD §8.6): the header with the meter's identity and its actions, the tab bar directly
below it, and the analysis in the default Analysis tab — so Sources and Events never sit under a wall of charts.
Tabs are addressed by stable keys (D-47) and written back to the address on a click (replace); the analysis reloads
only when its own keys change (D-46), and the one-shot `action` is consumed once, separately from both. *@
@if (_detail is null)
{
<PageTitle>MeterVault — @S.Common_Meter</PageTitle>
@if (_notFound)
{
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
<MudAlert Severity="Severity.Warning">@Loc.F(S.MeterDetail_NotFound, Id) <MudLink Href="/meters">@S.MeterDetail_BackToMeters</MudLink></MudAlert>
}
else if (_detailFailed)
{
<PanelError HasStaleValue="false" OnRetry="RetryDetailAsync" />
}
else
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
<MudProgressLinear Indeterminate="true" Color="Color.Primary" aria-label="@S.Refresh_Updating" />
}
}
else
{
<div class="d-flex align-center mb-4" style="gap:.75rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
<MudText Typo="Typo.h4">@_detail.Name</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
@if (!_detail.IsActive)
<MeterHeader Detail="_detail" Query="_query" Today="Clock.Today"
OnAddReading="OpenReadingAsync" OnRecordEvent="@(type => OpenEventAsync(type))" OnEdit="OpenEditorAsync">
@if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2">
@S.MeterDetail_NoTankConfigured
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
OnClick="OpenEditorAsync">@S.MeterDetail_SetUpTank</MudButton>
</MudAlert>
}
</div>
<MudGrid Class="mb-2">
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
@if (_detail.TotalGeneration != 0)
else if (IsUnstarted)
{
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
@* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
@S.MeterDetail_GetStarted
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
@if (TakesReadings)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OpenReadingAsync">@S.MeterDetail_AddFirstReading</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.SettingsInputComponent"
Href="@MeterLinks.Source(_detail.Id)">@S.MeterDetail_ConnectSource</MudButton>
}
else
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Straighten"
OnClick="@(() => OpenEventAsync(MeterEventType.TankLevel))">@S.MeterDetail_RecordTankLevel</MudButton>
}
</div>
</MudAlert>
}
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
<MudText Typo="Typo.h6">
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
</MudPaper>
</MudItem>
</MudGrid>
</MeterHeader>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
@if (_detail.RecentReadings.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
<tbody>
@foreach (var r in _detail.RecentReadings)
{
<tr>
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
<td>@QualityChip(r.Quality)</td>
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
@if (_detail.RecentConsumption.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
<tbody>
@foreach (var c in _detail.RecentConsumption)
{
<tr>
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
<td>@c.Kind</td>
<td>@QualityChip(c.Quality)</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
@if (_detail.Events.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
<tbody>
@foreach (var e in _detail.Events)
{
<tr>
<td>@e.Time.ToString("yyyy-MM-dd")</td>
<td>@e.Type</td>
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
<td>@e.Notes</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
@if (_detail.Tariffs.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
<tbody>
@foreach (var t in _detail.Tariffs)
{
<tr>
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
<td>@t.Component</td>
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
<td>@t.Unit</td>
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
<div class="d-flex justify-end mb-2">
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
Add source
</MudButton>
</div>
@if (_sources.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Type</th><th>Target</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
<tbody>
@foreach (var s in _sources)
{
<tr>
<td>@s.SourceType</td>
<td>@SourceTarget(s)</td>
<td>@(s.IsEnabled ? "yes" : "no")</td>
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
<td>@(s.LastStatus ?? "—")</td>
<td style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabs @key="@($"{_detail.Id}:{_detail.Mode}")" ActivePanelIndex="ActiveIndex" ActivePanelIndexChanged="OnTabIndexChanged"
Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Border="true" Class="mt-3">
@foreach (var key in MeterLinks.VisibleTabs(_detail.Mode))
{
<MudTabPanel Text="@TabLabel(key)" ID="@key">
<div class="pt-4">
@switch (key)
{
case MeterLinks.TabAnalysis:
<MeterAnalysisTab Detail="_detail" Query="_query" State="_analysis" OnQueryChanged="ReplaceQuery"
OnRetry="ReloadAnalysisAsync" OnAddReading="OpenReadingAsync"
OnRecordEvent="@(type => OpenEventAsync(type))" OnEdit="OpenEditorAsync" />
break;
case MeterLinks.TabReadings:
<MeterReadingsTab Detail="_detail" Query="_query" Version="_version" OnQueryChanged="ReplaceQuery"
OnChanged="RefreshAsync" OnAddReading="OpenReadingAsync" />
break;
case MeterLinks.TabNormalized:
<MeterNormalizedTab Detail="_detail" Query="_query" Version="_version" OnQueryChanged="ReplaceQuery" />
break;
case MeterLinks.TabEvents:
<MeterEventsTab Detail="_detail" Query="_query" Version="_version" OnQueryChanged="ReplaceQuery"
OnChanged="RefreshAsync" OnRecordEvent="@(type => OpenEventAsync(type))" />
break;
case MeterLinks.TabTariffs:
<MeterTariffsTab Detail="_detail" Version="_version" />
break;
case MeterLinks.TabSources:
<MeterSourcesTab Detail="_detail" Zone="Periods.Zone" Version="_version"
OnEdit="OpenSourceAsync" OnChanged="RefreshAsync" />
break;
case MeterLinks.TabCalculation:
<MeterCalculationTab Detail="_detail" Query="_query" Version="_version" OnEdit="OpenEditorAsync" />
break;
}
</div>
</MudTabPanel>
}
</MudTabs>
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
@foreach (var type in Enum.GetValues<SourceType>())
{
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
}
</MudSelect>
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
{
if (ConnectorsFor(needed).Count == 0)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink>
(set it up once; every source then just picks it).
</MudAlert>
}
else
{
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
@foreach (var e in ConnectorsFor(needed))
{
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
}
</MudSelect>
}
}
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
{
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollSeconds" Label="Poll interval (seconds)" Class="mb-2" />
}
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
{
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
}
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
@foreach (var kind in Enum.GetValues<SourceValueKind>())
{
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
}
</MudSelect>
<div class="d-flex" style="gap:1rem">
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
</DialogActions>
</MudDialog>
<ManualReadingDialog @ref="_reading" MeterId="_detail.Id" MeterName="@_detail.Name" Unit="@_detail.Unit" Zone="Periods.Zone"
Saved="RefreshAsync" SwitchToEvent="OnSwitchToEventAsync" />
<MeterSourceDialog @ref="_source" MeterId="_detail.Id" Saved="RefreshAsync" />
<MeterEventDialog @ref="_eventDialog" MeterId="_detail.Id" MeterName="@_detail.Name" Saved="OnEventSavedAsync" Cancelled="OnEventCancelledAsync" />
}
<MeterEditor @ref="_editor" Saved="OnMeterSavedAsync" SwapInsteadRequested="@(_ => OpenEventAsync(MeterEventType.MeterSwap))"
PeriodQuery="_query" />
@code {
[Parameter]
public int Id { get; set; }
/// <summary>Which tab to open: a stable key (<see cref="MeterLinks.VisibleTabs"/>); old keys resolve (D-47).</summary>
[SupplyParameterFromQuery(Name = "tab")]
public string? Tab { get; set; }
/// <summary>A dialog to open once the page is interactive; see <see cref="MeterLinks"/>.</summary>
[SupplyParameterFromQuery(Name = "action")]
public string? Action { get; set; }
/// <summary>With the source action: the existing source to open instead of a new one.</summary>
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
public int? SourceParam { get; set; }
/// <summary>With the source action: the source type to preset.</summary>
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
public string? SourceTypeParam { get; set; }
/// <summary>With the source action: the connector to preselect, typically one just created for it.</summary>
[SupplyParameterFromQuery(Name = MeterLinks.ParamConnector)]
public int? ConnectorParam { get; set; }
// The analysis keys (D-46). Declared so a change to any of them re-runs OnParametersSet; the query itself is parsed
// from the whole address, which is what AnalysisQuery reads.
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Period)]
public string? PeriodKey { get; set; }
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.From)]
public string? FromKey { get; set; }
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.To)]
public string? ToKey { get; set; }
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Bucket)]
public string? BucketKey { get; set; }
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Compare)]
public string? CompareKey { get; set; }
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Metric)]
public string? MetricKey { get; set; }
private readonly LoadSequencer _detailLoads = new();
private readonly LoadSequencer _analysisLoads = new();
private readonly LoadState<MeterAnalysisView> _analysis = new();
private MeterDetailView? _detail;
private bool _notFound;
private List<MeterSource> _sources = [];
private List<IngestionEndpoint> _endpoints = [];
private bool _sourceOpen;
private SourceEdit _sourceEdit = new();
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private bool _detailFailed;
private int? _loadedId;
/// <summary>The page's analysis state (scope: this meter).</summary>
private AnalysisQuery _query = AnalysisQuery.Default(AnalysisDefaults.History);
/// <summary>The query the analysis was last requested for; a tab change or an action drop never reloads it (D-46).</summary>
private AnalysisQuery? _analysisRequested;
private string _activeTab = MeterLinks.TabAnalysis;
private string? _appliedTab;
private bool _tabApplied;
/// <summary>Bumped whenever the meter's data changed, so the open tab reloads.</summary>
private int _version;
private string? _pendingAction;
private SourcePreset? _pendingSource;
private bool _droppingAction;
private bool _refreshBeforeAction;
private ManualReadingDialog? _reading;
private MeterSourceDialog? _source;
private MeterEventDialog? _eventDialog;
private MeterEditor? _editor;
private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Id);
private bool TakesReadings => _detail is not null && MeterEventRules.TakesReadings(_detail.Mode);
private bool IsUnstarted =>
_detail is { HasReadings: false, HasEvents: false, SourceCount: 0, IsVirtual: false };
private int ActiveIndex => _detail is null ? 0 : MeterLinks.PanelIndex(_activeTab, _detail.Mode);
protected override async Task OnParametersSetAsync()
{
_detail = null;
_notFound = false;
_detail = await Details.GetAsync(Id);
_notFound = _detail is null;
if (_detail is not null)
{
await LoadSourcesAsync();
}
}
var id = Id;
private async Task LoadSourcesAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync();
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
}
private static string SourceTarget(MeterSource s)
{
var config = SourceConfig.Parse(s.Config);
return s.SourceType == SourceType.HomeAssistant
? config.EntityId ?? "—"
: config.Topic ?? "—";
}
private void OpenSource(MeterSource? source)
{
if (source is null)
// Only a different meter reloads the page. The query string changes too — a deep link into a tab, the action
// being dropped once consumed, a period — and none of those should blank and refetch it.
var fresh = _loadedId != id;
if (fresh)
{
_sourceEdit = new SourceEdit();
OnSourceTypeChanged(_sourceEdit.SourceType);
}
else
{
var config = SourceConfig.Parse(source.Config);
_sourceEdit = new SourceEdit
{
Id = source.Id,
SourceType = source.SourceType,
EndpointId = source.EndpointId,
ValueKind = source.ValueKind,
Scale = source.Scale,
Offset = source.Offset,
Priority = source.Priority,
IsEnabled = source.IsEnabled,
EntityId = config.EntityId,
Attribute = config.Attribute,
PollSeconds = config.PollSeconds,
Topic = config.Topic,
Path = config.Path,
TimePath = config.TimePath,
};
}
_sourceOpen = true;
}
_loadedId = id;
_detail = null;
_notFound = false;
_detailFailed = false;
private async Task SaveSourceAsync()
{
// A live source without a matching connector has no connection details and would silently
// never ingest, so refuse it here rather than letting it look configured.
if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
{
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (selected is null)
{
Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error);
return;
}
if (selected.Type != needed)
{
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
return;
}
}
else
{
_sourceEdit.EndpointId = null;
// Per-meter view state: another meter opens on the tab its link names, shows none of the previous meter's
// figures, and an action meant for the previous meter must not fire on this one.
_analysis.Clear();
_analysisRequested = null;
_activeTab = MeterLinks.TabAnalysis;
_appliedTab = null;
_tabApplied = false;
_pendingAction = null;
_pendingSource = null;
_droppingAction = false;
_refreshBeforeAction = false;
await LoadDetailAsync(id);
}
var config = new SourceConfig
{
EntityId = Trim(_sourceEdit.EntityId),
Attribute = Trim(_sourceEdit.Attribute),
PollSeconds = _sourceEdit.PollSeconds,
Topic = Trim(_sourceEdit.Topic),
Path = Trim(_sourceEdit.Path),
TimePath = Trim(_sourceEdit.TimePath),
};
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
await using var db = await DbFactory.CreateDbContextAsync();
if (_sourceEdit.Id == 0)
{
db.MeterSources.Add(new MeterSource
{
MeterId = Id,
SourceType = _sourceEdit.SourceType,
EndpointId = _sourceEdit.EndpointId,
Config = configJson,
ValueKind = _sourceEdit.ValueKind,
Scale = _sourceEdit.Scale,
Offset = _sourceEdit.Offset,
Priority = _sourceEdit.Priority,
IsEnabled = _sourceEdit.IsEnabled,
});
}
else
{
var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id);
existing.SourceType = _sourceEdit.SourceType;
existing.EndpointId = _sourceEdit.EndpointId;
existing.Config = configJson;
existing.ValueKind = _sourceEdit.ValueKind;
existing.Scale = _sourceEdit.Scale;
existing.Offset = _sourceEdit.Offset;
existing.Priority = _sourceEdit.Priority;
existing.IsEnabled = _sourceEdit.IsEnabled;
}
await db.SaveChangesAsync();
_sourceOpen = false;
Snackbar.Add("Source saved.", Severity.Success);
await LoadSourcesAsync();
}
private async Task DeleteSourceAsync(MeterSource source)
{
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
if (id != Id || _detail is not { } detail || detail.Id != id)
{
return;
}
await using var db = await DbFactory.CreateDbContextAsync();
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
Snackbar.Add("Source deleted.", Severity.Success);
await LoadSourcesAsync();
}
_query = MeterAnalysisLoader.ForMeter(AnalysisQuery.Parse(Nav.Uri, Defaults), id);
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// A link's tab wins when it changes, or when the link also carries an action; otherwise the tab the user
// clicked since is kept, including when the action is dropped from the address.
if (!_tabApplied || !string.Equals(Tab, _appliedTab, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Action))
{
_activeTab = MeterLinks.ResolveTab(Tab, detail.Mode);
_tabApplied = true;
}
_appliedTab = Tab;
if (!string.IsNullOrEmpty(Action))
{
_pendingAction = Action;
_pendingSource = new SourcePreset(
SourceParam,
Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
ConnectorParam);
// Arriving on a page already showing this meter: reload first, so the header is current when the dialog opens.
_refreshBeforeAction |= !fresh;
}
else
{
_droppingAction = false;
}
if (_activeTab == MeterLinks.TabAnalysis)
{
await EnsureAnalysisAsync();
}
}
/// <summary>
/// Which connector kind a source type needs, or null if it needs none (manual/import/virtual).
/// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
/// Opens a deep-linked dialog. After render, because only the interactive render can show one — a
/// prerendered page has no circuit to drive it.
/// </summary>
private static EndpointType? RequiredEndpointType(SourceType sourceType) => sourceType switch
/// <remarks>
/// The action is dropped from the address <em>first</em> and the dialog opened once that navigation
/// has come back. The other order fails on a fresh load (bookmark, shared link, new tab): a circuit's
/// first location change makes MudBlazor's dialog provider dismiss every open dialog, so the dialog
/// would flash and close. Dropping it also means a reload does not reopen it.
/// </remarks>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
SourceType.HomeAssistant => EndpointType.HomeAssistant,
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
_ => null,
};
private List<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
_endpoints.Where(e => e.Type == type).ToList();
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
private void OnSourceTypeChanged(SourceType sourceType)
{
_sourceEdit.SourceType = sourceType;
var needed = RequiredEndpointType(sourceType);
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (needed is null || (selected is not null && selected.Type != needed))
if (_pendingAction is not { } action || _detail is null)
{
_sourceEdit.EndpointId = null;
return;
}
// Sole candidate: preselect it, so the common single-broker / single-HA setup is one click.
if (needed is not null && _sourceEdit.EndpointId is null)
if (!string.IsNullOrEmpty(Action))
{
var candidates = _endpoints.Where(e => e.Type == needed).ToList();
if (candidates.Count == 1)
if (!_droppingAction)
{
_sourceEdit.EndpointId = candidates[0].Id;
_droppingAction = true;
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
{
["action"] = null,
[MeterLinks.ParamSource] = null,
[MeterLinks.ParamSourceType] = null,
[MeterLinks.ParamConnector] = null,
}), replace: true);
}
return;
}
_pendingAction = null;
var sourcePreset = _pendingSource;
_pendingSource = null;
if (_refreshBeforeAction)
{
_refreshBeforeAction = false;
await ReloadDetailAsync();
if (_detail is null)
{
StateHasChanged();
return;
}
}
switch (action.ToLowerInvariant())
{
case MeterLinks.ActionReading when TakesReadings:
await OpenReadingAsync();
break;
case MeterLinks.ActionEdit:
await OpenEditorAsync();
break;
case MeterLinks.ActionSource when !_detail.IsVirtual:
await _source!.OpenFromLinkAsync(sourcePreset?.SourceId, sourcePreset?.Type, sourcePreset?.ConnectorId);
break;
default:
if (MeterLinks.EventFor(action) is { } type && MeterEventRules.CanRecord(_detail.Mode, type))
{
await OpenEventAsync(type);
}
break;
}
StateHasChanged();
}
private sealed class SourceEdit
/// <summary>
/// Loads the meter's identity. Sequenced: fast meter-to-meter navigation cancels the previous load, and an answer
/// for a meter the page has left is dropped (brief §8).
/// </summary>
private async Task LoadDetailAsync(int id)
{
public int Id { get; set; }
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
public int? EndpointId { get; set; }
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
public double Scale { get; set; } = 1;
public double Offset { get; set; }
public int Priority { get; set; }
public bool IsEnabled { get; set; } = true;
public string? EntityId { get; set; }
public string? Attribute { get; set; }
public int? PollSeconds { get; set; } = 60;
public string? Topic { get; set; }
public string? Path { get; set; }
public string? TimePath { get; set; }
var ticket = _detailLoads.Next();
MeterDetailView? detail;
try
{
detail = await Details.GetAsync(id, ticket.Token);
}
catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
if (_detailLoads.IsCurrent(ticket) && id == Id)
{
Logger.LogError(ex, "Loading meter {MeterId} failed", id);
_detailFailed = true;
}
return;
}
if (!_detailLoads.IsCurrent(ticket) || id != Id)
{
return;
}
_detail = detail;
_notFound = detail is null;
_detailFailed = false;
}
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
private async Task RetryDetailAsync()
{
_detailFailed = false;
_loadedId = null;
await OnParametersSetAsync();
}
/// <summary>Reloads the identity in place (the tabs stay; a meter that vanished shows "not found").</summary>
private async Task ReloadDetailAsync()
{
await LoadDetailAsync(Id);
if (_detail is { } detail)
{
// A mode change (edited into a virtual meter, say) can take the open tab away.
_activeTab = MeterLinks.ResolveTab(_activeTab, detail.Mode);
}
}
/// <summary>Loads the analysis for the current query unless it was already requested for it (D-46).</summary>
private async Task EnsureAnalysisAsync()
{
if (_detail is not { } detail || _query == _analysisRequested)
{
return;
}
var id = detail.Id;
var query = _query;
_analysisRequested = query;
var loader = new MeterAnalysisLoader(Periods, Reader, Costs, Details);
await _analysisLoads.RunAsync(_analysis, token => loader.LoadAsync(id, query, Clock.Now, token), Logger);
}
private async Task ReloadAnalysisAsync()
{
_analysisRequested = null;
await EnsureAnalysisAsync();
}
/// <summary>After any change to the meter's data: the identity, the open tab and — when shown — the analysis.</summary>
private async Task RefreshAsync()
{
_version++;
await ReloadDetailAsync();
_analysisRequested = null;
if (_activeTab == MeterLinks.TabAnalysis)
{
await EnsureAnalysisAsync();
}
else
{
// Stale for its next showing: the analysis tab reloads when it is opened again.
_analysis.Clear();
}
StateHasChanged();
}
/// <summary>A tab click: remembered in the address (replace, D-46) — never a reload of the analysis or an action.</summary>
private async Task OnTabIndexChanged(int index)
{
if (_detail is null)
{
return;
}
var tabs = MeterLinks.VisibleTabs(_detail.Mode);
if (index < 0 || index >= tabs.Count || tabs[index] == _activeTab)
{
return;
}
var key = tabs[index];
_activeTab = key;
_appliedTab = key;
Nav.NavigateTo(Nav.GetUriWithQueryParameter("tab", key), replace: true);
if (key == MeterLinks.TabAnalysis)
{
await EnsureAnalysisAsync();
}
}
/// <summary>A toolbar choice: written into the address, replacing the history entry (D-46).</summary>
private void ReplaceQuery(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
private static string TabLabel(string key) => key switch
{
MeterLinks.TabAnalysis => S.MeterDetail_TabAnalysis,
MeterLinks.TabReadings => S.MeterDetail_TabReadings,
MeterLinks.TabNormalized => S.MeterDetail_TabNormalized,
MeterLinks.TabEvents => S.MeterDetail_TabEvents,
MeterLinks.TabTariffs => S.MeterDetail_TabTariffs,
MeterLinks.TabSources => S.MeterDetail_TabSources,
_ => S.MeterDetail_TabCalculation,
};
private async Task OpenReadingAsync()
{
if (TakesReadings && _reading is not null)
{
await _reading.OpenAsync();
}
}
private async Task OpenEditorAsync()
{
if (_editor is not null)
{
await _editor.OpenAsync(Id);
}
}
private async Task OpenSourceAsync(MeterSource? source)
{
if (_source is not null)
{
await _source.OpenAsync(source);
}
}
private async Task OpenEventAsync(MeterEventType type, DateTimeOffset? at = null, bool keepResume = false)
{
if (_eventDialog is null)
{
return;
}
// Opened on its own, the event dialog replaces a reading still waiting for a swap it was detoured to.
if (!keepResume)
{
_reading?.DropResume();
}
await _eventDialog.OpenAsync(type, at);
}
/// <summary>From the reading dialog into the swap/reset dialog, at the time the reading was being entered.</summary>
private Task OnSwitchToEventAsync((MeterEventType Type, DateTimeOffset? At) request) =>
OpenEventAsync(request.Type, request.At, keepResume: true);
private async Task OnEventSavedAsync(MeterEventType type)
{
await RefreshAsync();
// Back to the reading that prompted the swap, with the typed digits still there.
if (_reading is { HasPendingResume: true } reading && MeterEventRules.IsRegisterBoundary(type) && _detail is not null)
{
await reading.ResumeAsync();
}
}
private async Task OnEventCancelledAsync()
{
// Abandoning the swap returns to the reading as it was, rather than silently losing it.
if (_reading is { HasPendingResume: true } reading)
{
await reading.ResumeAsync();
}
}
private async Task OnMeterSavedAsync((int MeterId, bool Created) saved) => await RefreshAsync();
public void Dispose()
{
_detailLoads.Dispose();
_analysisLoads.Dispose();
}
/// <summary>What a link presets in the source dialog; see <see cref="MeterLinks.Source"/>.</summary>
private sealed record SourcePreset(int? SourceId, SourceType? Type, int? ConnectorId);
}
@@ -0,0 +1,4 @@
@* A record dated after now (D-04): the record tabs list the whole named range, so a row stamped later this month — a
current-month label, a device clock ahead — is shown, and marked in words, since it is not counted in any actual yet. *@
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" Class="ml-1 mv-after-now"
title="@S.MeterDetail_AfterNowHint">@S.MeterDetail_AfterNow</MudChip>
@@ -0,0 +1,393 @@
@using Microsoft.Extensions.DependencyInjection
@using MeterVault.Infrastructure.Ingestion
@inject MeterDetailService Details
@inject IServiceScopeFactory Scopes
@inject ISnackbar Snackbar
@inject InstanceClock Clock
@inject ILogger<ManualReadingDialog> Logger
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
meter on a phone, where the default input sizes are fiddly. *@
<style>
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
move the keypad; the full wording is repeated in the alert below the fold. */
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
</style>
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, MeterName)</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
Label="@Loc.F(S.MeterDetail_ReadingLabel, Unit)" Variant="Variant.Outlined"
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
while it is being typed — the keypad pushes anything below it off a phone screen — but
anything that grows or shrinks here would move the keys out from under the user's
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
<div class="mv-reading-verdict mt-1 mb-3">
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {Unit}" : S.MeterDetail_EnterValue)
</MudText>
@if (Verdict.WouldBeRejected)
{
@* Names the likely cause, but deliberately is not a button: this line shows for most of
an ordinary entry (every prefix of 12351 is below 12345) and sits just above the
keypad, so a slightly high tap on the top keys would leave the reading mid-entry. The
swap and reset buttons are in the alert below and on the rejection message. *@
<MudText Typo="Typo.caption" Color="Color.Warning">@S.MeterDetail_SwappedOrResetHint</MudText>
}
else if (Verdict.ChangeSincePrevious is { } change)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">@ChangeSinceText(change)</MudText>
}
</div>
<div class="mv-keypad mb-3">
@foreach (var key in Keypad)
{
var pressed = key;
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
}
</div>
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
<MudDatePicker Date="_when.Date" DateChanged="OnDateChangedAsync" Label="@S.Common_Date" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:150px" />
<MudTimePicker Time="_when.TimeOfDay" TimeChanged="OnTimeChangedAsync" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:130px" />
<MudButton Size="Size.Small" Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNowAsync">@S.Common_Now</MudButton>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, Zone.Id)</MudText>
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
area, so nothing the user is aiming at moves. *@
@if (_when.IsSkipped)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
@Loc.F(S.MeterDetail_SkippedTime, Zone.Id)
</MudAlert>
}
@if (Verdict is { WouldBeRejected: true, Previous: { } previous })
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(previous.Value, 2), Unit)
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.SwapHoriz"
OnClick="@(() => SwitchToEventAsync(MeterEventType.MeterSwap))">@S.MeterDetail_RecordSwap</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.RestartAlt"
OnClick="@(() => SwitchToEventAsync(MeterEventType.CounterReset))">@S.MeterDetail_RecordReset</MudButton>
</div>
</MudAlert>
}
@if (Verdict.ReplacesRegisterStart)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_ReplaceSwapStartNotice</MudAlert>
}
else if (Verdict.ReplacesReading)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
@S.MeterDetail_ReplaceNotice
</MudAlert>
}
@if (Verdict.IsFuture)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
}
else if (Verdict.IsBackdated)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
@S.MeterDetail_BackdatedNotice
</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close" Disabled="_saving">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
OnClick="SaveAsync" Disabled="@(!CanSave)">
@(_saving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
</MudButton>
</DialogActions>
</MudDialog>
@code {
/// <summary>The meter the reading is for.</summary>
[Parameter, EditorRequired]
public int MeterId { get; set; }
/// <summary>Its name, for the title (user data).</summary>
[Parameter]
public string MeterName { get; set; } = string.Empty;
/// <summary>The raw unit of the register (readings are raw values).</summary>
[Parameter]
public string Unit { get; set; } = string.Empty;
/// <summary>The instance zone the date and time are typed in.</summary>
[Parameter, EditorRequired]
public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc;
/// <summary>Raised after a reading was stored (the meter is renormalized already).</summary>
[Parameter]
public EventCallback Saved { get; set; }
/// <summary>
/// Raised when the user leaves for a swap or reset at the entered time; the typed value is kept for
/// <see cref="ResumeAsync"/>.
/// </summary>
[Parameter]
public EventCallback<(MeterEventType Type, DateTimeOffset? At)> SwitchToEvent { get; set; }
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
/// <summary>
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
/// attribute as a transition.
/// </summary>
private const InputMode DecimalKeyboard = InputMode.@decimal;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private readonly ReadingEntry _entry = new();
private LocalTimeEntry _when = new(TimeZoneInfo.Utc);
private bool _open;
private bool _saving;
/// <summary>The context for the instant the pickers show (D-50); versioned, as the pickers move faster than queries return.</summary>
private ReadingEntryContext? _context;
private int _contextVersion;
/// <summary>
/// A reading typed before the user detoured into recording a swap. It is handed back to this dialog once the swap
/// is saved or abandoned, so the detour costs no retyping.
/// </summary>
private (string Text, DateTimeOffset? At)? _resume;
/// <summary>True while a typed reading waits for the swap or reset the user left to record.</summary>
public bool HasPendingResume => _resume is not null;
private ReadingEntryVerdict Verdict => ReadingEntryVerdict.Of(_context, _entry.Value, _when.Utc, Clock.Now);
private bool CanSave => !_saving && _entry.Value is not null && _when.WallClock is not null && !_when.IsSkipped;
protected override void OnParametersSet()
{
if (!ReferenceEquals(_when.Zone, Zone))
{
_when = new LocalTimeEntry(Zone);
}
}
/// <summary>
/// Opens the dialog at now, prefilled with the meter's latest reading (or its baseline while it has none) — a register
/// only moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
/// </summary>
public async Task OpenAsync()
{
_resume = null;
_entry.Clear();
_when.Set(Clock.Now);
await LoadContextAsync();
_entry.Prefill(_context?.Latest?.Value ?? _context?.InitialBaseline ?? 0);
_open = true;
StateHasChanged();
}
/// <summary>Reopens the dialog with the reading typed before the swap/reset detour, at its time.</summary>
public async Task ResumeAsync()
{
if (_resume is not { } resume)
{
return;
}
_resume = null;
_entry.SetText(resume.Text);
if (resume.At is { } at)
{
_when.Set(at);
}
else
{
_when.Set(Clock.Now);
}
await LoadContextAsync();
_open = true;
StateHasChanged();
}
/// <summary>Forgets a typed reading waiting for a detour (the user went on to something else).</summary>
public void DropResume() => _resume = null;
/// <summary>Closes the dialog without saving.</summary>
public void Close()
{
_open = false;
StateHasChanged();
}
private void OnVisibleChanged(bool visible)
{
if (!visible)
{
_open = false;
}
}
private void OnReadingTyped(string? value) => _entry.SetText(value);
private void PressKey(string key)
{
switch (key)
{
case "⌫":
_entry.Backspace();
break;
case ",":
_entry.AppendSeparator();
break;
default:
_entry.AppendDigit(key[0]);
break;
}
}
private async Task OnDateChangedAsync(DateTime? date)
{
_when.Date = date;
await LoadContextAsync();
}
private async Task OnTimeChangedAsync(TimeSpan? time)
{
_when.TimeOfDay = time;
await LoadContextAsync();
}
private async Task SetNowAsync()
{
_when.Set(Clock.Now);
await LoadContextAsync();
}
/// <summary>
/// Reads what the entry at the chosen instant is judged against (D-50). Versioned: a late answer for an earlier time
/// must not overwrite a newer one. A failure leaves no verdict, and the save still goes through the guard.
/// </summary>
private async Task LoadContextAsync()
{
if (_when.Utc is not { } utc)
{
return;
}
var version = ++_contextVersion;
try
{
var context = await Details.GetReadingEntryContextAsync(MeterId, utc);
if (version == _contextVersion)
{
_context = context;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogWarning(ex, "Reading the entry context of meter {MeterId} failed", MeterId);
}
}
private string LastReadingCaption() => _context switch
{
{ Latest: { } latest } => Loc.F(S.MeterDetail_LastReadingCaption,
Format.Number(latest.Value, 2), Unit, _when.Local(latest.Time).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)),
{ } context => Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(context.InitialBaseline, 2), Unit),
_ => string.Empty,
};
private string ChangeSinceText(double change) =>
Math.Abs(change) < 1e-9
? S.MeterDetail_NoChangeSinceLast
: Loc.F(S.MeterDetail_ChangeSinceLast, $"{(change > 0 ? "+" : "")}{Format.Number(Math.Abs(change), 2)}", Unit);
private async Task SaveAsync()
{
if (_entry.Value is not { } value || _when.Utc is not { } utc)
{
return;
}
_saving = true;
try
{
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
// long outlives the unit of work a single save should share one with.
await using var scope = Scopes.CreateAsyncScope();
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
IngestionOutcome outcome;
try
{
outcome = await ingestion.IngestByMeterAsync(MeterId, utc, value, renormalize: true, quality: ReadingQuality.Manual);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogError(ex, "Saving a manual reading on meter {MeterId} failed", MeterId);
Snackbar.Add(S.MeterDetail_ReadingFailed, Severity.Error);
return;
}
switch (outcome)
{
case IngestionOutcome.Written:
Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), Unit), Severity.Success);
break;
case IngestionOutcome.Updated:
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), Unit), Severity.Success);
break;
case IngestionOutcome.RejectedDecrease:
// Leave the dialog open with the value still on screen, and offer the fix right on
// the message: recording a swap or reset is a decision, not a retry.
Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error, config =>
{
config.Action = S.MeterDetail_RecordSwap;
config.ActionColor = Color.Inherit;
config.OnClick = _ => InvokeAsync(() => SwitchToEventAsync(MeterEventType.MeterSwap));
});
await LoadContextAsync();
return;
default:
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
return;
}
_open = false;
_resume = null;
await Saved.InvokeAsync();
}
finally
{
_saving = false;
}
}
/// <summary>
/// From this dialog into the swap/reset dialog, at the time the reading was being entered — the latest the swap can
/// have happened. The typed value is kept and handed back afterwards (<see cref="ResumeAsync"/>).
/// </summary>
private async Task SwitchToEventAsync(MeterEventType type)
{
_resume = (_entry.Text, _when.Utc);
_open = false;
await SwitchToEvent.InvokeAsync((type, _resume.Value.At));
}
}
@@ -0,0 +1,237 @@
@inject NavigationManager Nav
@* The meter's Analysis tab (brief §7.2): the shared period toolbar with CSV export, the period's quantity (normalized
unit, D-20) and cost (with its rule named, or why there is none), the projection kept apart from the actual (D-09),
the full-size chart with the comparison overlay, the accessible table, a click on a bucket drilling down (D-51), and
what qualifies the figures: coverage, resolution, opening balance, rows after now, freshness, and the events and
tariff changes inside the range. A virtual meter gets the same from its formula, with its sources' contributions. *@
<PeriodToolbar Query="Query" Period="Current?.Period" Plan="Current?.Quantities.Plan" Defaults="Defaults"
QueryChanged="OnQueryChanged" ExportHref="@ExportHref"
Metrics="Current?.Metrics" NaturalMetric="Current?.QuantityMetric" Class="mb-3" />
<LoadPanel State="State" OnRetry="OnRetry" Context="r">
@if (r.MeterId == Detail.Id)
{
var s = r.Series;
@if (s is null)
{
<MudAlert Severity="Severity.Warning" Dense="true">@Loc.F(S.MeterDetail_NotFound, Detail.Id)</MudAlert>
}
else if (r.Quantities.Refusal != AnalysisRefusal.None)
{
@* The toolbar above says why (too many points) and offers the coarser bucket. *@
}
else
{
<AttentionList Problems="ProblemsOf(r)" CostAttention="r.Cost?.Attention" Names="r.AttentionNames"
Query="Query" MaxItems="4" Class="mb-3" />
@if (s.IsPending)
{
<PendingState OnRefresh="OnRetry" />
}
else if (s.Total.Status == BucketStatus.Invalid)
{
<MudAlert Severity="Severity.Error" Class="mb-3">
@Loc.F(S.MeterDetail_CalculationNotEvaluable, (s.Virtual?.Status ?? VirtualMeterStatus.Invalid).Display())
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Functions"
OnClick="OnEdit">@S.MeterDetail_EditCalculation</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text"
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabCalculation, null, Query)">@S.MeterDetail_ShowCalculation</MudButton>
</div>
</MudAlert>
}
else if (r.Quantities.NotYetOccurred || s.Total.Status == BucketStatus.Missing)
{
<EmptyPeriodState NotYetOccurred="r.Quantities.NotYetOccurred" Availability="s.Availability" LatestHref="@LatestHref(s)" Class="mb-3">
@if (s.Availability is null)
{
<MudText Typo="Typo.body2" Class="mv-muted">@NextStepText</MudText>
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
@if (MeterEventRules.TakesReadings(Detail.Mode))
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.EditNote"
OnClick="OnAddReading">@S.MeterDetail_AddFirstReading</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.SettingsInputComponent"
Href="@MeterLinks.Source(Detail.Id)">@S.MeterDetail_ConnectSource</MudButton>
}
else if (Detail.Mode == MeterMode.ConsumableBalance)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Straighten"
OnClick="@(() => OnRecordEvent.InvokeAsync(MeterEventType.TankLevel))">@S.MeterDetail_RecordTankLevel</MudButton>
}
else if (Detail.IsVirtual)
{
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Functions"
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabCalculation, null, Query)">@S.MeterDetail_ShowCalculation</MudButton>
}
</div>
}
</EmptyPeriodState>
@if (Detail.IsVirtual)
{
<SeriesContributions Series="s" Query="Query" Class="mt-3" />
}
}
else
{
<MudGrid Spacing="2" Class="mb-1">
<MudItem xs="12" sm="6" md="4">
<MetricCard Title="@s.Kind.Display()" Value="s.Total" Unit="@s.Unit" Caption="@Format.PeriodRange(r.Period)"
Change="s.Comparison?.Change" Polarity="ChangePolarities.For(s.Kind)"
ChangeCaption="@ComparisonCaption">
@if (r.Projection is { } projection)
{
<ProjectionNote Days="projection.Days" ValueText="@Format.Quantity(projection.Value, projection.Unit)" Class="mt-2" />
}
</MetricCard>
</MudItem>
@if (r.Cost is { } cost)
{
<MudItem xs="12" sm="6" md="4">
@if (r.IsCosted)
{
<MetricCard Title="@S.MeterDetail_CostTitle" Cost="cost.Total" Currency="@cost.Currency"
Caption="@CostRuleText(cost)"
Change="CostChanges.ForCard(r.CostChange)" Polarity="CostChanges.Polarity(r.CostChange)"
ChangeCaption="@CostChanges.Caption(Query, r.CostChange)" />
}
else
{
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-metric">
<MudText Typo="Typo.overline" Class="mv-muted mv-metric__title">@S.MeterDetail_CostTitle</MudText>
<div class="mv-metric__value-row">
<span class="mv-metric__value mv-metric__value--words">@MeterCostRule.None.Display()</span>
</div>
<MudText Typo="Typo.caption" Class="mv-muted">@((cost.Meter?.NotCosted ?? MeterNotCostedReason.None).Display())</MudText>
</MudPaper>
}
</MudItem>
}
</MudGrid>
<ComparisonSummary Period="r.Period" Resolution="r.Quantities.Comparison?.Resolution" Matched="s.Comparison?.Matched" Class="mb-2" />
@* A click leads somewhere or is not offered (D-51): the chart is clickable, the table has its drill column and
the hint shows only when some bucket opens something. *@
var drills = r.Quantities.Plan.Buckets.Any(b => DrillHref(b, r, s) is not null);
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
@if (drills)
{
<AnalysisChart Buckets="r.Quantities.Plan.Buckets" Series="r.Chart" ComparisonPairs="r.Quantities.Comparison?.Buckets"
Title="@ChartTitle(r, s)" Height="340" OnBucketClick="b => Drill(b, r, s)"
Resolution="s.Resolution" OnUseBucket="UseBucketAsync" />
<MudText Typo="Typo.caption" Class="mv-muted">@DrillHint(s)</MudText>
}
else
{
<AnalysisChart Buckets="r.Quantities.Plan.Buckets" Series="r.Chart" ComparisonPairs="r.Quantities.Comparison?.Buckets"
Title="@ChartTitle(r, s)" Height="340" Resolution="s.Resolution" OnUseBucket="UseBucketAsync" />
}
</MudPaper>
<AnalysisTable Buckets="r.Quantities.Plan.Buckets" Series="r.Table" ComparisonPairs="r.Quantities.Comparison?.Buckets"
DrillHref="@(drills ? b => DrillHref(b, r, s) : null)" Caption="@ChartTitle(r, s)" />
@if (Detail.IsVirtual)
{
<MudText Typo="Typo.h6" Class="mt-5 mb-1">@S.MeterDetail_SourcesOfCalculation</MudText>
<SeriesContributions Series="s" Query="Query" />
}
}
<MeterCoverageNote View="r" Detail="Detail" Query="Query" OnEdit="OnEdit" Class="mt-5" />
}
}
</LoadPanel>
@code {
/// <summary>The meter.</summary>
[Parameter, EditorRequired]
public MeterDetailView Detail { get; set; } = null!;
/// <summary>The page's analysis state.</summary>
[Parameter, EditorRequired]
public AnalysisQuery Query { get; set; } = null!;
/// <summary>The page's analysis load (owned by the page, so switching tabs never reloads it, D-46).</summary>
[Parameter, EditorRequired]
public LoadState<MeterAnalysisView> State { get; set; } = null!;
/// <summary>A toolbar choice: the page writes it into its address (replace).</summary>
[Parameter]
public EventCallback<AnalysisQuery> OnQueryChanged { get; set; }
[Parameter]
public EventCallback OnRetry { get; set; }
[Parameter]
public EventCallback OnAddReading { get; set; }
[Parameter]
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
/// <summary>Opens the shared meter editor (install date, calculation).</summary>
[Parameter]
public EventCallback OnEdit { get; set; }
private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Detail.Id);
/// <summary>The committed value when it is this meter's.</summary>
private MeterAnalysisView? Current => State.Value is { } value && value.MeterId == Detail.Id ? value : null;
private string ExportHref => AnalysisLinks.Export(MeterAnalysisLoader.ForMeter(Query, Detail.Id));
private string? ComparisonCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display();
private string NextStepText => Detail.Mode switch
{
MeterMode.ConsumableBalance => S.MeterDetail_NextStepTank,
MeterMode.Virtual => S.MeterDetail_NextStepVirtual,
_ => S.MeterDetail_NextStepCounter,
};
/// <summary>
/// The readers' problems for the attention list. Rows recorded after now are explained with their dates and amount in
/// the coverage section below, so they are not listed twice.
/// </summary>
private static IEnumerable<AnalysisProblem> ProblemsOf(MeterAnalysisView view) =>
view.Quantities.Problems.Concat(view.Cost?.QuantityProblems ?? [])
.Where(p => p.Kind != AnalysisProblemKind.RecordedAfterNow);
private string CostRuleText(Infrastructure.Costing.CostAnalysis cost) =>
Loc.F(S.MeterDetail_CostRule, (cost.Meter?.Rule ?? MeterCostRule.None).Display());
private static string ChartTitle(MeterAnalysisView view, AnalysisSeries series) =>
view.ShowsCost
? Loc.F(S.MeterDetail_CostOf, AnalysisChartSeries.NameOf(series))
: Loc.F(S.MeterDetail_ChartTitle, series.Kind.Display(), AnalysisChartSeries.NameOf(series));
private string DrillHint(AnalysisSeries series) =>
Detail.IsVirtual ? S.MeterDetail_DrillHintVirtual : S.MeterDetail_DrillHint;
private string? LatestHref(AnalysisSeries series) =>
AnalysisNavigation.LatestData(Query, series.Availability) is { } latest ? MeterLinks.Analysis(Detail.Id, latest) : null;
/// <summary>
/// Where a bucket leads (D-51, <see cref="MeterDrill"/>): the next finer size the data resolves; otherwise a physical
/// meter's records of the bucket, or a virtual meter's own analysis over just that bucket, whose source contributions
/// link on to each source's records — never a dead end.
/// </summary>
private string? DrillHref(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series) =>
MeterDrill.Href(Detail.Id, Detail.IsVirtual, Query, view.Period, bucket, series.Resolution);
/// <summary>A chart click drills down, pushing a history entry so Back returns to the range it came from (D-46).</summary>
private void Drill(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series)
{
if (DrillHref(bucket, view, series) is { } href)
{
Nav.NavigateTo(href);
}
}
/// <summary>The buckets are finer than the data: apply the interval that shows it (the page replaces its address).</summary>
private Task UseBucketAsync(BucketSize size) => OnQueryChanged.InvokeAsync(Query.WithBucket(size));
}
@@ -0,0 +1,231 @@
@implements IDisposable
@inject MeterDetailService Details
@inject ILogger<MeterCalculationTab> Logger
@* A virtual meter's calculation (brief §5.1, D-25 D-31): its status (valid, legacy — confirm, needs configuration,
invalid), the formula with each m<id> token beside the meter's name, what it yields and in which unit, its cost rule,
the meters it reads (linked to their own analysis), and every validation problem with the meters involved — plus the
one action that fixes them: Edit calculation, in the shared meter editor. It replaces Sources (a calculation has no
ingest), and register details never show here. *@
<LoadPanel State="_state" OnRetry="LoadAsync" Context="calc" PlaceholderHeight="160">
@if (calc.MeterId == Detail.Id)
{
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem 1rem">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Color="@(calc.Status == VirtualMeterStatus.Valid ? Color.Success : calc.Status is VirtualMeterStatus.Legacy ? Color.Info : Color.Error)"
Icon="@(calc.Status == VirtualMeterStatus.Valid ? Icons.Material.Outlined.CheckCircle : Icons.Material.Outlined.Info)">
@calc.Status.Display()
</MudChip>
<MudText Typo="Typo.body2" Class="flex-grow-1" Style="min-width:12rem">@StatusText(calc.Status)</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Functions" OnClick="OnEdit">
@S.MeterDetail_EditCalculation
</MudButton>
</div>
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-4">
<MudText Typo="Typo.subtitle2" Class="mb-1">@S.Contributions_Formula</MudText>
@if (FormulaText.Split(calc.Expression) is { Count: > 0 } segments)
{
<div class="mv-formula mb-3" style="flex-wrap:wrap">
@foreach (var segment in segments)
{
@if (segment.MeterId is { } id)
{
<span class="mv-formula__ref">
@if (calc.NameOf(id) is { } name)
{
<MudLink Href="@MeterLinks.Analysis(id, Query)" Typo="Typo.body2">@name</MudLink>
}
else
{
<span class="mv-unknown">@S.MeterDetail_UnknownMeter</span>
}
<code>@segment.Text</code>
</span>
}
else
{
<code>@segment.Text</code>
}
}
</div>
}
else
{
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.MeterDetail_NoFormula</MudText>
}
<dl class="mv-calc-facts">
<dt>@S.MeterDetail_Result</dt>
<dd>@Loc.F(S.MeterDetail_QuantityIn, calc.Kind.Display(), calc.Unit)</dd>
<dt>@S.MeterDetail_CostRuleLabel</dt>
<dd>
@calc.CostRule.Display()
@if (calc.CostRuleProblem is not null && calc.DeclaredCostRule is { } declared && declared != calc.CostRule)
{
<div class="mv-cell-secondary">@Loc.F(S.MeterDetail_CostRuleIgnored, declared.Display())</div>
}
</dd>
</dl>
</MudPaper>
@if (calc.Problems.Count > 0 || calc.CostRuleProblem is not null || calc.Legacy is { NeedsConfiguration: true })
{
<MudText Typo="Typo.h6" Class="mb-1">@S.MeterDetail_CalcProblems</MudText>
<ul class="mv-calc-problems mb-4">
@if (calc.Legacy is { NeedsConfiguration: true } legacy)
{
<li>
<MudIcon Icon="@Icons.Material.Outlined.ErrorOutline" Size="Size.Small" aria-hidden="true" />
<span>@LegacyText(legacy)</span>
<MeterPath Ids="legacy.MeterIds" Calculation="calc" Query="Query" />
</li>
}
@foreach (var problem in calc.Problems.Concat(calc.CostRuleProblem is { } p ? [p] : []))
{
<li>
<MudIcon Icon="@Icons.Material.Outlined.ErrorOutline" Size="Size.Small" aria-hidden="true" />
<span>@ProblemText(problem)</span>
<MeterPath Ids="problem.MeterIds" Calculation="calc" Query="Query"
Separator="@(problem.Kind == VirtualProblemKind.DependencyCycle ? " → " : ", ")" />
</li>
}
</ul>
}
<MudText Typo="Typo.h6" Class="mb-1">@S.MeterDetail_ReferencedMeters</MudText>
@if (calc.Sources.Count == 0)
{
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_NoReferencedMeters</MudText>
}
else
{
<div class="mv-table-scroll" role="region" aria-label="@S.MeterDetail_ReferencedMeters" tabindex="0">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr>
<th scope="col">@S.Common_Name</th>
<th scope="col">@S.Common_Mode</th>
<th scope="col">@S.MeterDetail_Kind</th>
<th scope="col">@S.Common_Unit</th>
</tr>
</thead>
<tbody>
@foreach (var source in calc.Sources)
{
<tr>
<th scope="row" class="mv-row-label">
@if (source.Exists)
{
<MudLink Href="@MeterLinks.Analysis(source.MeterId, Query)" Typo="Typo.body2">@source.Name</MudLink>
<code class="ml-1 mv-muted">@($"m{source.MeterId}")</code>
}
else
{
<span class="mv-unknown">@S.MeterDetail_UnknownMeter</span>
<code class="ml-1 mv-muted">@($"m{source.MeterId}")</code>
}
</th>
<td>@(source.Exists ? source.Mode.Display() : Format.Unknown)</td>
<td>@(source.Exists ? source.Kind.Display() : Format.Unknown)</td>
<td>@(source.Exists ? source.Unit : Format.Unknown)</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
@if (calc.Sources.Any(s => s.IsVirtual) && calc.PhysicalLeaves.Count > 0)
{
<MudText Typo="Typo.body2" Class="mt-2">
@S.MeterDetail_ReadsPhysical
@for (var i = 0; i < calc.PhysicalLeaves.Count; i++)
{
var leaf = calc.PhysicalLeaves[i];
@(i > 0 ? ", " : " ")<MudLink Href="@MeterLinks.Analysis(leaf.MeterId, Query)" Typo="Typo.body2">@leaf.Name</MudLink>
}
</MudText>
}
}
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-4">@S.MeterDetail_CalculationVsFlow</MudText>
}
</LoadPanel>
@code {
[Parameter, EditorRequired]
public MeterDetailView Detail { get; set; } = null!;
/// <summary>The page's analysis state: links to the source meters carry its period.</summary>
[Parameter]
public AnalysisQuery? Query { get; set; }
/// <summary>Bumped by the page whenever the meter (or its definition) changed.</summary>
[Parameter]
public int Version { get; set; }
/// <summary>Opens the shared meter editor on this meter.</summary>
[Parameter]
public EventCallback OnEdit { get; set; }
private readonly LoadSequencer _loads = new();
private readonly LoadState<MeterCalculationView> _state = new();
private (int MeterId, int Version)? _loadedFor;
protected override async Task OnParametersSetAsync()
{
var key = (Detail.Id, Version);
if (_loadedFor == key)
{
return;
}
if (_loadedFor?.MeterId != Detail.Id)
{
_state.Clear();
}
_loadedFor = key;
await LoadAsync();
}
private Task LoadAsync()
{
var meterId = Detail.Id;
return _loads.RunAsync(_state, async token =>
await Details.GetCalculationAsync(meterId, token)
?? throw new InvalidOperationException($"Meter {meterId} is not a virtual meter."), Logger);
}
private static string StatusText(VirtualMeterStatus status) => status switch
{
VirtualMeterStatus.Valid => S.MeterDetail_CalcStatusValid,
VirtualMeterStatus.Legacy => S.MeterDetail_CalcStatusLegacy,
VirtualMeterStatus.NeedsConfiguration => S.MeterDetail_CalcStatusNeedsConfiguration,
VirtualMeterStatus.Malformed => S.MeterDetail_CalcStatusMalformed,
_ => S.MeterDetail_CalcStatusInvalid,
};
private static string LegacyText(LegacyDerivation legacy) => legacy.Outcome switch
{
LegacyDerivationOutcome.NoSources => S.MeterDetail_LegacyNoSources,
LegacyDerivationOutcome.UnknownSource => S.MeterDetail_LegacyUnknownSource,
LegacyDerivationOutcome.SourceNeedsConfiguration => S.MeterDetail_LegacySourceNeedsConfiguration,
LegacyDerivationOutcome.NotAdditive => S.MeterDetail_LegacyNotAdditive,
LegacyDerivationOutcome.MixedUnits => S.MeterDetail_LegacyMixed,
LegacyDerivationOutcome.MixedKinds => S.MeterDetail_LegacyMixed,
_ => S.MeterDetail_CalcStatusNeedsConfiguration,
};
/// <summary>
/// One validation finding in words — the same sentence the attention list and <see cref="DisplayNames"/> give it, with
/// the units or kinds involved; a syntax error in the editor's words, with its position. The meters involved follow
/// it as links (<see cref="MeterPath"/>).
/// </summary>
private static string ProblemText(VirtualProblem problem) =>
problem is { Kind: VirtualProblemKind.Syntax, SyntaxError: { } error }
? MeterVault.App.MeterEditing.MeterEditorText.FormulaError(error)
: AttentionItems.VirtualReasonWithoutMeters(problem);
public void Dispose() => _loads.Dispose();
}
@@ -0,0 +1,42 @@
.mv-calc-facts {
display: grid;
grid-template-columns: minmax(7rem, max-content) 1fr;
gap: .35rem 1.5rem;
margin: 0;
}
.mv-calc-facts dt {
color: var(--mud-palette-text-secondary);
font-size: .875rem;
}
.mv-calc-facts dd {
margin: 0;
min-width: 0;
}
.mv-calc-problems {
list-style: none;
margin: 0;
padding: 0;
}
.mv-calc-problems li {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: .25rem .5rem;
padding: .25rem 0;
color: var(--mud-palette-text-primary);
}
@media (max-width: 599.98px) {
.mv-calc-facts {
grid-template-columns: 1fr;
gap: .1rem;
}
.mv-calc-facts dd {
margin-bottom: .4rem;
}
}
@@ -0,0 +1,213 @@
@* What qualifies the meter's figures (brief §4.3, §7.2, D-13 D-19): the resolution its data has, the dates it covers,
an opening balance of unknown start (with the offer to set an install date), rows dated after now that are not
counted yet, how current the data is, what the normalized quantity assumes, and — as context for the chart — the
events and tariff changes inside the range. Worded, never a bare percentage the metadata cannot support. *@
<section class="@Class" aria-labelledby="@_headingId">
<MudText Typo="Typo.h6" id="@_headingId" Class="mb-2">@S.MeterDetail_QualityTitle</MudText>
<MudPaper Outlined="true" Elevation="0" Class="pa-3">
<dl class="mv-facts">
<div class="mv-facts__row">
<dt>@S.MeterDetail_Resolution</dt>
<dd>
@if (Series?.Resolution is { } resolution)
{
@resolution.Display()
@if (resolution >= ResolutionClass.Month)
{
<div class="mv-cell-secondary">@(Detail.IsVirtual ? S.MeterDetail_ResolutionMonthlyHintVirtual : S.MeterDetail_ResolutionMonthlyHint)</div>
}
}
else
{
@Format.Unknown
}
</dd>
</div>
<div class="mv-facts__row">
<dt>@S.MeterDetail_DataRange</dt>
<dd>
@if (Series?.Availability is { } available)
{
@Format.DateRange(available.FirstDay, available.LastDay)
}
else
{
@S.MeterDetail_NoDataAtAll
}
</dd>
</div>
<div class="mv-facts__row">
<dt>@S.MeterDetail_Freshness</dt>
<dd>
@if (Series?.Freshness is { State: not FreshnessState.NoData } freshness)
{
@freshness.State.Display()
@if (freshness.LastActivity is { } last)
{
<div class="mv-cell-secondary">@Loc.F(S.MeterDetail_LastActivity, Format.Date(PeriodResolver.LocalDate(last, View.Period.Zone)))</div>
}
@if (freshness.State == FreshnessState.Stale && !Detail.IsVirtual)
{
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabSources, null, Query)" Typo="Typo.body2">@S.Attention_CheckSource</MudLink>
}
}
else
{
@FreshnessState.NoData.Display()
}
</dd>
</div>
<div class="mv-facts__row">
<dt>@S.MeterDetail_Quantity</dt>
<dd>
@QuantityText
@foreach (var note in NoteTexts)
{
<div class="mv-cell-secondary">@note</div>
}
</dd>
</div>
</dl>
@if (HasOpeningBalance)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
@Loc.F(S.MeterDetail_OpeningBalanceNote, Format.Number(Detail.InitialBaseline, 2), Detail.Unit)
@if (Detail.InstalledAt is null && !Detail.IsVirtual)
{
<div class="mt-2">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Event"
OnClick="OnEdit">@S.MeterDetail_SetInstallDate</MudButton>
</div>
}
</MudAlert>
}
@foreach (var block in Series?.RecordedAfterNow ?? [])
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
@Loc.F(S.MeterDetail_RecordedAfterNow,
NameOf(block.MeterId),
block.Rows,
Format.Quantity(block.Amount, Series!.Unit),
Format.DateRange(block.FirstDay, block.LastDay))
<div class="mt-1">
<MudLink Href="@RecordsLink(block)" Typo="Typo.body2">@S.MeterDetail_ShowRecords</MudLink>
</div>
</MudAlert>
}
</MudPaper>
@if (!View.Markers.IsEmpty)
{
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.MeterDetail_InThisPeriod</MudText>
@* One dated list, newest first (A-42): events and price changes interleave, so the dates never jump back up. *@
<ul class="mv-markers">
@foreach (var marker in Markers)
{
<li>
<MudIcon Icon="@marker.Icon" Size="Size.Small" aria-hidden="true" />
<span class="mv-markers__date">@Format.Date(marker.Day)</span>
<span>@marker.Text</span>
</li>
}
</ul>
<div class="d-flex flex-wrap" style="gap:1rem">
@if (View.Markers.Events.Count > 0)
{
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabEvents, null, Query)" Typo="Typo.body2">
@(View.Markers.MoreEvents ? S.MeterDetail_AllEventsInPeriod : S.MeterDetail_GoToEvents)
</MudLink>
}
@if (View.Markers.TariffChanges.Count > 0)
{
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabTariffs, null, Query)" Typo="Typo.body2">@S.MeterDetail_OpenTariffs</MudLink>
}
</div>
}
</section>
@code {
/// <summary>The committed analysis.</summary>
[Parameter, EditorRequired]
public MeterAnalysisView View { get; set; } = null!;
[Parameter, EditorRequired]
public MeterDetailView Detail { get; set; } = null!;
[Parameter]
public AnalysisQuery? Query { get; set; }
/// <summary>Opens the meter editor (to set an install date).</summary>
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public string? Class { get; set; }
private readonly string _headingId = "mv-quality-" + Guid.NewGuid().ToString("N")[..8];
private AnalysisSeries? Series => View.Series;
/// <summary>The events and tariff changes of the range as one dated list, newest first (A-42).</summary>
private IReadOnlyList<MeterMarker> Markers => MeterMarkerList.Of(View.Markers, View.Period.Zone, Detail.EnergyType);
/// <summary>A first reading booked against the baseline with an unknown start (D-14) sits in the range.</summary>
private bool HasOpeningBalance =>
Series is { } s && (s.Total.Provenance.HasFlag(Provenance.OpeningBalance) || s.Values.Any(v => v.Provenance.HasFlag(Provenance.OpeningBalance)));
private string QuantityText => Series is { } s
? Loc.F(S.MeterDetail_QuantityIn, s.Kind.Display(), s.Unit)
: Format.Unknown;
private IEnumerable<string> NoteTexts
{
get
{
var notes = Series?.Notes ?? default;
if (notes.HasFlag(QuantityNotes.FixedRateEstimate))
{
yield return S.MeterDetail_NoteFixedRate;
}
if (notes.HasFlag(QuantityNotes.RateAssumedPerHour))
{
yield return S.MeterDetail_NoteRatePerHour;
}
if (notes.HasFlag(QuantityNotes.RateNotPerHour))
{
yield return S.MeterDetail_NoteRateNotPerHour;
}
if (notes.HasFlag(QuantityNotes.RegisterNotInHours))
{
yield return S.MeterDetail_NoteRegisterNotInHours;
}
if (notes.HasFlag(QuantityNotes.UndeclaredResult))
{
yield return S.MeterDetail_NoteUndeclaredResult;
}
}
}
private string NameOf(int meterId) =>
meterId == Detail.Id ? Detail.Name
: Series?.Contributions.SelectMany(Flatten).FirstOrDefault(c => c.MeterId == meterId)?.Name is { Length: > 0 } name ? name
: Loc.F(S.Attention_MeterFallback, meterId);
private static IEnumerable<SeriesContribution> Flatten(SeriesContribution contribution) =>
contribution.Nested.SelectMany(Flatten).Prepend(contribution);
/// <summary>The normalized records of the days holding rows after now, on the meter that holds them.</summary>
private string RecordsLink(RecordedAfterNow block)
{
var target = Query is null || !PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay)
? Query
: Query.WithCustomRange(block.FirstDay, block.LastDay);
return MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, target);
}
}
@@ -0,0 +1,53 @@
/* The quality facts as a two-column list on wide screens, stacked on a phone. Palette variables only. */
.mv-facts {
display: grid;
grid-template-columns: minmax(8rem, max-content) 1fr;
gap: .5rem 1.5rem;
margin: 0;
}
.mv-facts__row {
display: contents;
}
.mv-facts dt {
color: var(--mud-palette-text-secondary);
font-size: .875rem;
}
.mv-facts dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 599.98px) {
.mv-facts {
grid-template-columns: 1fr;
gap: .15rem;
}
.mv-facts dd {
margin-bottom: .5rem;
}
}
.mv-markers {
list-style: none;
margin: 0 0 .5rem;
padding: 0;
}
.mv-markers li {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: .25rem .5rem;
padding: .2rem 0;
}
.mv-markers__date {
color: var(--mud-palette-text-secondary);
font-variant-numeric: tabular-nums;
min-width: 8.5rem;
}
@@ -0,0 +1,144 @@
@inherits RecordTabBase<EventRow>
@using Microsoft.Extensions.DependencyInjection
@using MeterVault.Infrastructure.Ingestion
@inject IServiceScopeFactory Scopes
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* A meter's events (brief §7.2, D-50): swaps and resets that keep a register's history continuous, a tank's levels and
deliveries, notes. Recorded through MeterEventService (the dialog), deleted there too — an imported one only by
reverting its import. Paged and filtered by the page period. *@
<div class="d-flex align-center justify-space-between flex-wrap mb-3" style="gap:.5rem">
<MudText Typo="Typo.body2" Class="mv-muted">@EventsHint</MudText>
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
@foreach (var type in MeterEventRules.RecordableFor(Detail.Mode))
{
var chosen = type;
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OnRecordEvent.InvokeAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
}
</MudMenu>
</div>
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
@if (view.MeterId == Detail.Id)
{
@if (view.Page.Rows.Count == 0)
{
<MudText Typo="Typo.body2" Class="mv-muted">@(Detail.HasEvents ? S.MeterDetail_NoEventsInPeriod : S.MeterDetail_NoEvents)</MudText>
@if (Detail.HasEvents && view.Range.IsBounded)
{
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
}
}
else
{
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabEvents" tabindex="0">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr>
<th scope="col">@S.MeterDetail_Time</th>
<th scope="col">@S.Common_Type</th>
<th scope="col" class="mv-num">@S.Common_Amount</th>
<th scope="col" class="mv-num">@S.MeterDetail_PrevNew</th>
<th scope="col">@S.MeterDetail_Notes</th>
<th scope="col"><span class="mv-sr-only">@S.Common_Actions</span></th>
</tr>
</thead>
<tbody>
@foreach (var e in view.Page.Rows)
{
<tr>
<td style="white-space:nowrap">
@Local(e.Time)
@if (IsAfterNow(e.Time))
{
<AfterNowChip />
}
</td>
<td style="white-space:nowrap">
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" Class="mr-1" Style="vertical-align:middle" aria-hidden="true" />@e.Type.Display()
</td>
<td class="mv-num">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : Format.Unknown)</td>
<td class="mv-num" style="white-space:nowrap">@PrevNewText(e)</td>
<td>@e.Notes</td>
<td style="text-align:right">
@if (e.ImportBatchId is not null)
{
<MudTooltip Text="@S.MeterDetail_ImportedEventHint">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Href="/import">@S.MeterDetail_Imported</MudChip>
</MudTooltip>
}
else
{
<MudTooltip Text="@S.MeterDetail_DeleteEvent">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteAsync(e))" aria-label="@S.MeterDetail_DeleteEvent" />
</MudTooltip>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
}
}
</LoadPanel>
@code {
/// <summary>Opens the event dialog for a type (the page owns it).</summary>
[Parameter]
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
private string EventsHint => Detail.Mode switch
{
MeterMode.ConsumableBalance => S.MeterDetail_EventsHintTank,
MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => S.MeterDetail_EventsHintRegister,
_ => S.MeterDetail_EventsHintNote,
};
protected override Task<RecordPage<EventRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
Details.GetEventsAsync(meterId, range, cursor, cancellationToken);
private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch
{
(null, null) => Format.Unknown,
({ } prev, var next) => $"{Format.Number(prev, 2)} → {(next is { } n ? Format.Number(n, 2) : Format.Unknown)}",
(null, { } next) => $"→ {Format.Number(next, 2)}",
};
private async Task DeleteAsync(EventRow meterEvent)
{
var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type)
? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time))
: Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time));
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message))
{
return;
}
try
{
await using var scope = Scopes.CreateAsyncScope();
var result = await scope.ServiceProvider.GetRequiredService<MeterEventService>().DeleteEventAsync(Detail.Id, meterEvent.Id);
Snackbar.Add(result.Succeeded ? S.MeterDetail_EventDeleted : MeterEventText.Problem(result.Problem),
result.Succeeded ? Severity.Success : Severity.Error);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The service's transaction has rolled back; report rather than end the circuit.
LoggerFactory.CreateLogger<MeterEventsTab>().LogError(ex, "Deleting an event on meter {MeterId} failed", Detail.Id);
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
}
await OnChanged.InvokeAsync();
}
}
@@ -0,0 +1,106 @@
@* The meter page's header (brief §7.2, D-48): Overview → energy type → meter, carrying the period; the name; chips for
the energy type (its analysis), the mode and "retired"; and the meter's own actions, so entering a reading, recording
a swap or fixing a setting never depends on finding the right tab first. The tab bar follows directly below. *@
<PageHeader Title="@Detail.Name" Description="@IdentityLine()">
<Breadcrumbs>
<AnalysisBreadcrumbs Query="Query" EnergyTypeId="Detail.EnergyTypeId" EnergyTypeName="@Detail.EnergyType"
MeterId="Detail.Id" MeterName="@Detail.Name" />
</Breadcrumbs>
<Chips>
<MudTooltip Text="@Loc.F(S.MeterDetail_EnergyTypeAnalysis, Detail.EnergyType)">
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
Href="@AnalysisLinks.EnergyType(Detail.EnergyTypeId, AnalysisLinks.EnergyTabHistory, Query)">@Detail.EnergyType</MudChip>
</MudTooltip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@Detail.Mode.Display()</MudChip>
@if (IsRetired)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@RetiredText</MudChip>
}
</Chips>
<Actions>
@if (MeterEventRules.TakesReadings(Detail.Mode))
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OnAddReading">
@S.MeterDetail_AddReading
</MudButton>
}
else if (Detail.Mode == MeterMode.ConsumableBalance)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
OnClick="@(() => OnRecordEvent.InvokeAsync(MeterEventType.TankLevel))">
@S.MeterDetail_RecordTankLevel
</MudButton>
}
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Outlined" Color="Color.Primary"
EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
@foreach (var type in MeterEventRules.RecordableFor(Detail.Mode))
{
var chosen = type;
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OnRecordEvent.InvokeAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
}
</MudMenu>
<MudTooltip Text="@S.MeterDetail_EditMeter">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Variant="Variant.Outlined" Size="Size.Medium"
OnClick="OnEdit" aria-label="@S.MeterDetail_EditMeter" />
</MudTooltip>
</Actions>
<ChildContent>
@ChildContent
</ChildContent>
</PageHeader>
@code {
/// <summary>The meter.</summary>
[Parameter, EditorRequired]
public MeterDetailView Detail { get; set; } = null!;
/// <summary>The page's analysis state: breadcrumbs and the type chip carry its period.</summary>
[Parameter]
public AnalysisQuery? Query { get; set; }
/// <summary>Today in the instance zone: a retire date on or before it makes the meter retired.</summary>
[Parameter]
public DateOnly Today { get; set; }
[Parameter]
public EventCallback OnAddReading { get; set; }
[Parameter]
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
[Parameter]
public EventCallback OnEdit { get; set; }
/// <summary>Notices that belong to the header (a tank to set up, a first step).</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
private bool IsRetired => !Detail.IsActive || Detail.RetiredAt is { } retired && retired <= Today;
private string RetiredText => Detail.RetiredAt is { } retired
? Loc.F(S.MeterDetail_RetiredOn, Format.Date(retired))
: S.MeterDetail_Retired;
private string? IdentityLine()
{
var parts = new List<string>(3);
if (!string.IsNullOrWhiteSpace(Detail.SerialNumber))
{
parts.Add(Loc.F(S.MeterDetail_SerialValue, Detail.SerialNumber));
}
if (!string.IsNullOrWhiteSpace(Detail.Location))
{
parts.Add(Detail.Location);
}
var device = string.Join(' ', new[] { Detail.Manufacturer, Detail.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
if (device.Length > 0)
{
parts.Add(device);
}
return parts.Count == 0 ? null : string.Join(" · ", parts);
}
}
@@ -0,0 +1,65 @@
@inherits RecordTabBase<ConsumptionDetailRow>
@* Normalized data (brief §7.2, D-50): the consumption or generation deltas derived from the readings and events, in the
meter's normalized unit (D-20) — what every chart, total and cost is built from. Derived and reproducible: they are
rebuilt whenever a reading or event changes. A drill-down from a chart bucket the data cannot resolve lands here,
filtered to that bucket. *@
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@Loc.F(S.MeterDetail_NormalizedIntro, Detail.NormalizedUnit)</MudText>
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
@if (view.MeterId == Detail.Id)
{
@if (view.Page.Rows.Count == 0)
{
<MudText Typo="Typo.body2" Class="mv-muted">@(view.Range.IsBounded ? S.MeterDetail_NoNormalizedInPeriod : S.MeterDetail_NoConsumption)</MudText>
@if (view.Range.IsBounded)
{
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
}
}
else
{
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabNormalized" tabindex="0">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr>
<th scope="col">@S.MeterDetail_Time</th>
<th scope="col" class="mv-num">@Loc.F(S.MeterDetail_AmountIn, Detail.NormalizedUnit)</th>
<th scope="col">@S.MeterDetail_Kind</th>
<th scope="col">@S.MeterDetail_Quality</th>
</tr>
</thead>
<tbody>
@foreach (var c in view.Page.Rows)
{
<tr>
<td style="white-space:nowrap">
@Local(c.Time)
@if (IsAfterNow(c.Time))
{
<AfterNowChip />
}
</td>
<td class="mv-num">@Format.Number(c.Amount, 3)</td>
<td>@c.Kind.Display()</td>
<td><QualityChip Quality="c.Quality" /></td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
}
}
</LoadPanel>
@code {
protected override Task<RecordPage<ConsumptionDetailRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
Details.GetConsumptionAsync(meterId, range, cursor, cancellationToken);
}
@@ -0,0 +1,36 @@
@* The meters a calculation finding involves — the operands of a mismatch, the path of a loop — by name, each linked to
its own page over the same period; an id that names no meter says so. *@
@if (Ids.Count > 0)
{
<span class="mv-cell-secondary">
@for (var i = 0; i < Ids.Count; i++)
{
var id = Ids[i];
@(i > 0 ? Separator : string.Empty)
@if (Calculation.NameOf(id) is { } name)
{
<MudLink Href="@MeterLinks.Analysis(id, Query)" Typo="Typo.body2">@name</MudLink>
}
else
{
@Loc.F(S.Attention_MeterFallback, id)
}
}
</span>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<int> Ids { get; set; } = [];
[Parameter, EditorRequired]
public MeterCalculationView Calculation { get; set; } = null!;
[Parameter]
public AnalysisQuery? Query { get; set; }
[Parameter]
public string Separator { get; set; } = ", ";
}
@@ -0,0 +1,136 @@
@inherits RecordTabBase<ReadingRow>
@using Microsoft.Extensions.DependencyInjection
@using MeterVault.Infrastructure.Ingestion
@inject IServiceScopeFactory Scopes
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* Raw readings (brief §7.2, D-50, D-57): every value exactly as it arrived, in the register's raw unit — the audit record
everything else is derived from, never changed to fix a figure. Paged and filtered by the page period; hand-entered
ones can be deleted (the meter is recomputed). *@
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.MeterDetail_ReadingsIntro</MudText>
@if (Detail.Mode == MeterMode.ConsumableBalance)
{
@* A tank's consumption comes from level and delivery events; a reading typed here would
save cleanly and change nothing, so the tab sends the user where it counts. *@
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
@S.MeterDetail_TankUsesEvents
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Primary" Class="ml-2"
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabEvents, null, Query)">@S.MeterDetail_GoToEvents</MudButton>
</MudAlert>
}
else
{
<div class="d-flex align-center flex-wrap justify-space-between mb-2" style="gap:.5rem 1rem">
<MudText Typo="Typo.body2">
@if (Detail.FirstReading is { } first && Detail.LastReading is { } last)
{
@Loc.F(S.MeterDetail_RegisterSummary,
Format.Number(first.Value, 2), Local(first.Time), Format.Number(last.Value, 2), Local(last.Time), Detail.Unit)
}
<span class="mv-muted">@(" " + Loc.F(S.MeterDetail_BaselineValue, Format.Number(Detail.InitialBaseline, 2)))</span>
</MudText>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" OnClick="OnAddReading">
@S.MeterDetail_AddReading
</MudButton>
</div>
}
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
@if (view.MeterId == Detail.Id)
{
@if (view.Page.Rows.Count == 0)
{
<MudText Typo="Typo.body2" Class="mv-muted">@(Detail.HasReadings ? S.MeterDetail_NoReadingsInPeriod : S.MeterDetail_NoRawReadings)</MudText>
@if (Detail.HasReadings && view.Range.IsBounded)
{
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
}
}
else
{
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabReadings" tabindex="0">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr>
<th scope="col">@S.MeterDetail_Time</th>
<th scope="col" class="mv-num">@Loc.F(S.MeterDetail_ValueIn, Detail.Unit)</th>
<th scope="col">@S.MeterDetail_Quality</th>
<th scope="col">@S.MeterDetail_Flags</th>
<th scope="col"><span class="mv-sr-only">@S.Common_Actions</span></th>
</tr>
</thead>
<tbody>
@foreach (var r in view.Page.Rows)
{
<tr>
<td style="white-space:nowrap">
@Local(r.Time)
@if (IsAfterNow(r.Time))
{
<AfterNowChip />
}
</td>
<td class="mv-num">@Format.Number(r.Value, 2)</td>
<td><QualityChip Quality="r.Quality" /></td>
<td>@r.Flags.Display()</td>
<td style="text-align:right">
@if (r.Quality == ReadingQuality.Manual)
{
<MudTooltip Text="@S.MeterDetail_DeleteReading">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteAsync(r))" aria-label="@S.MeterDetail_DeleteReading" />
</MudTooltip>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
}
}
</LoadPanel>
@code {
/// <summary>Opens the manual-reading dialog (the page owns it).</summary>
[Parameter]
public EventCallback OnAddReading { get; set; }
protected override Task<RecordPage<ReadingRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
Details.GetReadingsAsync(meterId, range, cursor, cancellationToken);
private async Task DeleteAsync(ReadingRow reading)
{
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle,
Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), Detail.Unit, Local(reading.Time))))
{
return;
}
try
{
await using var scope = Scopes.CreateAsyncScope();
var result = await scope.ServiceProvider.GetRequiredService<MeterEventService>().DeleteManualReadingAsync(Detail.Id, reading.Time);
Snackbar.Add(result.Succeeded ? S.MeterDetail_ReadingDeleted : MeterEventText.Problem(result.Problem),
result.Succeeded ? Severity.Success : Severity.Error);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The service's transaction has rolled back; report rather than end the circuit.
LoggerFactory.CreateLogger<MeterReadingsTab>().LogError(ex, "Deleting a manual reading on meter {MeterId} failed", Detail.Id);
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
}
await OnChanged.InvokeAsync();
}
}
@@ -0,0 +1,377 @@
@using System.Globalization
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject DraftStore Drafts
@implements IDisposable
@* A meter's ingest source: its type, the connector that serves it and where the value sits in the payload. Every way to
a missing connector is a detour that comes back here with the connector picked and everything typed restored: the
dialog saves itself to the circuit's DraftStore when the page is left while it is open. *@
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_edit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="SourceType" Value="_edit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
@foreach (var type in Enum.GetValues<SourceType>())
{
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
}
</MudSelect>
@if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed)
{
@* Every way to a missing connector leads back here with it picked, so setting one up is a
detour rather than a dead end that loses the meter. *@
var usable = ConnectorsFor(needed);
if (usable.Count == 0)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
@if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled)
{
<span>@Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) <MudLink Href="@MeterLinks.EditConnector(MeterId, SourceIdOrNull, _edit.SourceType, disabled.Id)">@S.MeterDetail_EnableConnectorLink</MudLink></span>
}
else
{
<span>@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="@MeterLinks.NewConnector(MeterId, SourceIdOrNull, _edit.SourceType, needed)">@S.MeterDetail_CreateConnectorLink</MudLink> @S.MeterDetail_CreateConnectorHint</span>
}
</MudAlert>
}
else
{
<MudSelect T="int?" @bind-Value="_edit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-1">
@foreach (var e in usable)
{
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-2 d-flex flex-wrap" style="gap:.25rem 1rem">
@if (_edit.EndpointId is { } chosen && usable.Any(e => e.Id == chosen))
{
@* Change the connection itself (URL, token, broker) without losing what is typed here: the dialog's
draft is kept and the connector page leads back to it. *@
<MudLink Typo="Typo.caption" Href="@MeterLinks.EditConnector(MeterId, SourceIdOrNull, _edit.SourceType, chosen)">
@S.MeterDetail_EditConnectorLink
</MudLink>
}
<MudLink Typo="Typo.caption" Href="@MeterLinks.NewConnector(MeterId, SourceIdOrNull, _edit.SourceType, needed)">
@S.MeterDetail_AnotherConnector
</MudLink>
</div>
}
}
@if (_edit.SourceType == SourceType.HomeAssistant)
{
<MudTextField @bind-Value="_edit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
<MudTextField @bind-Value="_edit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
<MudNumericField T="int?" @bind-Value="_edit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
@S.MeterDetail_PollHint
</MudText>
}
else if (_edit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
{
<MudTextField @bind-Value="_edit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
<MudTextField @bind-Value="_edit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
<MudTextField @bind-Value="_edit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
}
<MudSelect T="SourceValueKind" @bind-Value="_edit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
@foreach (var kind in Enum.GetValues<SourceValueKind>())
{
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</MudSelectItem>
}
</MudSelect>
<div class="d-flex flex-wrap" style="column-gap:1rem">
<MudNumericField T="double" @bind-Value="_edit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" Style="min-width:6rem" />
<MudNumericField T="double" @bind-Value="_edit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" Style="min-width:6rem" />
<MudNumericField T="int" @bind-Value="_edit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" Style="min-width:6rem" />
</div>
<MudSwitch T="bool" @bind-Value="_edit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@code {
/// <summary>The meter the source feeds.</summary>
[Parameter, EditorRequired]
public int MeterId { get; set; }
/// <summary>Raised after a source was saved.</summary>
[Parameter]
public EventCallback Saved { get; set; }
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private List<IngestionEndpoint> _endpoints = [];
private SourceEdit _edit = new();
private bool _open;
/// <summary>The meter the dialog was opened for: a draft is only ever saved under the meter it belongs to.</summary>
private int? _openFor;
/// <summary>Opens the dialog for a new source, or for <paramref name="source"/>.</summary>
public async Task OpenAsync(MeterSource? source)
{
await LoadEndpointsAsync();
Fill(source);
Show();
}
/// <summary>
/// Opens the dialog a link asked for — typically the way back from the connector page, with the source it left, its
/// type and the connector just saved for it (<see cref="MeterLinks.Source"/>).
/// </summary>
public async Task OpenFromLinkAsync(int? sourceId, SourceType? type, int? connectorId)
{
await LoadEndpointsAsync();
MeterSource? source = null;
if (sourceId is { } id)
{
await using var db = await DbFactory.CreateDbContextAsync();
source = await db.MeterSources.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id && s.MeterId == MeterId);
}
Fill(source);
// Back from the connector page: everything typed before the detour comes back with the dialog. A
// link that names a source type is only ever that way back; other links open the dialog fresh.
if (type is not null && Drafts.TryTake<SourceEdit>(DraftKey(MeterId, sourceId), out var draft))
{
draft.Id = _edit.Id;
_edit = draft;
}
var connector = connectorId is { } cid ? _endpoints.FirstOrDefault(e => e.Id == cid) : null;
if ((type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } wanted
&& wanted != _edit.SourceType)
{
OnSourceTypeChanged(wanted);
}
// Only a connector that can serve the source: a disabled or mismatched one would be refused on save.
if (connector is { IsEnabled: true } && SourceRouting.Serves(connector.Type, _edit.SourceType))
{
_edit.EndpointId = connector.Id;
}
Show();
}
/// <summary>
/// Leaving the page with the dialog open — the connector links in it do exactly that — keeps what was typed for the
/// way back. Disposal runs after every input already sent has been applied.
/// </summary>
public void Dispose()
{
if (_open && _openFor is { } meterId)
{
Drafts.Save(DraftKey(meterId, SourceIdOrNull), _edit.Clone());
}
}
/// <summary>The draft key of a meter's source dialog (a new source when <paramref name="sourceId"/> is null).</summary>
public static string DraftKey(int meterId, int? sourceId) =>
$"meter:{meterId.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}";
private void Show()
{
_openFor = MeterId;
_open = true;
StateHasChanged();
}
/// <summary>
/// The dialog closed on its own (backdrop, Escape, or the dialog provider dismissing it on navigation). Only the
/// Cancel button throws the draft away: when the page is left through a connector link, the provider dismisses the
/// dialog after <see cref="Dispose"/> has saved what was typed, and that draft is the way back.
/// </summary>
private void OnVisibleChanged(bool visible)
{
if (!visible)
{
_open = false;
}
}
/// <summary>The source being edited, or null for a new one — what a detour to the connector page returns to.</summary>
private int? SourceIdOrNull => _edit.Id == 0 ? null : _edit.Id;
private void Cancel()
{
_open = false;
Drafts.Discard(DraftKey(MeterId, SourceIdOrNull));
}
private async Task LoadEndpointsAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
}
private void Fill(MeterSource? source)
{
if (source is null)
{
_edit = new SourceEdit();
OnSourceTypeChanged(_edit.SourceType);
return;
}
var config = SourceConfig.Parse(source.Config);
_edit = new SourceEdit
{
Id = source.Id,
SourceType = source.SourceType,
EndpointId = source.EndpointId,
ValueKind = source.ValueKind,
Scale = source.Scale,
Offset = source.Offset,
Priority = source.Priority,
IsEnabled = source.IsEnabled,
EntityId = config.EntityId,
Attribute = config.Attribute,
PollMinutes = config.PollMinutes,
Topic = config.Topic,
Path = config.Path,
TimePath = config.TimePath,
};
}
private async Task SaveAsync()
{
// A live source without a matching connector has no connection details and would silently
// never ingest, so refuse it here rather than letting it look configured.
if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed)
{
var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId);
if (selected is null)
{
Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _edit.SourceType.Display()), Severity.Error);
return;
}
if (selected.Type != needed)
{
Snackbar.Add(
Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _edit.SourceType.Display(), needed.Display()),
Severity.Error);
return;
}
if (!selected.IsEnabled)
{
Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error);
return;
}
}
else
{
_edit.EndpointId = null;
}
var config = new SourceConfig
{
EntityId = Trim(_edit.EntityId),
Attribute = Trim(_edit.Attribute),
PollMinutes = _edit.PollMinutes,
Topic = Trim(_edit.Topic),
Path = Trim(_edit.Path),
TimePath = Trim(_edit.TimePath),
};
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
await using (var db = await DbFactory.CreateDbContextAsync())
{
if (_edit.Id == 0)
{
db.MeterSources.Add(new MeterSource
{
MeterId = MeterId,
SourceType = _edit.SourceType,
EndpointId = _edit.EndpointId,
Config = configJson,
ValueKind = _edit.ValueKind,
Scale = _edit.Scale,
Offset = _edit.Offset,
Priority = _edit.Priority,
IsEnabled = _edit.IsEnabled,
});
}
else
{
var existing = await db.MeterSources.FirstAsync(s => s.Id == _edit.Id);
existing.SourceType = _edit.SourceType;
existing.EndpointId = _edit.EndpointId;
existing.Config = configJson;
existing.ValueKind = _edit.ValueKind;
existing.Scale = _edit.Scale;
existing.Offset = _edit.Offset;
existing.Priority = _edit.Priority;
existing.IsEnabled = _edit.IsEnabled;
}
await db.SaveChangesAsync();
}
_open = false;
Drafts.Discard(DraftKey(MeterId, SourceIdOrNull));
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
await Saved.InvokeAsync();
}
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering
// a disabled one would produce a source that saves cleanly and then never runs.
private List<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
_endpoints.Where(e => e.Type == type && e.IsEnabled).ToList();
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
private void OnSourceTypeChanged(SourceType sourceType)
{
_edit.SourceType = sourceType;
var needed = SourceRouting.RequiredEndpoint(sourceType);
var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId);
if (needed is null || (selected is not null && selected.Type != needed))
{
_edit.EndpointId = null;
}
// Sole candidate: preselect it, so the common single-broker / single-HA setup is one click. Only
// enabled ones count — the picker offers nothing else, so a disabled pick would be invisible.
if (needed is { } kind && _edit.EndpointId is null)
{
var candidates = ConnectorsFor(kind);
if (candidates.Count == 1)
{
_edit.EndpointId = candidates[0].Id;
}
}
}
private sealed class SourceEdit
{
public SourceEdit Clone() => (SourceEdit)MemberwiseClone();
public int Id { get; set; }
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
public int? EndpointId { get; set; }
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
public double Scale { get; set; } = 1;
public double Offset { get; set; }
public int Priority { get; set; }
public bool IsEnabled { get; set; } = true;
public string? EntityId { get; set; }
public string? Attribute { get; set; }
public int? PollMinutes { get; set; } = 60;
public string? Topic { get; set; }
public string? Path { get; set; }
public string? TimePath { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show More