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
+3 -1
View File
@@ -6,7 +6,7 @@ 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**. 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). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/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) + 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). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/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). **UI language** (M7's last item) is now English + German end to end — see *Localization* below. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
## Source of truth ## Source of truth
@@ -84,6 +84,8 @@ sources (Tasmota/HA/MQTT/manual/CSV)
**In-app update (`UpdateRunner`):** the dashboard shows a banner 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. **In-app update (`UpdateRunner`):** the dashboard shows a banner 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):** 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` — so components write `@S.Common_Save`, never a string key, and 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. Domain **enums stay bare identifiers** (they are persisted as text and appear in the REST API); `DisplayNames.Display()` is the single place that decides how each value is *spoken*, and `EnumDisplayNameTests` fails if a value has no wording. Anything from the database (meter names, energy-type display names, category names) is user data and is **never** translated. Language is 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. `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. **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/`) ## Reference-data behaviours the code must reproduce (from `sampledata/`)
+1
View File
@@ -67,6 +67,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|----------|---------| |----------|---------|
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string | | `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) | | `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
| `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__ApiKeys__0` | An API key accepted on the `X-Api-Key` header | | `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__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__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
+2
View File
@@ -20,6 +20,8 @@
<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" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
<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 to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/> <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="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="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>
+28 -2
View File
@@ -1,5 +1,8 @@
<!DOCTYPE html> @using System.Globalization
<html lang="en"> @using Microsoft.AspNetCore.Localization
<!DOCTYPE html>
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -23,3 +26,26 @@
</body> </body>
</html> </html>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// 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.
protected override void OnInitialized() =>
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,
});
}
+40 -4
View File
@@ -1,5 +1,7 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using System.Globalization
@using MeterVault.App.Theme @using MeterVault.App.Theme
@inject NavigationManager Navigation
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" /> <MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
<MudPopoverProvider /> <MudPopoverProvider />
@@ -9,11 +11,24 @@
<MudLayout> <MudLayout>
<MudAppBar Elevation="1" Dense="true"> <MudAppBar Elevation="1" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" <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" /> <MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
<MudText Typo="Typo.h6">MeterVault</MudText> <MudText Typo="Typo.h6">MeterVault</MudText>
<MudSpacer /> <MudSpacer />
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")"> <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="@(_darkMode ? S.Layout_LightMode : S.Layout_DarkMode)">
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)" <MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" /> Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
</MudTooltip> </MudTooltip>
@@ -31,12 +46,33 @@
</MudLayout> </MudLayout>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. @S.Layout_UnhandledError
<a href="." class="reload">Reload</a> <a href="." class="reload">@S.Layout_Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
@code { @code {
private bool _drawerOpen = true; private bool _drawerOpen = true;
private bool _darkMode = true; private bool _darkMode = true;
private string _current = Loc.SupportedCultures[0];
protected override void OnInitialized() =>
Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current);
// 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.
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);
}
} }
+12 -12
View File
@@ -3,25 +3,25 @@
@using MeterVault.Core.Domain @using MeterVault.Core.Domain
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</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">Trends</MudNavLink> <MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Trends</MudNavLink>
@foreach (var type in _energyTypes) @foreach (var type in _energyTypes)
{ {
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink> <MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
} }
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink> <MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">@S.Nav_Solar</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink> <MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">@S.Nav_Consumables</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink> <MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink> <MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
<MudDivider Class="my-2" /> <MudDivider Class="my-2" />
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false"> <MudNavGroup Title="@S.Nav_Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink> <MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink> <MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink> <MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink> <MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">@S.Nav_Connectors</MudNavLink>
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink> <MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">@S.Nav_Settings</MudNavLink>
</MudNavGroup> </MudNavGroup>
</MudNavMenu> </MudNavMenu>
@@ -7,25 +7,25 @@
<div></div> <div></div>
</div> </div>
<p class="components-reconnect-first-attempt-visible"> <p class="components-reconnect-first-attempt-visible">
Rejoining the server... @S.Reconnect_Rejoining
</p> </p>
<p class="components-reconnect-repeated-attempt-visible"> <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>
<p class="components-reconnect-failed-visible"> <p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page. @S.Reconnect_RejoinFailed<br />@S.Reconnect_RetryOrReload
</p> </p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible"> <button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry @S.Reconnect_Retry
</button> </button>
<p class="components-pause-visible"> <p class="components-pause-visible">
The session has been paused by the server. @S.Reconnect_Paused
</p> </p>
<p class="components-resume-failed-visible"> <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> </p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible"> <button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume @S.Reconnect_Resume
</button> </button>
</div> </div>
</dialog> </dialog>
+33 -33
View File
@@ -5,12 +5,12 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @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"> <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))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add category @S.Categories_AddCategory
</MudButton> </MudButton>
</div> </div>
@@ -22,22 +22,22 @@ else
{ {
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>@S.Common_Name</MudTh>
<MudTh>Sort</MudTh> <MudTh>@S.Categories_Sort</MudTh>
<MudTh>Members</MudTh> <MudTh>@S.Categories_Members</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name"> <MudTd DataLabel="@S.Common_Name">
@if (!string.IsNullOrWhiteSpace(context.ColorHex)) @if (!string.IsNullOrWhiteSpace(context.ColorHex))
{ {
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span> <span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
} }
@context.Name @context.Name
</MudTd> </MudTd>
<MudTd DataLabel="Sort">@context.Sort</MudTd> <MudTd DataLabel="@S.Categories_Sort">@context.Sort</MudTd>
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd> <MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <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.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -47,20 +47,20 @@ else
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <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> </TitleContent>
<DialogContent> <DialogContent>
<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" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" /> <MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" /> <MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
@if (_working.Id != 0) @if (_working.Id != 0)
{ {
<MudDivider Class="my-3" /> <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) @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 else
{ {
@@ -77,30 +77,30 @@ else
</MudList> </MudList>
} }
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap"> <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) @foreach (var meter in _meters)
{ {
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton> <MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">@S.Categories_Add</MudButton>
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px"> <MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" Dense="true" Style="min-width:180px">
@foreach (var t in _energyTypes) @foreach (var t in _energyTypes)
{ {
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
} }
</MudSelect> </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> </div>
} }
else 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> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -129,12 +129,12 @@ else
{ {
var meters = c.Members.Count(m => m.MeterId is not null); var meters = c.Members.Count(m => m.MeterId is not null);
var types = c.Members.Count(m => m.EnergyTypeId 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) => private string MemberLabel(CostCategoryMember m) =>
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}" m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}" : m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}")
: "—"; : "—";
private void OpenEdit(CostCategory? category) private void OpenEdit(CostCategory? category)
@@ -158,7 +158,7 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Name)) if (string.IsNullOrWhiteSpace(_working.Name))
{ {
Snackbar.Add("Name is required.", Severity.Warning); Snackbar.Add(S.Common_NameRequired, Severity.Warning);
return; return;
} }
@@ -169,7 +169,7 @@ else
db.CostCategories.Add(category); db.CostCategories.Add(category);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Re-open on the new category so members can be added. // 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(); await LoadAsync();
OpenEdit(_categories!.First(c => c.Id == category.Id)); OpenEdit(_categories!.First(c => c.Id == category.Id));
return; return;
@@ -181,7 +181,7 @@ else
existing.Sort = _working.Sort; existing.Sort = _working.Sort;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -235,8 +235,8 @@ else
private async Task DeleteAsync(CostCategory category) private async Task DeleteAsync(CostCategory category)
{ {
if (!await Confirm.DeleteAsync(DialogService, "Delete category", if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle,
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked.")) Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
{ {
return; return;
} }
@@ -244,7 +244,7 @@ else
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
// Members cascade with the category; manual_cost.category_id is SetNull. // Members cascade with the category; manual_cost.category_id is SetNull.
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync(); await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
+75 -60
View File
@@ -8,18 +8,17 @@
@using MeterVault.Infrastructure.Ingestion @using MeterVault.Infrastructure.Ingestion
@using MudBlazor @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"> <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))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add connector @S.Connectors_AddConnector
</MudButton> </MudButton>
</div> </div>
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true"> <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> @S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
(or Docker secret) resolved at runtime.
</MudAlert> </MudAlert>
@if (_endpoints is null) @if (_endpoints is null)
@@ -30,20 +29,20 @@ else
{ {
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>@S.Common_Name</MudTh>
<MudTh>Type</MudTh> <MudTh>@S.Common_Type</MudTh>
<MudTh>Enabled</MudTh> <MudTh>@S.Common_Enabled</MudTh>
<MudTh>Last status</MudTh> <MudTh>@S.Connectors_LastStatus</MudTh>
<MudTh>Last seen</MudTh> <MudTh>@S.Common_LastSeen</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd> <MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
<MudTd DataLabel="Type">@context.Type</MudTd> <MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd> <MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd> <MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd> <MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <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.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -51,49 +50,49 @@ else
</MudTable> </MudTable>
@if (_endpoints.Count == 0) @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"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <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> </TitleContent>
<DialogContent> <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>()) @foreach (var type in Enum.GetValues<EndpointType>())
{ {
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem> <MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
} }
</MudSelect> </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) @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.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="Enter the token here" Color="Color.Primary" Class="mb-1" /> <MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
@if (_working.UseDirectToken) @if (_working.UseDirectToken)
{ {
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1" <MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
Label="@(_working.HasStoredToken ? "Long-lived access token (stored — type to replace)" : "Long-lived access token")" /> Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
Encrypted before it is stored; database dumps and JSON exports carry nothing usable. @S.Connectors_EncryptedHint
</MudText> </MudText>
} }
else else
{ {
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-1" /> <MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
The variable's <em>name</em>, not the token. Set it on the server and restart the app. @S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
</MudText> </MudText>
} }
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" /> <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"> <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> </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"> <MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
Test connection @S.Connectors_TestConnection
</MudButton> </MudButton>
@if (_testing) @if (_testing)
{ {
@@ -101,36 +100,36 @@ else
} }
@if (_testResult is not null) @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 else
{ {
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" /> <MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" 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="TLS" Color="Color.Primary" 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="Enter credentials here" Color="Color.Primary" Class="mb-1" /> <MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
@if (_working.UseDirectCredentials) @if (_working.UseDirectCredentials)
{ {
<MudTextField @bind-Value="_working.Username" Label="Username (optional)" Class="mb-1" /> <MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1" <MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
Label="@(_working.HasStoredPassword ? "Password (stored — type to replace)" : "Password (optional)")" /> Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
Encrypted before it is stored; database dumps and JSON exports carry nothing usable. @S.Connectors_EncryptedHint
</MudText> </MudText>
} }
else else
{ {
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
} }
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" 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> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -210,9 +209,7 @@ else
// "Test connection" into an exfiltration primitive — point it at any host and the // "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 // 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. // it was saved for; testing elsewhere means typing the token again.
_testResult = new HaTestResult(false, _testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged);
"Base URL differs from the saved one. Re-enter the token to test against a different host — "
+ "a stored token is only sent to the host it was saved for.");
} }
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 }) else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
{ {
@@ -220,19 +217,17 @@ else
} }
else else
{ {
_testResult = new HaTestResult(false, "Enter a token first."); _testResult = new HaTestResult(false, S.Connectors_TestNoToken);
} }
} }
else if (string.IsNullOrWhiteSpace(_working.TokenEnv)) else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
{ {
_testResult = new HaTestResult(false, _testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
"Name the environment variable holding the token, or switch on \"Enter the token here\".");
} }
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken) else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
{ {
_testResult = new HaTestResult(false, _testResult = new HaTestResult(false,
$"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, " Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
+ "or switch on \"Enter the token here\" to store the token directly.");
} }
else else
{ {
@@ -249,7 +244,7 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Name)) if (string.IsNullOrWhiteSpace(_working.Name))
{ {
Snackbar.Add("Name is required.", Severity.Warning); Snackbar.Add(S.Common_NameRequired, Severity.Warning);
return; return;
} }
@@ -258,7 +253,7 @@ else
&& string.IsNullOrWhiteSpace(_working.Token) && string.IsNullOrWhiteSpace(_working.Token)
&& !_working.HasStoredToken) && !_working.HasStoredToken)
{ {
Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning); Snackbar.Add(S.Connectors_TokenRequired, Severity.Warning);
return; return;
} }
@@ -305,7 +300,7 @@ else
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -316,15 +311,16 @@ else
// Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely // Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
// rather than falling back to any broker. Say so plainly. // rather than falling back to any broker. Say so plainly.
var note = sourceCount > 0 var note = sourceCount > 0
? $" {sourceCount} source(s) use it and will stop ingesting until reassigned to another connector." ? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
: ""; : "";
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}")) if (!await Confirm.DeleteAsync(
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
{ {
return; return;
} }
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync(); await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -398,4 +394,23 @@ else
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc); 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,
};
} }
@@ -5,12 +5,12 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Energy types</PageTitle> <PageTitle>MeterVault — @S.Nav_EnergyTypes</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Energy types</MudText> <MudText Typo="Typo.h4">@S.Nav_EnergyTypes</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add energy type @S.EnergyTypes_Add
</MudButton> </MudButton>
</div> </div>
@@ -22,24 +22,24 @@ else
{ {
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Key</MudTh> <MudTh>@S.EnergyTypes_Key</MudTh>
<MudTh>Display name</MudTh> <MudTh>@S.EnergyTypes_DisplayName</MudTh>
<MudTh>Base unit</MudTh> <MudTh>@S.EnergyTypes_BaseUnit</MudTh>
<MudTh>Default mode</MudTh> <MudTh>@S.EnergyTypes_DefaultMode</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Key">@context.Key</MudTd> <MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
<MudTd DataLabel="Display name"> <MudTd DataLabel="@S.EnergyTypes_DisplayName">
@if (!string.IsNullOrWhiteSpace(context.ColorHex)) @if (!string.IsNullOrWhiteSpace(context.ColorHex))
{ {
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span> <span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
} }
@context.DisplayName @context.DisplayName
</MudTd> </MudTd>
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd> <MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd> <MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <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.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -49,24 +49,24 @@ else
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <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> </TitleContent>
<DialogContent> <DialogContent>
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" 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="Base unit (kWh, m3, L, h)" 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="Default mode" Class="mb-2"> <MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>()) @foreach (var mode in Enum.GetValues<MeterMode>())
{ {
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem> <MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" /> <MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -105,14 +105,14 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit)) 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; return;
} }
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id)) 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; return;
} }
@@ -141,7 +141,7 @@ else
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -151,11 +151,11 @@ else
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id); var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
if (meterCount > 0) 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; 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; return;
} }
@@ -165,7 +165,7 @@ else
{ {
db.EnergyTypes.Remove(target); db.EnergyTypes.Remove(target);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
} }
await LoadAsync(); await LoadAsync();
+19 -20
View File
@@ -2,29 +2,28 @@
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options @inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Settings</PageTitle> <PageTitle>MeterVault — @S.Nav_Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText> <MudText Typo="Typo.h4" Class="mb-2">@S.Nav_Settings</MudText>
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true"> <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 @S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the (<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
</MudAlert> </MudAlert>
<MudGrid> <MudGrid>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <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"> <MudSimpleTable Dense="true">
<tbody> <tbody>
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr> <tr><td>@S.Settings_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>@S.Settings_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>@S.Common_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_RawRetention</td><td style="text-align:right">@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays)</td></tr>
</tbody> </tbody>
</MudSimpleTable> </MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>, @S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>. <code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
</MudText> </MudText>
</MudPaper> </MudPaper>
@@ -32,7 +31,7 @@
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <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"> <MudSimpleTable Dense="true">
<tbody> <tbody>
<tr> <tr>
@@ -40,26 +39,26 @@
<td style="text-align:right"> <td style="text-align:right">
@if (_o.ApiKeys.Count > 0) @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) 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 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> </td>
</tr> </tr>
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "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>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "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>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "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> </tbody>
</MudSimpleTable> </MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2"> <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. @S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>. @S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
</MudText> </MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
+42 -41
View File
@@ -5,12 +5,12 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Tariffs</PageTitle> <PageTitle>MeterVault — @S.Nav_Tariffs</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Tariffs</MudText> <MudText Typo="Typo.h4">@S.Nav_Tariffs</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add tariff @S.Tariffs_AddTariff
</MudButton> </MudButton>
</div> </div>
@@ -22,22 +22,22 @@ else
{ {
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Scope</MudTh> <MudTh>@S.Common_Scope</MudTh>
<MudTh>Component</MudTh> <MudTh>@S.Tariffs_Component</MudTh>
<MudTh>Value</MudTh> <MudTh>@S.Common_Value</MudTh>
<MudTh>Unit</MudTh> <MudTh>@S.Common_Unit</MudTh>
<MudTh>Valid from</MudTh> <MudTh>@S.Tariffs_ValidFrom</MudTh>
<MudTh>Valid to</MudTh> <MudTh>@S.Tariffs_ValidTo</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd> <MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
<MudTd DataLabel="Component">@context.Component</MudTd> <MudTd DataLabel="@S.Tariffs_Component">@context.Component.Display()</MudTd>
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd> <MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd> <MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd> <MudTd DataLabel="@S.Tariffs_ValidFrom">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd> <MudTd DataLabel="@S.Tariffs_ValidTo">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <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.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -45,24 +45,24 @@ else
</MudTable> </MudTable>
@if (_tariffs.Count == 0) @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>
} }
} }
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <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> </TitleContent>
<DialogContent> <DialogContent>
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2"> <MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="@S.Common_Scope" Class="mb-2">
@foreach (var scope in Enum.GetValues<TariffScope>()) @foreach (var scope in Enum.GetValues<TariffScope>())
{ {
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem> <MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (_working.ScopeType == TariffScope.EnergyType) @if (_working.ScopeType == TariffScope.EnergyType)
{ {
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2"> <MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes) @foreach (var t in _energyTypes)
{ {
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
@@ -71,29 +71,29 @@ else
} }
else if (_working.ScopeType == TariffScope.Meter) else if (_working.ScopeType == TariffScope.Meter)
{ {
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2"> <MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_Meter" Class="mb-2">
@foreach (var m in _meters) @foreach (var m in _meters)
{ {
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
} }
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2"> <MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="@S.Tariffs_Component" Class="mb-2">
@foreach (var component in Enum.GetValues<TariffComponent>()) @foreach (var component in Enum.GetValues<TariffComponent>())
{ {
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem> <MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" /> <MudNumericField T="double" @bind-Value="_working.Value" Label="@S.Common_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.Unit" Label="@S.Tariffs_UnitLabel" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" /> <MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" /> <MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" /> <MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" /> <MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -117,9 +117,9 @@ else
private string ScopeLabel(Tariff t) => t.ScopeType switch private string ScopeLabel(Tariff t) => t.ScopeType switch
{ {
TariffScope.Global => "Global", TariffScope.Global => S.Tariffs_ScopeGlobal,
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}", TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}", TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"),
_ => t.ScopeType.ToString(), _ => t.ScopeType.ToString(),
}; };
@@ -147,13 +147,13 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null) 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; return;
} }
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null) 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; return;
} }
@@ -191,13 +191,14 @@ else
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
private async Task DeleteAsync(Tariff tariff) 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; return;
} }
@@ -208,7 +209,7 @@ else
{ {
db.Tariffs.Remove(target); db.Tariffs.Remove(target);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
} }
await LoadAsync(); await LoadAsync();
+25 -25
View File
@@ -2,15 +2,15 @@
@inject ConsumableService ConsumablesSvc @inject ConsumableService ConsumablesSvc
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Consumables</PageTitle> <PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Oil / consumables</MudText> <MudText Typo="Typo.h4">@S.Nav_Consumables</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px"> <MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem> <MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem> <MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
</MudSelect> </MudSelect>
</div> </div>
@@ -21,8 +21,8 @@
else if (_items.Count == 0) else if (_items.Count == 0)
{ {
<MudAlert Severity="Severity.Info"> <MudAlert Severity="Severity.Info">
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference @S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
data from <MudLink Href="/import">Import</MudLink>. <MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert> </MudAlert>
} }
else else
@@ -33,20 +33,20 @@ else
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText> <MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
<MudGrid> <MudGrid>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_TankLevel</MudText>
<MudText Typo="Typo.h5"> <MudText Typo="Typo.h5">
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—") @(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
</MudText> </MudText>
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" /> <MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
<MudText Typo="Typo.caption" Color="Color.Secondary"> <MudText Typo="Typo.caption" Color="Color.Secondary">
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit @Loc.F(S.Consumables_FillOfCapacity, Format.Number(item.FillFraction * 100, 0), Format.Number(item.Capacity, 0), item.Unit)
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm") @if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
{ {
<text> · @Format.Number(cm, 0) cm</text> <text> · @Format.Number(cm, 0) cm</text>
} }
@if (item.LevelAsOf is { } asOf) @if (item.LevelAsOf is { } asOf)
{ {
<text> · as of @asOf.ToString("yyyy-MM-dd")</text> <text> · @Loc.F(S.Consumables_AsOf, asOf.ToString("yyyy-MM-dd"))</text>
} }
</MudText> </MudText>
</MudItem> </MudItem>
@@ -54,15 +54,15 @@ else
<MudItem xs="12" md="8"> <MudItem xs="12" md="8">
<MudGrid> <MudGrid>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_UsedRange</MudText>
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText> <MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
</MudItem> </MudItem>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_BurnerRuntime</MudText>
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText> <MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
</MudItem> </MudItem>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_EffectiveRate</MudText>
<MudText Typo="Typo.subtitle1"> <MudText Typo="Typo.subtitle1">
@if (item.FixedRate is { } fr) @if (item.FixedRate is { } fr)
{ {
@@ -77,20 +77,20 @@ else
<text>—</text> <text>—</text>
} }
</MudText> </MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">@item.RateMode.Display()</MudText>
</MudItem> </MudItem>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText> <MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_ForecastEmpty</MudText>
<MudText Typo="Typo.subtitle1"> <MudText Typo="Typo.subtitle1">
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—") @(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
@if (item.AveragePerDay is { } apd) @if (item.AveragePerDay is { } apd)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
(@Format.Number(apd, 1) @item.Unit/day) @Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit)
</MudText> </MudText>
} }
</MudText> </MudText>
@@ -99,22 +99,22 @@ else
</MudItem> </MudItem>
<MudItem xs="12" md="7"> <MudItem xs="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">@S.Consumables_ConsumptionByMonth</MudText>
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" /> <SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
</MudItem> </MudItem>
<MudItem xs="12" md="5"> <MudItem xs="12" md="5">
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">@Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count)</MudText>
@if (item.Deliveries.Count == 0) @if (item.Deliveries.Count == 0)
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">@S.Consumables_NoDeliveries</MudText>
} }
else else
{ {
<div style="max-height:260px; overflow-y:auto"> <div style="max-height:260px; overflow-y:auto">
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<thead> <thead>
<tr><th>Date</th><th style="text-align:right">Amount</th></tr> <tr><th>@S.Common_Date</th><th style="text-align:right">@S.Common_Amount</th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var delivery in item.Deliveries) @foreach (var delivery in item.Deliveries)
@@ -171,9 +171,9 @@ else
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item) private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
{ {
var points = item.Months var points = item.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption)) .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Consumption))
.ToList(); .ToList();
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)]; return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)];
} }
private static Color FillColor(double fraction) => fraction switch private static Color FillColor(double fraction) => fraction switch
+9 -9
View File
@@ -1,9 +1,9 @@
@page "/" @page "/"
@inject DashboardService Dash @inject DashboardService Dash
<PageTitle>MeterVault — Overview</PageTitle> <PageTitle>MeterVault — @S.Nav_Overview</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText> <MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Overview</MudText>
<UpdateBanner /> <UpdateBanner />
@@ -16,28 +16,28 @@ else
<MudGrid> <MudGrid>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_ThisMonth</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
<DeltaChip Kpi="_summary.Month" /> <DeltaChip Kpi="_summary.Month" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" 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.h5">@Format.Euro(_summary.Year.Current)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
<DeltaChip Kpi="_summary.Year" /> <DeltaChip Kpi="_summary.Year" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_LatestMonthWithData</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatCostsMost</MudText>
@if (_breakdown is { Count: > 0 }) @if (_breakdown is { Count: > 0 })
{ {
<CategoryDonut Slices="_breakdown" /> <CategoryDonut Slices="_breakdown" />
@@ -55,17 +55,17 @@ else
} }
else else
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">@S.Dashboard_NoCostData</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <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> <MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatChanged</MudText>
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<thead> <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> <tr><th>@S.Dashboard_ColCategory</th><th style="text-align:right">@S.Dashboard_ColCurrent</th><th style="text-align:right">@S.Dashboard_ColPrevious</th><th style="text-align:right">Δ</th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var row in _difference) @foreach (var row in _difference)
+21 -21
View File
@@ -5,15 +5,15 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle> <PageTitle>MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <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> <MudText Typo="Typo.h4">@Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback)</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px"> <MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem> <MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem> <MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
</MudSelect> </MudSelect>
</div> </div>
@@ -24,8 +24,8 @@
else if (!_graph.HasData) else if (!_graph.HasData)
{ {
<MudAlert Severity="Severity.Info"> <MudAlert Severity="Severity.Info">
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the @S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
reference data from <MudLink Href="/import">Import</MudLink>. <MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert> </MudAlert>
} }
else else
@@ -33,48 +33,48 @@ else
<MudGrid Class="mb-2"> <MudGrid Class="mb-2">
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.EnergyView_TopLevelThroughput</MudText>
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText> <MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_Meters</MudText>
<MudText Typo="Typo.h5">@_meters.Count</MudText> <MudText Typo="Typo.h5">@_meters.Count</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText> <MudText Typo="Typo.h6" Class="mb-1">@S.EnergyView_Flow</MudText>
@if (_graph.HasChain) @if (_graph.HasChain)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder. @S.EnergyView_FlowCaption
</MudText> </MudText>
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" /> <SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
} }
else else
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3"> <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 @S.EnergyView_NoChainIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink> @S.EnergyView_NoChainMiddle
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other). <b>@S.EnergyView_NoChainUpstream</b> @S.EnergyView_NoChainRest
</MudAlert> </MudAlert>
@if (_graph.Nodes.Count > 0) @if (_graph.Nodes.Count > 0)
{ {
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead> <thead><tr><th>@S.Common_Meter</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
<tbody> <tbody>
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value)) @foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
{ {
<tr> <tr>
<td>@node.Label</td> <td>@(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label)</td>
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td> <td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
</tr> </tr>
} }
@@ -85,15 +85,15 @@ else
</MudPaper> </MudPaper>
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Common_Meters</MudText>
<MudSimpleTable Dense="true" Hover="true"> <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> <thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
<tbody> <tbody>
@foreach (var meter in _meters) @foreach (var meter in _meters)
{ {
<tr> <tr>
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td> <td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
<td>@meter.Mode</td> <td>@meter.Mode.Display()</td>
<td>@UpstreamLabel(meter.Id)</td> <td>@UpstreamLabel(meter.Id)</td>
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td> <td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
</tr> </tr>
+10 -10
View File
@@ -1,27 +1,27 @@
@page "/Error" @page "/Error"
@using System.Diagnostics @using System.Diagnostics
<PageTitle>Error</PageTitle> <PageTitle>@S.Error_PageTitle</PageTitle>
<h1 class="text-danger">Error.</h1> <h1 class="text-danger">@S.Error_Heading</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2> <h2 class="text-danger">@S.Error_Message</h2>
@if (ShowRequestId) @if (ShowRequestId)
{ {
<p> <p>
<strong>Request ID:</strong> <code>@RequestId</code> <strong>@S.Error_RequestId</strong> <code>@RequestId</code>
</p> </p>
} }
<h3>Development Mode</h3> <h3>@S.Error_DevelopmentMode</h3>
<p> <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>
<p> <p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong> <strong>@S.Error_DevelopmentWarning</strong>
It can result in displaying sensitive information from exceptions to end users. @S.Error_DevelopmentWarningDetail
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> @((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
and restarting the app.
</p> </p>
@code{ @code{
+34 -36
View File
@@ -9,21 +9,20 @@
@using MeterVault.Infrastructure.Persistence @using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore @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> <MudGrid>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <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"> <MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a @S.Import_ReferenceDatasetHelp
starter dataset with meters, tariffs and categories.
</MudText> </MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload" <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded"> OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
@(_referenceLoaded ? "Loaded" : "Load reference data") @(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
</MudButton> </MudButton>
@if (_loadingReference) @if (_loadingReference)
{ {
@@ -35,22 +34,21 @@
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<div class="d-flex align-center justify-space-between"> <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" <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> </div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1"> <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 @S.Import_YourOwnCsvHelp
revertible import. Or dry-run against one of the built-in reference profiles below.
</MudText> </MudText>
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2"> <MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem> <MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem> <MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem> <MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem> <MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
</MudSelect> </MudSelect>
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload"> <MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
Dry-run a reference sheet @S.Import_DryRunReferenceSheet
</MudButton> </MudButton>
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" /> <InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
</MudPaper> </MudPaper>
@@ -60,20 +58,20 @@
{ {
<MudItem xs="12"> <MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2"> <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"> <div class="d-flex" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText> <MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
<MudText>Events: <b>@_preview.Events.Count</b></MudText> <MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText> <MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText> <MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
</div> </div>
@if (_preview.Warnings.Count > 0) @if (_preview.Warnings.Count > 0)
{ {
<MudExpansionPanels Class="mt-3"> <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)) @foreach (var warning in _preview.Warnings.Take(50))
{ {
<MudText Typo="Typo.body2">@warning</MudText> <MudText Typo="Typo.body2">@warning.Display()</MudText>
} }
</MudExpansionPanel> </MudExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>
@@ -84,15 +82,15 @@
<MudItem xs="12"> <MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2"> <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) @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 else
{ {
<MudSimpleTable Dense="true" Hover="true"> <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 style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
<tbody> <tbody>
@foreach (var batch in _batches) @foreach (var batch in _batches)
{ {
@@ -104,18 +102,18 @@
<td> <td>
@if (batch.RevertedAt is not null) @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 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>
<td style="text-align:right"> <td style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error" <MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
StartIcon="@Icons.Material.Filled.Undo" StartIcon="@Icons.Material.Filled.Undo"
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)" Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
OnClick="@(() => RevertAsync(batch))">Revert</MudButton> OnClick="@(() => RevertAsync(batch))">@S.Import_Revert</MudButton>
</td> </td>
</tr> </tr>
} }
@@ -151,9 +149,9 @@
private async Task RevertAsync(ImportBatch batch) private async Task RevertAsync(ImportBatch batch)
{ {
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?", if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?", Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
"Revert")) S.Import_Revert))
{ {
return; return;
} }
@@ -162,12 +160,12 @@
try try
{ {
await ImportService.RevertAsync(batch.Id); 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(); await LoadBatchesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error); Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
} }
finally finally
{ {
@@ -183,12 +181,12 @@
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata"); var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await ReferenceImporter.LoadAsync(dir); await ReferenceImporter.LoadAsync(dir);
_referenceLoaded = true; _referenceLoaded = true;
Snackbar.Add("Reference data loaded.", Severity.Success); Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
await LoadBatchesAsync(); await LoadBatchesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error); Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
} }
finally finally
{ {
+50 -48
View File
@@ -10,29 +10,28 @@
@using MeterVault.Infrastructure.Persistence @using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore @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"> <div class="d-flex align-center mb-4" style="gap:1rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" /> <MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
<MudText Typo="Typo.h4">Import wizard</MudText> <MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
</div> </div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4"> <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 @S.ImportWizard_IntroLead
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes, <code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
</MudText> </MudText>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap"> <div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary" <MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload"> StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
Choose CSV @S.ImportWizard_ChooseCsv
</MudButton> </MudButton>
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" /> <InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
@if (_fileName is not null) @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> </div>
</MudPaper> </MudPaper>
@@ -40,10 +39,10 @@
@if (_colCount > 0) @if (_colCount > 0)
{ {
<MudPaper Class="pa-4 mb-4" Elevation="2"> <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> <MudGrid>
<MudItem xs="12" sm="6" md="3"> <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)) @foreach (var i in Enumerable.Range(0, _colCount))
{ {
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem> <MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
@@ -51,27 +50,27 @@
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true"> <MudSelect T="DateKind" @bind-Value="_dateKind" Label="@S.ImportWizard_DateFormat" Dense="true">
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem> <MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem> <MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem> <MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="6" sm="6" md="2"> <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>
<MudItem xs="6" sm="6" md="2"> <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>
<MudItem xs="12" md="2" Class="d-flex flex-column"> <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="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" /> <MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <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"> <div style="overflow-x:auto">
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%"> <MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
<thead> <thead>
@@ -79,7 +78,7 @@
@foreach (var i in Enumerable.Range(0, _colCount)) @foreach (var i in Enumerable.Range(0, _colCount))
{ {
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")"> <th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
Col @i@(i == _dateColumn ? " 📅" : "") @Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "")
</th> </th>
} }
</tr> </tr>
@@ -98,23 +97,23 @@
</MudSimpleTable> </MudSimpleTable>
</div> </div>
<MudText Typo="Typo.caption" Color="Color.Secondary"> <MudText Typo="Typo.caption" Color="Color.Secondary">
Faded rows are before the first data row. The 📅 column supplies the date. @S.ImportWizard_PreviewCaption
</MudText> </MudText>
</MudPaper> </MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <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"> <div style="overflow-x:auto">
<MudSimpleTable Dense="true" Style="min-width:100%"> <MudSimpleTable Dense="true" Style="min-width:100%">
<thead> <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> </thead>
<tbody> <tbody>
@foreach (var i in Enumerable.Range(0, _colCount)) @foreach (var i in Enumerable.Range(0, _colCount))
{ {
<tr> <tr>
<td> <td>
<b>Col @i</b> <b>@Loc.F(S.ImportWizard_ColumnN, i)</b>
@if (!string.IsNullOrWhiteSpace(Header(i))) @if (!string.IsNullOrWhiteSpace(Header(i)))
{ {
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span> <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"> <MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
@foreach (var role in Enum.GetValues<MappingRole>()) @foreach (var role in Enum.GetValues<MappingRole>())
{ {
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem> <MudSelectItem T="MappingRole" Value="role">@role.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
</td> </td>
@@ -133,7 +132,7 @@
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) @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" <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) @foreach (var m in _meters)
{ {
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem> <MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
@@ -143,7 +142,7 @@
else if (_columns[i].Role == MappingRole.ManualCost) else if (_columns[i].Role == MappingRole.ManualCost)
{ {
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense" <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) @foreach (var c in _categories)
{ {
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem> <MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
@@ -154,7 +153,7 @@
<td> <td>
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) @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> </td>
</tr> </tr>
@@ -176,12 +175,12 @@
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap"> <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" <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" <MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing"> OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
Commit import @S.ImportWizard_CommitButton
</MudButton> </MudButton>
@if (_committing) @if (_committing)
{ {
@@ -192,24 +191,24 @@
@if (_staged is not null) @if (_staged is not null)
{ {
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap"> <div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText> <MudText>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
<MudText>Events: <b>@_staged.Events.Count</b></MudText> <MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText> <MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText> <MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
</div> </div>
@if (_staged.TotalRows == 0) @if (_staged.TotalRows == 0)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2"> <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> </MudAlert>
} }
@if (_staged.Warnings.Count > 0) @if (_staged.Warnings.Count > 0)
{ {
<MudExpansionPanels Class="mt-3"> <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)) @foreach (var warning in _staged.Warnings.Take(100))
{ {
<MudText Typo="Typo.body2">@warning</MudText> <MudText Typo="Typo.body2">@warning.Display()</MudText>
} }
</MudExpansionPanel> </MudExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>
@@ -326,7 +325,7 @@
{ {
_staged = null; _staged = null;
_stagedMapping = null; _stagedMapping = null;
_validationErrors = ["The mapping changed after the preview. Run the dry run again, then commit."]; _validationErrors = [S.ImportWizard_MappingChanged];
return; return;
} }
@@ -334,12 +333,12 @@
try try
{ {
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson); 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"); Nav.NavigateTo("/import");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error); Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error);
} }
finally finally
{ {
@@ -352,7 +351,7 @@
var errors = new List<string>(); var errors = new List<string>();
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0) 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++) for (var i = 0; i < _columns.Length; i++)
@@ -360,12 +359,12 @@
var c = _columns[i]; var c = _columns[i];
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null) 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) 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));
} }
} }
@@ -379,9 +378,10 @@
foreach (var group in duplicateTargets) foreach (var group in duplicateTargets)
{ {
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}"; var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}")); ?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter."); 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; return errors;
@@ -431,6 +431,8 @@
private string ColLabel(int col) private string ColLabel(int col)
{ {
var header = Header(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);
} }
} }
+109 -110
View File
@@ -12,13 +12,13 @@
@using MeterVault.Infrastructure.Ingestion @using MeterVault.Infrastructure.Ingestion
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Meter</PageTitle> <PageTitle>MeterVault — @S.Common_Meter</PageTitle>
@if (_detail is null) @if (_detail is null)
{ {
@if (_notFound) @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 else
{ {
@@ -43,10 +43,10 @@ else
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" /> <MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
<MudText Typo="Typo.h4">@_detail.Name</MudText> <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" 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) @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> </div>
@@ -55,41 +55,41 @@ else
<MudGrid Class="mb-2"> <MudGrid Class="mb-2">
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2"> <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> <MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
@if (p.MonthIsPartial) @if (p.MonthIsPartial)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary"> <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> </MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2"> <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.h6">@ChangeText(p.MonthChange)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary"> <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> </MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2"> <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.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary"> <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> </MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2"> <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.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary"> <MudText Typo="Typo.caption" Color="Color.Secondary">
≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year @Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency)
@(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "") @(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "")
</MudText> </MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
@@ -98,7 +98,7 @@ else
@if (p.HasHistory) @if (p.HasHistory)
{ {
<MudPaper Class="pa-3 mb-2" Elevation="2"> <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"> <div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
@foreach (var m in p.Last12Months) @foreach (var m in p.Last12Months)
{ {
@@ -118,31 +118,30 @@ else
@if (_periods is null && _detail.Mode == MeterMode.Virtual) @if (_periods is null && _detail.Mode == MeterMode.Virtual)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2"> <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 @S.MeterDetail_VirtualNotice <MudLink Href="/trends">@S.MeterDetail_VirtualNoticeTrends</MudLink>
no stored series of its own. See <MudLink Href="/trends">Trends</MudLink> for its figures.
</MudAlert> </MudAlert>
} }
<MudExpansionPanels Elevation="0" Class="mb-2"> <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 class="d-flex flex-wrap" style="gap:2rem">
<div> <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"> <MudText Typo="Typo.body1">
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
@(_detail.LastReadingValue is { } l ? Format.Number(l, 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> </MudText>
</div> </div>
<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"> <MudText Typo="Typo.body1">
@_detail.ReadingCount · @_detail.ReadingCount ·
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—") @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
</MudText> </MudText>
</div> </div>
<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"> <MudText Typo="Typo.body1">
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit @Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
</MudText> </MudText>
@@ -152,12 +151,11 @@ else
</MudExpansionPanels> </MudExpansionPanels>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2"> <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) @if (_detail.Mode == MeterMode.Virtual)
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary"> <MudText Typo="Typo.body2" Color="Color.Secondary">
A virtual meter is an expression over other meters, so it stores no readings of its own — @S.MeterDetail_VirtualNoReadings
enter the reading on the meter the expression refers to.
</MudText> </MudText>
} }
else else
@@ -165,21 +163,21 @@ else
<div class="d-flex justify-end mb-2"> <div class="d-flex justify-end mb-2">
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" <MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading"> StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
Add reading @S.MeterDetail_AddReading
</MudButton> </MudButton>
</div> </div>
} }
@if (_detail.RecentReadings.Count == 0) @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 else
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary"> <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> </MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2"> <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> <tbody>
@foreach (var r in _detail.RecentReadings) @foreach (var r in _detail.RecentReadings)
{ {
@@ -187,7 +185,7 @@ else
<td>@Local(r.Time).ToString("yyyy-MM-dd HH:mm")</td> <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 style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
<td>@QualityChip(r.Quality)</td> <td>@QualityChip(r.Quality)</td>
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td> <td>@r.Flags.Display()</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -195,23 +193,23 @@ else
} }
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")"> <MudTabPanel Text="@Loc.F(S.MeterDetail_TabConsumption, _detail.ConsumptionCount)">
@if (_detail.RecentConsumption.Count == 0) @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 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"> <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> <tbody>
@foreach (var c in _detail.RecentConsumption) @foreach (var c in _detail.RecentConsumption)
{ {
<tr> <tr>
<td>@Local(c.Time).ToString("yyyy-MM-dd HH:mm")</td> <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 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> <td>@QualityChip(c.Quality)</td>
</tr> </tr>
} }
@@ -220,21 +218,21 @@ else
} }
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")"> <MudTabPanel Text="@Loc.F(S.MeterDetail_TabEvents, _detail.Events.Count)">
@if (_detail.Events.Count == 0) @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 else
{ {
<MudSimpleTable Dense="true" Hover="true"> <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> <tbody>
@foreach (var e in _detail.Events) @foreach (var e in _detail.Events)
{ {
<tr> <tr>
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td> <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.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 style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
<td>@e.Notes</td> <td>@e.Notes</td>
@@ -245,25 +243,25 @@ else
} }
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")"> <MudTabPanel Text="@Loc.F(S.MeterDetail_TabTariffs, _detail.Tariffs.Count)">
@if (_detail.Tariffs.Count == 0) @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 else
{ {
<MudSimpleTable Dense="true" Hover="true"> <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> <tbody>
@foreach (var t in _detail.Tariffs) @foreach (var t in _detail.Tariffs)
{ {
<tr> <tr>
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td> <td>@t.Scope.Display() @(t.ScopeId is { } id ? $"#{id}" : "")</td>
<td>@t.Component</td> <td>@t.Component.Display()</td>
<td style="text-align:right">@Format.Number(t.Value, 4)</td> <td style="text-align:right">@Format.Number(t.Value, 4)</td>
<td>@t.Unit</td> <td>@t.Unit</td>
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</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> </tr>
} }
</tbody> </tbody>
@@ -271,25 +269,25 @@ else
} }
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@($"Sources ({_sources.Count})")"> <MudTabPanel Text="@Loc.F(S.MeterDetail_TabSources, _sources.Count)">
<div class="d-flex justify-end mb-2"> <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))"> <MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
Add source @S.MeterDetail_AddSource
</MudButton> </MudButton>
</div> </div>
@if (_sources.Count == 0) @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 else
{ {
<MudSimpleTable Dense="true" Hover="true"> <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> <tbody>
@foreach (var s in _sources) @foreach (var s in _sources)
{ {
<tr> <tr>
<td>@s.SourceType</td> <td>@s.SourceType.Display()</td>
<td>@SourceTarget(s)</td> <td>@SourceTarget(s)</td>
<td> <td>
@{ var problem = ConnectorProblem(s); } @{ var problem = ConnectorProblem(s); }
@@ -305,7 +303,7 @@ else
</MudTooltip> </MudTooltip>
} }
</td> </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>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td> <td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
<td>@(s.LastStatus ?? "—")</td> <td>@(s.LastStatus ?? "—")</td>
@@ -323,13 +321,13 @@ else
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText> <MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, _detail.Name)</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true" <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" /> 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 @* 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. *@ 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"> <div class="mv-reading-verdict mt-1 mb-3">
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)"> <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> </MudText>
@if (ChangeSinceLast is { } change) @if (ChangeSinceLast is { } change)
{ {
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)"> <MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "") @ChangeSinceText(change)@(WouldBeRejected ? S.MeterDetail_WillBeRejectedSuffix : "")
</MudText> </MudText>
} }
</div> </div>
@@ -357,66 +355,64 @@ else
</div> </div>
<div class="d-flex align-center flex-wrap" style="gap:.75rem"> <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" /> 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" /> Class="flex-grow-1" Style="min-width:130px" />
<MudButton Size="Size.Small" Variant="Variant.Text" <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> </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 @* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
area, so nothing the user is aiming at moves. *@ area, so nothing the user is aiming at moves. *@
@if (EnteredTimeSkipped) @if (EnteredTimeSkipped)
{ {
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3"> <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> </MudAlert>
} }
@if (WouldBeRejected) @if (WouldBeRejected)
{ {
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3"> <MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a @Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
register that only counts up, so it will be rejected. If the meter was swapped or reset,
record that on the Events tab first.
</MudAlert> </MudAlert>
} }
@if (ReplacesRecentReading) @if (ReplacesRecentReading)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3"> <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> </MudAlert>
} }
@if (IsFuture) @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) else if (IsBackdated)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3"> <MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
Backdated before the latest reading — consumption from there on is recomputed. @S.MeterDetail_BackdatedNotice
</MudAlert> </MudAlert>
} }
</DialogContent> </DialogContent>
<DialogActions> <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" <MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)"> OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
@(_readingSaving ? "Saving…" : "Save reading") @(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
</MudButton> </MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
<TitleContent> <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> </TitleContent>
<DialogContent> <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>()) @foreach (var type in Enum.GetValues<SourceType>())
{ {
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem> <MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed) @if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
@@ -424,13 +420,13 @@ else
if (ConnectorsFor(needed).Count == 0) if (ConnectorsFor(needed).Count == 0)
{ {
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2"> <MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink> @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="/admin/connectors">@S.MeterDetail_CreateConnectorLink</MudLink>
(set it up once; every source then just picks it). @S.MeterDetail_CreateConnectorHint
</MudAlert> </MudAlert>
} }
else 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)) @foreach (var e in ConnectorsFor(needed))
{ {
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
@@ -440,35 +436,35 @@ else
} }
@if (_sourceEdit.SourceType == SourceType.HomeAssistant) @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.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" /> <MudTextField @bind-Value="_sourceEdit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="Poll interval (minutes)" Class="mb-1" /> <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"> <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> </MudText>
} }
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota) 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.Topic" Label="@S.MeterDetail_TopicLabel" 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.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" 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>()) @foreach (var kind in Enum.GetValues<SourceValueKind>())
{ {
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem> <MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
<div class="d-flex" style="gap:1rem"> <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.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" 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="Priority" Class="mb-2" /> <MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" />
</div> </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> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _sourceOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
} }
@@ -547,11 +543,11 @@ else
{ {
if (change is not { } c) if (change is not { } c)
{ {
return "no basis yet"; return S.MeterDetail_NoBasisYet;
} }
return Math.Abs(c) < 0.005 return Math.Abs(c) < 0.005
? "about the same" ? S.MeterDetail_AboutTheSame
: $"{(c > 0 ? "+" : "")}{Format.Number(Math.Abs(c) * 100, 0)}%"; : $"{(c > 0 ? "+" : "")}{Format.Number(Math.Abs(c) * 100, 0)}%";
} }
@@ -613,8 +609,9 @@ else
} }
return detail is { LastReadingValue: { } value, LastReadingTime: { } time } return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}." ? Loc.F(S.MeterDetail_LastReadingCaption,
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit})."; 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> /// <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) => private string ChangeSinceText(double change) =>
Math.Abs(change) < 1e-9 Math.Abs(change) < 1e-9
? "no change since last reading" ? S.MeterDetail_NoChangeSinceLast
: $"{(change > 0 ? "+" : "")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading"; : Loc.F(S.MeterDetail_ChangeSinceLast,
$"{(change > 0 ? "+" : "")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit);
private async Task SaveReadingAsync() private async Task SaveReadingAsync()
{ {
@@ -701,20 +699,18 @@ else
switch (outcome) switch (outcome)
{ {
case IngestionOutcome.Written: 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; break;
case IngestionOutcome.Updated: 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; break;
case IngestionOutcome.RejectedDecrease: case IngestionOutcome.RejectedDecrease:
// Leave the dialog open: the typed value is still on screen to correct, and the // 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. // alternative fix — recording a reset or swap — is a decision, not a retry.
Snackbar.Add( Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error);
"Rejected — below the previous reading on a register that only counts up. "
+ "Record a counter reset or meter swap first.", Severity.Error);
return; return;
default: default:
Snackbar.Add("This meter no longer exists.", Severity.Error); Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
return; return;
} }
@@ -783,19 +779,21 @@ else
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId); var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (selected is null) 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; return;
} }
if (selected.Type != needed) 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; return;
} }
if (!selected.IsEnabled) 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; return;
} }
} }
@@ -847,20 +845,21 @@ else
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_sourceOpen = false; _sourceOpen = false;
Snackbar.Add("Source saved.", Severity.Success); Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
await LoadSourcesAsync(); await LoadSourcesAsync();
} }
private async Task DeleteSourceAsync(MeterSource source) 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; return;
} }
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync(); 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(); await LoadSourcesAsync();
} }
@@ -898,9 +897,9 @@ else
var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId); var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId);
return endpoint switch return endpoint switch
{ {
null => "no connector — never ingests", null => S.MeterDetail_ProblemNoConnector,
{ IsEnabled: false } => $"'{endpoint.Name}' is disabled", { IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name),
_ when endpoint.Type != needed => $"'{endpoint.Name}' is {endpoint.Type}, needs {needed}", _ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()),
_ => null, _ => null,
}; };
} }
@@ -948,5 +947,5 @@ else
} }
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text" 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>;
} }
+48 -51
View File
@@ -6,12 +6,12 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Meters</PageTitle> <PageTitle>MeterVault — @S.Nav_Meters</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Meters</MudText> <MudText Typo="Typo.h4">@S.Common_Meters</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add meter @S.Meters_AddMeter
</MudButton> </MudButton>
</div> </div>
@@ -23,29 +23,29 @@ else
{ {
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>@S.Common_Name</MudTh>
<MudTh>Type</MudTh> <MudTh>@S.Common_Type</MudTh>
<MudTh>Mode</MudTh> <MudTh>@S.Common_Mode</MudTh>
<MudTh>Unit</MudTh> <MudTh>@S.Common_Unit</MudTh>
<MudTh>Sources</MudTh> <MudTh>@S.Meters_Sources</MudTh>
<MudTh>Last seen</MudTh> <MudTh>@S.Common_LastSeen</MudTh>
<MudTh>Active</MudTh> <MudTh>@S.Meters_Active</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd> <MudTd DataLabel="@S.Common_Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd> <MudTd DataLabel="@S.Common_Type">@context.EnergyType?.DisplayName</MudTd>
<MudTd DataLabel="Mode">@context.Mode</MudTd> <MudTd DataLabel="@S.Common_Mode">@context.Mode.Display()</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd> <MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd> <MudTd DataLabel="@S.Meters_Sources">@context.Sources.Count</MudTd>
<MudTd DataLabel="Last seen"> <MudTd DataLabel="@S.Common_LastSeen">
@{ @{
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max(); var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
} }
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
</MudTd> </MudTd>
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd> <MudTd DataLabel="@S.Meters_Active">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <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.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -55,76 +55,73 @@ else
@if (_meters.Count == 0) @if (_meters.Count == 0)
{ {
<MudAlert Severity="Severity.Info" Class="mt-4"> <MudAlert Severity="Severity.Info" Class="mt-4">
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data. @S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
</MudAlert> </MudAlert>
} }
} }
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText> <MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<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" />
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2"> <MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes) @foreach (var t in _energyTypes)
{ {
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem> <MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2"> <MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.Meters_MeasurementMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>()) @foreach (var mode in Enum.GetValues<MeterMode>())
{ {
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem> <MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (_working.Mode == MeterMode.Virtual) @if (_working.Mode == MeterMode.Virtual)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2"> <MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the @S.Meters_VirtualHelp
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
</MudAlert> </MudAlert>
} }
else if (_working.Mode == MeterMode.InstantRate) else if (_working.Mode == MeterMode.InstantRate)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2"> <MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption. @S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
Store the value as a <b>per-hour</b> rate in this meter's unit (e.g. kW for kWh, L/h for L): a
source reporting W or L/min should carry a scale factor to convert it first.
</MudAlert> </MudAlert>
} }
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" /> <MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2"> <MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" Class="mb-2">
<MudSelectItem T="string" Value="@("")">— none</MudSelectItem> <MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem> <MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem> <MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem> <MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
</MudSelect> </MudSelect>
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream" <MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
Label="Sub-meter of (upstream meters)" Class="mb-2" Label="@S.Meters_UpstreamLabel" Class="mb-2"
MultiSelectionTextFunc="@(ids => UpstreamText(ids))" MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
HelperText="This meter measures a subsection of the selected meter(s)' flow."> HelperText="@S.Meters_UpstreamHelp">
@foreach (var m in AvailableUpstream()) @foreach (var m in AvailableUpstream())
{ {
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem> <MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" Class="mb-2" />
<div class="d-flex" style="gap:1rem"> <div class="d-flex" style="gap:1rem">
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
</div> </div>
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" /> <MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_Active" Color="Color.Primary" />
@if (_working.Id != 0 && _working.RecomputeNeeded) @if (_working.Id != 0 && _working.RecomputeNeeded)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert> <MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -228,7 +225,7 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0) if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
{ {
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning); Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
return; return;
} }
@@ -286,7 +283,7 @@ else
await SyncUpstreamAsync(db, meterId, _working.Upstream); await SyncUpstreamAsync(db, meterId, _working.Upstream);
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -314,11 +311,11 @@ else
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id); var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id); var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
var detail = readings + consumption > 0 var message = readings + consumption > 0
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)." ? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption)
: ""; : Loc.F(S.Meters_DeleteConfirm, meter.Name);
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone.")) if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message))
{ {
return; return;
} }
@@ -330,7 +327,7 @@ else
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
await tx.CommitAsync(); await tx.CommitAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
+2 -2
View File
@@ -1,5 +1,5 @@
@page "/not-found" @page "/not-found"
@layout MainLayout @layout MainLayout
<h3>Not Found</h3> <h3>@S.NotFound_Title</h3>
<p>Sorry, the content you are looking for does not exist.</p> <p>@S.NotFound_Message</p>
+23 -23
View File
@@ -2,15 +2,15 @@
@inject SolarService SolarSvc @inject SolarService SolarSvc
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Solar / PV</PageTitle> <PageTitle>MeterVault — @S.Nav_Solar</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Solar / PV</MudText> <MudText Typo="Typo.h4">@S.Nav_Solar</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px"> <MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem> <MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem> <MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
</MudSelect> </MudSelect>
</div> </div>
@@ -21,8 +21,8 @@
else if (!_summary.HasGeneration) else if (!_summary.HasGeneration)
{ {
<MudAlert Severity="Severity.Info"> <MudAlert Severity="Severity.Info">
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from @S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
<MudLink Href="/import">Import</MudLink>. <MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert> </MudAlert>
} }
else else
@@ -30,47 +30,47 @@ else
<MudGrid> <MudGrid>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Generation</MudText>
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText> <MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_SelfConsumption</MudText>
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText> <MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
@if (_summary.SelfConsumptionRatio is { } ratio) @if (_summary.SelfConsumptionRatio is { } ratio)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0))</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Autarky</MudText>
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText> <MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
@if (_summary.GridImport is { } grid) @if (_summary.GridImport is { } grid)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_GridDraw, Format.Number(grid, 0))</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Savings</MudText>
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText> <MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="8"> <MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation &amp; self-consumption</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
<SeriesChart Series="_chart" Decimals="0" Height="340" /> <SeriesChart Series="_chart" Decimals="0" Height="340" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationByMeter</MudText>
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<tbody> <tbody>
@foreach (var meter in _summary.Meters) @foreach (var meter in _summary.Meters)
@@ -85,8 +85,8 @@ else
@if (!_summary.HasLoadContext) @if (!_summary.HasLoadContext)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3"> <MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata) @S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>
to unlock self-consumption, autarky and savings. @S.Solar_TagMetersTail
</MudAlert> </MudAlert>
} }
</MudPaper> </MudPaper>
@@ -124,18 +124,18 @@ else
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); _summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
var generation = _summary.Months var generation = _summary.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation)) .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Generation))
.ToList(); .ToList();
var series = new List<SeriesChart.SeriesDef> var series = new List<SeriesChart.SeriesDef>
{ {
new("Generation", ApexCharts.SeriesType.Bar, generation), new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation),
}; };
if (_summary.HasLoadContext) if (_summary.HasLoadContext)
{ {
var self = _summary.Months var self = _summary.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0)) .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0))
.ToList(); .ToList();
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self)); series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self));
} }
_chart = series; _chart = series;
+8 -8
View File
@@ -1,18 +1,18 @@
@page "/trends" @page "/trends"
@inject DashboardService Dash @inject DashboardService Dash
<PageTitle>MeterVault — Trends</PageTitle> <PageTitle>MeterVault — @S.Nav_Trends</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText> <MudText Typo="Typo.h4" Class="mb-4">@S.Trends_Title</MudText>
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<div class="d-flex align-center mb-3" style="gap:1rem"> <div class="d-flex align-center mb-3" style="gap:1rem">
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px"> <MudSelect T="int" @bind-Value="_months" Label="@S.Common_Range" Dense="true" Style="max-width:180px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem> <MudSelectItem T="int" Value="48">@S.Trends_RangeLast48Months</MudSelectItem>
</MudSelect> </MudSelect>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">Apply</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">@S.Trends_Apply</MudButton>
</div> </div>
@if (_loading) @if (_loading)
@@ -23,7 +23,7 @@
{ {
<TrendChart Points="_points" /> <TrendChart Points="_points" />
<MudText Typo="Typo.subtitle2" Class="mt-4"> <MudText Typo="Typo.subtitle2" Class="mt-4">
Total over range: @Format.Euro(_points.Sum(p => p.Cost)) @Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost)))
</MudText> </MudText>
} }
</MudPaper> </MudPaper>
@@ -6,7 +6,7 @@
<ApexPointSeries TItem="CategorySlice" <ApexPointSeries TItem="CategorySlice"
Items="Slices" Items="Slices"
SeriesType="SeriesType.Donut" SeriesType="SeriesType.Donut"
Name="Cost" Name="@S.CategoryDonut_Cost"
XValue="s => s.Name" XValue="s => s.Name"
YValue="s => (decimal)Math.Round(s.Cost, 2)" /> YValue="s => (decimal)Math.Round(s.Cost, 2)" />
</ApexChart> </ApexChart>
+12 -5
View File
@@ -5,7 +5,7 @@
@if (string.IsNullOrEmpty(_svg)) @if (string.IsNullOrEmpty(_svg))
{ {
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText> <MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">@S.Sankey_NoFlow</MudBlazor.MudText>
} }
else else
{ {
@@ -102,11 +102,11 @@ else
} }
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B"); var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
var label = Nodes.ToDictionary(n => n.Id, n => n.Label); var label = Nodes.ToDictionary(n => n.Id, NodeLabel);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture, sb.Append(CultureInfo.InvariantCulture,
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">"); $"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"{Enc(S.Sankey_AriaLabel)}\">");
// Ribbons first (under nodes). // Ribbons first (under nodes).
foreach (var link in Links) foreach (var link in Links)
@@ -142,7 +142,7 @@ else
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6; var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
var anchor = rightmost ? "end" : "start"; var anchor = rightmost ? "end" : "start";
var fill = Enc(node.ColorHex ?? "#607D8B"); var fill = Enc(node.ColorHex ?? "#607D8B");
var name = Enc(node.Label); var name = Enc(NodeLabel(node));
var val = Enc(Fmt(node.Value)); var val = Enc(Fmt(node.Value));
sb.Append(CultureInfo.InvariantCulture, sb.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>"); $"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
@@ -155,7 +155,14 @@ else
return sb.ToString(); return sb.ToString();
} }
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim(); /// <summary>
/// A remainder node carries only its parent meter's name (see <see cref="FlowNode"/>); the
/// "Other (…)" framing is added here, where the reader's language is known.
/// </summary>
private static string NodeLabel(FlowNode node) =>
node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label;
private string Fmt(double value) => $"{Format.Number(value)} {Unit}".Trim();
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
+1 -1
View File
@@ -16,7 +16,7 @@
} }
else else
{ {
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText> <MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
} }
@code { @code {
+4 -3
View File
@@ -7,14 +7,14 @@
<ApexPointSeries TItem="TrendPoint" <ApexPointSeries TItem="TrendPoint"
Items="Points" Items="Points"
SeriesType="SeriesType.Bar" SeriesType="SeriesType.Bar"
Name="Monthly cost" Name="@S.TrendChart_MonthlyCost"
XValue="Label" XValue="Label"
YValue="p => (decimal)Math.Round(p.Cost, 2)" /> YValue="p => (decimal)Math.Round(p.Cost, 2)" />
</ApexChart> </ApexChart>
} }
else else
{ {
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText> <MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
} }
@code { @code {
@@ -27,5 +27,6 @@ else
DataLabels = new DataLabels { Enabled = false }, DataLabels = new DataLabels { Enabled = false },
}; };
private static object Label(TrendPoint p) => p.Period.ToString("MMM yy", CultureInfo.InvariantCulture); // Qualified: ApexCharts also exports a `Format`, and this file imports it.
private static object Label(TrendPoint p) => MeterVault.App.Format.MonthLabel(p.Period);
} }
+31 -10
View File
@@ -1,3 +1,4 @@
@using System.Net
@using MeterVault.Infrastructure.Update @using MeterVault.Infrastructure.Update
@inject UpdateCheckService Updates @inject UpdateCheckService Updates
@inject UpdateRunner Runner @inject UpdateRunner Runner
@@ -12,12 +13,12 @@
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt"> <MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt">
<div class="d-flex flex-wrap align-center" style="gap:.75rem"> <div class="d-flex flex-wrap align-center" style="gap:.75rem">
<span>MeterVault <b>@latest</b> is available — this instance runs <b>@running</b>.</span> <span>@((MarkupString)Loc.F(S.UpdateBanner_ReleaseAvailable, Bold(latest), Bold(running)))</span>
@if (Runner.Availability is UpdateAvailability.Allowed) @if (Runner.Availability is UpdateAvailability.Allowed)
{ {
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" <MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.SystemUpdateAlt" OnClick="@(() => _confirmOpen = true)"> StartIcon="@Icons.Material.Filled.SystemUpdateAlt" OnClick="@(() => _confirmOpen = true)">
Update now @S.UpdateBanner_UpdateNow
</MudButton> </MudButton>
} }
else else
@@ -29,17 +30,16 @@
} }
<MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions">
<TitleContent><MudText Typo="Typo.h6">Update MeterVault</MudText></TitleContent> <TitleContent><MudText Typo="Typo.h6">@S.UpdateBanner_DialogTitle</MudText></TitleContent>
<DialogContent> <DialogContent>
<MudText Typo="Typo.body2" Class="mb-3"> <MudText Typo="Typo.body2" Class="mb-3">
This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes, @S.UpdateBanner_DialogBody
during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart.
</MudText> </MudText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">Cancel</MudButton> <MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting"> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting">
@(_starting ? "Starting…" : "Update now") @(_starting ? S.UpdateBanner_Starting : S.UpdateBanner_UpdateNow)
</MudButton> </MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -80,7 +80,7 @@
try try
{ {
var launch = await Runner.LaunchAsync(); var launch = await Runner.LaunchAsync();
Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error); Snackbar.Add(OutcomeText(launch), launch.Started ? Severity.Success : Severity.Error);
if (launch.Started) if (launch.Started)
{ {
_confirmOpen = false; _confirmOpen = false;
@@ -92,6 +92,24 @@
} }
} }
// UpdateLaunch.Message stays English for the log; the reader gets the outcome in their own
// language, with the launcher's own diagnostic text appended untranslated — it comes from
// systemd, not from us, and mangling it would make it unsearchable.
private static string OutcomeText(UpdateLaunch launch) => launch.Outcome switch
{
UpdateOutcome.Started => S.Update_LaunchStarted,
UpdateOutcome.NotAllowed => Loc.F(
S.Update_LaunchNotAllowed,
launch.Availability?.Display() ?? string.Empty),
UpdateOutcome.LauncherMissing => S.Update_LaunchLauncherMissing,
UpdateOutcome.LauncherFailed => launch.Detail is { Length: > 0 } detail
? Loc.F(S.Update_LaunchFailedWithDetail, detail)
: S.Update_LaunchFailed,
_ => launch.Detail is { Length: > 0 } error
? Loc.F(S.Update_LaunchFailedWithDetail, error)
: S.Update_LaunchFailed,
};
/// <summary> /// <summary>
/// How this particular install updates. The LXC has an <c>update</c> command; a container is /// How this particular install updates. The LXC has an <c>update</c> command; a container is
/// replaced by pulling a new image, and telling those users to run <c>update</c> would send them /// replaced by pulling a new image, and telling those users to run <c>update</c> would send them
@@ -99,6 +117,9 @@
/// </summary> /// </summary>
private static string UpdateCommandHint() => private static string UpdateCommandHint() =>
UpdateRunner.IsSupportedHere UpdateRunner.IsSupportedHere
? "run: update" ? S.UpdateBanner_HintRunUpdate
: "pull the new image and recreate the container"; : S.UpdateBanner_HintPullImage;
/// <summary>Emphasises a version inside the banner sentence without letting it carry markup.</summary>
private static string Bold(object value) => $"<b>{WebUtility.HtmlEncode(value.ToString())}</b>";
} }
+4
View File
@@ -8,6 +8,10 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using MeterVault.App @using MeterVault.App
@using MeterVault.App.Localization
@* UI strings live in Localization/Strings.resx and are reached through this alias: @S.Common_Save.
Compiled properties, so a stale key fails the build. @Loc.F(...) formats the {0} ones. *@
@using S = MeterVault.App.Localization.Strings
@using MeterVault.App.Components @using MeterVault.App.Components
@using MeterVault.App.Components.Layout @using MeterVault.App.Components.Layout
@using MeterVault.App.Components.Shared @using MeterVault.App.Components.Shared
+3 -2
View File
@@ -1,3 +1,4 @@
using MeterVault.App.Localization;
using MudBlazor; using MudBlazor;
namespace MeterVault.App; namespace MeterVault.App;
@@ -7,7 +8,7 @@ public static class Confirm
{ {
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message) public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
{ {
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel") var result = await dialog.ShowMessageBoxAsync(title, message, yesText: Strings.Common_Delete, cancelText: Strings.Common_Cancel)
.ConfigureAwait(false); .ConfigureAwait(false);
return result == true; return result == true;
} }
@@ -15,7 +16,7 @@ public static class Confirm
/// <summary>A generic yes/cancel confirmation with a caller-supplied confirm-button label.</summary> /// <summary>A generic yes/cancel confirmation with a caller-supplied confirm-button label.</summary>
public static async Task<bool> ConfirmAsync(IDialogService dialog, string title, string message, string confirmText) public static async Task<bool> ConfirmAsync(IDialogService dialog, string title, string message, string confirmText)
{ {
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: "Cancel") var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: Strings.Common_Cancel)
.ConfigureAwait(false); .ConfigureAwait(false);
return result == true; return result == true;
} }
+23 -6
View File
@@ -2,18 +2,35 @@ using System.Globalization;
namespace MeterVault.App; namespace MeterVault.App;
/// <summary>Small display formatters for the UI (locale-aware formatting arrives with i18n in M7).</summary> /// <summary>
/// Small display formatters for the UI.
/// </summary>
/// <remarks>
/// Everything formats against <see cref="CultureInfo.CurrentCulture"/>, which the request
/// localization middleware sets from the reader's chosen UI language — so 1234.5 renders as
/// "1.234,5" for a German reader and "1,234.5" for an English one, from the same call site. This is
/// display only: the CSV importer still parses the spreadsheet dialect with an explicit de-DE
/// culture (<see cref="Core.Parsing.GermanNumber"/>), because that dialect is a property of the
/// files, not of who is looking at them.
/// </remarks>
public static class Format public static class Format
{ {
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE"); /// <summary>
/// A money amount. The symbol is the euro rather than the culture's own, because the figure is
public static string Euro(double value) => value.ToString("N2", Culture) + " €"; /// in the instance's configured currency — switching UI language must not restate the amount as
/// dollars. Only the digit grouping follows the reader.
/// </summary>
public static string Euro(double value) => value.ToString("N2", CultureInfo.CurrentCulture) + " €";
public static string Number(double value, int decimals = 0) => public static string Number(double value, int decimals = 0) =>
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), Culture); value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.CurrentCulture);
public static string Percent(double value) => public static string Percent(double value) =>
(value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %"; (value >= 0 ? "+" : "") + value.ToString("N1", CultureInfo.CurrentCulture) + " %";
/// <summary>A month label for chart axes and period lists ("Mrz 25" / "Mar 25").</summary>
public static string MonthLabel(DateOnly month) =>
month.ToString("MMM yy", CultureInfo.CurrentCulture);
public static string DirectionIcon(int direction) => direction switch public static string DirectionIcon(int direction) => direction switch
{ {
+63
View File
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Localization;
namespace MeterVault.App.Localization;
/// <summary>The endpoint behind the language picker in the app bar.</summary>
public static class CultureEndpoints
{
/// <summary>
/// Persists a UI language and returns the user to where they were.
/// </summary>
/// <remarks>
/// A redirect rather than an interactive state change on purpose: a Blazor Server circuit
/// captures <see cref="System.Globalization.CultureInfo.CurrentUICulture"/> from the request
/// that opened it, so switching language has to re-establish the circuit. The picker therefore
/// navigates here with <c>forceLoad</c>, this writes the culture cookie the localization
/// middleware reads, and the reload comes back in the new language.
/// </remarks>
public static IEndpointRouteBuilder MapCultureEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapGet("/culture/set", (HttpContext http, string? culture, string? redirectUri) =>
{
// Only ever store a language we ship, so a hand-edited link can't park an unusable
// culture in the cookie and leave the UI stuck in fallback.
if (!Loc.TryResolve(culture, out var resolved))
{
return Results.BadRequest($"Unsupported culture '{culture}'.");
}
http.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(resolved)),
new CookieOptions
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddYears(1),
SameSite = SameSiteMode.Lax,
// Read only by the localization middleware, never by script.
HttpOnly = true,
// A language preference is exempt from consent gating, and the app is
// self-hosted and single-user anyway.
IsEssential = true,
});
// Anything but a local path is refused rather than followed: this endpoint takes a
// redirect target from the query string, which is exactly the shape of an open redirect.
return Results.LocalRedirect(IsLocalPath(redirectUri) ? redirectUri! : "/");
})
.ExcludeFromDescription();
return endpoints;
}
/// <summary>
/// The framework's own local-URL rule: rooted, and not the "//host" or "/\host" forms browsers
/// resolve as protocol-relative absolute URLs.
/// </summary>
private static bool IsLocalPath(string? url) =>
!string.IsNullOrEmpty(url)
&& url[0] == '/'
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
}
+188
View File
@@ -0,0 +1,188 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Update;
namespace MeterVault.App.Localization;
/// <summary>
/// Human wording for the domain enums the UI puts on screen.
/// </summary>
/// <remarks>
/// <para>
/// The enums themselves stay bare identifiers: they are persisted as text (SDD §5.2) and appear in
/// the REST API, so their names are part of the data contract and must not move with the reader's
/// language. This is the one place that decides how each value is <em>spoken</em>, which keeps the
/// domain layer free of presentation and gives every page the same word for the same concept —
/// a <c>TariffComponent.UnitPrice</c> is "Arbeitspreis" in the table, the dropdown and the
/// confirm dialog alike.
/// </para>
/// <para>
/// Every arm resolves a <c>Enum_&lt;Type&gt;_&lt;Value&gt;</c> resource. The fallback arms return the
/// identifier rather than throwing, so adding an enum value can never crash a dashboard — and
/// <c>EnumDisplayNameTests</c> fails the build if one is ever left without a translation, which is
/// what stops that safety net from quietly becoming the shipping behaviour.
/// </para>
/// </remarks>
public static class DisplayNames
{
/// <summary>The enums this class is responsible for; the resource-coverage test walks this list.</summary>
public static IReadOnlyList<Type> LocalizedEnums { get; } =
[
typeof(MeterMode),
typeof(ConsumptionKind),
typeof(ReadingQuality),
typeof(ReadingFlags),
typeof(MeterEventType),
typeof(SourceType),
typeof(SourceValueKind),
typeof(TariffScope),
typeof(TariffComponent),
typeof(TankRateMode),
typeof(EndpointType),
typeof(MappingRole),
typeof(UpdateAvailability),
];
public static string Display(this MeterMode value) => value switch
{
MeterMode.CumulativeCounter => Strings.Enum_MeterMode_CumulativeCounter,
MeterMode.GenerationCounter => Strings.Enum_MeterMode_GenerationCounter,
MeterMode.RuntimeCounter => Strings.Enum_MeterMode_RuntimeCounter,
MeterMode.ConsumableBalance => Strings.Enum_MeterMode_ConsumableBalance,
MeterMode.DirectDelta => Strings.Enum_MeterMode_DirectDelta,
MeterMode.InstantRate => Strings.Enum_MeterMode_InstantRate,
MeterMode.Virtual => Strings.Enum_MeterMode_Virtual,
_ => value.ToString(),
};
public static string Display(this ConsumptionKind value) => value switch
{
ConsumptionKind.Consumption => Strings.Enum_ConsumptionKind_Consumption,
ConsumptionKind.Generation => Strings.Enum_ConsumptionKind_Generation,
_ => value.ToString(),
};
public static string Display(this ReadingQuality value) => value switch
{
ReadingQuality.Measured => Strings.Enum_ReadingQuality_Measured,
ReadingQuality.Estimated => Strings.Enum_ReadingQuality_Estimated,
ReadingQuality.Manual => Strings.Enum_ReadingQuality_Manual,
ReadingQuality.Imported => Strings.Enum_ReadingQuality_Imported,
ReadingQuality.Interpolated => Strings.Enum_ReadingQuality_Interpolated,
_ => value.ToString(),
};
/// <summary>
/// A bitmask rendered as the set flags, comma-joined. <see cref="ReadingFlags.None"/> gives an
/// empty string: the readings table shows a flag column that is blank for almost every row, and
/// printing "None" a thousand times down a page is noise, not information.
/// </summary>
public static string Display(this ReadingFlags value)
{
if (value == ReadingFlags.None)
{
return string.Empty;
}
var names = new List<string>(3);
if (value.HasFlag(ReadingFlags.CounterReset))
{
names.Add(Strings.Enum_ReadingFlags_CounterReset);
}
if (value.HasFlag(ReadingFlags.MeterSwap))
{
names.Add(Strings.Enum_ReadingFlags_MeterSwap);
}
if (value.HasFlag(ReadingFlags.Anomaly))
{
names.Add(Strings.Enum_ReadingFlags_Anomaly);
}
return names.Count > 0 ? string.Join(", ", names) : value.ToString();
}
public static string Display(this MeterEventType value) => value switch
{
MeterEventType.MeterSwap => Strings.Enum_MeterEventType_MeterSwap,
MeterEventType.CounterReset => Strings.Enum_MeterEventType_CounterReset,
MeterEventType.Delivery => Strings.Enum_MeterEventType_Delivery,
MeterEventType.TankLevel => Strings.Enum_MeterEventType_TankLevel,
MeterEventType.Correction => Strings.Enum_MeterEventType_Correction,
MeterEventType.Note => Strings.Enum_MeterEventType_Note,
_ => value.ToString(),
};
public static string Display(this SourceType value) => value switch
{
SourceType.Mqtt => Strings.Enum_SourceType_Mqtt,
SourceType.Tasmota => Strings.Enum_SourceType_Tasmota,
SourceType.HomeAssistant => Strings.Enum_SourceType_HomeAssistant,
SourceType.Manual => Strings.Enum_SourceType_Manual,
SourceType.Import => Strings.Enum_SourceType_Import,
SourceType.Virtual => Strings.Enum_SourceType_Virtual,
_ => value.ToString(),
};
public static string Display(this SourceValueKind value) => value switch
{
SourceValueKind.Register => Strings.Enum_SourceValueKind_Register,
SourceValueKind.Delta => Strings.Enum_SourceValueKind_Delta,
SourceValueKind.Rate => Strings.Enum_SourceValueKind_Rate,
SourceValueKind.Level => Strings.Enum_SourceValueKind_Level,
SourceValueKind.Runtime => Strings.Enum_SourceValueKind_Runtime,
_ => value.ToString(),
};
public static string Display(this TariffScope value) => value switch
{
TariffScope.Global => Strings.Enum_TariffScope_Global,
TariffScope.EnergyType => Strings.Enum_TariffScope_EnergyType,
TariffScope.Meter => Strings.Enum_TariffScope_Meter,
_ => value.ToString(),
};
public static string Display(this TariffComponent value) => value switch
{
TariffComponent.UnitPrice => Strings.Enum_TariffComponent_UnitPrice,
TariffComponent.BasePrice => Strings.Enum_TariffComponent_BasePrice,
TariffComponent.FeedIn => Strings.Enum_TariffComponent_FeedIn,
TariffComponent.Bonus => Strings.Enum_TariffComponent_Bonus,
TariffComponent.Discount => Strings.Enum_TariffComponent_Discount,
TariffComponent.Tax => Strings.Enum_TariffComponent_Tax,
_ => value.ToString(),
};
public static string Display(this TankRateMode value) => value switch
{
TankRateMode.Fixed => Strings.Enum_TankRateMode_Fixed,
TankRateMode.Empirical => Strings.Enum_TankRateMode_Empirical,
_ => value.ToString(),
};
public static string Display(this EndpointType value) => value switch
{
EndpointType.MqttBroker => Strings.Enum_EndpointType_MqttBroker,
EndpointType.HomeAssistant => Strings.Enum_EndpointType_HomeAssistant,
_ => value.ToString(),
};
public static string Display(this UpdateAvailability value) => value switch
{
UpdateAvailability.Allowed => Strings.Enum_UpdateAvailability_Allowed,
UpdateAvailability.NotEnabled => Strings.Enum_UpdateAvailability_NotEnabled,
UpdateAvailability.NotSupportedHere => Strings.Enum_UpdateAvailability_NotSupportedHere,
_ => value.ToString(),
};
public static string Display(this MappingRole value) => value switch
{
MappingRole.Ignore => Strings.Enum_MappingRole_Ignore,
MappingRole.Reading => Strings.Enum_MappingRole_Reading,
MappingRole.Delivery => Strings.Enum_MappingRole_Delivery,
MappingRole.TankLevel => Strings.Enum_MappingRole_TankLevel,
MappingRole.ManualCost => Strings.Enum_MappingRole_ManualCost,
_ => value.ToString(),
};
}
+36
View File
@@ -0,0 +1,36 @@
using MeterVault.Infrastructure.Import;
namespace MeterVault.App.Localization;
/// <summary>
/// Says a staging warning in the reader's language.
/// </summary>
/// <remarks>
/// The importer emits <see cref="ImportWarning"/> values carrying an English sentence plus the
/// arguments that filled it (see <c>ImportWarnings</c>). Re-formatting from the arguments rather
/// than translating the finished sentence is what lets the numbers pick up the reader's digit
/// grouping — a register that reads <c>2.940,19</c> everywhere else must not read <c>2940.19</c>
/// only inside a warning.
/// </remarks>
public static class ImportWarningText
{
public static string Display(this ImportWarning warning)
{
ArgumentNullException.ThrowIfNull(warning);
var args = warning.Args as object?[] ?? [.. warning.Args];
return warning.Kind switch
{
ImportWarningKind.RowSkipped => Loc.F(Strings.ImportWarning_RowSkipped, args),
ImportWarningKind.UnparseableDate => Loc.F(Strings.ImportWarning_UnparseableDate, args),
ImportWarningKind.UnitMismatch => Loc.F(Strings.ImportWarning_UnitMismatch, args),
ImportWarningKind.RegisterDropped => Loc.F(Strings.ImportWarning_RegisterDropped, args),
ImportWarningKind.RegisterDroppedWithAmount =>
Loc.F(Strings.ImportWarning_RegisterDroppedWithAmount, args),
// A kind added later still says something useful rather than blanking the panel.
_ => warning.Message,
};
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Globalization;
namespace MeterVault.App.Localization;
/// <summary>
/// UI-language plumbing around <see cref="Strings"/>, the strongly-typed accessor MSBuild generates
/// from <c>Strings.resx</c> (see the <c>EmbeddedResource</c> block in the project file).
/// </summary>
/// <remarks>
/// <para>
/// The neutral resource is English and every other language ships as a satellite assembly, so an
/// <c>Accept-Language</c> we don't translate degrades to English rather than to raw resource keys.
/// Strings are referenced as compiled properties (<c>S.Common_Save</c>), not string lookups, so a
/// key that no longer exists is a build error instead of a mystery label at runtime.
/// </para>
/// <para>
/// Number and date <em>formatting</em> follows <see cref="CultureInfo.CurrentCulture"/> and the UI
/// language follows <see cref="CultureInfo.CurrentUICulture"/>; the request-localization middleware
/// sets both from the same choice, so the two never disagree.
/// </para>
/// </remarks>
public static class Loc
{
/// <summary>Cultures the UI ships translations for. The first entry is the neutral fallback.</summary>
public static IReadOnlyList<string> SupportedCultures { get; } = ["en", "de"];
/// <summary>Formats a resource carrying <c>{0}</c>-style placeholders in the request's culture.</summary>
public static string F(string format, params object?[] args) =>
string.Format(CultureInfo.CurrentCulture, format, args);
/// <summary>
/// Maps a requested language onto one we actually ship, matching on the two-letter tag so
/// <c>de-AT</c> and <c>de-CH</c> get German instead of falling through to English.
/// </summary>
/// <returns><c>true</c> when the request named a language we translate.</returns>
public static bool TryResolve(string? requested, out string resolved)
{
resolved = SupportedCultures[0];
if (string.IsNullOrWhiteSpace(requested))
{
return false;
}
var trimmed = requested.Trim();
var separator = trimmed.IndexOfAny(['-', '_']);
var language = separator < 0 ? trimmed : trimmed[..separator];
foreach (var supported in SupportedCultures)
{
if (string.Equals(supported, language, StringComparison.OrdinalIgnoreCase))
{
resolved = supported;
return true;
}
}
return false;
}
/// <summary>The display name of a supported culture, written in that language ("Deutsch", "English").</summary>
public static string DisplayName(string culture) =>
CultureInfo.GetCultureInfo(culture).NativeName;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16
View File
@@ -26,4 +26,20 @@
<Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<!-- UI strings (SDD §12, M7). The neutral resx generates a strongly-typed `Strings` class, so
components reference compiled properties (S.Common_Save) instead of string keys: a renamed or
deleted string breaks the build rather than silently rendering its key. Generation runs in
MSBuild, not the IDE designer, so `dotnet build` alone reproduces it on any platform.
Translations live in Strings.<culture>.resx and ship as satellite assemblies. -->
<ItemGroup>
<EmbeddedResource Update="Localization\Strings.resx">
<Generator>MSBuild:Compile</Generator>
<StronglyTypedFileName>$(IntermediateOutputPath)\Strings.Designer.cs</StronglyTypedFileName>
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
<StronglyTypedNamespace>MeterVault.App.Localization</StronglyTypedNamespace>
<StronglyTypedClassName>Strings</StronglyTypedClassName>
<PublicClass>true</PublicClass>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>
+21
View File
@@ -1,5 +1,6 @@
using MeterVault.App.Api; using MeterVault.App.Api;
using MeterVault.App.Components; using MeterVault.App.Components;
using MeterVault.App.Localization;
using MeterVault.Infrastructure; using MeterVault.Infrastructure;
using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
@@ -71,6 +72,7 @@ try
builder.Services.AddMeterVaultIngestion(); builder.Services.AddMeterVaultIngestion();
} }
builder.Services.AddLocalization();
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -100,6 +102,24 @@ try
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy. // that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
app.UseAntiforgery(); app.UseAntiforgery();
// UI language (SDD §12, M7). Must run before MapRazorComponents: a Blazor Server circuit takes
// its culture from the request that opens it, so this is what every render downstream sees.
// Order of preference is the culture cookie the picker writes, then Accept-Language, then
// MeterVault__Locale — an instance can be pinned to one language, and a user can still switch.
// TryResolve yields the neutral fallback when it fails, so defaultCulture is usable either way.
if (!Loc.TryResolve(options.Locale, out var defaultCulture) && !string.IsNullOrWhiteSpace(options.Locale))
{
Log.Warning(
"MeterVault__Locale is '{Locale}', which has no translations; falling back to '{Fallback}'. "
+ "Supported: {Supported}",
options.Locale, defaultCulture, string.Join(", ", Loc.SupportedCultures));
}
app.UseRequestLocalization(new RequestLocalizationOptions()
.SetDefaultCulture(defaultCulture)
.AddSupportedCultures([.. Loc.SupportedCultures])
.AddSupportedUICultures([.. Loc.SupportedCultures]));
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1")); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
@@ -108,6 +128,7 @@ try
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
app.MapMeterVaultApi(); app.MapMeterVaultApi();
app.MapCultureEndpoints();
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9). // Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
@@ -1,6 +1,11 @@
namespace MeterVault.Infrastructure.Dashboard; namespace MeterVault.Infrastructure.Dashboard;
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary> /// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
/// <param name="Label">
/// A meter's name — for a remainder node (<paramref name="IsOther"/>) the <em>parent</em> meter's
/// name. The surrounding wording ("Other (…)") is the UI's to supply, because it is the only layer
/// that knows the reader's language; see <c>Flow_OtherNode</c>.
/// </param>
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId); public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary> /// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
+3 -1
View File
@@ -115,7 +115,9 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
if (remainder > Epsilon) if (remainder > Epsilon)
{ {
var otherId = $"other{meter.Id}"; var otherId = $"other{meter.Id}";
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null)); // Just the parent's name: the "Other (…)" phrasing is added by the UI, which is
// where the reader's language is known.
nodes.Add(new FlowNode(otherId, meter.Name, remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder)); flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
} }
} }
@@ -14,7 +14,8 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost)
/// <summary> /// <summary>
/// A meter framed the way it is actually read: what it used this period, how that compares with the /// A meter framed the way it is actually read: what it used this period, how that compares with the
/// last one, and where the year is heading. Amounts are generation for a generation counter and /// last one, and where the year is heading. Amounts are generation for a generation counter and
/// consumption otherwise, so <see cref="Label"/> says which. /// consumption otherwise, so <see cref="Kind"/> says which — as the enum, not a word, because the
/// wording belongs to whichever language the reader picked.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Month- and year-to-date are compared against a <em>projection</em> of the current period rather /// Month- and year-to-date are compared against a <em>projection</em> of the current period rather
@@ -22,7 +23,7 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost)
/// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them. /// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them.
/// </remarks> /// </remarks>
public sealed record MeterPeriodView( public sealed record MeterPeriodView(
string Label, ConsumptionKind Kind,
string Unit, string Unit,
string Currency, string Currency,
double MonthToDate, double MonthToDate,
@@ -86,7 +86,7 @@ public sealed class MeterPeriodService(
var yearToDateCost = SumYear(costs, today.Year); var yearToDateCost = SumYear(costs, today.Year);
return new MeterPeriodView( return new MeterPeriodView(
Label: isGeneration ? "Generation" : "Consumption", Kind: kind,
Unit: meter.Unit, Unit: meter.Unit,
Currency: _options.Currency, Currency: _options.Currency,
MonthToDate: monthToDate, MonthToDate: monthToDate,
+6 -7
View File
@@ -31,7 +31,7 @@ public sealed class CsvImporter
staged.SkippedRows++; staged.SkippedRows++;
if (!reason.StartsWith("blank", StringComparison.Ordinal)) if (!reason.StartsWith("blank", StringComparison.Ordinal))
{ {
staged.Warnings.Add($"Row {r + 1}: skipped ({reason})."); staged.Warnings.Add(ImportWarnings.RowSkipped(r + 1, reason));
} }
continue; continue;
@@ -40,7 +40,7 @@ public sealed class CsvImporter
if (!TryGetDate(row, profile, out var time)) if (!TryGetDate(row, profile, out var time))
{ {
staged.SkippedRows++; staged.SkippedRows++;
staged.Warnings.Add($"Row {r + 1}: unparseable date, skipped."); staged.Warnings.Add(ImportWarnings.UnparseableDate(r + 1));
continue; continue;
} }
@@ -140,8 +140,7 @@ public sealed class CsvImporter
if (column.Unit is not null && split.Value.Unit is not null if (column.Unit is not null && split.Value.Unit is not null
&& !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase)) && !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase))
{ {
staged.Warnings.Add( staged.Warnings.Add(ImportWarnings.UnitMismatch(rowIndex + 1, column.Unit, split.Value.Unit));
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
} }
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous) if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
@@ -157,9 +156,9 @@ public sealed class CsvImporter
Amount = override_, Amount = override_,
Notes = "Auto-detected register decrease (meter swap / reset candidate).", Notes = "Auto-detected register decrease (meter swap / reset candidate).",
}); });
staged.Warnings.Add( staged.Warnings.Add(override_ is { } amount
$"Row {rowIndex + 1}: register for meter {meterId} dropped {previous}→{value}; " + ? ImportWarnings.RegisterDroppedWithAmount(rowIndex + 1, meterId, previous, value, amount)
(override_ is null ? "swap event created (verify)." : $"swap consumption set to {override_}.")); : ImportWarnings.RegisterDropped(rowIndex + 1, meterId, previous, value));
} }
staged.Readings.Add(new Reading staged.Readings.Add(new Reading
@@ -0,0 +1,74 @@
using System.Globalization;
namespace MeterVault.Infrastructure.Import;
/// <summary>What a staging warning is about.</summary>
public enum ImportWarningKind
{
/// <summary>A row matched a skip rule (a summary line, an all-zero placeholder). Args: row, reason.</summary>
RowSkipped,
/// <summary>The date cell parsed as neither <c>Monat JJJJ</c> nor <c>TT.MM.JJJJ</c>. Args: row.</summary>
UnparseableDate,
/// <summary>The value carried a different unit suffix than the column expects. Args: row, expected, found.</summary>
UnitMismatch,
/// <summary>A register went backwards; a swap event was staged for review. Args: row, meter, previous, new.</summary>
RegisterDropped,
/// <summary>Same, with the consumption across the swap taken from the sheet. Args: row, meter, previous, new, amount.</summary>
RegisterDroppedWithAmount,
}
/// <summary>
/// One thing worth telling the user about a staged row.
/// </summary>
/// <param name="Message">
/// The English sentence, kept for logs and any non-UI caller.
/// </param>
/// <param name="Args">
/// The values that filled it, in order. The UI re-formats them into its own language's sentence, so
/// numbers pick up the reader's digit grouping instead of being frozen into a string here.
/// </param>
public sealed record ImportWarning(ImportWarningKind Kind, string Message, IReadOnlyList<object?> Args)
{
/// <summary>Falls back to the English sentence, so a plain <c>ToString()</c> is still useful.</summary>
public override string ToString() => Message;
}
/// <summary>
/// Builds the warnings the CSV importer emits.
/// </summary>
/// <remarks>
/// Each kind has exactly one English format string here, and the arguments travel alongside the
/// rendered sentence — so the importer stays free of any notion of who is reading, and the admin UI
/// can say the same thing in German without this assembly growing a resource file.
/// </remarks>
internal static class ImportWarnings
{
public static ImportWarning RowSkipped(int row, string reason) =>
Create(ImportWarningKind.RowSkipped, "Row {0}: skipped ({1}).", row, reason);
public static ImportWarning UnparseableDate(int row) =>
Create(ImportWarningKind.UnparseableDate, "Row {0}: unparseable date, skipped.", row);
public static ImportWarning UnitMismatch(int row, string expected, string found) =>
Create(ImportWarningKind.UnitMismatch, "Row {0}: expected unit '{1}' but found '{2}'.", row, expected, found);
public static ImportWarning RegisterDropped(int row, int meterId, double previous, double current) =>
Create(
ImportWarningKind.RegisterDropped,
"Row {0}: register for meter {1} dropped {2}→{3}; swap event created (verify).",
row, meterId, previous, current);
public static ImportWarning RegisterDroppedWithAmount(
int row, int meterId, double previous, double current, double amount) =>
Create(
ImportWarningKind.RegisterDroppedWithAmount,
"Row {0}: register for meter {1} dropped {2}→{3}; swap consumption set to {4}.",
row, meterId, previous, current, amount);
private static ImportWarning Create(ImportWarningKind kind, string format, params object?[] args) =>
new(kind, string.Format(CultureInfo.InvariantCulture, format, args), args);
}
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class StagedImport
public List<ManualCost> ManualCosts { get; } = []; public List<ManualCost> ManualCosts { get; } = [];
public List<string> Warnings { get; } = []; public List<ImportWarning> Warnings { get; } = [];
public int SkippedRows { get; set; } public int SkippedRows { get; set; }
@@ -3,8 +3,55 @@ using Microsoft.Extensions.Logging;
namespace MeterVault.Infrastructure.Ingestion; namespace MeterVault.Infrastructure.Ingestion;
/// <summary>Which way a Home Assistant connectivity test ended.</summary>
/// <remarks>
/// The verdict is shown to whoever clicked "Test connection", so it has to be sayable in their
/// language — and this assembly has no business knowing what that is. The outcome travels as a
/// value and the admin UI supplies the words.
/// </remarks>
public enum HaTestOutcome
{
/// <summary>
/// No test was run — the caller decided beforehand (no token configured, base URL edited since
/// saving) and put its own, already-localized wording in <see cref="HaTestResult.Message"/>.
/// The default, so a result built by the UI needs no ceremony to say so.
/// </summary>
Precondition,
/// <summary>Reachable and the token was accepted; no entity was named to sample.</summary>
Connected,
/// <summary>Reachable and the named entity returned a number — see <see cref="HaTestResult.SampleValue"/>.</summary>
ConnectedWithValue,
/// <summary>No base URL was given.</summary>
BaseUrlMissing,
/// <summary>No token was available to test with.</summary>
TokenMissing,
/// <summary>HA answered, but not with success. <see cref="HaTestResult.Detail"/> carries the status.</summary>
HttpError,
/// <summary>Reachable, but the named entity has no numeric state (unavailable/unknown/non-numeric).</summary>
NoNumericState,
/// <summary>The request threw. <see cref="HaTestResult.Detail"/> carries the exception message.</summary>
RequestFailed,
}
/// <summary>Outcome of a Home Assistant connectivity test.</summary> /// <summary>Outcome of a Home Assistant connectivity test.</summary>
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null); /// <param name="Message">The English summary, kept for logs and non-UI callers.</param>
/// <param name="Outcome">The same verdict as a value, for a UI that has to phrase it in some language.</param>
/// <param name="EntityId">The entity that was sampled, when one was named.</param>
/// <param name="Detail">Diagnostic text (HTTP status, exception message). Not ours to translate.</param>
public sealed record HaTestResult(
bool Ok,
string Message,
double? SampleValue = null,
HaTestOutcome Outcome = HaTestOutcome.Precondition,
string? EntityId = null,
string? Detail = null);
/// <summary> /// <summary>
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against /// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against
@@ -26,12 +73,12 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo
{ {
if (string.IsNullOrWhiteSpace(baseUrl)) if (string.IsNullOrWhiteSpace(baseUrl))
{ {
return new HaTestResult(false, "Base URL is required."); return new HaTestResult(false, "Base URL is required.", Outcome: HaTestOutcome.BaseUrlMissing);
} }
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
{ {
return new HaTestResult(false, "No token available to test."); return new HaTestResult(false, "No token available to test.", Outcome: HaTestOutcome.TokenMissing);
} }
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
@@ -44,23 +91,29 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}."); return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.",
Outcome: HaTestOutcome.HttpError,
Detail: $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim());
} }
if (string.IsNullOrWhiteSpace(entityId)) if (string.IsNullOrWhiteSpace(entityId))
{ {
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted."); return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.",
Outcome: HaTestOutcome.Connected);
} }
var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false); var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false);
return state is { } value return state is { } value
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value) ? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value,
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric)."); HaTestOutcome.ConnectedWithValue, entityId)
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).",
Outcome: HaTestOutcome.NoNumericState, EntityId: entityId);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl); _logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
return new HaTestResult(false, $"Connection failed: {ex.Message}"); return new HaTestResult(false, $"Connection failed: {ex.Message}",
Outcome: HaTestOutcome.RequestFailed, Detail: ex.Message);
} }
} }
} }
+45 -6
View File
@@ -17,8 +17,43 @@ public enum UpdateAvailability
NotSupportedHere, NotSupportedHere,
} }
/// <summary>Which way an attempt to launch the updater ended.</summary>
/// <remarks>
/// The UI needs to say this in the reader's language, and this assembly has no business knowing
/// what that language is — so the outcome travels as a value and the wording is chosen upstairs.
/// </remarks>
public enum UpdateOutcome
{
/// <summary>The transient unit is running; the service restarts when the rebuild finishes.</summary>
Started,
/// <summary><see cref="UpdateRunner.Availability"/> refused before anything was launched.</summary>
NotAllowed,
/// <summary><c>systemd-run</c> could not be started at all.</summary>
LauncherMissing,
/// <summary><c>systemd-run</c> ran and exited non-zero — most often an update already in flight.</summary>
LauncherFailed,
/// <summary>Launching threw. <see cref="UpdateLaunch.Detail"/> carries the exception message.</summary>
LaunchError,
}
/// <summary>Outcome of trying to launch the updater.</summary> /// <summary>Outcome of trying to launch the updater.</summary>
public sealed record UpdateLaunch(bool Started, string Message); /// <param name="Message">
/// The English summary. Kept as-is for logs and any non-UI caller, so a translation can never change
/// what an operator reads in a log line.
/// </param>
/// <param name="Outcome">The same result as a value, for a UI that has to phrase it in some language.</param>
/// <param name="Availability">Why it was refused, when <paramref name="Outcome"/> is <see cref="UpdateOutcome.NotAllowed"/>.</param>
/// <param name="Detail">Diagnostic text (stderr, an exception message). Never translated — it isn't ours.</param>
public sealed record UpdateLaunch(
bool Started,
string Message,
UpdateOutcome Outcome = UpdateOutcome.LaunchError,
UpdateAvailability? Availability = null,
string? Detail = null);
/// <summary> /// <summary>
/// Starts the in-container updater on request, gated hard. /// Starts the in-container updater on request, gated hard.
@@ -75,7 +110,8 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
{ {
if (Availability is not UpdateAvailability.Allowed) if (Availability is not UpdateAvailability.Allowed)
{ {
return new UpdateLaunch(false, $"Update cannot be started: {Availability}."); return new UpdateLaunch(false, $"Update cannot be started: {Availability}.",
UpdateOutcome.NotAllowed, Availability);
} }
try try
@@ -97,7 +133,7 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
using var process = Process.Start(start); using var process = Process.Start(start);
if (process is null) if (process is null)
{ {
return new UpdateLaunch(false, "Could not start systemd-run."); return new UpdateLaunch(false, "Could not start systemd-run.", UpdateOutcome.LauncherMissing);
} }
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
@@ -108,7 +144,8 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
// Most likely an update already running: the unit name is taken until it is collected. // Most likely an update already running: the unit name is taken until it is collected.
_logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error); _logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error);
return new UpdateLaunch(false, return new UpdateLaunch(false,
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error); string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error,
UpdateOutcome.LauncherFailed, Detail: string.IsNullOrWhiteSpace(error) ? null : error);
} }
// Deliberately loud. With no key there is no caller to attribute this to, so the log is // Deliberately loud. With no key there is no caller to attribute this to, so the log is
@@ -116,12 +153,14 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
// held in memory survives. // held in memory survives.
_logger.LogWarning("In-app update started as transient unit {Unit}", TransientUnit); _logger.LogWarning("In-app update started as transient unit {Unit}", TransientUnit);
return new UpdateLaunch(true, return new UpdateLaunch(true,
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes."); "Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.",
UpdateOutcome.Started);
} }
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
{ {
_logger.LogWarning(ex, "Could not launch the updater"); _logger.LogWarning(ex, "Could not launch the updater");
return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}"); return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}",
UpdateOutcome.LaunchError, Detail: ex.Message);
} }
} }
} }
@@ -2,6 +2,7 @@ using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -111,6 +112,45 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative))) var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
.Content.ReadAsStringAsync(); .Content.ReadAsStringAsync();
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal); Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
// this covers the labels that only exist once rows have rendered — the branch a
// smoke test against a bare database silently skips.
using var germanClient = factory.CreateClient();
germanClient.DefaultRequestHeaders.Add(
"Cookie",
CookieRequestCultureProvider.DefaultCookieName
+ "="
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
// Decoded, because Blazor entity-encodes non-ASCII: "Übersicht" ships as "&#xDC;bersicht".
var germanOverview = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/", UriKind.Relative)));
Assert.Contains("lang=\"de\"", germanOverview, StringComparison.Ordinal);
Assert.Contains("Übersicht", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("This month", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("Latest month with data", germanOverview, StringComparison.Ordinal);
// Meter names are user data: they stay exactly as imported, in either language. The
// meter list is where they render — the overview shows cost categories, not meters.
var germanMeters = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/meters", UriKind.Relative)));
Assert.Contains("Zähler Haus", germanMeters, StringComparison.Ordinal);
// ...while the meter's mode, which is an enum and not user data, is translated.
Assert.Contains("Zählerstand (kumulativ)", germanMeters, StringComparison.Ordinal);
Assert.DoesNotContain("CumulativeCounter", germanMeters, StringComparison.Ordinal);
foreach (var path in new[]
{
"/meters", "/trends", "/solar", "/consumables", "/import",
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
$"/energy/{electricityTypeId}",
})
{
var response = await germanClient.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode();
}
} }
finally finally
{ {
@@ -0,0 +1,180 @@
using System.Net;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc.Testing;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// The language picker's endpoint and the culture the middleware hands each render (SDD §12, M7).
/// </summary>
/// <remarks>
/// <para>
/// A Blazor Server circuit is fixed to the culture of the request that opened it, so switching
/// language cannot be an interactive state change — it is a redirect that writes a cookie and forces
/// a reload. That makes <c>/culture/set</c> a redirector taking its target from the query string,
/// which is the exact shape of an open redirect, so the off-site cases below are load-bearing.
/// </para>
/// <para>
/// Nothing here touches the database: no page on these paths queries, and startup migration is off,
/// so the connection string only has to parse. They run without Docker.
/// </para>
/// </remarks>
public sealed class CultureEndpointTests : IDisposable
{
private const string UnusedConnection = "Host=localhost;Port=1;Database=metervault;Username=none;Password=none";
/// <summary>Renders the full layout (app bar + nav) without needing any data.</summary>
private static readonly Uri LayoutOnlyPage = new("/admin/settings", UriKind.Relative);
private readonly List<MeterVaultAppFactory> _factories = [];
[Fact]
public async Task Setting_a_supported_culture_stores_the_cookie_and_returns_the_user_to_the_page()
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri("/culture/set?culture=de&redirectUri=%2Fmeters%2F7", UriKind.Relative));
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Equal("/meters/7", response.Headers.Location?.OriginalString);
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
}
[Fact]
public async Task A_regional_variant_is_stored_as_the_language_we_ship()
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri("/culture/set?culture=de-AT&redirectUri=%2F", UriKind.Relative));
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
}
[Theory]
[InlineData("fr")]
[InlineData("")]
[InlineData("../../etc/passwd")]
public async Task A_language_we_do_not_ship_is_refused_rather_than_stored(string culture)
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri($"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri=%2F", UriKind.Relative));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(CultureCookie(response));
}
[Theory]
[InlineData("https://evil.example/phish")]
[InlineData("//evil.example/phish")]
[InlineData("/\\evil.example/phish")]
[InlineData("")]
public async Task An_off_site_redirect_target_lands_on_the_dashboard_instead(string redirectUri)
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri($"/culture/set?culture=de&redirectUri={Uri.EscapeDataString(redirectUri)}", UriKind.Relative));
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Equal("/", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task The_culture_cookie_decides_the_language_of_the_rendered_page()
{
using var client = Factory().CreateClient();
client.DefaultRequestHeaders.Add(
"Cookie",
CookieRequestCultureProvider.DefaultCookieName
+ "="
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal);
Assert.Contains("Übersicht", html, StringComparison.Ordinal);
Assert.DoesNotContain("Overview", html, StringComparison.Ordinal);
}
[Fact]
public async Task Without_a_cookie_the_instance_default_locale_applies()
{
using var client = Factory(locale: "de").CreateClient();
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal);
Assert.Contains("Übersicht", html, StringComparison.Ordinal);
}
[Fact]
public async Task English_is_the_shipped_default_when_nothing_is_configured()
{
using var client = Factory().CreateClient();
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal);
Assert.Contains("Overview", html, StringComparison.Ordinal);
}
[Fact]
public async Task An_unshipped_default_locale_degrades_to_english_instead_of_failing_to_boot()
{
using var client = Factory(locale: "fr").CreateClient();
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal);
Assert.Contains("Overview", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_first_visit_pins_the_negotiated_culture_into_the_cookie()
{
// Otherwise a reader whose browser asks for German is served German, but opens the picker
// to find English ticked — the cookie is the only thing either side agrees to read.
using var client = RedirectlessClient();
client.DefaultRequestHeaders.Add("Accept-Language", "de-DE,de;q=0.9,en;q=0.8");
using var response = await client.GetAsync(LayoutOnlyPage);
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
}
public void Dispose()
{
foreach (var factory in _factories)
{
factory.Dispose();
}
}
/// <summary>
/// Blazor entity-encodes every non-ASCII character it renders, so "Übersicht" arrives as
/// "&amp;#xDC;bersicht" — decoding first is what lets these assertions read like the UI does.
/// </summary>
private static async Task<string> RenderedTextAsync(HttpClient client, Uri page) =>
WebUtility.HtmlDecode(await client.GetStringAsync(page));
private static string? CultureCookie(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var cookies)
? cookies.FirstOrDefault(c =>
c.StartsWith(CookieRequestCultureProvider.DefaultCookieName, StringComparison.Ordinal))
: null;
private MeterVaultAppFactory Factory(string? locale = null)
{
var factory = new MeterVaultAppFactory(UnusedConnection, configureApiKey: false) { Locale = locale };
_factories.Add(factory);
return factory;
}
private HttpClient RedirectlessClient() =>
Factory().CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
}
@@ -0,0 +1,119 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.Core.Domain;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// Every domain enum value the UI renders has wording in every language (SDD §12, M7).
/// </summary>
/// <remarks>
/// <see cref="DisplayNames"/> ends each mapping with a fallback arm that returns the bare identifier,
/// so adding an enum value can never throw mid-render on a dashboard. The cost of that safety is that
/// a forgotten value degrades silently to English — these tests are what make it loud instead, by
/// demanding a matching <c>Enum_&lt;Type&gt;_&lt;Value&gt;</c> resource for every declared value.
/// </remarks>
public sealed class EnumDisplayNameTests
{
[Fact]
public void Every_localized_enum_value_has_a_resource_in_every_language()
{
var missing = new List<string>();
foreach (var type in DisplayNames.LocalizedEnums)
{
foreach (var name in Enum.GetNames(type))
{
// None is the empty bitmask, deliberately rendered as nothing at all.
if (type == typeof(ReadingFlags) && name == nameof(ReadingFlags.None))
{
continue;
}
var key = $"Enum_{type.Name}_{name}";
foreach (var culture in Loc.SupportedCultures)
{
var value = Strings.ResourceManager.GetString(key, CultureInfo.GetCultureInfo(culture));
if (string.IsNullOrWhiteSpace(value))
{
missing.Add($"{key} [{culture}]");
}
}
}
}
Assert.True(missing.Count == 0, $"Missing enum display names: {string.Join(", ", missing)}");
}
[Fact]
public void Display_names_are_translated_rather_than_echoing_the_identifier()
{
// Not every value can differ — "Tasmota", "Bonus" and "Global" are the same word in German —
// but if a whole enum came back as its own identifiers, the mapping was never written.
var untranslated = new List<string>();
foreach (var type in DisplayNames.LocalizedEnums)
{
var names = Enum.GetNames(type);
var translated = 0;
foreach (var name in names)
{
var german = Strings.ResourceManager.GetString($"Enum_{type.Name}_{name}", CultureInfo.GetCultureInfo("de"));
if (german is not null && !string.Equals(german, name, StringComparison.Ordinal))
{
translated++;
}
}
if (translated == 0)
{
untranslated.Add(type.Name);
}
}
Assert.True(untranslated.Count == 0, $"Enums with no German wording at all: {string.Join(", ", untranslated)}");
}
[Theory]
[InlineData("en", "Consumption")]
[InlineData("de", "Verbrauch")]
public void The_reader_s_language_decides_the_wording(string culture, string expected) =>
Assert.Equal(expected, WithUiCulture(culture, () => ConsumptionKind.Consumption.Display()));
[Fact]
public void A_bitmask_lists_the_flags_it_actually_carries()
{
// Blank rather than "None": the readings table has a flag column that is empty on almost
// every row, and printing a word down the whole page is noise, not information.
Assert.Equal(string.Empty, ReadingFlags.None.Display());
var both = WithUiCulture("de", () => (ReadingFlags.CounterReset | ReadingFlags.MeterSwap).Display());
Assert.Equal("Zählerreset, Zählerwechsel", both);
Assert.Equal("Meter swap", WithUiCulture("en", () => ReadingFlags.MeterSwap.Display()));
}
[Fact]
public void An_undeclared_enum_value_degrades_to_its_identifier_instead_of_throwing()
{
// Guards the fallback arm itself: a value cast in from the database (or a future migration)
// must not take a dashboard down.
var unknown = (MeterMode)999;
Assert.Equal("999", unknown.Display());
}
private static T WithUiCulture<T>(string culture, Func<T> body)
{
var previous = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
return body();
}
finally
{
CultureInfo.CurrentUICulture = previous;
}
}
}
@@ -0,0 +1,80 @@
using System.Globalization;
using MeterVault.App;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// <see cref="Format"/> formats against the reader's culture rather than a fixed de-DE (SDD §12, M7).
/// </summary>
/// <remarks>
/// The importer's de-DE parsing is deliberately untouched by this: that dialect is a property of the
/// spreadsheet files, not of who is looking at the dashboard, and <c>GermanParsingTests</c> pins it.
/// </remarks>
public sealed class FormatCultureTests
{
[Fact]
public void Digit_grouping_follows_the_reader()
{
Assert.Equal("1.234,5", WithCulture("de", () => Format.Number(1234.5, 1)));
Assert.Equal("1,234.5", WithCulture("en", () => Format.Number(1234.5, 1)));
Assert.Equal("2.940", WithCulture("de", () => Format.Number(2940)));
Assert.Equal("2,940", WithCulture("en", () => Format.Number(2940)));
}
[Fact]
public void The_currency_symbol_stays_the_instances_own()
{
// Only the grouping is localized. The figures are in the instance's configured currency, so
// an English reader must see the same money written their way — not relabelled as dollars.
Assert.Equal("1.234,50 €", WithCulture("de", () => Format.Euro(1234.5)));
Assert.Equal("1,234.50 €", WithCulture("en", () => Format.Euro(1234.5)));
}
[Fact]
public void Percentages_keep_their_explicit_sign()
{
Assert.Equal("+12,4 %", WithCulture("de", () => Format.Percent(12.4)));
Assert.Equal("+12.4 %", WithCulture("en", () => Format.Percent(12.4)));
Assert.Equal("-7,2 %", WithCulture("de", () => Format.Percent(-7.2)));
Assert.Equal("-7.2 %", WithCulture("en", () => Format.Percent(-7.2)));
}
[Fact]
public void Month_labels_are_written_in_the_readers_language()
{
var march = new DateOnly(2025, 3, 1);
var german = WithCulture("de", () => Format.MonthLabel(march));
var english = WithCulture("en", () => Format.MonthLabel(march));
// Asserting the exact German abbreviation would pin us to one ICU version ("Mrz" vs "Mär"),
// so assert what actually matters: the label is culture-sensitive, not invariant.
Assert.Equal("Mar 25", english);
Assert.NotEqual(english, german);
Assert.EndsWith("25", german, StringComparison.Ordinal);
}
[Fact]
public void Direction_icons_are_language_neutral()
{
Assert.Equal("▲", Format.DirectionIcon(1));
Assert.Equal("▼", Format.DirectionIcon(-1));
Assert.Equal("—", Format.DirectionIcon(0));
}
private static T WithCulture<T>(string culture, Func<T> body)
{
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
return body();
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
}
@@ -0,0 +1,220 @@
using System.Collections;
using System.Globalization;
using System.Reflection;
using System.Resources;
using System.Text.RegularExpressions;
using MeterVault.App.Localization;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// Guards the UI string catalogue (SDD §12, M7).
/// </summary>
/// <remarks>
/// Resource lookup fails soft by design — ask for a key the German satellite doesn't carry and
/// <see cref="ResourceManager"/> quietly serves the English one. That is the right runtime
/// behaviour and the wrong build behaviour: a half-translated release would look perfectly healthy.
/// These tests read each culture's resource set with <c>tryParents: false</c>, which is the only way
/// to see what a satellite actually contains, and turn "untranslated" back into a failure.
/// No database, so they run without Docker.
/// </remarks>
public sealed class StringResourceTests
{
/// <summary>Matches {0}, {1:N2}, {0,-8} — the index is what has to agree across languages.</summary>
private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled);
private static readonly IReadOnlyDictionary<string, string> Neutral = ResourcesFor(CultureInfo.InvariantCulture);
[Fact]
public void Neutral_resources_exist()
{
Assert.NotEmpty(Neutral);
// Every generated property is backed by a real entry, so `S.Foo` can never compile against
// a key the resx no longer defines.
var generated = typeof(Strings)
.GetProperties(BindingFlags.Public | BindingFlags.Static)
.Where(p => p.PropertyType == typeof(string) && p.Name != "Culture")
.Select(p => p.Name)
.ToList();
Assert.NotEmpty(generated);
Assert.Empty(generated.Except(Neutral.Keys, StringComparer.Ordinal));
}
[Theory]
[MemberData(nameof(TranslatedCultures))]
public void Every_string_is_translated(string culture)
{
var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture));
var missing = Neutral.Keys.Except(translated.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
Assert.True(
missing.Count == 0,
$"Strings.{culture}.resx is missing {missing.Count} key(s): {string.Join(", ", missing)}");
// An entry that exists but is blank renders as nothing at all — worse than falling back.
var blank = translated.Where(e => string.IsNullOrWhiteSpace(e.Value)).Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
Assert.True(blank.Count == 0, $"Strings.{culture}.resx has blank value(s): {string.Join(", ", blank)}");
}
[Theory]
[MemberData(nameof(TranslatedCultures))]
public void No_translation_is_orphaned(string culture)
{
var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture));
// A key only the translation has is dead weight: nothing can reference it, because the
// strongly-typed accessor is generated from the neutral resx alone.
var orphans = translated.Keys.Except(Neutral.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
Assert.True(
orphans.Count == 0,
$"Strings.{culture}.resx defines {orphans.Count} key(s) the neutral resx does not: {string.Join(", ", orphans)}");
}
[Theory]
[MemberData(nameof(TranslatedCultures))]
public void Placeholders_survive_translation(string culture)
{
var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture));
// Loc.F feeds these to string.Format, so a placeholder dropped or invented in translation is
// a FormatException or a silently missing number at runtime, in that language only.
var broken = new List<string>();
foreach (var (key, english) in Neutral)
{
if (!translated.TryGetValue(key, out var other))
{
continue;
}
var expected = PlaceholderIndexes(english);
var actual = PlaceholderIndexes(other);
if (!expected.SetEquals(actual))
{
broken.Add($"{key} (en: {{{string.Join(",", expected.Order())}}}, {culture}: {{{string.Join(",", actual.Order())}}})");
}
}
Assert.True(broken.Count == 0, $"Placeholder mismatch in Strings.{culture}.resx: {string.Join("; ", broken)}");
}
[Fact]
public void Supported_cultures_all_resolve()
{
foreach (var culture in Loc.SupportedCultures)
{
Assert.True(Loc.TryResolve(culture, out var resolved));
Assert.Equal(culture, resolved);
// The picker labels itself with these, so an unnamed culture would render blank.
Assert.False(string.IsNullOrWhiteSpace(Loc.DisplayName(culture)));
}
}
[Theory]
[InlineData("de-DE", "de")]
[InlineData("de-AT", "de")]
[InlineData("de_CH", "de")]
[InlineData("EN-gb", "en")]
public void Regional_variants_resolve_to_the_language_we_ship(string requested, string expected)
{
Assert.True(Loc.TryResolve(requested, out var resolved));
Assert.Equal(expected, resolved);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("fr")]
[InlineData("klingon")]
public void Untranslated_languages_fall_back_to_the_neutral_culture(string? requested)
{
Assert.False(Loc.TryResolve(requested, out var resolved));
Assert.Equal(Loc.SupportedCultures[0], resolved);
}
[Fact]
public void No_string_is_defined_but_never_used()
{
// The compiler catches the other direction — S.Foo against a deleted key is a build error —
// but a key nothing references compiles perfectly and quietly costs a translator work on
// every language we ever add. Enum_* is exempt: those are reached by name from
// DisplayNames' switch arms, which EnumDisplayNameTests covers instead.
var root = FindRepositoryRoot();
if (root is null)
{
return; // Running detached from the source tree; the other tests still cover the catalogue.
}
var sources = new[] { Path.Combine(root, "src") }
.SelectMany(dir => Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
.Where(f => (f.EndsWith(".cs", StringComparison.Ordinal) || f.EndsWith(".razor", StringComparison.Ordinal))
&& !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Select(File.ReadAllText)
.ToList();
Assert.NotEmpty(sources);
var all = string.Join('\n', sources);
var unused = Neutral.Keys
.Where(k => !k.StartsWith("Enum_", StringComparison.Ordinal))
.Where(k => !Regex.IsMatch(all, $@"\b{Regex.Escape(k)}\b"))
.Order(StringComparer.Ordinal)
.ToList();
Assert.True(unused.Count == 0, $"{unused.Count} unused string(s): {string.Join(", ", unused)}");
}
/// <summary>Walks up from the test binaries to the checkout, identified by the solution file.</summary>
private static string? FindRepositoryRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeterVault.slnx")))
{
dir = dir.Parent;
}
return dir?.FullName;
}
/// <summary>Every shipped language except the neutral one, which is the baseline being compared against.</summary>
public static TheoryData<string> TranslatedCultures()
{
var data = new TheoryData<string>();
foreach (var culture in Loc.SupportedCultures.Skip(1))
{
data.Add(culture);
}
return data;
}
private static HashSet<int> PlaceholderIndexes(string value) =>
[.. PlaceholderPattern.Matches(value).Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))];
/// <summary>
/// The entries one culture's resource set actually defines. <c>tryParents: false</c> is the
/// whole point: with fallback on, a missing German string is indistinguishable from a present one.
/// </summary>
private static IReadOnlyDictionary<string, string> ResourcesFor(CultureInfo culture)
{
var set = Strings.ResourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
if (set is null)
{
return entries;
}
foreach (DictionaryEntry entry in set)
{
if (entry.Key is string key && entry.Value is string value)
{
entries[key] = value;
}
}
return entries;
}
}
@@ -30,7 +30,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
var view = await NewService().GetAsync(meterId); var view = await NewService().GetAsync(meterId);
Assert.NotNull(view); Assert.NotNull(view);
Assert.Equal("Consumption", view!.Label); Assert.Equal(ConsumptionKind.Consumption, view!.Kind);
Assert.Equal(30d, view.MonthToDate, 3); Assert.Equal(30d, view.MonthToDate, 3);
Assert.Equal(100d, view.LastMonth, 3); Assert.Equal(100d, view.LastMonth, 3);
Assert.Equal(130d, view.YearToDate, 3); Assert.Equal(130d, view.YearToDate, 3);
@@ -55,7 +55,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
var view = await NewService().GetAsync(meterId); var view = await NewService().GetAsync(meterId);
Assert.NotNull(view); Assert.NotNull(view);
Assert.Equal("Generation", view!.Label); Assert.Equal(ConsumptionKind.Generation, view!.Kind);
Assert.Equal(42d, view.MonthToDate, 3); Assert.Equal(42d, view.MonthToDate, 3);
await CleanupAsync(db, meterId); await CleanupAsync(db, meterId);
@@ -11,8 +11,16 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure
{ {
public const string ApiKey = "test-api-key"; public const string ApiKey = "test-api-key";
/// <summary>Overrides <c>MeterVault__Locale</c>, the instance's default UI language.</summary>
public string? Locale { get; init; }
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
if (Locale is not null)
{
builder.UseSetting("MeterVault:Locale", Locale);
}
builder.UseEnvironment("Testing"); builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Default", connectionString); builder.UseSetting("ConnectionStrings:Default", connectionString);
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false"); builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");