Meter detail: lead with periods and change, not register totals
ci / build-test (push) Successful in 1m23s
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
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
@page "/meters/{Id:int}"
|
||||
@inject MeterDetailService Details
|
||||
@inject MeterPeriodService Periods
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@@ -34,42 +35,98 @@ else
|
||||
}
|
||||
</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)
|
||||
{
|
||||
<MudItem xs="6" sm="3">
|
||||
@if (_periods is { } p)
|
||||
{
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="6" md="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>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@p.Label this month</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
|
||||
@if (p.MonthIsPartial)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">vs last month</MudText>
|
||||
<MudText Typo="Typo.h6">@ChangeText(p.MonthChange)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
last month @Format.Number(p.LastMonth, 0) @p.Unit
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost this year</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year
|
||||
@(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (p.HasHistory)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Last 12 months</MudText>
|
||||
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
|
||||
@foreach (var m in p.Last12Months)
|
||||
{
|
||||
<div class="d-flex flex-column align-center" style="flex:1; height:100%">
|
||||
<div style="flex:1; display:flex; align-items:flex-end; width:100%">
|
||||
<div title="@($"{m.Month:yyyy-MM}: {Format.Number(m.Amount, 0)} {p.Unit}")"
|
||||
style="@BarStyle(m.Amount, p.Last12Months)"></div>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@m.Month.ToString("MMM")</MudText>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
|
||||
<MudExpansionPanels Elevation="0" Class="mb-2">
|
||||
<MudExpansionPanel Text="Meter register details">
|
||||
<div class="d-flex flex-wrap" style="gap:2rem">
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||
(baseline @Format.Number(_detail.InitialBaseline, 0))
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@_detail.ReadingCount ·
|
||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Lifetime total</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
@@ -279,6 +336,7 @@ else
|
||||
public int Id { get; set; }
|
||||
|
||||
private MeterDetailView? _detail;
|
||||
private MeterPeriodView? _periods;
|
||||
private bool _notFound;
|
||||
private List<MeterSource> _sources = [];
|
||||
private List<IngestionEndpoint> _endpoints = [];
|
||||
@@ -289,15 +347,43 @@ else
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_detail = null;
|
||||
_periods = null;
|
||||
_notFound = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
if (_detail is not null)
|
||||
{
|
||||
_periods = await Periods.GetAsync(Id);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "+12%" / "−4%" against the previous period. Less is better for consumption and worse for
|
||||
/// generation, so colour is left to the caller's context rather than hardcoded green/red here.
|
||||
/// </summary>
|
||||
private static string ChangeText(double? change)
|
||||
{
|
||||
if (change is not { } c)
|
||||
{
|
||||
return "no basis yet";
|
||||
}
|
||||
|
||||
return Math.Abs(c) < 0.005
|
||||
? "about the same"
|
||||
: $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%";
|
||||
}
|
||||
|
||||
private static string BarStyle(double amount, IReadOnlyList<MeterMonthPoint> history)
|
||||
{
|
||||
var peak = history.Max(h => Math.Abs(h.Amount));
|
||||
var fraction = peak < 1e-9 ? 0 : Math.Abs(amount) / peak;
|
||||
// Floor at 2% so a month with a little usage is still visibly distinct from an empty one.
|
||||
var height = amount == 0 ? 0 : Math.Max(2, fraction * 100);
|
||||
return $"width:100%; height:{height.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}%; "
|
||||
+ "background:var(--mud-palette-primary); border-radius:2px 2px 0 0";
|
||||
}
|
||||
|
||||
private async Task LoadSourcesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
Reference in New Issue
Block a user