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
- 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
-
- @(_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)
{
-
Meter
Consumption
+
@S.Common_Meter
@S.EnergyView_ColConsumption
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
{
- 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"))
- Your own CSV
+ @S.Import_YourOwnCsvMapping 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
- 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
}
@@ -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
-
Column
Sample
Role
Target
Unit
+
@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 (_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))
- 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)
-
Time
Value
Quality
Flags
+
@S.MeterDetail_Time
@S.Common_Value
@S.MeterDetail_Quality
@S.MeterDetail_Flags
@foreach (var r in _detail.RecentReadings)
{
@@ -187,7 +185,7 @@ else
@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
{
-
Type
Target
Connector
Enabled
Last seen
Last value
Status
Actions
+
@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.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()
}
- 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_RoleNonetotal_loadgrid_importgrid_export
+ HelperText="@S.Meters_UpstreamHelp">
@foreach (var m in AvailableUpstream())
{
@m.Name
}
-
-
+
+
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,
- $"