From bfa0b537eee7f7d82a788b861fba9cfe6f358804 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Thu, 13 Aug 2026 16:36:25 +0200 Subject: [PATCH] i18n: ship the UI in English and German 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 --- CLAUDE.md | 4 +- README.md | 1 + deploy/unraid-template.xml | 2 + src/App/Components/App.razor | 30 +- src/App/Components/Layout/MainLayout.razor | 44 +- src/App/Components/Layout/NavMenu.razor | 24 +- .../Components/Layout/ReconnectModal.razor | 14 +- .../Components/Pages/Admin/Categories.razor | 66 +- .../Components/Pages/Admin/Connectors.razor | 135 +- .../Components/Pages/Admin/EnergyTypes.razor | 58 +- src/App/Components/Pages/Admin/Settings.razor | 39 +- src/App/Components/Pages/Admin/Tariffs.razor | 83 +- src/App/Components/Pages/Consumables.razor | 50 +- src/App/Components/Pages/Dashboard.razor | 18 +- src/App/Components/Pages/EnergyView.razor | 42 +- src/App/Components/Pages/Error.razor | 20 +- src/App/Components/Pages/Import.razor | 70 +- src/App/Components/Pages/ImportWizard.razor | 98 +- src/App/Components/Pages/MeterDetail.razor | 219 ++- src/App/Components/Pages/Meters.razor | 99 +- src/App/Components/Pages/NotFound.razor | 4 +- src/App/Components/Pages/Solar.razor | 46 +- src/App/Components/Pages/Trends.razor | 16 +- src/App/Components/Shared/CategoryDonut.razor | 2 +- src/App/Components/Shared/SankeyChart.razor | 17 +- src/App/Components/Shared/SeriesChart.razor | 2 +- src/App/Components/Shared/TrendChart.razor | 7 +- src/App/Components/Shared/UpdateBanner.razor | 41 +- src/App/Components/_Imports.razor | 4 + src/App/Confirm.cs | 5 +- src/App/Format.cs | 29 +- src/App/Localization/CultureEndpoints.cs | 63 + src/App/Localization/DisplayNames.cs | 188 ++ src/App/Localization/ImportWarningText.cs | 36 + src/App/Localization/Loc.cs | 64 + src/App/Localization/Strings.de.resx | 1635 +++++++++++++++++ src/App/Localization/Strings.resx | 1635 +++++++++++++++++ src/App/MeterVault.App.csproj | 16 + src/App/Program.cs | 21 + src/Infrastructure/Dashboard/FlowModels.cs | 5 + src/Infrastructure/Dashboard/FlowService.cs | 4 +- .../Dashboard/MeterDetailModels.cs | 5 +- .../Dashboard/MeterPeriodService.cs | 2 +- src/Infrastructure/Import/CsvImporter.cs | 13 +- src/Infrastructure/Import/ImportWarning.cs | 74 + src/Infrastructure/Import/StagedImport.cs | 2 +- .../Ingestion/HaConnectionTester.cs | 69 +- src/Infrastructure/Update/UpdateRunner.cs | 51 +- .../Integration.Tests/DashboardRenderTests.cs | 40 + .../Localization/CultureEndpointTests.cs | 180 ++ .../Localization/EnumDisplayNameTests.cs | 119 ++ .../Localization/FormatCultureTests.cs | 80 + .../Localization/StringResourceTests.cs | 220 +++ .../MeterPeriodServiceTests.cs | 4 +- .../Integration.Tests/MeterVaultAppFactory.cs | 8 + 55 files changed, 5215 insertions(+), 608 deletions(-) create mode 100644 src/App/Localization/CultureEndpoints.cs create mode 100644 src/App/Localization/DisplayNames.cs create mode 100644 src/App/Localization/ImportWarningText.cs create mode 100644 src/App/Localization/Loc.cs create mode 100644 src/App/Localization/Strings.de.resx create mode 100644 src/App/Localization/Strings.resx create mode 100644 src/Infrastructure/Import/ImportWarning.cs create mode 100644 tests/Integration.Tests/Localization/CultureEndpointTests.cs create mode 100644 tests/Integration.Tests/Localization/EnumDisplayNameTests.cs create mode 100644 tests/Integration.Tests/Localization/FormatCultureTests.cs create mode 100644 tests/Integration.Tests/Localization/StringResourceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index a0ceb5c..0313e68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**. -**Status: implemented (M0–M7) + 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 (M0–M7) + 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 @@ -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. +**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. ## Reference-data behaviours the code must reproduce (from `sampledata/`) diff --git a/README.md b/README.md index 2f91b2e..15ec967 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map |----------|---------| | `ConnectionStrings__Default` | PostgreSQL/Timescale connection string | | `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__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 | diff --git a/deploy/unraid-template.xml b/deploy/unraid-template.xml index abecb74..7b2dc19 100644 --- a/deploy/unraid-template.xml +++ b/deploy/unraid-template.xml @@ -20,6 +20,8 @@ Europe/Berlin + en + false diff --git a/src/App/Components/App.razor b/src/App/Components/App.razor index 8d802ac..04096f6 100644 --- a/src/App/Components/App.razor +++ b/src/App/Components/App.razor @@ -1,5 +1,8 @@ - - +@using System.Globalization +@using Microsoft.AspNetCore.Localization + + + @@ -23,3 +26,26 @@ + +@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, + }); +} diff --git a/src/App/Components/Layout/MainLayout.razor b/src/App/Components/Layout/MainLayout.razor index 951ec76..753577e 100644 --- a/src/App/Components/Layout/MainLayout.razor +++ b/src/App/Components/Layout/MainLayout.razor @@ -1,5 +1,7 @@ @inherits LayoutComponentBase +@using System.Globalization @using MeterVault.App.Theme +@inject NavigationManager Navigation @@ -9,11 +11,24 @@ + OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="@S.Layout_ToggleNavigation" /> MeterVault - + + + @foreach (var culture in Loc.SupportedCultures) + { + + @Loc.DisplayName(culture) + + } + + + @@ -31,12 +46,33 @@
- An unhandled error has occurred. - Reload + @S.Layout_UnhandledError + @S.Layout_Reload 🗙
@code { private bool _drawerOpen = 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); + } } diff --git a/src/App/Components/Layout/NavMenu.razor b/src/App/Components/Layout/NavMenu.razor index 2b5012f..60c08c1 100644 --- a/src/App/Components/Layout/NavMenu.razor +++ b/src/App/Components/Layout/NavMenu.razor @@ -3,25 +3,25 @@ @using MeterVault.Core.Domain - Overview - Trends + @S.Nav_Overview + @S.Nav_Trends @foreach (var type in _energyTypes) { @type.DisplayName } - Solar / PV - Oil / consumables - Meters - Import + @S.Nav_Solar + @S.Nav_Consumables + @S.Nav_Meters + @S.Nav_Import - - Energy types - Tariffs - Cost categories - Connectors - Settings + + @S.Nav_EnergyTypes + @S.Nav_Tariffs + @S.Nav_CostCategories + @S.Nav_Connectors + @S.Nav_Settings diff --git a/src/App/Components/Layout/ReconnectModal.razor b/src/App/Components/Layout/ReconnectModal.razor index e740b0c..42fe994 100644 --- a/src/App/Components/Layout/ReconnectModal.razor +++ b/src/App/Components/Layout/ReconnectModal.razor @@ -7,25 +7,25 @@

- Rejoining the server... + @S.Reconnect_Rejoining

- Rejoin failed... trying again in seconds. + @S.Reconnect_RetryCountdownPrefix @S.Reconnect_RetryCountdownSuffix

- Failed to rejoin.
Please retry or reload the page. + @S.Reconnect_RejoinFailed
@S.Reconnect_RetryOrReload

- The session has been paused by the server. + @S.Reconnect_Paused

- Failed to resume the session.
Please retry or reload the page. + @S.Reconnect_ResumeFailed
@S.Reconnect_RetryOrReload

diff --git a/src/App/Components/Pages/Admin/Categories.razor b/src/App/Components/Pages/Admin/Categories.razor index a6f564f..e8269a0 100644 --- a/src/App/Components/Pages/Admin/Categories.razor +++ b/src/App/Components/Pages/Admin/Categories.razor @@ -5,12 +5,12 @@ @using Microsoft.EntityFrameworkCore @using MudBlazor -MeterVault — Cost categories +MeterVault — @S.Nav_CostCategories
- Cost categories + @S.Nav_CostCategories - Add category + @S.Categories_AddCategory
@@ -22,22 +22,22 @@ else { - Name - Sort - Members - Actions + @S.Common_Name + @S.Categories_Sort + @S.Categories_Members + @S.Common_Actions - + @if (!string.IsNullOrWhiteSpace(context.ColorHex)) { } @context.Name - @context.Sort - @MemberSummary(context) - + @context.Sort + @MemberSummary(context) + @@ -47,20 +47,20 @@ else - @(_working.Id == 0 ? "New category" : $"Edit {_working.Name}") + @(_working.Id == 0 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name)) - - - + + + @if (_working.Id != 0) { - Members + @S.Categories_Members @if (_members.Count == 0) { - No members yet — add a meter or an energy type. + @S.Categories_NoMembers } else { @@ -77,30 +77,30 @@ else }
- + @foreach (var meter in _meters) { @meter.Name } - Add - + @S.Categories_Add + @foreach (var t in _energyTypes) { @t.DisplayName } - Add + @S.Categories_Add
} else { - Save the category first to add members. + @S.Categories_SaveFirst }
- Close - Save + @S.Categories_Close + @S.Common_Save
@@ -129,12 +129,12 @@ else { var meters = c.Members.Count(m => m.MeterId is not null); var types = c.Members.Count(m => m.EnergyTypeId is not null); - return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)"; + return meters + types == 0 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types); } private string MemberLabel(CostCategoryMember m) => - m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}" - : m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}" + m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}") + : m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}") : "—"; private void OpenEdit(CostCategory? category) @@ -158,7 +158,7 @@ else { if (string.IsNullOrWhiteSpace(_working.Name)) { - Snackbar.Add("Name is required.", Severity.Warning); + Snackbar.Add(S.Common_NameRequired, Severity.Warning); return; } @@ -169,7 +169,7 @@ else db.CostCategories.Add(category); await db.SaveChangesAsync(); // Re-open on the new category so members can be added. - Snackbar.Add("Saved. Add members below.", Severity.Success); + Snackbar.Add(S.Categories_SavedAddMembers, Severity.Success); await LoadAsync(); OpenEdit(_categories!.First(c => c.Id == category.Id)); return; @@ -181,7 +181,7 @@ else existing.Sort = _working.Sort; await db.SaveChangesAsync(); _editOpen = false; - Snackbar.Add("Saved.", Severity.Success); + Snackbar.Add(S.Common_Saved, Severity.Success); await LoadAsync(); } @@ -235,8 +235,8 @@ else private async Task DeleteAsync(CostCategory category) { - if (!await Confirm.DeleteAsync(DialogService, "Delete category", - $"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked.")) + if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle, + Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count))) { return; } @@ -244,7 +244,7 @@ else await using var db = await DbFactory.CreateDbContextAsync(); // Members cascade with the category; manual_cost.category_id is SetNull. await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync(); - Snackbar.Add("Deleted.", Severity.Success); + Snackbar.Add(S.Common_Deleted, Severity.Success); await LoadAsync(); } diff --git a/src/App/Components/Pages/Admin/Connectors.razor b/src/App/Components/Pages/Admin/Connectors.razor index 826fad2..1f23163 100644 --- a/src/App/Components/Pages/Admin/Connectors.razor +++ b/src/App/Components/Pages/Admin/Connectors.razor @@ -8,18 +8,17 @@ @using MeterVault.Infrastructure.Ingestion @using MudBlazor -MeterVault — Connectors +MeterVault — @S.Nav_Connectors
- Connectors + @S.Nav_Connectors - Add connector + @S.Connectors_AddConnector
- Secrets are never stored here. Credentials/tokens are referenced by the name of an environment variable - (or Docker secret) resolved at runtime. + @S.Connectors_SecretsNoticePrefix @S.Connectors_SecretsNoticeEnvVar @S.Connectors_SecretsNoticeSuffix @if (_endpoints is null) @@ -30,20 +29,20 @@ else { - Name - Type - Enabled - Last status - Last seen - Actions + @S.Common_Name + @S.Common_Type + @S.Common_Enabled + @S.Connectors_LastStatus + @S.Common_LastSeen + @S.Common_Actions - @context.Name - @context.Type - @(context.IsEnabled ? "yes" : "no") - @(context.LastStatus ?? "—") - @(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") - + @context.Name + @context.Type.Display() + @(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No) + @(context.LastStatus ?? "—") + @(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") + @@ -51,49 +50,49 @@ else @if (_endpoints.Count == 0) { - No connectors yet. Add an MQTT broker or a Home Assistant connection. + @S.Connectors_Empty } } - @(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}") + @(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name)) - + @foreach (var type in Enum.GetValues()) { - @type + @type.Display() } - + @if (_working.Type == EndpointType.HomeAssistant) { - - + + @if (_working.UseDirectToken) { + Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" /> - Encrypted before it is stored; database dumps and JSON exports carry nothing usable. + @S.Connectors_EncryptedHint } else { - + - The variable's name, not the token. Set it on the server and restart the app. + @S.Connectors_TokenEnvHintPrefix @S.Connectors_TokenEnvHintEmphasis@S.Connectors_TokenEnvHintSuffix } - + - On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval. + @S.Connectors_WebSocketHint - + - Test connection + @S.Connectors_TestConnection @if (_testing) { @@ -101,36 +100,36 @@ else } @if (_testResult is not null) { - @_testResult.Message + @TestText(_testResult) } } else { - - - - + + + + @if (_working.UseDirectCredentials) { - + + Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" /> - Encrypted before it is stored; database dumps and JSON exports carry nothing usable. + @S.Connectors_EncryptedHint } else { - - + + } - + } - + - Cancel - Save + @S.Common_Cancel + @S.Common_Save @@ -210,9 +209,7 @@ else // "Test connection" into an exfiltration primitive — point it at any host and the // token arrives as a Bearer header. A stored secret only ever goes to the origin // it was saved for; testing elsewhere means typing the token again. - _testResult = new HaTestResult(false, - "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."); + _testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged); } else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 }) { @@ -220,19 +217,17 @@ else } else { - _testResult = new HaTestResult(false, "Enter a token first."); + _testResult = new HaTestResult(false, S.Connectors_TestNoToken); } } else if (string.IsNullOrWhiteSpace(_working.TokenEnv)) { - _testResult = new HaTestResult(false, - "Name the environment variable holding the token, or switch on \"Enter the token here\"."); + _testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv); } else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken) { _testResult = new HaTestResult(false, - $"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, " - + "or switch on \"Enter the token here\" to store the token directly."); + Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv)); } else { @@ -249,7 +244,7 @@ else { if (string.IsNullOrWhiteSpace(_working.Name)) { - Snackbar.Add("Name is required.", Severity.Warning); + Snackbar.Add(S.Common_NameRequired, Severity.Warning); return; } @@ -258,7 +253,7 @@ else && string.IsNullOrWhiteSpace(_working.Token) && !_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; } @@ -305,7 +300,7 @@ else await db.SaveChangesAsync(); _editOpen = false; - Snackbar.Add("Saved.", Severity.Success); + Snackbar.Add(S.Common_Saved, Severity.Success); await LoadAsync(); } @@ -316,15 +311,16 @@ else // Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely // rather than falling back to any broker. Say so plainly. var note = sourceCount > 0 - ? $" {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; } await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync(); - Snackbar.Add("Deleted.", Severity.Success); + Snackbar.Add(S.Common_Deleted, Severity.Success); await LoadAsync(); } @@ -398,4 +394,23 @@ else 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, + }; } diff --git a/src/App/Components/Pages/Admin/EnergyTypes.razor b/src/App/Components/Pages/Admin/EnergyTypes.razor index 4945042..33004e2 100644 --- a/src/App/Components/Pages/Admin/EnergyTypes.razor +++ b/src/App/Components/Pages/Admin/EnergyTypes.razor @@ -5,12 +5,12 @@ @using Microsoft.EntityFrameworkCore @using MudBlazor -MeterVault — Energy types +MeterVault — @S.Nav_EnergyTypes
- Energy types + @S.Nav_EnergyTypes - Add energy type + @S.EnergyTypes_Add
@@ -22,24 +22,24 @@ else { - Key - Display name - Base unit - Default mode - Actions + @S.EnergyTypes_Key + @S.EnergyTypes_DisplayName + @S.EnergyTypes_BaseUnit + @S.EnergyTypes_DefaultMode + @S.Common_Actions - @context.Key - + @context.Key + @if (!string.IsNullOrWhiteSpace(context.ColorHex)) { } @context.DisplayName - @context.BaseUnit - @context.DefaultMode - + @context.BaseUnit + @context.DefaultMode.Display() + @@ -49,24 +49,24 @@ else - @(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}") + @(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName)) - - - - + + + + @foreach (var mode in Enum.GetValues()) { - @mode + @mode.Display() } - - + + - Cancel - Save + @S.Common_Cancel + @S.Common_Save @@ -105,14 +105,14 @@ else { if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit)) { - Snackbar.Add("Key, display name and base unit are required.", Severity.Warning); + Snackbar.Add(S.EnergyTypes_RequiredFields, Severity.Warning); return; } await using var db = await DbFactory.CreateDbContextAsync(); if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id)) { - Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error); + Snackbar.Add(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), Severity.Error); return; } @@ -141,7 +141,7 @@ else await db.SaveChangesAsync(); _editOpen = false; - Snackbar.Add("Saved.", Severity.Success); + Snackbar.Add(S.Common_Saved, Severity.Success); await LoadAsync(); } @@ -151,11 +151,11 @@ else var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id); if (meterCount > 0) { - Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error); + Snackbar.Add(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error); return; } - if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone.")) + if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName))) { return; } @@ -165,7 +165,7 @@ else { db.EnergyTypes.Remove(target); await db.SaveChangesAsync(); - Snackbar.Add("Deleted.", Severity.Success); + Snackbar.Add(S.Common_Deleted, Severity.Success); } await LoadAsync(); diff --git a/src/App/Components/Pages/Admin/Settings.razor b/src/App/Components/Pages/Admin/Settings.razor index b01987f..7755e9d 100644 --- a/src/App/Components/Pages/Admin/Settings.razor +++ b/src/App/Components/Pages/Admin/Settings.razor @@ -2,29 +2,28 @@ @inject Microsoft.Extensions.Options.IOptions Options @using MudBlazor -MeterVault — Settings +MeterVault — @S.Nav_Settings -Settings +@S.Nav_Settings - These are the effective settings the running instance is using. They are configured via environment - variables (MeterVault__Key / Section__Key) or Docker/compose, not stored in the - database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart. + @S.Settings_EffectiveLead @S.Settings_EffectiveEmphasis @S.Settings_EffectiveRest + (MeterVault__Key / Section__Key) @S.Settings_EffectiveTail - Locale & time + @S.Settings_LocaleAndTime - Timezone@_o.TimeZone - Locale@_o.Locale - Currency@_o.Currency - Raw-reading retention@_o.RawRetentionDays days + @S.Settings_Timezone@_o.TimeZone + @S.Settings_Locale@_o.Locale + @S.Common_Currency@_o.Currency + @S.Settings_RawRetention@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays) - Env keys: MeterVault__TimeZone, MeterVault__Locale, + @S.Settings_EnvKeysLabel MeterVault__TimeZone, MeterVault__Locale, MeterVault__Currency, MeterVault__RawRetentionDays. @@ -32,7 +31,7 @@ - Access & ingestion + @S.Settings_AccessAndIngestion @@ -40,26 +39,26 @@ @if (_o.ApiKeys.Count > 0) { - @_o.ApiKeys.Count key(s) configured + @Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count) } else if (_o.AllowAnonymousApi) { - open (anonymous) + @S.Settings_ApiOpen } else { - closed (401) + @S.Settings_ApiClosed } - Reverse-proxy trust@(_o.ReverseProxyTrust ? "on" : "off") - Live ingestion workers@(_o.EnableLiveIngestion ? "on" : "off") - Seed reference data on start@(_o.SeedReferenceData ? "on" : "off") + @S.Settings_ReverseProxyTrust@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off) + @S.Settings_LiveIngestionWorkers@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off) + @S.Settings_SeedReferenceData@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off) - Set API keys with MeterVault__ApiKeys__0. Keys themselves are never shown here. - API docs at /swagger. + @S.Settings_ApiKeysHintBefore MeterVault__ApiKeys__0. @S.Settings_ApiKeysHintAfter + @S.Settings_ApiDocsLabel /swagger. diff --git a/src/App/Components/Pages/Admin/Tariffs.razor b/src/App/Components/Pages/Admin/Tariffs.razor index af641ca..34ec480 100644 --- a/src/App/Components/Pages/Admin/Tariffs.razor +++ b/src/App/Components/Pages/Admin/Tariffs.razor @@ -5,12 +5,12 @@ @using Microsoft.EntityFrameworkCore @using MudBlazor -MeterVault — Tariffs +MeterVault — @S.Nav_Tariffs
- Tariffs + @S.Nav_Tariffs - Add tariff + @S.Tariffs_AddTariff
@@ -22,22 +22,22 @@ else { - Scope - Component - Value - Unit - Valid from - Valid to - Actions + @S.Common_Scope + @S.Tariffs_Component + @S.Common_Value + @S.Common_Unit + @S.Tariffs_ValidFrom + @S.Tariffs_ValidTo + @S.Common_Actions - @ScopeLabel(context) - @context.Component - @Format.Number(context.Value, 4) - @context.Unit - @context.ValidFrom.ToString("yyyy-MM-dd") - @(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open") - + @ScopeLabel(context) + @context.Component.Display() + @Format.Number(context.Value, 4) + @context.Unit + @context.ValidFrom.ToString("yyyy-MM-dd") + @(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded) + @@ -45,24 +45,24 @@ else @if (_tariffs.Count == 0) { - No tariffs yet. Add one, or load the reference data from Import. + @S.Tariffs_EmptyState @S.Nav_Import. } } - @(_working.Id == 0 ? "New tariff" : "Edit tariff") + @(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff) - + @foreach (var scope in Enum.GetValues()) { - @scope + @scope.Display() } @if (_working.ScopeType == TariffScope.EnergyType) { - + @foreach (var t in _energyTypes) { @t.DisplayName @@ -71,29 +71,29 @@ else } else if (_working.ScopeType == TariffScope.Meter) { - + @foreach (var m in _meters) { @m.Name } } - + @foreach (var component in Enum.GetValues()) { - @component + @component.Display() } - - - - - - + + + + + + - Cancel - Save + @S.Common_Cancel + @S.Common_Save @@ -117,9 +117,9 @@ else private string ScopeLabel(Tariff t) => t.ScopeType switch { - TariffScope.Global => "Global", - TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}", - TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}", + TariffScope.Global => S.Tariffs_ScopeGlobal, + TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"), + TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"), _ => t.ScopeType.ToString(), }; @@ -147,13 +147,13 @@ else { if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null) { - Snackbar.Add("Unit and valid-from are required.", Severity.Warning); + Snackbar.Add(S.Tariffs_UnitAndValidFromRequired, Severity.Warning); return; } if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null) { - Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning); + Snackbar.Add(S.Tariffs_ScopeTargetRequired, Severity.Warning); return; } @@ -191,13 +191,14 @@ else await db.SaveChangesAsync(); _editOpen = false; - Snackbar.Add("Saved.", Severity.Success); + Snackbar.Add(S.Common_Saved, Severity.Success); await LoadAsync(); } private async Task DeleteAsync(Tariff tariff) { - if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?")) + if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle, + Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), Format.Number(tariff.Value, 4), tariff.Unit))) { return; } @@ -208,7 +209,7 @@ else { db.Tariffs.Remove(target); await db.SaveChangesAsync(); - Snackbar.Add("Deleted.", Severity.Success); + Snackbar.Add(S.Common_Deleted, Severity.Success); } await LoadAsync(); diff --git a/src/App/Components/Pages/Consumables.razor b/src/App/Components/Pages/Consumables.razor index 71724ba..c90fead 100644 --- a/src/App/Components/Pages/Consumables.razor +++ b/src/App/Components/Pages/Consumables.razor @@ -2,15 +2,15 @@ @inject ConsumableService ConsumablesSvc @using MudBlazor -MeterVault — Consumables +MeterVault — @S.Consumables_PageTitle
- Oil / consumables - - Last 12 months - Last 24 months - Last 5 years - All time + @S.Nav_Consumables + + @S.Common_RangeLast12Months + @S.Common_RangeLast24Months + @S.Common_RangeLast5Years + @S.Common_RangeAllTime
@@ -21,8 +21,8 @@ else if (_items.Count == 0) { - No consumable meters found. Add a meter with mode ConsumableBalance and a tank, or load the reference - data from Import. + @S.Consumables_NoMetersLead @MeterMode.ConsumableBalance.Display() @S.Consumables_NoMetersTail + @S.Nav_Import. } else @@ -33,20 +33,20 @@ else @item.Name - Tank level + @S.Consumables_TankLevel @(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—") - @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") { · @Format.Number(cm, 0) cm } @if (item.LevelAsOf is { } asOf) { - · as of @asOf.ToString("yyyy-MM-dd") + · @Loc.F(S.Consumables_AsOf, asOf.ToString("yyyy-MM-dd")) } @@ -54,15 +54,15 @@ else - Used (range) + @S.Consumables_UsedRange @Format.Number(item.ConsumptionInRange, 0) @item.Unit - Burner runtime + @S.Consumables_BurnerRuntime @(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—") - Effective rate + @S.Consumables_EffectiveRate @if (item.FixedRate is { } fr) { @@ -77,20 +77,20 @@ else } - @(item.RateMode) + @item.RateMode.Display() - Cost (range) + @S.Common_CostRange @Format.Euro(item.CostInRange) - Forecast to empty + @S.Consumables_ForecastEmpty @(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—") @if (item.AveragePerDay is { } apd) { - (@Format.Number(apd, 1) @item.Unit/day) + @Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit) } @@ -99,22 +99,22 @@ else - Consumption by month + @S.Consumables_ConsumptionByMonth - Deliveries (@item.Deliveries.Count) + @Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count) @if (item.Deliveries.Count == 0) { - No deliveries recorded. + @S.Consumables_NoDeliveries } else {
- DateAmount + @S.Common_Date@S.Common_Amount @foreach (var delivery in item.Deliveries) @@ -171,9 +171,9 @@ else private static IReadOnlyList ChartFor(ConsumableSummary item) { 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(); - 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 diff --git a/src/App/Components/Pages/Dashboard.razor b/src/App/Components/Pages/Dashboard.razor index 6ca90a2..0331968 100644 --- a/src/App/Components/Pages/Dashboard.razor +++ b/src/App/Components/Pages/Dashboard.razor @@ -1,9 +1,9 @@ @page "/" @inject DashboardService Dash -MeterVault — Overview +MeterVault — @S.Nav_Overview -Overview +@S.Nav_Overview @@ -16,28 +16,28 @@ else - This month + @S.Dashboard_ThisMonth @Format.Euro(_summary.Month.Current) - This year + @S.Common_ThisYear @Format.Euro(_summary.Year.Current) - Latest month with data + @S.Dashboard_LatestMonthWithData @Format.Euro(_summary.LatestMonthCost) - What costs most (this year) + @S.Dashboard_WhatCostsMost @if (_breakdown is { Count: > 0 }) { @@ -55,17 +55,17 @@ else } else { - No cost data yet — import a sheet or add tariffs. + @S.Dashboard_NoCostData } - What cost more / less (year vs last year) + @S.Dashboard_WhatChanged - CategoryNowPrevΔ + @S.Dashboard_ColCategory@S.Dashboard_ColCurrent@S.Dashboard_ColPreviousΔ @foreach (var row in _difference) diff --git a/src/App/Components/Pages/EnergyView.razor b/src/App/Components/Pages/EnergyView.razor index 970840a..e08643c 100644 --- a/src/App/Components/Pages/EnergyView.razor +++ b/src/App/Components/Pages/EnergyView.razor @@ -5,15 +5,15 @@ @using Microsoft.EntityFrameworkCore @using MudBlazor -MeterVault — @(_graph?.EnergyType ?? "Energy") +MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)
- @(_graph?.EnergyType ?? "Energy") flow - - Last 12 months - Last 24 months - Last 5 years - All time + @Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback) + + @S.Common_RangeLast12Months + @S.Common_RangeLast24Months + @S.Common_RangeLast5Years + @S.Common_RangeAllTime
@@ -24,8 +24,8 @@ else if (!_graph.HasData) { - No meters for this energy type yet. Add meters in Meters, or load the - reference data from Import. + @S.EnergyView_NoMetersIntro @S.Nav_Meters@S.EnergyView_NoMetersOr + @S.Nav_Import. } else @@ -33,48 +33,48 @@ else - Top-level throughput + @S.EnergyView_TopLevelThroughput @Format.Number(_graph.Total, 0) @_graph.Unit - Cost (range) + @S.Common_CostRange @Format.Euro(_cost) - Meters + @S.Common_Meters @_meters.Count - Flow + @S.EnergyView_Flow @if (_graph.HasChain) { - Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder. + @S.EnergyView_FlowCaption } else { - No meter chain configured yet. In Meters → edit a sub-meter and set its - upstream meter(s) to show where the main meter's flow divides (e.g. main → car, pool, other). + @S.EnergyView_NoChainIntro @S.Nav_Meters @S.EnergyView_NoChainMiddle + @S.EnergyView_NoChainUpstream @S.EnergyView_NoChainRest @if (_graph.Nodes.Count > 0) { - MeterConsumption + @S.Common_Meter@S.EnergyView_ColConsumption @foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value)) { - @node.Label + @(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label) @Format.Number(node.Value, 0) @_graph.Unit } @@ -85,15 +85,15 @@ else - Meters + @S.Common_Meters - NameModeUpstream ofConsumption + @S.Common_Name@S.Common_Mode@S.EnergyView_ColUpstreamOf@S.EnergyView_ColConsumption @foreach (var meter in _meters) { @meter.Name - @meter.Mode + @meter.Mode.Display() @UpstreamLabel(meter.Id) @Format.Number(NodeValue(meter.Id), 0) @_graph.Unit diff --git a/src/App/Components/Pages/Error.razor b/src/App/Components/Pages/Error.razor index 576cc2d..749f4e3 100644 --- a/src/App/Components/Pages/Error.razor +++ b/src/App/Components/Pages/Error.razor @@ -1,27 +1,27 @@ @page "/Error" @using System.Diagnostics -Error +@S.Error_PageTitle -

Error.

-

An error occurred while processing your request.

+

@S.Error_Heading

+

@S.Error_Message

@if (ShowRequestId) {

- Request ID: @RequestId + @S.Error_RequestId @RequestId

} -

Development Mode

+

@S.Error_DevelopmentMode

- Swapping to Development 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, "Development"))

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. + @S.Error_DevelopmentWarning + @S.Error_DevelopmentWarningDetail + @((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "Development", "ASPNETCORE_ENVIRONMENT"))

@code{ diff --git a/src/App/Components/Pages/Import.razor b/src/App/Components/Pages/Import.razor index 6647e6b..7654698 100644 --- a/src/App/Components/Pages/Import.razor +++ b/src/App/Components/Pages/Import.razor @@ -9,21 +9,20 @@ @using MeterVault.Infrastructure.Persistence @using Microsoft.EntityFrameworkCore -MeterVault — Import +MeterVault — @S.Nav_Import -Import +@S.Nav_Import - Reference dataset + @S.Import_ReferenceDataset - Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a - starter dataset with meters, tariffs and categories. + @S.Import_ReferenceDatasetHelp - @(_referenceLoaded ? "Loaded" : "Load reference data") + @(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData) @if (_loadingReference) { @@ -35,22 +34,21 @@
- Your own CSV + @S.Import_YourOwnCsv Mapping wizard + StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard
- Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a - revertible import. Or dry-run against one of the built-in reference profiles below. + @S.Import_YourOwnCsvHelp - - Electricity (Strom) - Water (Wasser) - Heating oil (Heizöl) - Costs (Kosten) + + @S.Import_ProfileElectricity + @S.Import_ProfileWater + @S.Import_ProfileHeatingOil + @S.Import_ProfileCosts - Dry-run a reference sheet + @S.Import_DryRunReferenceSheet
@@ -60,20 +58,20 @@ { - Preview + @S.Common_Preview
- Readings: @_preview.Readings.Count - Events: @_preview.Events.Count - Manual costs: @_preview.ManualCosts.Count - Skipped rows: @_preview.SkippedRows + @S.Common_ReadingsLabel @_preview.Readings.Count + @S.Common_EventsLabel @_preview.Events.Count + @S.Common_ManualCostsLabel @_preview.ManualCosts.Count + @S.Common_SkippedRowsLabel @_preview.SkippedRows
@if (_preview.Warnings.Count > 0) { - + @foreach (var warning in _preview.Warnings.Take(50)) { - @warning + @warning.Display() } @@ -84,15 +82,15 @@ - Recent imports + @S.Import_RecentImports @if (_batches.Count == 0) { - No imports yet. + @S.Import_NoImportsYet } else { - #SourceRowsImportedStatus + #@S.Import_ColumnSource@S.Import_ColumnRows@S.Import_ColumnImported@S.Common_Status @foreach (var batch in _batches) { @@ -104,18 +102,18 @@ @if (batch.RevertedAt is not null) { - reverted + @S.Import_StatusReverted } else { - active + @S.Import_StatusActive } Revert + OnClick="@(() => RevertAsync(batch))">@S.Import_Revert } @@ -151,9 +149,9 @@ private async Task RevertAsync(ImportBatch batch) { - if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?", - $"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?", - "Revert")) + if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle, + Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource), + S.Import_Revert)) { return; } @@ -162,12 +160,12 @@ try { await ImportService.RevertAsync(batch.Id); - Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success); + Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success); await LoadBatchesAsync(); } catch (Exception ex) { - Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error); + Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error); } finally { @@ -183,12 +181,12 @@ var dir = Path.Combine(AppContext.BaseDirectory, "sampledata"); await ReferenceImporter.LoadAsync(dir); _referenceLoaded = true; - Snackbar.Add("Reference data loaded.", Severity.Success); + Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success); await LoadBatchesAsync(); } catch (Exception ex) { - Snackbar.Add($"Import failed: {ex.Message}", Severity.Error); + Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error); } finally { diff --git a/src/App/Components/Pages/ImportWizard.razor b/src/App/Components/Pages/ImportWizard.razor index 3ab735b..95e80e6 100644 --- a/src/App/Components/Pages/ImportWizard.razor +++ b/src/App/Components/Pages/ImportWizard.razor @@ -10,29 +10,28 @@ @using MeterVault.Infrastructure.Persistence @using Microsoft.EntityFrameworkCore -MeterVault — Import wizard +MeterVault — @S.ImportWizard_Title
- - Import wizard + + @S.ImportWizard_Title
- Upload any CSV, map its columns to your meters and categories, preview what would be staged, then - commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes, - Monat JJJJ or TT.MM.JJJJ dates) — the same parser the reference sheets use. + @S.ImportWizard_IntroLead + Monat JJJJ @S.ImportWizard_IntroOr TT.MM.JJJJ @S.ImportWizard_IntroTail
- Choose CSV + @S.ImportWizard_ChooseCsv
@@ -40,10 +39,10 @@ @if (_colCount > 0) { - 1. Parsing options + @S.ImportWizard_Step1Title - + @foreach (var i in Enumerable.Range(0, _colCount)) { @ColLabel(i) @@ -51,27 +50,27 @@ - - Auto-detect - Month name (Januar 2024) - Day (31.12.2024) + + @S.ImportWizard_DateAutoDetect + @S.ImportWizard_DateMonthName + @S.ImportWizard_DateDay - + - + - - + + - 2. Column preview + @S.ImportWizard_Step2Title
@@ -79,7 +78,7 @@ @foreach (var i in Enumerable.Range(0, _colCount)) { - Col @i@(i == _dateColumn ? " 📅" : "") + @Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "") } @@ -98,23 +97,23 @@
- Faded rows are before the first data row. The 📅 column supplies the date. + @S.ImportWizard_PreviewCaption
- 3. Map columns + @S.ImportWizard_Step3Title
- ColumnSampleRoleTargetUnit + @S.ImportWizard_HeaderColumn@S.ImportWizard_HeaderSample@S.ImportWizard_HeaderRole@S.Common_Target@S.Common_Unit @foreach (var i in Enumerable.Range(0, _colCount)) { - Col @i + @Loc.F(S.ImportWizard_ColumnN, i) @if (!string.IsNullOrWhiteSpace(Header(i))) {
@Header(i) @@ -125,7 +124,7 @@ @foreach (var role in Enum.GetValues()) { - @role + @role.Display() } @@ -133,7 +132,7 @@ @if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) { + Placeholder="@S.ImportWizard_SelectMeter" Clearable="true"> @foreach (var m in _meters) { @m.Name (@m.Unit) @@ -143,7 +142,7 @@ else if (_columns[i].Role == MappingRole.ManualCost) { + Placeholder="@S.ImportWizard_SelectCategory" Clearable="true"> @foreach (var c in _categories) { @c.Name @@ -154,7 +153,7 @@ @if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) { - + } @@ -176,12 +175,12 @@
- 4. Preview & commit + @S.ImportWizard_Step4Title Dry-run preview + OnClick="Preview">@S.ImportWizard_DryRunButton - Commit import + @S.ImportWizard_CommitButton @if (_committing) { @@ -192,24 +191,24 @@ @if (_staged is not null) {
- Readings: @_staged.Readings.Count - Events: @_staged.Events.Count - Manual costs: @_staged.ManualCosts.Count - Skipped rows: @_staged.SkippedRows + @S.Common_ReadingsLabel @_staged.Readings.Count + @S.Common_EventsLabel @_staged.Events.Count + @S.Common_ManualCostsLabel @_staged.ManualCosts.Count + @S.Common_SkippedRowsLabel @_staged.SkippedRows
@if (_staged.TotalRows == 0) { - Nothing staged. Check the first-data-row, date column and column mappings above. + @S.ImportWizard_NothingStaged } @if (_staged.Warnings.Count > 0) { - + @foreach (var warning in _staged.Warnings.Take(100)) { - @warning + @warning.Display() } @@ -326,7 +325,7 @@ { _staged = null; _stagedMapping = null; - _validationErrors = ["The mapping changed after the preview. Run the dry run again, then commit."]; + _validationErrors = [S.ImportWizard_MappingChanged]; return; } @@ -334,12 +333,12 @@ try { var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson); - Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success); + Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success); Nav.NavigateTo("/import"); } catch (Exception ex) { - Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error); + Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error); } finally { @@ -352,7 +351,7 @@ var errors = new List(); 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++) @@ -360,12 +359,12 @@ var c = _columns[i]; if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null) { - errors.Add($"Col {i} ({c.Role}) needs a target meter."); + errors.Add(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display())); } if (c.Role == MappingRole.ManualCost && c.CategoryId is null) { - errors.Add($"Col {i} (ManualCost) needs a target category."); + errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i)); } } @@ -379,9 +378,10 @@ foreach (var group in duplicateTargets) { - var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}"; - var cols = string.Join(", ", group.Select(x => $"Col {x.Index}")); - errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter."); + var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name + ?? Loc.F(S.ImportWizard_MeterFallback, group.Key); + var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index))); + errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName)); } return errors; @@ -431,6 +431,8 @@ private string ColLabel(int col) { var header = Header(col); - return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}"; + return string.IsNullOrWhiteSpace(header) + ? Loc.F(S.ImportWizard_ColumnN, col) + : Loc.F(S.ImportWizard_ColumnWithHeader, col, header); } } diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor index e9ff6e1..33154d4 100644 --- a/src/App/Components/Pages/MeterDetail.razor +++ b/src/App/Components/Pages/MeterDetail.razor @@ -12,13 +12,13 @@ @using MeterVault.Infrastructure.Ingestion @using MudBlazor -MeterVault — Meter +MeterVault — @S.Common_Meter @if (_detail is null) { @if (_notFound) { - Meter #@Id not found. Back to meters + @Loc.F(S.MeterDetail_NotFound, Id) @S.MeterDetail_BackToMeters } else { @@ -43,10 +43,10 @@ else @_detail.Name @_detail.EnergyType - @_detail.Mode + @_detail.Mode.Display() @if (!_detail.IsActive) { - retired + @S.MeterDetail_Retired }
@@ -55,41 +55,41 @@ else - @p.Label this month + @Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display()) @Format.Number(p.MonthToDate, 0) @p.Unit @if (p.MonthIsPartial) { - ≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end + @Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 0), p.Unit) } - vs last month + @S.MeterDetail_VsLastMonth @ChangeText(p.MonthChange) - last month @Format.Number(p.LastMonth, 0) @p.Unit + @Loc.F(S.MeterDetail_LastMonthValue, Format.Number(p.LastMonth, 0), p.Unit) - This year + @S.Common_ThisYear @Format.Number(p.YearToDate, 0) @p.Unit - @ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year + @Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0)) - Cost this year + @S.MeterDetail_CostThisYear @Format.Number(p.YearToDateCost, 2) @p.Currency - ≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year - @(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "") + @Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency) + @(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "") @@ -98,7 +98,7 @@ else @if (p.HasHistory) { - Last 12 months + @S.Common_RangeLast12Months
@foreach (var m in p.Last12Months) { @@ -118,31 +118,30 @@ else @if (_periods is null && _detail.Mode == MeterMode.Virtual) { - Virtual meter — its value is an expression over other meters, evaluated when read, so it has - no stored series of its own. See Trends for its figures. + @S.MeterDetail_VirtualNotice @S.MeterDetail_VirtualNoticeTrends } - +
- Register span + @S.MeterDetail_RegisterSpan @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") - (baseline @Format.Number(_detail.InitialBaseline, 0)) + @Loc.F(S.MeterDetail_BaselineValue, Format.Number(_detail.InitialBaseline, 0))
- Readings + @S.MeterDetail_Readings @_detail.ReadingCount · @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
- Lifetime total + @S.MeterDetail_LifetimeTotal @Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit @@ -152,12 +151,11 @@ else - + @if (_detail.Mode == MeterMode.Virtual) { - A virtual meter is an expression over other meters, so it stores no readings of its own — - enter the reading on the meter the expression refers to. + @S.MeterDetail_VirtualNoReadings } else @@ -165,21 +163,21 @@ else
- Add reading + @S.MeterDetail_AddReading
} @if (_detail.RecentReadings.Count == 0) { - No raw readings. + @S.MeterDetail_NoRawReadings } else { - Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id. + @Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id) - TimeValueQualityFlags + @S.MeterDetail_Time@S.Common_Value@S.MeterDetail_Quality@S.MeterDetail_Flags @foreach (var r in _detail.RecentReadings) { @@ -187,7 +185,7 @@ else @Local(r.Time).ToString("yyyy-MM-dd HH:mm") @Format.Number(r.Value, 2) @_detail.Unit @QualityChip(r.Quality) - @(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString()) + @r.Flags.Display() } @@ -195,23 +193,23 @@ else }
- + @if (_detail.RecentConsumption.Count == 0) { - No normalized consumption yet. + @S.MeterDetail_NoConsumption } else { - Most recent @_detail.RecentConsumption.Count normalized deltas. + @Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count) - TimeAmountKindQuality + @S.MeterDetail_Time@S.Common_Amount@S.MeterDetail_Kind@S.MeterDetail_Quality @foreach (var c in _detail.RecentConsumption) { @Local(c.Time).ToString("yyyy-MM-dd HH:mm") @Format.Number(c.Amount, 2) @_detail.Unit - @c.Kind + @c.Kind.Display() @QualityChip(c.Quality) } @@ -220,21 +218,21 @@ else } - + @if (_detail.Events.Count == 0) { - No events (swaps, deliveries, corrections). + @S.MeterDetail_NoEvents } else { - TimeTypeAmountPrev→NewNotes + @S.MeterDetail_Time@S.Common_Type@S.Common_Amount@S.MeterDetail_PrevNew@S.MeterDetail_Notes @foreach (var e in _detail.Events) { @Local(e.Time).ToString("yyyy-MM-dd") - @e.Type + @e.Type.Display() @(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—") @(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—") @e.Notes @@ -245,25 +243,25 @@ else } - + @if (_detail.Tariffs.Count == 0) { - No applicable tariffs. + @S.MeterDetail_NoTariffs } else { - ScopeComponentValueUnitFromTo + @S.Common_Scope@S.MeterDetail_Component@S.Common_Value@S.Common_Unit@S.MeterDetail_From@S.MeterDetail_To @foreach (var t in _detail.Tariffs) { - @t.Scope @(t.ScopeId is { } id ? $"#{id}" : "") - @t.Component + @t.Scope.Display() @(t.ScopeId is { } id ? $"#{id}" : "") + @t.Component.Display() @Format.Number(t.Value, 4) @t.Unit @t.ValidFrom.ToString("yyyy-MM-dd") - @(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open") + @(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd) } @@ -271,25 +269,25 @@ else } - +
- Add source + @S.MeterDetail_AddSource
@if (_sources.Count == 0) { - No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant. + @S.MeterDetail_NoSources } else { - TypeTargetConnectorEnabledLast seenLast valueStatusActions + @S.Common_Type@S.Common_Target@S.MeterDetail_Connector@S.Common_Enabled@S.Common_LastSeen@S.MeterDetail_LastValue@S.Common_Status@S.Common_Actions @foreach (var s in _sources) { - @s.SourceType + @s.SourceType.Display() @SourceTarget(s) @{ var problem = ConnectorProblem(s); } @@ -305,7 +303,7 @@ else } - @(s.IsEnabled ? "yes" : "no") + @(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No) @(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(s.LastValue is { } v ? Format.Number(v, 2) : "—") @(s.LastStatus ?? "—") @@ -323,13 +321,13 @@ else - Add reading — @_detail.Name + @Loc.F(S.MeterDetail_AddReadingTitle, _detail.Name) @LastReadingCaption() @* 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. *@
- @(_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) @if (ChangeSinceLast is { } change) { - @ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "") + @ChangeSinceText(change)@(WouldBeRejected ? S.MeterDetail_WillBeRejectedSuffix : "") }
@@ -357,66 +355,64 @@ else
- - Now + StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">@S.Common_Now
- Local time in @_tz.Id. + @Loc.F(S.MeterDetail_LocalTimeIn, _tz.Id) @* Everything below here can reflow freely: the dialog's buttons sit outside this scroll area, so nothing the user is aiming at moves. *@ @if (EnteredTimeSkipped) { - That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time. + @Loc.F(S.MeterDetail_SkippedTime, _tz.Id) } @if (WouldBeRejected) { - Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a - register that only counts up, so it will be rejected. If the meter was swapped or reset, - record that on the Events tab first. + @Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit) } @if (ReplacesRecentReading) { - This meter already has a reading at that time — saving replaces its value. + @S.MeterDetail_ReplaceNotice } @if (IsFuture) { - That time is in the future. + @S.MeterDetail_FutureTime } else if (IsBackdated) { - Backdated before the latest reading — consumption from there on is recomputed. + @S.MeterDetail_BackdatedNotice } - Cancel + @S.Common_Cancel - @(_readingSaving ? "Saving…" : "Save reading") + @(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading) - @(_sourceEdit.Id == 0 ? "New source" : "Edit source") + @(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource) - + @foreach (var type in Enum.GetValues()) { - @type + @type.Display() } @if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed) @@ -424,13 +420,13 @@ else if (ConnectorsFor(needed).Count == 0) { - No @needed connector yet — create one - (set it up once; every source then just picks it). + @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) @S.MeterDetail_CreateConnectorLink + @S.MeterDetail_CreateConnectorHint } else { - + @foreach (var e in ConnectorsFor(needed)) { @e.Name @@ -440,35 +436,35 @@ else } @if (_sourceEdit.SourceType == SourceType.HomeAssistant) { - - - + + + - Hourly is plenty for a meter — monthly totals and cost come out identical, with far less raw data. + @S.MeterDetail_PollHint } else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota) { - - - + + + } - + @foreach (var kind in Enum.GetValues()) { - @kind + @kind.Display() }
- - - + + +
- +
- Cancel - Save + @S.Common_Cancel + @S.Common_Save
} @@ -547,11 +543,11 @@ else { if (change is not { } c) { - return "no basis yet"; + return S.MeterDetail_NoBasisYet; } return Math.Abs(c) < 0.005 - ? "about the same" + ? S.MeterDetail_AboutTheSame : $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%"; } @@ -613,8 +609,9 @@ else } return detail is { LastReadingValue: { } value, LastReadingTime: { } time } - ? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}." - : $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit})."; + ? Loc.F(S.MeterDetail_LastReadingCaption, + Format.Number(value, 2), detail.Unit, Local(time).ToString("yyyy-MM-dd HH:mm")) + : Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit); } /// The wall-clock instant the two pickers describe, read in the instance timezone. @@ -678,8 +675,9 @@ else private string ChangeSinceText(double change) => Math.Abs(change) < 1e-9 - ? "no change since last reading" - : $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading"; + ? S.MeterDetail_NoChangeSinceLast + : Loc.F(S.MeterDetail_ChangeSinceLast, + $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit); private async Task SaveReadingAsync() { @@ -701,20 +699,18 @@ else switch (outcome) { case IngestionOutcome.Written: - Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success); + Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), _detail.Unit), Severity.Success); break; case IngestionOutcome.Updated: - Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success); + Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success); break; case IngestionOutcome.RejectedDecrease: // Leave the dialog open: the typed value is still on screen to correct, and the // alternative fix — recording a reset or swap — is a decision, not a retry. - Snackbar.Add( - "Rejected — below the previous reading on a register that only counts up. " - + "Record a counter reset or meter swap first.", Severity.Error); + Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error); return; default: - Snackbar.Add("This meter no longer exists.", Severity.Error); + Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error); return; } @@ -783,19 +779,21 @@ else var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId); if (selected is null) { - Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error); + Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _sourceEdit.SourceType.Display()), Severity.Error); return; } if (selected.Type != needed) { - Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error); + Snackbar.Add( + Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _sourceEdit.SourceType.Display(), needed.Display()), + Severity.Error); return; } if (!selected.IsEnabled) { - Snackbar.Add($"'{selected.Name}' is disabled, so this source would never ingest. Enable it first.", Severity.Error); + Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error); return; } } @@ -847,20 +845,21 @@ else await db.SaveChangesAsync(); _sourceOpen = false; - Snackbar.Add("Source saved.", Severity.Success); + Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success); await LoadSourcesAsync(); } private async Task DeleteSourceAsync(MeterSource source) { - if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?")) + if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle, + Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display()))) { return; } await using var db = await DbFactory.CreateDbContextAsync(); await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync(); - Snackbar.Add("Source deleted.", Severity.Success); + Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success); await LoadSourcesAsync(); } @@ -898,9 +897,9 @@ else var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId); return endpoint switch { - null => "no connector — never ingests", - { IsEnabled: false } => $"'{endpoint.Name}' is disabled", - _ when endpoint.Type != needed => $"'{endpoint.Name}' is {endpoint.Type}, needs {needed}", + null => S.MeterDetail_ProblemNoConnector, + { IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name), + _ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()), _ => null, }; } @@ -948,5 +947,5 @@ else } private static RenderFragment QualityChip(ReadingQuality quality) =>@@quality; + Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality.Display(); } diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor index 0dba6d1..c69ada7 100644 --- a/src/App/Components/Pages/Meters.razor +++ b/src/App/Components/Pages/Meters.razor @@ -6,12 +6,12 @@ @using Microsoft.EntityFrameworkCore @using MudBlazor -MeterVault — Meters +MeterVault — @S.Nav_Meters
- Meters + @S.Common_Meters - Add meter + @S.Meters_AddMeter
@@ -23,29 +23,29 @@ else { - Name - Type - Mode - Unit - Sources - Last seen - Active - Actions + @S.Common_Name + @S.Common_Type + @S.Common_Mode + @S.Common_Unit + @S.Meters_Sources + @S.Common_LastSeen + @S.Meters_Active + @S.Common_Actions - @context.Name - @context.EnergyType?.DisplayName - @context.Mode - @context.Unit - @context.Sources.Count - + @context.Name + @context.EnergyType?.DisplayName + @context.Mode.Display() + @context.Unit + @context.Sources.Count + @{ var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max(); } @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") - @(context.IsActive ? "yes" : "no") - + @(context.IsActive ? S.Meters_Yes : S.Meters_No) + @@ -55,76 +55,73 @@ else @if (_meters.Count == 0) { - No meters yet. Add one, or go to Import to load the reference data. + @S.Meters_EmptyBefore @S.Nav_Import @S.Meters_EmptyAfter } } - @(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}") + @(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name)) - - + + @foreach (var t in _energyTypes) { @t.DisplayName } - + @foreach (var mode in Enum.GetValues()) { - @mode + @mode.Display() } @if (_working.Mode == MeterMode.Virtual) { - Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the - upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2). + @S.Meters_VirtualHelp } else if (_working.Mode == MeterMode.InstantRate) { - Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption. - Store the value as a per-hour 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. + @S.Meters_InstantRateHelpBefore @S.Meters_InstantRateHelpPerHour @S.Meters_InstantRateHelpAfter } - - - - — none — + + + + @S.Meters_RoleNone total_load grid_import grid_export + HelperText="@S.Meters_UpstreamHelp"> @foreach (var m in AvailableUpstream()) { @m.Name } - - + +
- - + +
- + @if (_working.Id != 0 && _working.RecomputeNeeded) { - Mode/baseline changed — consumption will be recomputed on save. + @S.Meters_RecomputeNotice }
- Cancel - Save + @S.Common_Cancel + @S.Common_Save
@@ -228,7 +225,7 @@ else { 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; } @@ -286,7 +283,7 @@ else await SyncUpstreamAsync(db, meterId, _working.Upstream); _editOpen = false; - Snackbar.Add("Saved.", Severity.Success); + Snackbar.Add(S.Common_Saved, Severity.Success); await LoadAsync(); } @@ -314,11 +311,11 @@ else await using var db = await DbFactory.CreateDbContextAsync(); var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id); var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id); - var detail = readings + consumption > 0 - ? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)." - : ""; + var message = readings + consumption > 0 + ? 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; } @@ -330,7 +327,7 @@ else await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); await tx.CommitAsync(); - Snackbar.Add("Deleted.", Severity.Success); + Snackbar.Add(S.Common_Deleted, Severity.Success); await LoadAsync(); } diff --git a/src/App/Components/Pages/NotFound.razor b/src/App/Components/Pages/NotFound.razor index 917ada1..b6ed2c9 100644 --- a/src/App/Components/Pages/NotFound.razor +++ b/src/App/Components/Pages/NotFound.razor @@ -1,5 +1,5 @@ @page "/not-found" @layout MainLayout -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file +

@S.NotFound_Title

+

@S.NotFound_Message

\ No newline at end of file diff --git a/src/App/Components/Pages/Solar.razor b/src/App/Components/Pages/Solar.razor index 76f77e7..ed32320 100644 --- a/src/App/Components/Pages/Solar.razor +++ b/src/App/Components/Pages/Solar.razor @@ -2,15 +2,15 @@ @inject SolarService SolarSvc @using MudBlazor -MeterVault — Solar / PV +MeterVault — @S.Nav_Solar
- Solar / PV - - Last 12 months - Last 24 months - Last 5 years - All time + @S.Nav_Solar + + @S.Common_RangeLast12Months + @S.Common_RangeLast24Months + @S.Common_RangeLast5Years + @S.Common_RangeAllTime
@@ -21,8 +21,8 @@ else if (!_summary.HasGeneration) { - No generation meters found. Add a meter with mode GenerationCounter, or load the reference data from - Import. + @S.Solar_NoGenerationLead @MeterMode.GenerationCounter.Display() @S.Solar_NoGenerationTail + @S.Nav_Import. } else @@ -30,47 +30,47 @@ else - Generation + @S.Solar_Generation @Format.Number(_summary.Generation, 0) kWh - Self-consumption + @S.Solar_SelfConsumption @(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—") @if (_summary.SelfConsumptionRatio is { } ratio) { - @Format.Number(ratio * 100, 0)% of generation + @Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0)) } - Autarky + @S.Solar_Autarky @(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—") @if (_summary.GridImport is { } grid) { - Grid draw @Format.Number(grid, 0) kWh + @Loc.F(S.Solar_GridDraw, Format.Number(grid, 0)) } - Savings (Ersparnis) + @S.Solar_Savings @(_summary.Savings is { } sav ? Format.Euro(sav) : "—") - Generation & self-consumption + @S.Solar_GenerationAndSelfConsumption - Generation by meter + @S.Solar_GenerationByMeter @foreach (var meter in _summary.Meters) @@ -85,8 +85,8 @@ else @if (!_summary.HasLoadContext) { - Tag a meter total_load and one grid_import (in meter metadata) - to unlock self-consumption, autarky and savings. + @S.Solar_TagMetersLead total_load @S.Solar_TagMetersMid grid_import + @S.Solar_TagMetersTail } @@ -124,18 +124,18 @@ else _summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); 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(); var series = new List { - new("Generation", ApexCharts.SeriesType.Bar, generation), + new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation), }; if (_summary.HasLoadContext) { 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(); - series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self)); + series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self)); } _chart = series; diff --git a/src/App/Components/Pages/Trends.razor b/src/App/Components/Pages/Trends.razor index c2304d0..ff8b606 100644 --- a/src/App/Components/Pages/Trends.razor +++ b/src/App/Components/Pages/Trends.razor @@ -1,18 +1,18 @@ @page "/trends" @inject DashboardService Dash -MeterVault — Trends +MeterVault — @S.Nav_Trends -Cost trend +@S.Trends_Title
- - Last 12 months - Last 24 months - Last 48 months + + @S.Common_RangeLast12Months + @S.Common_RangeLast24Months + @S.Trends_RangeLast48Months - Apply + @S.Trends_Apply
@if (_loading) @@ -23,7 +23,7 @@ { - Total over range: @Format.Euro(_points.Sum(p => p.Cost)) + @Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost))) }
diff --git a/src/App/Components/Shared/CategoryDonut.razor b/src/App/Components/Shared/CategoryDonut.razor index 5f1d2b0..26a1ee4 100644 --- a/src/App/Components/Shared/CategoryDonut.razor +++ b/src/App/Components/Shared/CategoryDonut.razor @@ -6,7 +6,7 @@ diff --git a/src/App/Components/Shared/SankeyChart.razor b/src/App/Components/Shared/SankeyChart.razor index 10b86b7..b7e04dd 100644 --- a/src/App/Components/Shared/SankeyChart.razor +++ b/src/App/Components/Shared/SankeyChart.razor @@ -5,7 +5,7 @@ @if (string.IsNullOrEmpty(_svg)) { - No flow to show for this period. + @S.Sankey_NoFlow } else { @@ -102,11 +102,11 @@ else } 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(); sb.Append(CultureInfo.InvariantCulture, - $""); + $""); // Ribbons first (under nodes). foreach (var link in Links) @@ -142,7 +142,7 @@ else var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6; var anchor = rightmost ? "end" : "start"; var fill = Enc(node.ColorHex ?? "#607D8B"); - var name = Enc(node.Label); + var name = Enc(NodeLabel(node)); var val = Enc(Fmt(node.Value)); sb.Append(CultureInfo.InvariantCulture, $"{name}: {val}"); @@ -155,7 +155,14 @@ else return sb.ToString(); } - private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim(); + /// + /// A remainder node carries only its parent meter's name (see ); the + /// "Other (…)" framing is added here, where the reader's language is known. + /// + 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); diff --git a/src/App/Components/Shared/SeriesChart.razor b/src/App/Components/Shared/SeriesChart.razor index c323912..1b1f264 100644 --- a/src/App/Components/Shared/SeriesChart.razor +++ b/src/App/Components/Shared/SeriesChart.razor @@ -16,7 +16,7 @@ } else { - No data in this range. + @S.Common_NoDataInRange } @code { diff --git a/src/App/Components/Shared/TrendChart.razor b/src/App/Components/Shared/TrendChart.razor index 8ebbce1..95bfeb4 100644 --- a/src/App/Components/Shared/TrendChart.razor +++ b/src/App/Components/Shared/TrendChart.razor @@ -7,14 +7,14 @@ } else { - No data in this range. + @S.Common_NoDataInRange } @code { @@ -27,5 +27,6 @@ else 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); } diff --git a/src/App/Components/Shared/UpdateBanner.razor b/src/App/Components/Shared/UpdateBanner.razor index 81cd78c..ecb071b 100644 --- a/src/App/Components/Shared/UpdateBanner.razor +++ b/src/App/Components/Shared/UpdateBanner.razor @@ -1,3 +1,4 @@ +@using System.Net @using MeterVault.Infrastructure.Update @inject UpdateCheckService Updates @inject UpdateRunner Runner @@ -12,12 +13,12 @@ {
- MeterVault @latest is available — this instance runs @running. + @((MarkupString)Loc.F(S.UpdateBanner_ReleaseAvailable, Bold(latest), Bold(running))) @if (Runner.Availability is UpdateAvailability.Allowed) { - Update now + @S.UpdateBanner_UpdateNow } else @@ -29,17 +30,16 @@ } - Update MeterVault + @S.UpdateBanner_DialogTitle - This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes, - during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart. + @S.UpdateBanner_DialogBody - Cancel + @S.Common_Cancel - @(_starting ? "Starting…" : "Update now") + @(_starting ? S.UpdateBanner_Starting : S.UpdateBanner_UpdateNow) @@ -80,7 +80,7 @@ try { 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) { _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, + }; + /// /// How this particular install updates. The LXC has an update command; a container is /// replaced by pulling a new image, and telling those users to run update would send them @@ -99,6 +117,9 @@ /// private static string UpdateCommandHint() => UpdateRunner.IsSupportedHere - ? "run: update" - : "pull the new image and recreate the container"; + ? S.UpdateBanner_HintRunUpdate + : S.UpdateBanner_HintPullImage; + + /// Emphasises a version inside the banner sentence without letting it carry markup. + private static string Bold(object value) => $"{WebUtility.HtmlEncode(value.ToString())}"; } diff --git a/src/App/Components/_Imports.razor b/src/App/Components/_Imports.razor index ff95347..73e3eb8 100644 --- a/src/App/Components/_Imports.razor +++ b/src/App/Components/_Imports.razor @@ -8,6 +8,10 @@ @using Microsoft.JSInterop @using MudBlazor @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.Layout @using MeterVault.App.Components.Shared diff --git a/src/App/Confirm.cs b/src/App/Confirm.cs index 3d5fcd2..79e6a28 100644 --- a/src/App/Confirm.cs +++ b/src/App/Confirm.cs @@ -1,3 +1,4 @@ +using MeterVault.App.Localization; using MudBlazor; namespace MeterVault.App; @@ -7,7 +8,7 @@ public static class Confirm { public static async Task 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); return result == true; } @@ -15,7 +16,7 @@ public static class Confirm /// A generic yes/cancel confirmation with a caller-supplied confirm-button label. public static async Task 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); return result == true; } diff --git a/src/App/Format.cs b/src/App/Format.cs index 5c60083..4ae3528 100644 --- a/src/App/Format.cs +++ b/src/App/Format.cs @@ -2,18 +2,35 @@ using System.Globalization; namespace MeterVault.App; -/// Small display formatters for the UI (locale-aware formatting arrives with i18n in M7). +/// +/// Small display formatters for the UI. +/// +/// +/// Everything formats against , 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 (), because that dialect is a property of the +/// files, not of who is looking at them. +/// public static class Format { - private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE"); - - public static string Euro(double value) => value.ToString("N2", Culture) + " €"; + /// + /// A money amount. The symbol is the euro rather than the culture's own, because the figure is + /// in the instance's configured currency — switching UI language must not restate the amount as + /// dollars. Only the digit grouping follows the reader. + /// + public static string Euro(double value) => value.ToString("N2", CultureInfo.CurrentCulture) + " €"; 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) => - (value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %"; + (value >= 0 ? "+" : "") + value.ToString("N1", CultureInfo.CurrentCulture) + " %"; + + /// A month label for chart axes and period lists ("Mrz 25" / "Mar 25"). + public static string MonthLabel(DateOnly month) => + month.ToString("MMM yy", CultureInfo.CurrentCulture); public static string DirectionIcon(int direction) => direction switch { diff --git a/src/App/Localization/CultureEndpoints.cs b/src/App/Localization/CultureEndpoints.cs new file mode 100644 index 0000000..2bc381f --- /dev/null +++ b/src/App/Localization/CultureEndpoints.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Localization; + +namespace MeterVault.App.Localization; + +/// The endpoint behind the language picker in the app bar. +public static class CultureEndpoints +{ + /// + /// Persists a UI language and returns the user to where they were. + /// + /// + /// A redirect rather than an interactive state change on purpose: a Blazor Server circuit + /// captures from the request + /// that opened it, so switching language has to re-establish the circuit. The picker therefore + /// navigates here with forceLoad, this writes the culture cookie the localization + /// middleware reads, and the reload comes back in the new language. + /// + 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; + } + + /// + /// The framework's own local-URL rule: rooted, and not the "//host" or "/\host" forms browsers + /// resolve as protocol-relative absolute URLs. + /// + private static bool IsLocalPath(string? url) => + !string.IsNullOrEmpty(url) + && url[0] == '/' + && (url.Length == 1 || (url[1] != '/' && url[1] != '\\')); +} diff --git a/src/App/Localization/DisplayNames.cs b/src/App/Localization/DisplayNames.cs new file mode 100644 index 0000000..e894853 --- /dev/null +++ b/src/App/Localization/DisplayNames.cs @@ -0,0 +1,188 @@ +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Import; +using MeterVault.Infrastructure.Update; + +namespace MeterVault.App.Localization; + +/// +/// Human wording for the domain enums the UI puts on screen. +/// +/// +/// +/// 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 spoken, which keeps the +/// domain layer free of presentation and gives every page the same word for the same concept — +/// a TariffComponent.UnitPrice is "Arbeitspreis" in the table, the dropdown and the +/// confirm dialog alike. +/// +/// +/// Every arm resolves a Enum_<Type>_<Value> resource. The fallback arms return the +/// identifier rather than throwing, so adding an enum value can never crash a dashboard — and +/// EnumDisplayNameTests fails the build if one is ever left without a translation, which is +/// what stops that safety net from quietly becoming the shipping behaviour. +/// +/// +public static class DisplayNames +{ + /// The enums this class is responsible for; the resource-coverage test walks this list. + public static IReadOnlyList 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(), + }; + + /// + /// A bitmask rendered as the set flags, comma-joined. 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. + /// + public static string Display(this ReadingFlags value) + { + if (value == ReadingFlags.None) + { + return string.Empty; + } + + var names = new List(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(), + }; +} diff --git a/src/App/Localization/ImportWarningText.cs b/src/App/Localization/ImportWarningText.cs new file mode 100644 index 0000000..84b07ba --- /dev/null +++ b/src/App/Localization/ImportWarningText.cs @@ -0,0 +1,36 @@ +using MeterVault.Infrastructure.Import; + +namespace MeterVault.App.Localization; + +/// +/// Says a staging warning in the reader's language. +/// +/// +/// The importer emits values carrying an English sentence plus the +/// arguments that filled it (see ImportWarnings). 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 2.940,19 everywhere else must not read 2940.19 +/// only inside a warning. +/// +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, + }; + } +} diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs new file mode 100644 index 0000000..ffb2e5f --- /dev/null +++ b/src/App/Localization/Loc.cs @@ -0,0 +1,64 @@ +using System.Globalization; + +namespace MeterVault.App.Localization; + +/// +/// UI-language plumbing around , the strongly-typed accessor MSBuild generates +/// from Strings.resx (see the EmbeddedResource block in the project file). +/// +/// +/// +/// The neutral resource is English and every other language ships as a satellite assembly, so an +/// Accept-Language we don't translate degrades to English rather than to raw resource keys. +/// Strings are referenced as compiled properties (S.Common_Save), not string lookups, so a +/// key that no longer exists is a build error instead of a mystery label at runtime. +/// +/// +/// Number and date formatting follows and the UI +/// language follows ; the request-localization middleware +/// sets both from the same choice, so the two never disagree. +/// +/// +public static class Loc +{ + /// Cultures the UI ships translations for. The first entry is the neutral fallback. + public static IReadOnlyList SupportedCultures { get; } = ["en", "de"]; + + /// Formats a resource carrying {0}-style placeholders in the request's culture. + public static string F(string format, params object?[] args) => + string.Format(CultureInfo.CurrentCulture, format, args); + + /// + /// Maps a requested language onto one we actually ship, matching on the two-letter tag so + /// de-AT and de-CH get German instead of falling through to English. + /// + /// true when the request named a language we translate. + 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; + } + + /// The display name of a supported culture, written in that language ("Deutsch", "English"). + public static string DisplayName(string culture) => + CultureInfo.GetCultureInfo(culture).NativeName; +} diff --git a/src/App/Localization/Strings.de.resx b/src/App/Localization/Strings.de.resx new file mode 100644 index 0000000..ce99752 --- /dev/null +++ b/src/App/Localization/Strings.de.resx @@ -0,0 +1,1635 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Hinzufügen + + + Kategorie hinzufügen + + + Energieart hinzufügen + + + Zähler hinzufügen + + + Schließen + + + Farbe als Hex-Wert (optional, z. B. #ff9800) + + + '{0}' und die {1} Zuordnung(en) löschen? Manuelle Kosten dieser Kategorie bleiben erhalten, verlieren aber die Zuordnung. + + + Kategorie löschen + + + {0} bearbeiten + + + Zähler: {0} + + + {0} Zähler, {1} Energieart(en) + + + Energieart: {0} + + + Zuordnungen + + + Neue Kategorie + + + Noch keine Mitglieder — Zähler oder Energieart hinzufügen. + + + Kategorie zuerst speichern, um Mitglieder hinzuzufügen. + + + Gespeichert. Mitglieder unten hinzufügen. + + + Sortierung + + + Sortierreihenfolge + + + Kosten + + + Aktionen + + + Menge + + + Abbrechen + + + Kosten (Zeitraum) + + + Währung + + + Datum + + + Löschen + + + Gelöscht. + + + Aktiviert + + + Energieart + + + Ereignisse: + + + Zuletzt gesehen + + + Manuelle Kosten: + + + Zähler + + + Zähler + + + Modus + + + Name + + + Name ist erforderlich. + + + Keine Daten in diesem Zeitraum. + + + Jetzt + + + Vorschau + + + Zeitraum + + + Gesamter Zeitraum + + + Letzte 12 Monate + + + Letzte 24 Monate + + + Letzte 5 Jahre + + + Zählerstände: + + + Speichern + + + Gespeichert. + + + Geltungsbereich + + + Übersprungene Zeilen: + + + Status + + + Ziel + + + Dieses Jahr + + + Typ + + + Einheit + + + Wert + + + Konnektor hinzufügen + + + Basis-URL (z. B. http://homeassistant.local:8123) + + + „{0}“ löschen? + + + {0} Quelle(n) nutzen ihn und erfassen keine Daten mehr, bis sie einem anderen Konnektor zugeordnet sind. + + + Konnektor löschen + + + {0} bearbeiten + + + Noch keine Konnektoren. MQTT-Broker oder Home-Assistant-Verbindung hinzufügen. + + + Wird vor dem Speichern verschlüsselt; Datenbank-Dumps und JSON-Exporte enthalten nichts Verwertbares. + + + Zugangsdaten hier eingeben + + + Token hier eingeben + + + Zusätzliche Topics (kommagetrennt, optional) + + + Host + + + Letzter Status + + + Neuer Konnektor + + + nein + + + Name der Passwort-Umgebungsvariablen (optional) + + + Passwort (optional) + + + Passwort (gespeichert – zum Ersetzen neu eingeben) + + + Port + + + Namen einer Umgebungsvariablen + + + Secrets werden hier niemals gespeichert. Zugangsdaten/Tokens werden über den + + + (oder eines Docker-Secrets) referenziert und zur Laufzeit aufgelöst. + + + Die Basis-URL weicht von der gespeicherten ab. Für einen Test gegen einen anderen Host das Token erneut eingeben – ein gespeichertes Token wird nur an den Host gesendet, für den es gespeichert wurde. + + + Die Basis-URL ist erforderlich. + + + Verbunden — Home-Assistant-API erreichbar, Token akzeptiert. + + + Verbunden — {0} = {1}. + + + Verbindung testen + + + Test-Entity-ID (optional, z. B. sensor.house_power) + + + Die Umgebungsvariable „{0}“ ist auf dem Server nicht gesetzt. Die Variable dort setzen und die App neu starten – oder „Token hier eingeben“ aktivieren, um das Token direkt zu speichern. + + + Verbindung fehlgeschlagen: {0} + + + Home Assistant antwortete mit {0}. + + + Verbunden, aber „{0}“ hat keinen numerischen Zustand (nicht verfügbar, unbekannt oder keine Zahl). + + + Zuerst ein Token eingeben. + + + Die Umgebungsvariable mit dem Token benennen oder „Token hier eingeben“ aktivieren. + + + Kein Token zum Testen vorhanden. + + + TLS + + + Langlebiges Zugriffstoken + + + Name der Token-Umgebungsvariablen (z. B. HA_TOKEN) + + + Name der Variablen + + + Der + + + , nicht das Token. Auf dem Server setzen und die App neu starten. + + + Token eingeben oder „Token hier eingeben“ deaktivieren und eine Umgebungsvariable benennen. + + + Langlebiges Zugriffstoken (gespeichert – zum Ersetzen neu eingeben) + + + Name der Benutzernamen-Umgebungsvariablen (optional) + + + Benutzername (optional) + + + Ein: Zustandsänderungen abonnieren und in Echtzeit erfassen. Aus: REST-Abfrage im Intervall der jeweiligen Quelle. + + + Echtzeit-Push über WebSocket + + + ja + + + Stand {0} + + + Brennerstunden + + + Verbrauch je Monat + + + Lieferungen ({0}) + + + Verbrauchsrate + + + {0} % von {1} {2} + + + Voraussichtlich leer + + + Keine Lieferungen erfasst. + + + Keine Zähler für Vorräte gefunden. Legen Sie einen Zähler mit dem Modus + + + und einem Tank an oder laden Sie die Referenzdaten über + + + Vorräte + + + ({0} {1}/Tag) + + + Füllstand + + + Verbrauch ({0}) + + + Verbrauch (Zeitraum) + + + Kategorie + + + Dieses Jahr + + + Vorjahr + + + Letzter Monat mit Daten + + + Noch keine Kostendaten – Tabelle importieren oder Tarife anlegen. + + + Dieser Monat + + + Was mehr / weniger kostet (Jahr vs. Vorjahr) + + + Größte Kostenanteile (dieses Jahr) + + + Energieart hinzufügen + + + Basiseinheit + + + Basiseinheit (kWh, m3, L, h) + + + Farbe als Hex-Wert (optional, z. B. #4caf50) + + + Standardmodus + + + Löschen nicht möglich: „{0}“ wird noch von {1} Zähler(n) verwendet. + + + „{0}“ wirklich löschen? Das kann nicht rückgängig gemacht werden. + + + Energieart löschen + + + Anzeigename + + + {0} bearbeiten + + + Icon (optional) + + + Schlüssel + + + Schlüssel „{0}“ wird bereits verwendet. + + + Schlüssel (stabiler technischer Schlüssel, z. B. electricity) + + + Neue Energieart + + + Schlüssel, Anzeigename und Basiseinheit sind erforderlich. + + + Verbrauch + + + Nachgelagerte Zähler + + + Energie + + + Energiefluss + + + Wohin der Fluss der obersten Ebene geht. Pfeilstärke ∝ Menge; „Sonstige“ ist der nicht erfasste Rest. + + + Fluss: {0} + + + Noch keine Zählerkette konfiguriert. Unter + + + → einen Unterzähler bearbeiten und dessen + + + festlegen, um zu zeigen, wie sich der Fluss des Hauptzählers aufteilt (z. B. Haupt → Auto, Pool, Sonstiges). + + + vorgelagerte Zähler + + + Für diese Energieart gibt es noch keine Zähler. Legen Sie welche an unter + + + oder laden Sie Referenzdaten über + + + Gesamtdurchsatz + + + Verbrauch + + + Erzeugung + + + Home Assistant + + + MQTT-Broker + + + Lieferung + + + Ignorieren + + + Manuelle Kosten + + + Zählerstand + + + Füllstand + + + Korrektur + + + Zählerreset + + + Lieferung + + + Zählerwechsel + + + Notiz + + + Füllstand + + + Tankbestand + + + Zählerstand (kumulativ) + + + Direkte Differenz + + + Erzeugungszähler + + + Momentanwert + + + Betriebsstundenzähler + + + Virtuell + + + Anomalie + + + Zählerreset + + + Zählerwechsel + + + Geschätzt + + + Importiert + + + Interpoliert + + + Manuell + + + Gemessen + + + Home Assistant + + + Import + + + Manuell + + + MQTT + + + Tasmota + + + Virtuell + + + Differenz + + + Füllstand + + + Momentanwert + + + Zählerstand + + + Betriebsstunden + + + Empirisch + + + Fest + + + Grundpreis + + + Bonus + + + Rabatt + + + Einspeisevergütung + + + Steuer + + + Arbeitspreis + + + Energieart + + + Global + + + Zähler + + + Zulässig + + + nicht aktiviert (MeterVault__AllowInAppUpdate) + + + auf dieser Installation nicht unterstützt + + + Für lokales Debugging die Umgebung {0} aktivieren: dazu die Umgebungsvariable {1} auf {0} setzen und die App neu starten. + + + Entwicklungsmodus + + + Ein Wechsel in die Umgebung {0} zeigt ausführlichere Informationen zu dem aufgetretenen Fehler. + + + Die Umgebung Development sollte in produktiven Installationen nicht aktiviert sein. + + + Sie kann vertrauliche Informationen aus Ausnahmen für Endnutzer sichtbar machen. + + + Fehler. + + + Bei der Verarbeitung der Anfrage ist ein Fehler aufgetreten. + + + Fehler + + + Anfrage-ID: + + + Sonstige ({0}) + + + Zeile {0}: Zählerstand von Zähler {1} ist von {2} auf {3} gefallen; ein Zählerwechsel wurde angelegt (bitte prüfen). + + + Zeile {0}: Zählerstand von Zähler {1} ist von {2} auf {3} gefallen; Verbrauch über den Wechsel auf {4} gesetzt. + + + Zeile {0}: übersprungen ({1}). + + + Zeile {0}: Einheit „{1}“ erwartet, „{2}“ gefunden. + + + Zeile {0}: Datum nicht lesbar, übersprungen. + + + Zurück zum Import + + + CSV wählen + + + Spalte {0} + + + Spalte {0}: {1} + + + Import übernehmen + + + Übernahme fehlgeschlagen: {0} + + + Import #{0} übernommen: {1} Zeilen. Verbrauch neu berechnet. + + + Automatisch erkennen + + + Datumsspalte + + + Tag (31.12.2024) + + + Datumsformat + + + Monatsname (Januar 2024) + + + Zählerwechsel erkennen + + + Probelauf + + + {0} Zeilen, {1} Spalten + + + Erste Datenzeile + + + Spalte + + + Rolle + + + Kopfzeile + + + Beispiel + + + Beliebige CSV hochladen, ihre Spalten den Zählern und Kategorien zuordnen, die Vorschau prüfen und als Import übernehmen, der sich jederzeit zurücknehmen lässt. Werte dürfen im deutschen Format vorliegen (Dezimalkomma, Einheitensuffixe, + + + oder + + + als Datum) — derselbe Parser wie bei den Referenzblättern. + + + Die Zuordnung hat sich nach der Vorschau geändert. Erneut einen Probelauf ausführen und dann übernehmen. + + + Zähler {0} + + + Nichts zu importieren. Bitte oben erste Datenzeile, Datumsspalte und Spaltenzuordnung prüfen. + + + Abgeblendete Zeilen liegen vor der ersten Datenzeile. Die Spalte mit 📅 liefert das Datum. + + + Kategorie wählen + + + Zähler wählen + + + Nullzeilen überspringen + + + 1. Einleseoptionen + + + 2. Spaltenvorschau + + + 3. Spalten zuordnen + + + 4. Vorschau & Übernahme + + + Import-Assistent + + + z. B. kWh + + + {0} sind alle „{1}“ zugeordnet. Jede Spalte mit der Rolle „Zählerstand“ braucht einen eigenen Zähler. + + + Spalte {0} („Manuelle Kosten“) braucht eine Zielkategorie. + + + Spalte {0} („{1}“) braucht einen Zielzähler. + + + Mindestens eine Spalte muss einer anderen Rolle als „Ignorieren“ zugeordnet sein. + + + Import #{0} zurückgenommen. + + + Importiert am + + + Zeilen + + + Quelle + + + Probelauf mit Referenzblatt + + + Import fehlgeschlagen: {0} + + + Referenzdaten laden + + + Zuordnungsassistent + + + Noch keine Importe. + + + Kosten + + + Strom + + + Heizöl + + + Wasser + + + Letzte Importe + + + Referenzdaten geladen. + + + Referenzdatensatz + + + Die mitgelieferten Energiebilanz-Tabellen (Strom, Wasser, Heizöl, Kosten) als Startdatensatz mit Zählern, Tarifen und Kategorien laden. + + + Geladen + + + Referenzprofil + + + Zurücknehmen + + + Alle {0} Zeilen aus Import #{1} ({2}) löschen und die betroffenen Zähler neu berechnen? + + + Import zurücknehmen? + + + Zurücknehmen fehlgeschlagen: {0} + + + aktiv + + + zurückgenommen + + + ohne Namen + + + {0} Warnungen + + + Eigene CSV + + + Die Spalten einer beliebigen Tabelle den eigenen Zählern und Kategorien zuordnen, in der Vorschau prüfen und als Import übernehmen, der sich jederzeit zurücknehmen lässt. Oder unten mit einem der mitgelieferten Referenzprofile einen Probelauf machen. + + + Dunkler Modus + + + Sprache + + + Heller Modus + + + Neu laden + + + Navigation umschalten + + + Ein unbehandelter Fehler ist aufgetreten. + + + etwa gleich + + + Zählerstand erfassen + + + Zählerstand erfassen — {0} + + + Quelle hinzufügen + + + Attribut (optional; leer = state) + + + Zurück zur Zählerliste + + + Der Zeitpunkt liegt vor dem letzten Zählerstand — der Verbrauch wird ab dort neu berechnet. + + + (Anfangs-Zählerstand {0}) + + + {0} {1} seit dem letzten Stand + + + Komponente + + + Konnektor + + + „{0}“ ist deaktiviert; diese Quelle würde also nie Daten erfassen. Zuerst aktivieren. + + + „{0}“ ist ein {1}-Konnektor; eine {2}-Quelle benötigt {3}. + + + Kosten dieses Jahr + + + (einmal einrichten; jede Quelle wählt ihn danach nur noch aus). + + + einen anlegen + + + Unter dem letzten Zählerstand ({0} {1}) bei einem Zählwerk, das nur vorwärts zählt — der Wert wird abgelehnt. Nach einem Zählerwechsel oder Zählerreset dieses Ereignis zuerst im Tab „Ereignisse“ erfassen. + + + Diese {0}-Quelle löschen? + + + Quelle löschen + + + Quelle bearbeiten + + + Wert eingeben + + + Entity-ID (z. B. sensor.house_power) + + + Flags + + + Von + + + Dieser Zeitpunkt liegt in der Zukunft. + + + Art + + + {0} diesen Monat + + + Vormonat {0} {1} + + + Letzter Zählerstand {0} {1} am {2}. + + + Letzter Wert + + + · {0} im Vorjahr + + + Gesamt seit Beginn + + + Ortszeit in {0}. + + + Diesen Zähler gibt es nicht mehr. + + + Neue Quelle + + + nein + + + noch kein Vergleich + + + keine Änderung seit dem letzten Stand + + + Noch kein {0}-Konnektor — + + + Noch kein normalisierter Verbrauch. + + + Keine Ereignisse (Zählerwechsel, Lieferungen, Korrekturen). + + + Noch keine Rohdaten. + + + Noch keine Zählerstände — vorbelegt mit dem Anfangs-Zählerstand ({0} {1}). + + + Diesem Zähler ist keine Quelle zugeordnet. Eine Quelle hinzufügen, um Daten von MQTT/Tasmota oder Home Assistant zu erfassen. + + + Keine passenden Tarife. + + + Zähler #{0} nicht gefunden. + + + Notizen + + + Offset + + + Für diese {1}-Quelle einen {0}-Konnektor auswählen. + + + Stündlich reicht für einen Zähler völlig — Monatssummen und Kosten sind identisch, bei deutlich weniger Rohdaten. + + + Abfrageintervall (Minuten) + + + Alt→Neu + + + Priorität + + + „{0}“ ist deaktiviert + + + kein Konnektor — erfasst nie Daten + + + „{0}“ ist {1}, benötigt {2} + + + ≈ {0} {1} bis Monatsende + + + ≈ {0} {1} im Gesamtjahr + + + Qualität + + + Zählerstand ({0}) + + + Abgelehnt — unter dem vorherigen Zählerstand bei einem Zählwerk, das nur vorwärts zählt. Zuerst einen Zählerreset oder Zählerwechsel erfassen. + + + Zählerstand zu diesem Zeitpunkt ersetzt durch {0} {1}. + + + Zählerstand gespeichert: {0} {1}. + + + Zählerstände + + + Die letzten {0} normalisierten Differenzen. + + + Die letzten {0} (Rohdaten, unveränderlich und revisionssicher). Zeiten in {1}. + + + Details zum Zählwerk + + + Zählwerk (von → bis) + + + Für diesen Zeitpunkt gibt es bereits einen Zählerstand — beim Speichern wird sein Wert ersetzt. + + + stillgelegt + + + Zählerstand speichern + + + Wird gespeichert… + + + Skalierung + + + Diese Uhrzeit gab es in {0} nicht — die Uhren wurden vorgestellt. Bitte eine andere Zeit wählen. + + + Quelle gelöscht. + + + Quelle gespeichert. + + + Quellentyp + + + Verbrauch ({0}) + + + Ereignisse ({0}) + + + Zählerstände ({0}) + + + Quellen ({0}) + + + Tarife ({0}) + + + offen + + + Zeit + + + Uhrzeit + + + Zeitpfad (optional, z. B. Time) + + + Bis + + + MQTT-Topic (z. B. tele/plug1/SENSOR) + + + Wertart + + + Wertpfad (z. B. ENERGY.Total; leer = einfacher Zahlenwert) + + + Ein virtueller Zähler berechnet sich per Formel aus anderen Zählern und speichert keine eigenen Zählerstände — den Zählerstand an dem Zähler eintragen, auf den sich die Formel bezieht. + + + Virtueller Zähler — sein Wert wird beim Abruf per Formel aus anderen Zählern berechnet; er hat also keine eigene gespeicherte Zeitreihe. + + + Die Werte stehen unter Trends. + + + ggü. Vormonat + + + {0} ggü. {1} im Vorjahr + + + — wird abgelehnt + + + ja + + + Aktiv + + + Zähler hinzufügen + + + „{0}“ wirklich löschen? Das kann nicht rückgängig gemacht werden. + + + „{0}“ wirklich löschen? Dabei werden auch {1} Zählerstände und {2} Verbrauchswerte gelöscht. Das kann nicht rückgängig gemacht werden. + + + Zähler löschen + + + {0} bearbeiten + + + die Referenzdaten. + + + Noch keine Zähler. Legen Sie einen an oder laden Sie über + + + Anfangs-Zählerstand + + + in der Einheit dieses Zählers (z. B. kW für kWh, L/h für L): eine Quelle, die W oder L/min meldet, braucht dafür einen Skalierungsfaktor. + + + Leistungs-/Durchflusssensor — Messwerte sind ein Momentanwert, der über die Zeit zum Verbrauch integriert wird. Hinterlegen Sie den Wert als + + + Stundenrate + + + Standort (optional) + + + Hersteller (optional) + + + Messmodus + + + Modell (optional) + + + Neuer Zähler + + + nein + + + PV-Rolle (optional) + + + Modus/Anfangs-Zählerstand geändert — der Verbrauch wird beim Speichern neu berechnet. + + + Name, Energieart und Einheit sind erforderlich. + + + — keine — + + + Seriennummer (optional) + + + Quellen + + + Dieser Zähler misst einen Teilbereich des Flusses der gewählten Zähler. + + + Unterzähler von (vorgelagerte Zähler) + + + Virtueller „Summen“-Zähler — er hat keine eigenen Zählerstände. In der Flussansicht entspricht er der Summe der unten gewählten vorgelagerten Zähler (z. B. Summe Solar = Solar 1 + Solar 2). + + + ja + + + Verwaltung + + + Konnektoren + + + Öl / Vorräte + + + Kostenkategorien + + + Energiearten + + + Import + + + Zähler + + + Übersicht + + + Einstellungen + + + Solar / PV + + + Tarife + + + Trends + + + Der gesuchte Inhalt existiert leider nicht. + + + Nicht gefunden + + + Die Sitzung wurde vom Server pausiert. + + + Verbindung konnte nicht wiederhergestellt werden. + + + Verbindung zum Server wird wiederhergestellt... + + + Fortsetzen + + + Die Sitzung konnte nicht fortgesetzt werden. + + + Erneut versuchen + + + Verbindung fehlgeschlagen... nächster Versuch in + + + Sekunden. + + + Bitte erneut versuchen oder die Seite neu laden. + + + Flussdiagramm + + + Kein Fluss in diesem Zeitraum. + + + Zugriff & Datenerfassung + + + geschlossen (401) + + + API-Dokumentation unter + + + {0} Schlüssel konfiguriert + + + Die Schlüssel selbst werden hier nie angezeigt. + + + API-Schlüssel setzt man über + + + offen (anonym) + + + tatsächlich wirksamen + + + Dies sind die + + + Einstellungen dieser laufenden Instanz. Sie werden über Umgebungsvariablen + + + oder Docker/Compose gesetzt und nicht in der Datenbank gespeichert — so bleibt die Konfiguration reproduzierbar und Zugangsdaten landen nie in der DB. Zum Ändern die Compose-/Env-Konfiguration anpassen und neu starten. + + + Umgebungsvariablen: + + + Live-Erfassungsdienste + + + Gebietsschema + + + Gebietsschema & Zeit + + + aus + + + ein + + + Aufbewahrung der Rohdaten + + + {0} Tage + + + Reverse-Proxy vertrauen + + + Referenzdaten beim Start anlegen + + + Zeitzone + + + Autarkie + + + Erzeugung + + + Erzeugung & Eigenverbrauch + + + Erzeugung je Zähler + + + Netzbezug {0} kWh + + + Keine Erzeugungszähler gefunden. Legen Sie einen Zähler mit dem Modus + + + an oder laden Sie die Referenzdaten über + + + Ersparnis + + + Eigenverbrauch + + + {0} % der Erzeugung + + + Markieren Sie einen Zähler als + + + und einen als + + + (in den Zähler-Metadaten), um Eigenverbrauch, Autarkie und Ersparnis freizuschalten. + + + Tarif hinzufügen + + + Komponente + + + Diesen {0}-Tarif löschen ({1} {2})? + + + Tarif löschen + + + Tarif bearbeiten + + + Noch keine Tarife vorhanden. Neu anlegen oder Referenzdaten laden unter + + + Neuer Tarif + + + Notizen (optional) + + + offen + + + Global + + + Zähler: {0} + + + Bitte auswählen, für welche Energieart oder welchen Zähler der Tarif gilt. + + + Energieart: {0} + + + Einheit und „Gültig ab“ sind erforderlich. + + + Einheit (z. B. EUR/kWh, EUR/m3, EUR/month) + + + Gültig ab + + + Gültig bis + + + Gültig bis (leer = unbefristet) + + + Monatliche Kosten + + + Anwenden + + + Letzte 48 Monate + + + Kostenverlauf + + + Gesamt im Zeitraum: {0} + + + Dabei wird der aktuelle Quellcode geladen, neu gebaut und der Dienst neu gestartet. Das dauert einige Minuten, in denen MeterVault nicht erreichbar ist. Zählerstände sind nicht betroffen — die Erfassung läuft nach dem Neustart weiter. + + + MeterVault aktualisieren + + + das neue Image ziehen und den Container neu erstellen + + + ausführen: update + + + MeterVault {0} ist verfügbar — diese Instanz läuft mit {1}. + + + Wird gestartet… + + + Jetzt aktualisieren + + + Das Update konnte nicht gestartet werden. + + + Das Update konnte nicht gestartet werden: {0} + + + systemd-run konnte nicht gestartet werden. + + + Das Update kann nicht gestartet werden: {0}. + + + Update gestartet. Der Dienst startet neu, sobald der Build fertig ist — das dauert meist einige Minuten. + + diff --git a/src/App/Localization/Strings.resx b/src/App/Localization/Strings.resx new file mode 100644 index 0000000..dcfb651 --- /dev/null +++ b/src/App/Localization/Strings.resx @@ -0,0 +1,1635 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Add + + + Add category + + + Add energy type + + + Add meter + + + Close + + + Color hex (optional, e.g. #ff9800) + + + Delete '{0}' and its {1} membership(s)? Manual costs in this category are kept but unlinked. + + + Delete category + + + Edit {0} + + + Meter: {0} + + + {0} meter(s), {1} type(s) + + + Type: {0} + + + Members + + + New category + + + No members yet — add a meter or an energy type. + + + Save the category first to add members. + + + Saved. Add members below. + + + Sort + + + Sort order + + + Cost + + + Actions + + + Amount + + + Cancel + + + Cost (range) + + + Currency + + + Date + + + Delete + + + Deleted. + + + Enabled + + + Energy type + + + Events: + + + Last seen + + + Manual costs: + + + Meter + + + Meters + + + Mode + + + Name + + + Name is required. + + + No data in this range. + + + Now + + + Preview + + + Range + + + All time + + + Last 12 months + + + Last 24 months + + + Last 5 years + + + Readings: + + + Save + + + Saved. + + + Scope + + + Skipped rows: + + + Status + + + Target + + + This year + + + Type + + + Unit + + + Value + + + Add connector + + + Base URL (e.g. http://homeassistant.local:8123) + + + Delete '{0}'? + + + {0} source(s) use it and will stop ingesting until reassigned to another connector. + + + Delete connector + + + Edit {0} + + + No connectors yet. Add an MQTT broker or a Home Assistant connection. + + + Encrypted before it is stored; database dumps and JSON exports carry nothing usable. + + + Enter credentials here + + + Enter the token here + + + Extra topics (comma-separated, optional) + + + Host + + + Last status + + + New connector + + + no + + + Password env-var name (optional) + + + Password (optional) + + + Password (stored — type to replace) + + + Port + + + name of an environment variable + + + Secrets are never stored here. Credentials/tokens are referenced by the + + + (or Docker secret) resolved at runtime. + + + 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. + + + Base URL is required. + + + Connected — Home Assistant API reachable and token accepted. + + + Connected — {0} = {1}. + + + Test connection + + + Test entity id (optional, e.g. sensor.house_power) + + + Environment variable '{0}' is not set on the server. Set it and restart the app, or switch on "Enter the token here" to store the token directly. + + + Connection failed: {0} + + + Home Assistant answered {0}. + + + Connected, but "{0}" has no numeric state (unavailable, unknown or not a number). + + + Enter a token first. + + + Name the environment variable holding the token, or switch on "Enter the token here". + + + No token available to test. + + + TLS + + + Long-lived access token + + + Token env-var name (e.g. HA_TOKEN) + + + name + + + The variable's + + + , not the token. Set it on the server and restart the app. + + + Enter the token, or switch off "Enter the token here" and name an env var. + + + Long-lived access token (stored — type to replace) + + + Username env-var name (optional) + + + Username (optional) + + + On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval. + + + Real-time WebSocket push + + + yes + + + as of {0} + + + Burner runtime + + + Consumption by month + + + Deliveries ({0}) + + + Effective rate + + + {0}% of {1} {2} + + + Forecast to empty + + + No deliveries recorded. + + + No consumable meters found. Add a meter with mode + + + and a tank, or load the reference data from + + + Consumables + + + ({0} {1}/day) + + + Tank level + + + {0} used + + + Used (range) + + + Category + + + Now + + + Prev + + + Latest month with data + + + No cost data yet — import a sheet or add tariffs. + + + This month + + + What cost more / less (year vs last year) + + + What costs most (this year) + + + Add energy type + + + Base unit + + + Base unit (kWh, m3, L, h) + + + Color hex (optional, e.g. #4caf50) + + + Default mode + + + Cannot delete '{0}': {1} meter(s) still use it. + + + Delete '{0}'? This cannot be undone. + + + Delete energy type + + + Display name + + + Edit {0} + + + Icon (optional) + + + Key + + + Key '{0}' is already in use. + + + Key (stable machine key, e.g. electricity) + + + New energy type + + + Key, display name and base unit are required. + + + Consumption + + + Upstream of + + + Energy + + + Flow + + + Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder. + + + {0} flow + + + No meter chain configured yet. In + + + → edit a sub-meter and set its + + + to show where the main meter's flow divides (e.g. main → car, pool, other). + + + upstream meter(s) + + + No meters for this energy type yet. Add meters in + + + , or load the reference data from + + + Top-level throughput + + + Consumption + + + Generation + + + Home Assistant + + + MQTT broker + + + Delivery + + + Ignore + + + Manual cost + + + Reading + + + Tank level + + + Correction + + + Counter reset + + + Delivery + + + Meter swap + + + Note + + + Tank level + + + Consumable balance + + + Cumulative counter + + + Direct delta + + + Generation counter + + + Instant rate + + + Runtime counter + + + Virtual + + + Anomaly + + + Counter reset + + + Meter swap + + + Estimated + + + Imported + + + Interpolated + + + Manual + + + Measured + + + Home Assistant + + + Import + + + Manual + + + MQTT + + + Tasmota + + + Virtual + + + Delta + + + Level + + + Rate + + + Register + + + Runtime + + + Empirical + + + Fixed + + + Base price + + + Bonus + + + Discount + + + Feed-in + + + Tax + + + Unit price + + + Energy type + + + Global + + + Meter + + + Allowed + + + not enabled (MeterVault__AllowInAppUpdate) + + + not supported on this install + + + For local debugging, enable the {0} environment by setting the {1} environment variable to {0} and restarting the app. + + + Development Mode + + + Swapping to {0} environment will display more detailed information about the error that occurred. + + + The Development environment shouldn't be enabled for deployed applications. + + + It can result in displaying sensitive information from exceptions to end users. + + + Error. + + + An error occurred while processing your request. + + + Error + + + Request ID: + + + Other ({0}) + + + Row {0}: register for meter {1} fell {2}→{3}; a swap event was created (please check). + + + Row {0}: register for meter {1} fell {2}→{3}; consumption across the swap set to {4}. + + + Row {0}: skipped ({1}). + + + Row {0}: expected unit "{1}" but found "{2}". + + + Row {0}: unparseable date, skipped. + + + Back to Import + + + Choose CSV + + + Col {0} + + + Col {0}: {1} + + + Commit import + + + Commit failed: {0} + + + Imported batch #{0}: {1} rows staged. Consumption recomputed. + + + Auto-detect + + + Date column + + + Day (31.12.2024) + + + Date format + + + Month name (Januar 2024) + + + Detect swaps + + + Dry-run preview + + + {0} rows, {1} columns + + + First data row + + + Column + + + Role + + + Header row + + + Sample + + + Upload any CSV, map its columns to your meters and categories, preview what would be staged, then commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes, + + + or + + + dates) — the same parser the reference sheets use. + + + The mapping changed after the preview. Run the dry run again, then commit. + + + meter {0} + + + Nothing staged. Check the first-data-row, date column and column mappings above. + + + Faded rows are before the first data row. The 📅 column supplies the date. + + + Select category + + + Select meter + + + Skip zero rows + + + 1. Parsing options + + + 2. Column preview + + + 3. Map columns + + + 4. Preview & commit + + + Import wizard + + + e.g. kWh + + + {0} all read into '{1}'. Each Reading column needs its own meter. + + + Col {0} (ManualCost) needs a target category. + + + Col {0} ({1}) needs a target meter. + + + Map at least one column to a role other than Ignore. + + + Import #{0} reverted. + + + Imported + + + Rows + + + Source + + + Dry-run a reference sheet + + + Import failed: {0} + + + Load reference data + + + Mapping wizard + + + No imports yet. + + + Costs (Kosten) + + + Electricity (Strom) + + + Heating oil (Heizöl) + + + Water (Wasser) + + + Recent imports + + + Reference data loaded. + + + Reference dataset + + + Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a starter dataset with meters, tariffs and categories. + + + Loaded + + + Reference profile + + + Revert + + + Delete all {0} rows from import #{1} ({2}) and recompute the affected meters? + + + Revert import? + + + Revert failed: {0} + + + active + + + reverted + + + unnamed + + + {0} warnings + + + Your own CSV + + + Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a revertible import. Or dry-run against one of the built-in reference profiles below. + + + Dark mode + + + Language + + + Light mode + + + Reload + + + Toggle navigation + + + An unhandled error has occurred. + + + about the same + + + Add reading + + + Add reading — {0} + + + Add source + + + Attribute (optional; blank = state) + + + Back to meters + + + Backdated before the latest reading — consumption from there on is recomputed. + + + (baseline {0}) + + + {0} {1} since last reading + + + Component + + + Connector + + + '{0}' is disabled, so this source would never ingest. Enable it first. + + + '{0}' is a {1} connector; a {2} source needs {3}. + + + Cost this year + + + (set it up once; every source then just picks it). + + + create one + + + Below the last reading ({0} {1}) on a register that only counts up, so it will be rejected. If the meter was swapped or reset, record that on the Events tab first. + + + Delete this {0} source? + + + Delete source + + + Edit source + + + Enter a value + + + Entity id (e.g. sensor.house_power) + + + Flags + + + From + + + That time is in the future. + + + Kind + + + {0} this month + + + last month {0} {1} + + + Last reading {0} {1} on {2}. + + + Last value + + + · {0} last year + + + Lifetime total + + + Local time in {0}. + + + This meter no longer exists. + + + New source + + + no + + + no basis yet + + + no change since last reading + + + No {0} connector yet — + + + No normalized consumption yet. + + + No events (swaps, deliveries, corrections). + + + No raw readings. + + + No readings yet — prefilled with this meter's baseline ({0} {1}). + + + No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant. + + + No applicable tariffs. + + + Meter #{0} not found. + + + Notes + + + Offset + + + Pick a {0} connector for this {1} source. + + + Hourly is plenty for a meter — monthly totals and cost come out identical, with far less raw data. + + + Poll interval (minutes) + + + Prev→New + + + Priority + + + '{0}' is disabled + + + no connector — never ingests + + + '{0}' is {1}, needs {2} + + + ≈ {0} {1} by month end + + + ≈ {0} {1} full year + + + Quality + + + Reading ({0}) + + + Rejected — below the previous reading on a register that only counts up. Record a counter reset or meter swap first. + + + Replaced the reading at that time with {0} {1}. + + + Reading saved: {0} {1}. + + + Readings + + + Most recent {0} normalized deltas. + + + Most recent {0} (raw, immutable audit truth). Times in {1}. + + + Meter register details + + + Register span + + + This meter already has a reading at that time — saving replaces its value. + + + retired + + + Save reading + + + Saving… + + + Scale + + + That clock time never happened in {0} — the clocks moved forward. Pick another time. + + + Source deleted. + + + Source saved. + + + Source type + + + Consumption ({0}) + + + Events ({0}) + + + Readings ({0}) + + + Sources ({0}) + + + Tariffs ({0}) + + + open + + + Time + + + Time + + + Time path (optional, e.g. Time) + + + To + + + MQTT topic (e.g. tele/plug1/SENSOR) + + + Value kind + + + Value path (e.g. ENERGY.Total; blank = bare scalar) + + + A virtual meter is an expression over other meters, so it stores no readings of its own — enter the reading on the meter the expression refers to. + + + Virtual meter — its value is an expression over other meters, evaluated when read, so it has no stored series of its own. + + + See Trends for its figures. + + + vs last month + + + {0} vs {1} last year + + + — will be rejected + + + yes + + + Active + + + Add meter + + + Delete '{0}'? This cannot be undone. + + + Delete '{0}'? This will also delete {1} reading(s) and {2} consumption row(s). This cannot be undone. + + + Delete meter + + + Edit {0} + + + to load the reference data. + + + No meters yet. Add one, or go to + + + Initial register baseline + + + 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. + + + Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption. Store the value as a + + + per-hour + + + Location (optional) + + + Manufacturer (optional) + + + Measurement mode + + + Model (optional) + + + New meter + + + no + + + PV role (optional) + + + Mode/baseline changed — consumption will be recomputed on save. + + + Name, energy type and unit are required. + + + — none — + + + Serial number (optional) + + + Sources + + + This meter measures a subsection of the selected meter(s)' flow. + + + Sub-meter of (upstream meters) + + + Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2). + + + yes + + + Admin + + + Connectors + + + Oil / consumables + + + Cost categories + + + Energy types + + + Import + + + Meters + + + Overview + + + Settings + + + Solar / PV + + + Tariffs + + + Trends + + + Sorry, the content you are looking for does not exist. + + + Not Found + + + The session has been paused by the server. + + + Failed to rejoin. + + + Rejoining the server... + + + Resume + + + Failed to resume the session. + + + Retry + + + Rejoin failed... trying again in + + + seconds. + + + Please retry or reload the page. + + + Flow diagram + + + No flow to show for this period. + + + Access & ingestion + + + closed (401) + + + API docs at + + + {0} key(s) configured + + + Keys themselves are never shown here. + + + Set API keys with + + + open (anonymous) + + + effective + + + These are the + + + settings the running instance is using. They are configured via environment variables + + + or Docker/compose, not stored in the database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart. + + + Env keys: + + + Live ingestion workers + + + Locale + + + Locale & time + + + off + + + on + + + Raw-reading retention + + + {0} days + + + Reverse-proxy trust + + + Seed reference data on start + + + Timezone + + + Autarky + + + Generation + + + Generation & self-consumption + + + Generation by meter + + + Grid draw {0} kWh + + + No generation meters found. Add a meter with mode + + + or load the reference data from + + + Savings (Ersparnis) + + + Self-consumption + + + {0}% of generation + + + Tag a meter + + + and one + + + (in meter metadata) to unlock self-consumption, autarky and savings. + + + Add tariff + + + Component + + + Delete this {0} tariff ({1} {2})? + + + Delete tariff + + + Edit tariff + + + No tariffs yet. Add one, or load the reference data from + + + New tariff + + + Notes (optional) + + + open + + + Global + + + Meter: {0} + + + Select the energy type or meter this tariff applies to. + + + Type: {0} + + + Unit and valid-from are required. + + + Unit (e.g. EUR/kWh, EUR/m3, EUR/month) + + + Valid from + + + Valid to + + + Valid to (empty = open-ended) + + + Monthly cost + + + Apply + + + Last 48 months + + + Cost trend + + + Total over range: {0} + + + This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes, during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart. + + + Update MeterVault + + + pull the new image and recreate the container + + + run: update + + + MeterVault {0} is available — this instance runs {1}. + + + Starting… + + + Update now + + + Could not start the update. + + + Could not start the update: {0} + + + Could not start systemd-run. + + + Update cannot be started: {0}. + + + Update started. The service restarts when the rebuild finishes — this usually takes a few minutes. + + diff --git a/src/App/MeterVault.App.csproj b/src/App/MeterVault.App.csproj index e1b6108..c59697d 100644 --- a/src/App/MeterVault.App.csproj +++ b/src/App/MeterVault.App.csproj @@ -26,4 +26,20 @@ + + + + MSBuild:Compile + $(IntermediateOutputPath)\Strings.Designer.cs + CSharp + MeterVault.App.Localization + Strings + true + + + diff --git a/src/App/Program.cs b/src/App/Program.cs index 61e3782..f4edfd2 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -1,5 +1,6 @@ using MeterVault.App.Api; using MeterVault.App.Components; +using MeterVault.App.Localization; using MeterVault.Infrastructure; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; @@ -71,6 +72,7 @@ try builder.Services.AddMeterVaultIngestion(); } + builder.Services.AddLocalization(); builder.Services.AddMudServices(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -100,6 +102,24 @@ try // that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy. 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.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1")); @@ -108,6 +128,7 @@ try .AddInteractiveServerRenderMode(); app.MapMeterVaultApi(); + app.MapCultureEndpoints(); // Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9). app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); diff --git a/src/Infrastructure/Dashboard/FlowModels.cs b/src/Infrastructure/Dashboard/FlowModels.cs index ddb4469..55c5fb0 100644 --- a/src/Infrastructure/Dashboard/FlowModels.cs +++ b/src/Infrastructure/Dashboard/FlowModels.cs @@ -1,6 +1,11 @@ namespace MeterVault.Infrastructure.Dashboard; /// A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder. +/// +/// A meter's name — for a remainder node () the parent 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 Flow_OtherNode. +/// public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId); /// A directed flow edge with the quantity that flows along it, in the energy type's base unit. diff --git a/src/Infrastructure/Dashboard/FlowService.cs b/src/Infrastructure/Dashboard/FlowService.cs index fd163e2..7c395e4 100644 --- a/src/Infrastructure/Dashboard/FlowService.cs +++ b/src/Infrastructure/Dashboard/FlowService.cs @@ -115,7 +115,9 @@ public sealed class FlowService(IDbContextFactory contextFa if (remainder > Epsilon) { 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)); } } diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs index 7c45118..1506839 100644 --- a/src/Infrastructure/Dashboard/MeterDetailModels.cs +++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs @@ -14,7 +14,8 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost) /// /// 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 -/// consumption otherwise, so says which. +/// consumption otherwise, so says which — as the enum, not a word, because the +/// wording belongs to whichever language the reader picked. /// /// /// Month- and year-to-date are compared against a projection 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. /// public sealed record MeterPeriodView( - string Label, + ConsumptionKind Kind, string Unit, string Currency, double MonthToDate, diff --git a/src/Infrastructure/Dashboard/MeterPeriodService.cs b/src/Infrastructure/Dashboard/MeterPeriodService.cs index 529095f..684adb5 100644 --- a/src/Infrastructure/Dashboard/MeterPeriodService.cs +++ b/src/Infrastructure/Dashboard/MeterPeriodService.cs @@ -86,7 +86,7 @@ public sealed class MeterPeriodService( var yearToDateCost = SumYear(costs, today.Year); return new MeterPeriodView( - Label: isGeneration ? "Generation" : "Consumption", + Kind: kind, Unit: meter.Unit, Currency: _options.Currency, MonthToDate: monthToDate, diff --git a/src/Infrastructure/Import/CsvImporter.cs b/src/Infrastructure/Import/CsvImporter.cs index 12a0504..093503a 100644 --- a/src/Infrastructure/Import/CsvImporter.cs +++ b/src/Infrastructure/Import/CsvImporter.cs @@ -31,7 +31,7 @@ public sealed class CsvImporter staged.SkippedRows++; if (!reason.StartsWith("blank", StringComparison.Ordinal)) { - staged.Warnings.Add($"Row {r + 1}: skipped ({reason})."); + staged.Warnings.Add(ImportWarnings.RowSkipped(r + 1, reason)); } continue; @@ -40,7 +40,7 @@ public sealed class CsvImporter if (!TryGetDate(row, profile, out var time)) { staged.SkippedRows++; - staged.Warnings.Add($"Row {r + 1}: unparseable date, skipped."); + staged.Warnings.Add(ImportWarnings.UnparseableDate(r + 1)); continue; } @@ -140,8 +140,7 @@ public sealed class CsvImporter if (column.Unit is not null && split.Value.Unit is not null && !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase)) { - staged.Warnings.Add( - $"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'."); + staged.Warnings.Add(ImportWarnings.UnitMismatch(rowIndex + 1, column.Unit, split.Value.Unit)); } if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous) @@ -157,9 +156,9 @@ public sealed class CsvImporter Amount = override_, Notes = "Auto-detected register decrease (meter swap / reset candidate).", }); - staged.Warnings.Add( - $"Row {rowIndex + 1}: register for meter {meterId} dropped {previous}→{value}; " + - (override_ is null ? "swap event created (verify)." : $"swap consumption set to {override_}.")); + staged.Warnings.Add(override_ is { } amount + ? ImportWarnings.RegisterDroppedWithAmount(rowIndex + 1, meterId, previous, value, amount) + : ImportWarnings.RegisterDropped(rowIndex + 1, meterId, previous, value)); } staged.Readings.Add(new Reading diff --git a/src/Infrastructure/Import/ImportWarning.cs b/src/Infrastructure/Import/ImportWarning.cs new file mode 100644 index 0000000..643e2d1 --- /dev/null +++ b/src/Infrastructure/Import/ImportWarning.cs @@ -0,0 +1,74 @@ +using System.Globalization; + +namespace MeterVault.Infrastructure.Import; + +/// What a staging warning is about. +public enum ImportWarningKind +{ + /// A row matched a skip rule (a summary line, an all-zero placeholder). Args: row, reason. + RowSkipped, + + /// The date cell parsed as neither Monat JJJJ nor TT.MM.JJJJ. Args: row. + UnparseableDate, + + /// The value carried a different unit suffix than the column expects. Args: row, expected, found. + UnitMismatch, + + /// A register went backwards; a swap event was staged for review. Args: row, meter, previous, new. + RegisterDropped, + + /// Same, with the consumption across the swap taken from the sheet. Args: row, meter, previous, new, amount. + RegisterDroppedWithAmount, +} + +/// +/// One thing worth telling the user about a staged row. +/// +/// +/// The English sentence, kept for logs and any non-UI caller. +/// +/// +/// 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. +/// +public sealed record ImportWarning(ImportWarningKind Kind, string Message, IReadOnlyList Args) +{ + /// Falls back to the English sentence, so a plain ToString() is still useful. + public override string ToString() => Message; +} + +/// +/// Builds the warnings the CSV importer emits. +/// +/// +/// 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. +/// +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); +} diff --git a/src/Infrastructure/Import/StagedImport.cs b/src/Infrastructure/Import/StagedImport.cs index 6f1fae4..4b3aaeb 100644 --- a/src/Infrastructure/Import/StagedImport.cs +++ b/src/Infrastructure/Import/StagedImport.cs @@ -15,7 +15,7 @@ public sealed class StagedImport public List ManualCosts { get; } = []; - public List Warnings { get; } = []; + public List Warnings { get; } = []; public int SkippedRows { get; set; } diff --git a/src/Infrastructure/Ingestion/HaConnectionTester.cs b/src/Infrastructure/Ingestion/HaConnectionTester.cs index 64a5b5d..92114d8 100644 --- a/src/Infrastructure/Ingestion/HaConnectionTester.cs +++ b/src/Infrastructure/Ingestion/HaConnectionTester.cs @@ -3,8 +3,55 @@ using Microsoft.Extensions.Logging; namespace MeterVault.Infrastructure.Ingestion; +/// Which way a Home Assistant connectivity test ended. +/// +/// 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. +/// +public enum HaTestOutcome +{ + /// + /// No test was run — the caller decided beforehand (no token configured, base URL edited since + /// saving) and put its own, already-localized wording in . + /// The default, so a result built by the UI needs no ceremony to say so. + /// + Precondition, + + /// Reachable and the token was accepted; no entity was named to sample. + Connected, + + /// Reachable and the named entity returned a number — see . + ConnectedWithValue, + + /// No base URL was given. + BaseUrlMissing, + + /// No token was available to test with. + TokenMissing, + + /// HA answered, but not with success. carries the status. + HttpError, + + /// Reachable, but the named entity has no numeric state (unavailable/unknown/non-numeric). + NoNumericState, + + /// The request threw. carries the exception message. + RequestFailed, +} + /// Outcome of a Home Assistant connectivity test. -public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null); +/// The English summary, kept for logs and non-UI callers. +/// The same verdict as a value, for a UI that has to phrase it in some language. +/// The entity that was sampled, when one was named. +/// Diagnostic text (HTTP status, exception message). Not ours to translate. +public sealed record HaTestResult( + bool Ok, + string Message, + double? SampleValue = null, + HaTestOutcome Outcome = HaTestOutcome.Precondition, + string? EntityId = null, + string? Detail = null); /// /// 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)) { - return new HaTestResult(false, "Base URL is required."); + return new HaTestResult(false, "Base URL is required.", Outcome: HaTestOutcome.BaseUrlMissing); } 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(); @@ -44,23 +91,29 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); 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)) { - 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); return state is { } 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)."); + ? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value, + 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) { _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); } } } diff --git a/src/Infrastructure/Update/UpdateRunner.cs b/src/Infrastructure/Update/UpdateRunner.cs index 82ac17e..a03251b 100644 --- a/src/Infrastructure/Update/UpdateRunner.cs +++ b/src/Infrastructure/Update/UpdateRunner.cs @@ -17,8 +17,43 @@ public enum UpdateAvailability NotSupportedHere, } +/// Which way an attempt to launch the updater ended. +/// +/// 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. +/// +public enum UpdateOutcome +{ + /// The transient unit is running; the service restarts when the rebuild finishes. + Started, + + /// refused before anything was launched. + NotAllowed, + + /// systemd-run could not be started at all. + LauncherMissing, + + /// systemd-run ran and exited non-zero — most often an update already in flight. + LauncherFailed, + + /// Launching threw. carries the exception message. + LaunchError, +} + /// Outcome of trying to launch the updater. -public sealed record UpdateLaunch(bool Started, string 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. +/// +/// The same result as a value, for a UI that has to phrase it in some language. +/// Why it was refused, when is . +/// Diagnostic text (stderr, an exception message). Never translated — it isn't ours. +public sealed record UpdateLaunch( + bool Started, + string Message, + UpdateOutcome Outcome = UpdateOutcome.LaunchError, + UpdateAvailability? Availability = null, + string? Detail = null); /// /// Starts the in-container updater on request, gated hard. @@ -75,7 +110,8 @@ public sealed class UpdateRunner(IOptions options, ILogger options, ILogger options, ILogger options, ILogger +/// The language picker's endpoint and the culture the middleware hands each render (SDD §12, M7). +/// +/// +/// +/// 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 /culture/set 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. +/// +/// +/// 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. +/// +/// +public sealed class CultureEndpointTests : IDisposable +{ + private const string UnusedConnection = "Host=localhost;Port=1;Database=metervault;Username=none;Password=none"; + + /// Renders the full layout (app bar + nav) without needing any data. + private static readonly Uri LayoutOnlyPage = new("/admin/settings", UriKind.Relative); + + private readonly List _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(); + } + } + + /// + /// Blazor entity-encodes every non-ASCII character it renders, so "Übersicht" arrives as + /// "&#xDC;bersicht" — decoding first is what lets these assertions read like the UI does. + /// + private static async Task 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 }); +} diff --git a/tests/Integration.Tests/Localization/EnumDisplayNameTests.cs b/tests/Integration.Tests/Localization/EnumDisplayNameTests.cs new file mode 100644 index 0000000..53e93f1 --- /dev/null +++ b/tests/Integration.Tests/Localization/EnumDisplayNameTests.cs @@ -0,0 +1,119 @@ +using System.Globalization; +using MeterVault.App.Localization; +using MeterVault.Core.Domain; + +namespace MeterVault.Integration.Tests.Localization; + +/// +/// Every domain enum value the UI renders has wording in every language (SDD §12, M7). +/// +/// +/// 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 Enum_<Type>_<Value> resource for every declared value. +/// +public sealed class EnumDisplayNameTests +{ + [Fact] + public void Every_localized_enum_value_has_a_resource_in_every_language() + { + var missing = new List(); + + 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(); + + 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(string culture, Func body) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture); + return body(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/tests/Integration.Tests/Localization/FormatCultureTests.cs b/tests/Integration.Tests/Localization/FormatCultureTests.cs new file mode 100644 index 0000000..58642ef --- /dev/null +++ b/tests/Integration.Tests/Localization/FormatCultureTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using MeterVault.App; + +namespace MeterVault.Integration.Tests.Localization; + +/// +/// formats against the reader's culture rather than a fixed de-DE (SDD §12, M7). +/// +/// +/// 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 GermanParsingTests pins it. +/// +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(string culture, Func body) + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + return body(); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/tests/Integration.Tests/Localization/StringResourceTests.cs b/tests/Integration.Tests/Localization/StringResourceTests.cs new file mode 100644 index 0000000..3add918 --- /dev/null +++ b/tests/Integration.Tests/Localization/StringResourceTests.cs @@ -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; + +/// +/// Guards the UI string catalogue (SDD §12, M7). +/// +/// +/// Resource lookup fails soft by design — ask for a key the German satellite doesn't carry and +/// 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 tryParents: false, 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. +/// +public sealed class StringResourceTests +{ + /// Matches {0}, {1:N2}, {0,-8} — the index is what has to agree across languages. + private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled); + + private static readonly IReadOnlyDictionary 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(); + 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)}"); + } + + /// Walks up from the test binaries to the checkout, identified by the solution file. + 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; + } + + /// Every shipped language except the neutral one, which is the baseline being compared against. + public static TheoryData TranslatedCultures() + { + var data = new TheoryData(); + foreach (var culture in Loc.SupportedCultures.Skip(1)) + { + data.Add(culture); + } + + return data; + } + + private static HashSet PlaceholderIndexes(string value) => + [.. PlaceholderPattern.Matches(value).Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))]; + + /// + /// The entries one culture's resource set actually defines. tryParents: false is the + /// whole point: with fallback on, a missing German string is indistinguishable from a present one. + /// + private static IReadOnlyDictionary ResourcesFor(CultureInfo culture) + { + var set = Strings.ResourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false); + var entries = new Dictionary(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; + } +} diff --git a/tests/Integration.Tests/MeterPeriodServiceTests.cs b/tests/Integration.Tests/MeterPeriodServiceTests.cs index 34d8869..7adb614 100644 --- a/tests/Integration.Tests/MeterPeriodServiceTests.cs +++ b/tests/Integration.Tests/MeterPeriodServiceTests.cs @@ -30,7 +30,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx) var view = await NewService().GetAsync(meterId); Assert.NotNull(view); - Assert.Equal("Consumption", view!.Label); + Assert.Equal(ConsumptionKind.Consumption, view!.Kind); Assert.Equal(30d, view.MonthToDate, 3); Assert.Equal(100d, view.LastMonth, 3); Assert.Equal(130d, view.YearToDate, 3); @@ -55,7 +55,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx) var view = await NewService().GetAsync(meterId); Assert.NotNull(view); - Assert.Equal("Generation", view!.Label); + Assert.Equal(ConsumptionKind.Generation, view!.Kind); Assert.Equal(42d, view.MonthToDate, 3); await CleanupAsync(db, meterId); diff --git a/tests/Integration.Tests/MeterVaultAppFactory.cs b/tests/Integration.Tests/MeterVaultAppFactory.cs index 4ecc78c..86216fd 100644 --- a/tests/Integration.Tests/MeterVaultAppFactory.cs +++ b/tests/Integration.Tests/MeterVaultAppFactory.cs @@ -11,8 +11,16 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure { public const string ApiKey = "test-api-key"; + /// Overrides MeterVault__Locale, the instance's default UI language. + public string? Locale { get; init; } + protected override void ConfigureWebHost(IWebHostBuilder builder) { + if (Locale is not null) + { + builder.UseSetting("MeterVault:Locale", Locale); + } + builder.UseEnvironment("Testing"); builder.UseSetting("ConnectionStrings:Default", connectionString); builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");