fix(strommix): full i18n + drop hardcoded grid constraints
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 52s

Two issues from the audit:

1. Hardcoded `lg:col-span-8 lg:col-start-3` collided with the layout
   coming from `getBlockLayoutClasses(block.layout)`. Tailwind picked
   `lg:col-start-3` from the hardcoded class (no opposing
   `lg:col-start-1` in the layout output) so the widget started at
   column 3 with span 12 → overflowed past the 12-col grid on
   desktop, looking like it was "hanging right". Drop the hardcoded
   constraints; the layout config from the CMS controls width
   (mobile/tablet/desktop = 12 by default = full width).
2. Roughly 25 German strings were inline. Extracted them to
   translation keys (T.strommix_*, registered in translations.ts) and
   read via `useTranslate()` like ImageGalleryBlock /
   PostOverviewBlock / YoutubeVideoBlock. Includes field labels for
   the breakdown disclosure, source attribution variants, status
   bucket markers, error / loading / fallback messages, and the
   conventional formula text shown under the "Konventionell jetzt"
   number.

Default DE strings live in cms_content/windwiderstand/translation_bundle/
app.json5 (separate CMS push).
This commit is contained in:
Peter Meier
2026-05-05 00:02:27 +02:00
parent 2d85681252
commit 9ebca1c237
2 changed files with 460 additions and 287 deletions
+203 -66
View File
@@ -4,10 +4,12 @@
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { LiveStrommixBlockData } from "$lib/block-types"; import type { LiveStrommixBlockData } from "$lib/block-types";
import type { StrommixResponse } from "$lib/strommix"; import type { StrommixResponse } from "$lib/strommix";
import { useTranslate, T } from "$lib/translations";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
let { block }: { block: LiveStrommixBlockData } = $props(); let { block }: { block: LiveStrommixBlockData } = $props();
const t = useTranslate();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
@@ -77,17 +79,29 @@
return map[bucket] ?? ""; return map[bucket] ?? "";
}); });
const argumentHtml = $derived(argumentMd ? (marked.parse(argumentMd) as string) : ""); const argumentHtml = $derived(
const introHtml = $derived(block.intro_text ? (marked.parse(block.intro_text) as string) : ""); argumentMd ? (marked.parse(argumentMd) as string) : "",
const disclaimerHtml = $derived(block.disclaimer ? (marked.parse(block.disclaimer) as string) : ""); );
const introHtml = $derived(
block.intro_text ? (marked.parse(block.intro_text) as string) : "",
);
const disclaimerHtml = $derived(
block.disclaimer ? (marked.parse(block.disclaimer) as string) : "",
);
const enableFr = $derived(block.enable_france_comparison !== false); const enableFr = $derived(block.enable_france_comparison !== false);
function fmtMw(mw: number): string { function fmtMw(mw: number): string {
return (mw / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " GW"; return (
(mw / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) +
" GW"
);
} }
function fmtGw(gw: number): string { function fmtGw(gw: number): string {
return Math.abs(gw).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " GW"; return (
Math.abs(gw).toLocaleString("de-DE", { maximumFractionDigits: 1 }) +
" GW"
);
} }
function fmtPrice(eurMwh: number | null): string { function fmtPrice(eurMwh: number | null): string {
if (eurMwh == null) return "—"; if (eurMwh == null) return "—";
@@ -96,7 +110,10 @@
function fmtTime(unix: number | null): string { function fmtTime(unix: number | null): string {
if (!unix) return ""; if (!unix) return "";
const d = new Date(unix * 1000); const d = new Date(unix * 1000);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); return d.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
} }
/** Cross-border data is considered stale (and worth flagging) when its /** Cross-border data is considered stale (and worth flagging) when its
@@ -104,7 +121,8 @@
* typically lags 1.52 h, so this is the rule rather than the * typically lags 1.52 h, so this is the rule rather than the
* exception — surfacing the stamp keeps the widget honest. */ * exception — surfacing the stamp keeps the widget honest. */
const cbStale = $derived.by(() => { const cbStale = $derived.by(() => {
if (!live?.measuredAtUnix || !live?.crossborderMeasuredAtUnix) return false; if (!live?.measuredAtUnix || !live?.crossborderMeasuredAtUnix)
return false;
return live.measuredAtUnix - live.crossborderMeasuredAtUnix > 30 * 60; return live.measuredAtUnix - live.crossborderMeasuredAtUnix > 30 * 60;
}); });
@@ -122,15 +140,35 @@
const accentClasses = $derived.by(() => { const accentClasses = $derived.by(() => {
switch (bucketAccent) { switch (bucketAccent) {
case "danger": case "danger":
return { box: "border-red-300 bg-red-50", pct: "text-red-800", label: "text-red-700" }; return {
box: "border-red-300 bg-red-50",
pct: "text-red-800",
label: "text-red-700",
};
case "warning": case "warning":
return { box: "border-amber-300 bg-amber-50", pct: "text-amber-800", label: "text-amber-700" }; return {
box: "border-amber-300 bg-amber-50",
pct: "text-amber-800",
label: "text-amber-700",
};
case "info": case "info":
return { box: "border-sky-300 bg-sky-50", pct: "text-sky-800", label: "text-sky-700" }; return {
box: "border-sky-300 bg-sky-50",
pct: "text-sky-800",
label: "text-sky-700",
};
case "success": case "success":
return { box: "border-emerald-300 bg-emerald-50", pct: "text-emerald-800", label: "text-emerald-700" }; return {
box: "border-emerald-300 bg-emerald-50",
pct: "text-emerald-800",
label: "text-emerald-700",
};
default: default:
return { box: "border-neutral-200 bg-neutral-50", pct: "text-neutral-800", label: "text-neutral-700" }; return {
box: "border-neutral-200 bg-neutral-50",
pct: "text-neutral-800",
label: "text-neutral-700",
};
} }
}); });
@@ -143,7 +181,10 @@
}); });
const isStale = $derived( const isStale = $derived(
!!(live && (live.staleStrommix || live.staleCrossborder || live.stalePrice)) !!(
live &&
(live.staleStrommix || live.staleCrossborder || live.stalePrice)
),
); );
const importDirection = $derived.by(() => { const importDirection = $derived.by(() => {
if (!live) return null; if (!live) return null;
@@ -152,7 +193,7 @@
</script> </script>
<div <div
class="live-strommix col-span-12 lg:col-span-8 lg:col-start-3 {layoutClasses}" class="live-strommix {layoutClasses}"
data-block-type="live_strommix" data-block-type="live_strommix"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
@@ -167,35 +208,59 @@
</header> </header>
{/if} {/if}
<div class="rounded-lg border-2 shadow-sm overflow-hidden {accentClasses.box}"> <div
class="rounded-xs border-2 shadow-sm overflow-hidden {accentClasses.box}"
>
<!-- Header bar --> <!-- Header bar -->
<div class="flex items-center justify-between px-4 py-2 border-b border-current/10 text-xs uppercase tracking-wide font-semibold {accentClasses.label}"> <div
class="flex items-center justify-between px-4 py-2 border-b border-current/10 text-xs uppercase tracking-wide font-semibold {accentClasses.label}"
>
<span class="inline-flex items-center gap-1.5"> <span class="inline-flex items-center gap-1.5">
<Icon icon="mdi:transmission-tower" class="size-4" aria-hidden="true" /> <Icon
Strommix jetzt icon="mdi:transmission-tower"
class="size-4"
aria-hidden="true"
/>
{t(T.strommix_header)}
</span> </span>
{#if live?.measuredAtUnix} {#if live?.measuredAtUnix}
<span class="font-normal normal-case">Stand: {fmtTime(live.measuredAtUnix)} Uhr</span> <span class="font-normal normal-case"
>{t(T.strommix_stand, {
time: fmtTime(live.measuredAtUnix),
})}</span
>
{/if} {/if}
</div> </div>
<!-- Main: percentage + status --> <!-- Main: percentage + status -->
{#if loadError} {#if loadError}
<div class="p-6 text-sm text-red-700"> <div class="p-6 text-sm text-red-700">
Live-Daten nicht erreichbar: {loadError} {t(T.strommix_load_error, { error: loadError })}
</div> </div>
{:else if !live} {:else if !live}
<div class="p-6 text-center text-neutral-500 text-sm">Lade Live-Daten…</div> <div class="p-6 text-center text-neutral-500 text-sm">
{t(T.strommix_loading)}
</div>
{:else} {:else}
<div class="p-6 text-center"> <div class="p-6 text-center">
{#if renewablePct != null} {#if renewablePct != null}
<div class="text-6xl sm:text-7xl font-extrabold tabular-nums {accentClasses.pct}"> <div
class="text-6xl sm:text-7xl font-extrabold tabular-nums {accentClasses.pct}"
>
{Math.round(renewablePct)}% {Math.round(renewablePct)}%
</div> </div>
<div class="mt-1 text-sm text-neutral-700">Wind &amp; Solar am Bedarf</div> <div class="mt-1 text-sm text-neutral-700">
<div class="mt-3 text-base font-semibold {accentClasses.label}">{statusLabel}</div> {t(T.strommix_wind_solar_share)}
</div>
<div
class="mt-3 text-base font-semibold {accentClasses.label}"
>
{statusLabel}
</div>
{#if argumentHtml} {#if argumentHtml}
<div class="prose prose-sm mt-3 max-w-prose mx-auto text-neutral-800"> <div
class="prose prose-sm mt-3 max-w-prose mx-auto text-neutral-800"
>
{@html argumentHtml} {@html argumentHtml}
</div> </div>
{/if} {/if}
@@ -203,22 +268,37 @@
<!-- Production data arrived but load is missing → no percentage, <!-- Production data arrived but load is missing → no percentage,
but we still show what we have so the widget isn't blank. --> but we still show what we have so the widget isn't blank. -->
<div class="text-sm text-amber-700"> <div class="text-sm text-amber-700">
Lastwert von SMARD gerade nicht verfügbar — Prozentanzeige ausgesetzt. {t(T.strommix_load_missing)}
Erzeugung wird unten in der Detailansicht weiter angezeigt.
</div> </div>
{/if} {/if}
</div> </div>
<!-- Context row --> <!-- Context row -->
<div class="grid grid-cols-2 divide-x divide-current/10 border-t border-current/10 text-sm"> <div
class="grid grid-cols-2 divide-x divide-current/10 border-t border-current/10 text-sm"
>
<div class="px-4 py-3"> <div class="px-4 py-3">
<div class="text-xs text-neutral-600 uppercase tracking-wide">Konventionell jetzt</div> <div
<div class="text-lg font-semibold tabular-nums">{fmtMw(live.conventionalMw)}</div> class="text-xs text-neutral-600 uppercase tracking-wide"
<div class="text-[11px] text-neutral-500 mt-0.5">Braunkohle + Steinkohle + Erdgas + Kernkraft</div> >
{t(T.strommix_conventional_now)}
</div>
<div class="text-lg font-semibold tabular-nums">
{fmtMw(live.conventionalMw)}
</div>
<div class="text-[11px] text-neutral-500 mt-0.5">
{t(T.strommix_conventional_formula)}
</div>
</div> </div>
<div class="px-4 py-3"> <div class="px-4 py-3">
<div class="text-xs text-neutral-600 uppercase tracking-wide"> <div
{importDirection ?? "Saldo"} class="text-xs text-neutral-600 uppercase tracking-wide"
>
{importDirection === "Import"
? t(T.strommix_import)
: importDirection === "Export"
? t(T.strommix_export)
: t(T.strommix_saldo)}
</div> </div>
<div class="text-lg font-semibold tabular-nums"> <div class="text-lg font-semibold tabular-nums">
{#if importDirection === "Import"}{/if} {#if importDirection === "Import"}{/if}
@@ -227,8 +307,12 @@
</div> </div>
{#if live.crossborderMeasuredAtUnix} {#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-neutral-500 mt-0.5"> <div class="text-[11px] text-neutral-500 mt-0.5">
Stand: {fmtTime(live.crossborderMeasuredAtUnix)} Uhr {t(T.strommix_stand, {
{#if cbStale}<span class="text-amber-700"> · verzögert</span>{/if} time: fmtTime(live.crossborderMeasuredAtUnix),
})}
{#if cbStale}<span class="text-amber-700">
· {t(T.strommix_delayed)}</span
>{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -237,19 +321,35 @@
<!-- Price row --> <!-- Price row -->
<div class="border-t border-current/10 px-4 py-3 text-sm"> <div class="border-t border-current/10 px-4 py-3 text-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1"> <div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold">Strompreis DE: <span class="tabular-nums">{fmtPrice(live.priceDeEurMwh)}</span></span> <span class="font-semibold"
>{t(T.strommix_price_de, {
price: fmtPrice(live.priceDeEurMwh),
})}</span
>
{#if enableFr && live.priceFrEurMwh != null} {#if enableFr && live.priceFrEurMwh != null}
<span class="text-neutral-600">(Frankreich: <span class="tabular-nums">{fmtPrice(live.priceFrEurMwh)}</span>)</span> <span class="text-neutral-600"
>{t(T.strommix_price_fr_inline, {
price: fmtPrice(live.priceFrEurMwh),
})}</span
>
{/if} {/if}
{#if live.priceMeasuredAtUnix} {#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-neutral-500"> <span class="text-[11px] text-neutral-500">
· Slot {fmtTime(live.priceMeasuredAtUnix)} · {t(T.strommix_slot, {
time: fmtTime(live.priceMeasuredAtUnix),
})}
</span> </span>
{/if} {/if}
{#if (live.priceDeEurMwh ?? 0) < 0} {#if (live.priceDeEurMwh ?? 0) < 0}
<span class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold"> <span
<Icon icon="mdi:alert-circle-outline" class="size-4" aria-hidden="true" /> class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold"
Negativpreis >
<Icon
icon="mdi:alert-circle-outline"
class="size-4"
aria-hidden="true"
/>
{t(T.strommix_negative_price)}
</span> </span>
{/if} {/if}
</div> </div>
@@ -261,52 +361,89 @@
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). --> kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
{#if live} {#if live}
<details class="mt-3 text-xs"> <details class="mt-3 text-xs">
<summary class="cursor-pointer text-neutral-600 hover:text-neutral-900"> <summary
Wert prüfen — alle Komponenten anzeigen class="cursor-pointer text-neutral-600 hover:text-neutral-900"
>
{t(T.strommix_check_values)}
</summary> </summary>
<div class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 tabular-nums sm:grid-cols-3"> <div
<div class="text-neutral-500">Wind onshore</div><div>{fmtMw(live.windOnshoreMw)}</div> class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 tabular-nums sm:grid-cols-3"
<div class="text-neutral-500">Wind offshore</div><div>{fmtMw(live.windOffshoreMw)}</div> >
<div class="text-neutral-500">Photovoltaik</div><div>{fmtMw(live.solarMw)}</div> <div class="text-neutral-500">
<div class="text-neutral-500">Braunkohle</div><div>{fmtMw(live.ligniteMw)}</div> {t(T.strommix_field_wind_onshore)}
<div class="text-neutral-500">Steinkohle</div><div>{fmtMw(live.hardCoalMw)}</div> </div>
<div class="text-neutral-500">Erdgas</div><div>{fmtMw(live.gasMw)}</div> <div>{fmtMw(live.windOnshoreMw)}</div>
<div class="text-neutral-500">Kernkraft</div><div>{fmtMw(live.nuclearMw)}</div> <div class="text-neutral-500">
<div class="text-neutral-500">Biomasse</div><div>{fmtMw(live.biomassMw)}</div> {t(T.strommix_field_wind_offshore)}
<div class="text-neutral-500">Wasserkraft</div><div>{fmtMw(live.hydroMw)}</div> </div>
<div class="text-neutral-500 font-semibold">Last</div><div class="font-semibold">{fmtMw(live.loadMw)}</div> <div>{fmtMw(live.windOffshoreMw)}</div>
<div class="text-neutral-500">{t(T.strommix_field_solar)}</div>
<div>{fmtMw(live.solarMw)}</div>
<div class="text-neutral-500">
{t(T.strommix_field_lignite)}
</div>
<div>{fmtMw(live.ligniteMw)}</div>
<div class="text-neutral-500">
{t(T.strommix_field_hard_coal)}
</div>
<div>{fmtMw(live.hardCoalMw)}</div>
<div class="text-neutral-500">{t(T.strommix_field_gas)}</div>
<div>{fmtMw(live.gasMw)}</div>
<div class="text-neutral-500">
{t(T.strommix_field_nuclear)}
</div>
<div>{fmtMw(live.nuclearMw)}</div>
<div class="text-neutral-500">
{t(T.strommix_field_biomass)}
</div>
<div>{fmtMw(live.biomassMw)}</div>
<div class="text-neutral-500">{t(T.strommix_field_hydro)}</div>
<div>{fmtMw(live.hydroMw)}</div>
<div class="text-neutral-500 font-semibold">
{t(T.strommix_field_load)}
</div>
<div class="font-semibold">{fmtMw(live.loadMw)}</div>
</div> </div>
<p class="mt-1 text-[11px] text-neutral-500"> <p class="mt-1 text-[11px] text-neutral-500">
Quelle: {t(T.strommix_source)}
{#if live.dataSource === "smard"} {#if live.dataSource === "smard"}
smard.de (Bundesnetzagentur) {t(T.strommix_source_smard)}
{:else if live.dataSource === "smard+energy-charts"} {:else if live.dataSource === "smard+energy-charts"}
smard.de + energy-charts.info (Last-Wert ergänzt) {t(T.strommix_source_hybrid)}
{:else} {:else}
energy-charts.info (ENTSO-E + SMARD) {t(T.strommix_source_energy_charts)}
{/if} {/if}
15-Min-Auflösung, Stand:&nbsp;{fmtTime(live.measuredAtUnix)}&nbsp;Uhr. {t(T.strommix_source_resolution, {
Konventionell = Braunkohle + Steinkohle + Erdgas + Kernkraft (Pumpspeicher, time: fmtTime(live.measuredAtUnix),
Sonstige Konventionelle und Wasserkraft sind separat ausgewiesen). })}
{t(T.strommix_conventional_explanation)}
</p> </p>
</details> </details>
{/if} {/if}
<!-- Footer: disclaimer + explanation link + stale flag --> <!-- Footer: disclaimer + explanation link + stale flag -->
{#if disclaimerHtml || explanationHref || isStale} {#if disclaimerHtml || explanationHref || isStale}
<footer class="mt-3 text-xs text-neutral-600 flex flex-wrap items-start gap-x-4 gap-y-1"> <footer
class="mt-3 text-xs text-neutral-600 flex flex-wrap items-start gap-x-4 gap-y-1"
>
{#if disclaimerHtml} {#if disclaimerHtml}
<div class="flex-1 min-w-0 prose prose-xs">{@html disclaimerHtml}</div> <div class="flex-1 min-w-0 prose prose-xs">
{@html disclaimerHtml}
</div>
{/if} {/if}
{#if explanationHref} {#if explanationHref}
<a href={explanationHref} class="underline hover:no-underline"> <a href={explanationHref} class="underline hover:no-underline">
Wie wird das berechnet? {t(T.strommix_how_calculated)}
</a> </a>
{/if} {/if}
{#if isStale} {#if isStale}
<span class="inline-flex items-center gap-1 text-amber-700"> <span class="inline-flex items-center gap-1 text-amber-700">
<Icon icon="mdi:cloud-off-outline" class="size-3.5" aria-hidden="true" /> <Icon
gespeicherte Daten (Upstream gerade unerreichbar) icon="mdi:cloud-off-outline"
class="size-3.5"
aria-hidden="true"
/>
{t(T.strommix_stored_data)}
</span> </span>
{/if} {/if}
</footer> </footer>
+36
View File
@@ -168,6 +168,42 @@ const TRANSLATION_KEYS = [
"post_action_copied", "post_action_copied",
"post_action_print", "post_action_print",
"post_action_reading_time", "post_action_reading_time",
// Strommix-Live-Widget (StrommixBlock.svelte).
"strommix_header",
"strommix_stand", // {{time}}
"strommix_load_error", // {{error}}
"strommix_loading",
"strommix_wind_solar_share",
"strommix_load_missing",
"strommix_conventional_now",
"strommix_conventional_formula",
"strommix_saldo",
"strommix_import",
"strommix_export",
"strommix_delayed",
"strommix_price_de", // {{price}}
"strommix_price_fr_inline", // {{price}}
"strommix_slot", // {{time}}
"strommix_negative_price",
"strommix_check_values",
"strommix_field_wind_onshore",
"strommix_field_wind_offshore",
"strommix_field_solar",
"strommix_field_lignite",
"strommix_field_hard_coal",
"strommix_field_gas",
"strommix_field_nuclear",
"strommix_field_biomass",
"strommix_field_hydro",
"strommix_field_load",
"strommix_source",
"strommix_source_smard",
"strommix_source_hybrid",
"strommix_source_energy_charts",
"strommix_source_resolution", // {{time}}
"strommix_conventional_explanation",
"strommix_stored_data",
"strommix_how_calculated",
] as const; ] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number]; export type TranslationKey = (typeof TRANSLATION_KEYS)[number];