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:
@@ -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">Prev→New</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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user