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
This commit is contained in:
Florian Schmidt
2026-08-13 16:36:25 +02:00
parent af786c7b28
commit bfa0b537ee
55 changed files with 5215 additions and 608 deletions
+109 -110
View File
@@ -12,13 +12,13 @@
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
<PageTitle>MeterVault — Meter</PageTitle>
<PageTitle>MeterVault — @S.Common_Meter</PageTitle>
@if (_detail is null)
{
@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
{
@@ -43,10 +43,10 @@ else
<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>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode.Display()</MudChip>
@if (!_detail.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.MeterDetail_Retired</MudChip>
}
</div>
@@ -55,41 +55,41 @@ else
<MudGrid Class="mb-2">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">@p.Label this month</MudText>
<MudText Typo="Typo.overline" Color="Color.Secondary">@Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display())</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
@Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 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">vs last month</MudText>
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_VsLastMonth</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
@Loc.F(S.MeterDetail_LastMonthValue, 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.overline" Color="Color.Secondary">@S.Common_ThisYear</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
@Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0))
</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.overline" Color="Color.Secondary">@S.MeterDetail_CostThisYear</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" : "")
@Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency)
@(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "")
</MudText>
</MudPaper>
</MudItem>
@@ -98,7 +98,7 @@ else
@if (p.HasHistory)
{
<MudPaper Class="pa-3 mb-2" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Last 12 months</MudText>
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_RangeLast12Months</MudText>
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
@foreach (var m in p.Last12Months)
{
@@ -118,31 +118,30 @@ else
@if (_periods is null && _detail.Mode == MeterMode.Virtual)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
Virtual meter — its value is an expression over other meters, evaluated when read, so it has
no stored series of its own. See <MudLink Href="/trends">Trends</MudLink> for its figures.
@S.MeterDetail_VirtualNotice <MudLink Href="/trends">@S.MeterDetail_VirtualNoticeTrends</MudLink>
</MudAlert>
}
<MudExpansionPanels Elevation="0" Class="mb-2">
<MudExpansionPanel Text="Meter register details">
<MudExpansionPanel Text="@S.MeterDetail_RegisterDetails">
<div class="d-flex flex-wrap" style="gap:2rem">
<div>
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_RegisterSpan</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))
@Loc.F(S.MeterDetail_BaselineValue, Format.Number(_detail.InitialBaseline, 0))
</MudText>
</div>
<div>
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_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.overline" Color="Color.Secondary">@S.MeterDetail_LifetimeTotal</MudText>
<MudText Typo="Typo.body1">
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
</MudText>
@@ -152,12 +151,11 @@ else
</MudExpansionPanels>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabReadings, _detail.ReadingCount)">
@if (_detail.Mode == MeterMode.Virtual)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
A virtual meter is an expression over other meters, so it stores no readings of its own —
enter the reading on the meter the expression refers to.
@S.MeterDetail_VirtualNoReadings
</MudText>
}
else
@@ -165,21 +163,21 @@ else
<div class="d-flex justify-end mb-2">
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
Add reading
@S.MeterDetail_AddReading
</MudButton>
</div>
}
@if (_detail.RecentReadings.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoRawReadings</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
@Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id)
</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>
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Value</th><th>@S.MeterDetail_Quality</th><th>@S.MeterDetail_Flags</th></tr></thead>
<tbody>
@foreach (var r in _detail.RecentReadings)
{
@@ -187,7 +185,7 @@ else
<td>@Local(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>
<td>@r.Flags.Display()</td>
</tr>
}
</tbody>
@@ -195,23 +193,23 @@ else
}
</MudTabPanel>
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabConsumption, _detail.ConsumptionCount)">
@if (_detail.RecentConsumption.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoConsumption</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count)</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>
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Amount</th><th>@S.MeterDetail_Kind</th><th>@S.MeterDetail_Quality</th></tr></thead>
<tbody>
@foreach (var c in _detail.RecentConsumption)
{
<tr>
<td>@Local(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>@c.Kind.Display()</td>
<td>@QualityChip(c.Quality)</td>
</tr>
}
@@ -220,21 +218,21 @@ else
}
</MudTabPanel>
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabEvents, _detail.Events.Count)">
@if (_detail.Events.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoEvents</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">PrevNew</th><th>Notes</th></tr></thead>
<thead><tr><th>@S.MeterDetail_Time</th><th>@S.Common_Type</th><th style="text-align:right">@S.Common_Amount</th><th style="text-align:right">@S.MeterDetail_PrevNew</th><th>@S.MeterDetail_Notes</th></tr></thead>
<tbody>
@foreach (var e in _detail.Events)
{
<tr>
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
<td>@e.Type</td>
<td>@e.Type.Display()</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>
@@ -245,25 +243,25 @@ else
}
</MudTabPanel>
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabTariffs, _detail.Tariffs.Count)">
@if (_detail.Tariffs.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoTariffs</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>
<thead><tr><th>@S.Common_Scope</th><th>@S.MeterDetail_Component</th><th style="text-align:right">@S.Common_Value</th><th>@S.Common_Unit</th><th>@S.MeterDetail_From</th><th>@S.MeterDetail_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>@t.Scope.Display() @(t.ScopeId is { } id ? $"#{id}" : "")</td>
<td>@t.Component.Display()</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>
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd)</td>
</tr>
}
</tbody>
@@ -271,25 +269,25 @@ else
}
</MudTabPanel>
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabSources, _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
@S.MeterDetail_AddSource
</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>
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoSources</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Type</th><th>Target</th><th>Connector</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>
<thead><tr><th>@S.Common_Type</th><th>@S.Common_Target</th><th>@S.MeterDetail_Connector</th><th>@S.Common_Enabled</th><th>@S.Common_LastSeen</th><th style="text-align:right">@S.MeterDetail_LastValue</th><th>@S.Common_Status</th><th style="text-align:right">@S.Common_Actions</th></tr></thead>
<tbody>
@foreach (var s in _sources)
{
<tr>
<td>@s.SourceType</td>
<td>@s.SourceType.Display()</td>
<td>@SourceTarget(s)</td>
<td>
@{ var problem = ConnectorProblem(s); }
@@ -305,7 +303,7 @@ else
</MudTooltip>
}
</td>
<td>@(s.IsEnabled ? "yes" : "no")</td>
<td>@(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_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>
@@ -323,13 +321,13 @@ else
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, _detail.Name)</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="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
Label="@Loc.F(S.MeterDetail_ReadingLabel, _detail.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
@@ -338,12 +336,12 @@ else
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)} {_detail.Unit}" : "Enter a value")
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : S.MeterDetail_EnterValue)
</MudText>
@if (ChangeSinceLast is { } change)
{
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
@ChangeSinceText(change)@(WouldBeRejected ? S.MeterDetail_WillBeRejectedSuffix : "")
</MudText>
}
</div>
@@ -357,66 +355,64 @@ else
</div>
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
<MudDatePicker @bind-Date="_readingDate" Label="@S.Common_Date" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:150px" />
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
<MudTimePicker @bind-Time="_readingTime" 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="SetNow">Now</MudButton>
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">@S.Common_Now</MudButton>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, _tz.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 (EnteredTimeSkipped)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
@Loc.F(S.MeterDetail_SkippedTime, _tz.Id)
</MudAlert>
}
@if (WouldBeRejected)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
register that only counts up, so it will be rejected. If the meter was swapped or reset,
record that on the Events tab first.
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
</MudAlert>
}
@if (ReplacesRecentReading)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
This meter already has a reading at that time — saving replaces its value.
@S.MeterDetail_ReplaceNotice
</MudAlert>
}
@if (IsFuture)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
}
else if (IsBackdated)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
Backdated before the latest reading — consumption from there on is recomputed.
@S.MeterDetail_BackdatedNotice
</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
@(_readingSaving ? "Saving…" : "Save reading")
@(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
@foreach (var type in Enum.GetValues<SourceType>())
{
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
}
</MudSelect>
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
@@ -424,13 +420,13 @@ else
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).
@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="/admin/connectors">@S.MeterDetail_CreateConnectorLink</MudLink>
@S.MeterDetail_CreateConnectorHint
</MudAlert>
}
else
{
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-2">
@foreach (var e in ConnectorsFor(needed))
{
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
@@ -440,35 +436,35 @@ else
}
@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.PollMinutes" Label="Poll interval (minutes)" Class="mb-1" />
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
Hourly is plenty for a meter — monthly totals and cost come out identical, with far less raw data.
@S.MeterDetail_PollHint
</MudText>
}
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" />
<MudTextField @bind-Value="_sourceEdit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
}
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
@foreach (var kind in Enum.GetValues<SourceValueKind>())
{
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</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" />
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
<MudButton OnClick="@(() => _sourceOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
}
@@ -547,11 +543,11 @@ else
{
if (change is not { } c)
{
return "no basis yet";
return S.MeterDetail_NoBasisYet;
}
return Math.Abs(c) < 0.005
? "about the same"
? S.MeterDetail_AboutTheSame
: $"{(c > 0 ? "+" : "")}{Format.Number(Math.Abs(c) * 100, 0)}%";
}
@@ -613,8 +609,9 @@ else
}
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
? Loc.F(S.MeterDetail_LastReadingCaption,
Format.Number(value, 2), detail.Unit, Local(time).ToString("yyyy-MM-dd HH:mm"))
: Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit);
}
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
@@ -678,8 +675,9 @@ else
private string ChangeSinceText(double change) =>
Math.Abs(change) < 1e-9
? "no change since last reading"
: $"{(change > 0 ? "+" : "")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
? S.MeterDetail_NoChangeSinceLast
: Loc.F(S.MeterDetail_ChangeSinceLast,
$"{(change > 0 ? "+" : "")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit);
private async Task SaveReadingAsync()
{
@@ -701,20 +699,18 @@ else
switch (outcome)
{
case IngestionOutcome.Written:
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), _detail.Unit), Severity.Success);
break;
case IngestionOutcome.Updated:
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success);
break;
case IngestionOutcome.RejectedDecrease:
// Leave the dialog open: the typed value is still on screen to correct, and the
// alternative fix — recording a reset or swap — is a decision, not a retry.
Snackbar.Add(
"Rejected — below the previous reading on a register that only counts up. "
+ "Record a counter reset or meter swap first.", Severity.Error);
Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error);
return;
default:
Snackbar.Add("This meter no longer exists.", Severity.Error);
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
return;
}
@@ -783,19 +779,21 @@ else
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);
Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _sourceEdit.SourceType.Display()), Severity.Error);
return;
}
if (selected.Type != needed)
{
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
Snackbar.Add(
Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _sourceEdit.SourceType.Display(), needed.Display()),
Severity.Error);
return;
}
if (!selected.IsEnabled)
{
Snackbar.Add($"'{selected.Name}' is disabled, so this source would never ingest. Enable it first.", Severity.Error);
Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error);
return;
}
}
@@ -847,20 +845,21 @@ else
await db.SaveChangesAsync();
_sourceOpen = false;
Snackbar.Add("Source saved.", Severity.Success);
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
await LoadSourcesAsync();
}
private async Task DeleteSourceAsync(MeterSource source)
{
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle,
Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display())))
{
return;
}
await using var db = await DbFactory.CreateDbContextAsync();
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
Snackbar.Add("Source deleted.", Severity.Success);
Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success);
await LoadSourcesAsync();
}
@@ -898,9 +897,9 @@ else
var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId);
return endpoint switch
{
null => "no connector — never ingests",
{ IsEnabled: false } => $"'{endpoint.Name}' is disabled",
_ when endpoint.Type != needed => $"'{endpoint.Name}' is {endpoint.Type}, needs {needed}",
null => S.MeterDetail_ProblemNoConnector,
{ IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name),
_ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()),
_ => null,
};
}
@@ -948,5 +947,5 @@ else
}
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>;
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality.Display()</MudChip>;
}