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
+424 -287
View File
@@ -1,314 +1,451 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { LiveStrommixBlockData } from "$lib/block-types";
import type { StrommixResponse } from "$lib/strommix";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { onMount, onDestroy } from "svelte";
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { LiveStrommixBlockData } from "$lib/block-types";
import type { StrommixResponse } from "$lib/strommix";
import { useTranslate, T } from "$lib/translations";
import Icon from "@iconify/svelte";
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));
// Live data + lifecycle
let live = $state<StrommixResponse | null>(null);
let loadError = $state<string | null>(null);
let lastFetchAt = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Live data + lifecycle
let live = $state<StrommixResponse | null>(null);
let loadError = $state<string | null>(null);
let lastFetchAt = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function loadLive() {
try {
const res = await fetch("/api/strommix", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
live = (await res.json()) as StrommixResponse;
lastFetchAt = Date.now();
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
async function loadLive() {
try {
const res = await fetch("/api/strommix", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
live = (await res.json()) as StrommixResponse;
lastFetchAt = Date.now();
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
}
onMount(() => {
loadLive();
pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
onMount(() => {
loadLive();
pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
// Derived numbers
const renewablePct = $derived.by(() => {
if (!live || live.loadMw <= 0) return null;
return (live.weatherRenewableMw / live.loadMw) * 100;
});
// Derived numbers
const renewablePct = $derived.by(() => {
if (!live || live.loadMw <= 0) return null;
return (live.weatherRenewableMw / live.loadMw) * 100;
});
type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch";
const bucket = $derived.by((): Bucket | null => {
if (renewablePct == null) return null;
const t1 = block.threshold_dunkelflaute ?? 15;
const t2 = block.threshold_niedrig ?? 35;
const t3 = block.threshold_mittel ?? 60;
if (renewablePct < t1) return "dunkelflaute";
if (renewablePct < t2) return "niedrig";
if (renewablePct < t3) return "mittel";
return "hoch";
});
type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch";
const bucket = $derived.by((): Bucket | null => {
if (renewablePct == null) return null;
const t1 = block.threshold_dunkelflaute ?? 15;
const t2 = block.threshold_niedrig ?? 35;
const t3 = block.threshold_mittel ?? 60;
if (renewablePct < t1) return "dunkelflaute";
if (renewablePct < t2) return "niedrig";
if (renewablePct < t3) return "mittel";
return "hoch";
});
const statusLabel = $derived.by(() => {
if (!bucket) return "";
const labels: Record<Bucket, string | undefined> = {
dunkelflaute: block.label_dunkelflaute,
niedrig: block.label_niedrig,
mittel: block.label_mittel,
hoch: block.label_hoch,
};
return labels[bucket] ?? bucket;
});
const statusLabel = $derived.by(() => {
if (!bucket) return "";
const labels: Record<Bucket, string | undefined> = {
dunkelflaute: block.label_dunkelflaute,
niedrig: block.label_niedrig,
mittel: block.label_mittel,
hoch: block.label_hoch,
};
return labels[bucket] ?? bucket;
});
const argumentMd = $derived.by(() => {
if (!bucket) return "";
const map: Record<Bucket, string | undefined> = {
dunkelflaute: block.argument_dunkelflaute,
niedrig: block.argument_niedrig,
mittel: block.argument_mittel,
hoch: block.argument_hoch,
};
return map[bucket] ?? "";
});
const argumentMd = $derived.by(() => {
if (!bucket) return "";
const map: Record<Bucket, string | undefined> = {
dunkelflaute: block.argument_dunkelflaute,
niedrig: block.argument_niedrig,
mittel: block.argument_mittel,
hoch: block.argument_hoch,
};
return map[bucket] ?? "";
});
const argumentHtml = $derived(argumentMd ? (marked.parse(argumentMd) 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 argumentHtml = $derived(
argumentMd ? (marked.parse(argumentMd) 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 {
return (mw / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " GW";
}
function fmtGw(gw: number): string {
return Math.abs(gw).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " GW";
}
function fmtPrice(eurMwh: number | null): string {
if (eurMwh == null) return "—";
return Math.round(eurMwh).toLocaleString("de-DE") + " €/MWh";
}
function fmtTime(unix: number | null): string {
if (!unix) return "";
const d = new Date(unix * 1000);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
/** Cross-border data is considered stale (and worth flagging) when its
* timestamp is more than 30 min behind the production-snapshot. cbpf
* typically lags 1.52 h, so this is the rule rather than the
* exception — surfacing the stamp keeps the widget honest. */
const cbStale = $derived.by(() => {
if (!live?.measuredAtUnix || !live?.crossborderMeasuredAtUnix) return false;
return live.measuredAtUnix - live.crossborderMeasuredAtUnix > 30 * 60;
});
const bucketAccent = $derived.by(() => {
if (!bucket) return "neutral";
return bucket === "dunkelflaute"
? "danger"
: bucket === "niedrig"
? "warning"
: bucket === "mittel"
? "info"
: "success";
});
const accentClasses = $derived.by(() => {
switch (bucketAccent) {
case "danger":
return { box: "border-red-300 bg-red-50", pct: "text-red-800", label: "text-red-700" };
case "warning":
return { box: "border-amber-300 bg-amber-50", pct: "text-amber-800", label: "text-amber-700" };
case "info":
return { box: "border-sky-300 bg-sky-50", pct: "text-sky-800", label: "text-sky-700" };
case "success":
return { box: "border-emerald-300 bg-emerald-50", pct: "text-emerald-800", label: "text-emerald-700" };
default:
return { box: "border-neutral-200 bg-neutral-50", pct: "text-neutral-800", label: "text-neutral-700" };
function fmtMw(mw: number): string {
return (
(mw / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) +
" GW"
);
}
function fmtGw(gw: number): string {
return (
Math.abs(gw).toLocaleString("de-DE", { maximumFractionDigits: 1 }) +
" GW"
);
}
function fmtPrice(eurMwh: number | null): string {
if (eurMwh == null) return "—";
return Math.round(eurMwh).toLocaleString("de-DE") + " €/MWh";
}
function fmtTime(unix: number | null): string {
if (!unix) return "";
const d = new Date(unix * 1000);
return d.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
}
});
const explanationHref = $derived.by(() => {
const ep = block.explanation_page;
if (!ep) return "";
if (typeof ep === "string") return ep ? `/${ep}/` : "";
if (ep._slug) return `/${ep._slug}/`;
return "";
});
/** Cross-border data is considered stale (and worth flagging) when its
* timestamp is more than 30 min behind the production-snapshot. cbpf
* typically lags 1.52 h, so this is the rule rather than the
* exception — surfacing the stamp keeps the widget honest. */
const cbStale = $derived.by(() => {
if (!live?.measuredAtUnix || !live?.crossborderMeasuredAtUnix)
return false;
return live.measuredAtUnix - live.crossborderMeasuredAtUnix > 30 * 60;
});
const isStale = $derived(
!!(live && (live.staleStrommix || live.staleCrossborder || live.stalePrice))
);
const importDirection = $derived.by(() => {
if (!live) return null;
return live.netFlowGw >= 0 ? "Export" : "Import";
});
const bucketAccent = $derived.by(() => {
if (!bucket) return "neutral";
return bucket === "dunkelflaute"
? "danger"
: bucket === "niedrig"
? "warning"
: bucket === "mittel"
? "info"
: "success";
});
const accentClasses = $derived.by(() => {
switch (bucketAccent) {
case "danger":
return {
box: "border-red-300 bg-red-50",
pct: "text-red-800",
label: "text-red-700",
};
case "warning":
return {
box: "border-amber-300 bg-amber-50",
pct: "text-amber-800",
label: "text-amber-700",
};
case "info":
return {
box: "border-sky-300 bg-sky-50",
pct: "text-sky-800",
label: "text-sky-700",
};
case "success":
return {
box: "border-emerald-300 bg-emerald-50",
pct: "text-emerald-800",
label: "text-emerald-700",
};
default:
return {
box: "border-neutral-200 bg-neutral-50",
pct: "text-neutral-800",
label: "text-neutral-700",
};
}
});
const explanationHref = $derived.by(() => {
const ep = block.explanation_page;
if (!ep) return "";
if (typeof ep === "string") return ep ? `/${ep}/` : "";
if (ep._slug) return `/${ep._slug}/`;
return "";
});
const isStale = $derived(
!!(
live &&
(live.staleStrommix || live.staleCrossborder || live.stalePrice)
),
);
const importDirection = $derived.by(() => {
if (!live) return null;
return live.netFlowGw >= 0 ? "Export" : "Import";
});
</script>
<div
class="live-strommix col-span-12 lg:col-span-8 lg:col-start-3 {layoutClasses}"
data-block-type="live_strommix"
data-block-slug={block._slug}
class="live-strommix {layoutClasses}"
data-block-type="live_strommix"
data-block-slug={block._slug}
>
{#if block.intro_headline || introHtml}
<header class="mb-4">
{#if block.intro_headline}
<h2 class="text-2xl font-bold mb-2">{block.intro_headline}</h2>
{/if}
{#if introHtml}
<div class="prose prose-sm">{@html introHtml}</div>
{/if}
</header>
{/if}
{#if block.intro_headline || introHtml}
<header class="mb-4">
{#if block.intro_headline}
<h2 class="text-2xl font-bold mb-2">{block.intro_headline}</h2>
{/if}
{#if introHtml}
<div class="prose prose-sm">{@html introHtml}</div>
{/if}
</header>
{/if}
<div class="rounded-lg border-2 shadow-sm overflow-hidden {accentClasses.box}">
<!-- 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}">
<span class="inline-flex items-center gap-1.5">
<Icon icon="mdi:transmission-tower" class="size-4" aria-hidden="true" />
Strommix jetzt
</span>
{#if live?.measuredAtUnix}
<span class="font-normal normal-case">Stand: {fmtTime(live.measuredAtUnix)} Uhr</span>
{/if}
<div
class="rounded-xs border-2 shadow-sm overflow-hidden {accentClasses.box}"
>
<!-- 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}"
>
<span class="inline-flex items-center gap-1.5">
<Icon
icon="mdi:transmission-tower"
class="size-4"
aria-hidden="true"
/>
{t(T.strommix_header)}
</span>
{#if live?.measuredAtUnix}
<span class="font-normal normal-case"
>{t(T.strommix_stand, {
time: fmtTime(live.measuredAtUnix),
})}</span
>
{/if}
</div>
<!-- Main: percentage + status -->
{#if loadError}
<div class="p-6 text-sm text-red-700">
{t(T.strommix_load_error, { error: loadError })}
</div>
{:else if !live}
<div class="p-6 text-center text-neutral-500 text-sm">
{t(T.strommix_loading)}
</div>
{:else}
<div class="p-6 text-center">
{#if renewablePct != null}
<div
class="text-6xl sm:text-7xl font-extrabold tabular-nums {accentClasses.pct}"
>
{Math.round(renewablePct)}%
</div>
<div class="mt-1 text-sm text-neutral-700">
{t(T.strommix_wind_solar_share)}
</div>
<div
class="mt-3 text-base font-semibold {accentClasses.label}"
>
{statusLabel}
</div>
{#if argumentHtml}
<div
class="prose prose-sm mt-3 max-w-prose mx-auto text-neutral-800"
>
{@html argumentHtml}
</div>
{/if}
{:else}
<!-- Production data arrived but load is missing → no percentage,
but we still show what we have so the widget isn't blank. -->
<div class="text-sm text-amber-700">
{t(T.strommix_load_missing)}
</div>
{/if}
</div>
<!-- Context row -->
<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="text-xs text-neutral-600 uppercase tracking-wide"
>
{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 class="px-4 py-3">
<div
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 class="text-lg font-semibold tabular-nums">
{#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
</div>
{#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-neutral-500 mt-0.5">
{t(T.strommix_stand, {
time: fmtTime(live.crossborderMeasuredAtUnix),
})}
{#if cbStale}<span class="text-amber-700">
· {t(T.strommix_delayed)}</span
>{/if}
</div>
{/if}
</div>
</div>
<!-- Price row -->
<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">
<span class="font-semibold"
>{t(T.strommix_price_de, {
price: fmtPrice(live.priceDeEurMwh),
})}</span
>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-neutral-600"
>{t(T.strommix_price_fr_inline, {
price: fmtPrice(live.priceFrEurMwh),
})}</span
>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-neutral-500">
· {t(T.strommix_slot, {
time: fmtTime(live.priceMeasuredAtUnix),
})}
</span>
{/if}
{#if (live.priceDeEurMwh ?? 0) < 0}
<span
class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold"
>
<Icon
icon="mdi:alert-circle-outline"
class="size-4"
aria-hidden="true"
/>
{t(T.strommix_negative_price)}
</span>
{/if}
</div>
</div>
{/if}
</div>
<!-- Main: percentage + status -->
{#if loadError}
<div class="p-6 text-sm text-red-700">
Live-Daten nicht erreichbar: {loadError}
</div>
{:else if !live}
<div class="p-6 text-center text-neutral-500 text-sm">Lade Live-Daten…</div>
{:else}
<div class="p-6 text-center">
{#if renewablePct != null}
<div class="text-6xl sm:text-7xl font-extrabold tabular-nums {accentClasses.pct}">
{Math.round(renewablePct)}%
</div>
<div class="mt-1 text-sm text-neutral-700">Wind &amp; Solar am Bedarf</div>
<div class="mt-3 text-base font-semibold {accentClasses.label}">{statusLabel}</div>
{#if argumentHtml}
<div class="prose prose-sm mt-3 max-w-prose mx-auto text-neutral-800">
{@html argumentHtml}
</div>
{/if}
{:else}
<!-- Production data arrived but load is missing → no percentage,
but we still show what we have so the widget isn't blank. -->
<div class="text-sm text-amber-700">
Lastwert von SMARD gerade nicht verfügbar — Prozentanzeige ausgesetzt.
Erzeugung wird unten in der Detailansicht weiter angezeigt.
</div>
{/if}
</div>
<!-- Context row -->
<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="text-xs text-neutral-600 uppercase tracking-wide">Konventionell jetzt</div>
<div class="text-lg font-semibold tabular-nums">{fmtMw(live.conventionalMw)}</div>
<div class="text-[11px] text-neutral-500 mt-0.5">Braunkohle + Steinkohle + Erdgas + Kernkraft</div>
</div>
<div class="px-4 py-3">
<div class="text-xs text-neutral-600 uppercase tracking-wide">
{importDirection ?? "Saldo"}
</div>
<div class="text-lg font-semibold tabular-nums">
{#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
</div>
{#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-neutral-500 mt-0.5">
Stand: {fmtTime(live.crossborderMeasuredAtUnix)} Uhr
{#if cbStale}<span class="text-amber-700"> · verzögert</span>{/if}
</div>
{/if}
</div>
</div>
<!-- Price row -->
<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">
<span class="font-semibold">Strompreis DE: <span class="tabular-nums">{fmtPrice(live.priceDeEurMwh)}</span></span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-neutral-600">(Frankreich: <span class="tabular-nums">{fmtPrice(live.priceFrEurMwh)}</span>)</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-neutral-500">
· Slot {fmtTime(live.priceMeasuredAtUnix)}
</span>
{/if}
{#if (live.priceDeEurMwh ?? 0) < 0}
<span class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold">
<Icon icon="mdi:alert-circle-outline" class="size-4" aria-hidden="true" />
Negativpreis
</span>
{/if}
</div>
</div>
{/if}
</div>
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
{#if live}
<details class="mt-3 text-xs">
<summary class="cursor-pointer text-neutral-600 hover:text-neutral-900">
Wert prüfen — alle Komponenten anzeigen
</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 class="text-neutral-500">Wind onshore</div><div>{fmtMw(live.windOnshoreMw)}</div>
<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">Braunkohle</div><div>{fmtMw(live.ligniteMw)}</div>
<div class="text-neutral-500">Steinkohle</div><div>{fmtMw(live.hardCoalMw)}</div>
<div class="text-neutral-500">Erdgas</div><div>{fmtMw(live.gasMw)}</div>
<div class="text-neutral-500">Kernkraft</div><div>{fmtMw(live.nuclearMw)}</div>
<div class="text-neutral-500">Biomasse</div><div>{fmtMw(live.biomassMw)}</div>
<div class="text-neutral-500">Wasserkraft</div><div>{fmtMw(live.hydroMw)}</div>
<div class="text-neutral-500 font-semibold">Last</div><div class="font-semibold">{fmtMw(live.loadMw)}</div>
</div>
<p class="mt-1 text-[11px] text-neutral-500">
Quelle:
{#if live.dataSource === "smard"}
smard.de (Bundesnetzagentur)
{:else if live.dataSource === "smard+energy-charts"}
smard.de + energy-charts.info (Last-Wert ergänzt)
{:else}
energy-charts.info (ENTSO-E + SMARD)
{/if}
— 15-Min-Auflösung, Stand:&nbsp;{fmtTime(live.measuredAtUnix)}&nbsp;Uhr.
Konventionell = Braunkohle + Steinkohle + Erdgas + Kernkraft (Pumpspeicher,
Sonstige Konventionelle und Wasserkraft sind separat ausgewiesen).
</p>
</details>
{/if}
{#if live}
<details class="mt-3 text-xs">
<summary
class="cursor-pointer text-neutral-600 hover:text-neutral-900"
>
{t(T.strommix_check_values)}
</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 class="text-neutral-500">
{t(T.strommix_field_wind_onshore)}
</div>
<div>{fmtMw(live.windOnshoreMw)}</div>
<div class="text-neutral-500">
{t(T.strommix_field_wind_offshore)}
</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>
<p class="mt-1 text-[11px] text-neutral-500">
{t(T.strommix_source)}
{#if live.dataSource === "smard"}
{t(T.strommix_source_smard)}
{:else if live.dataSource === "smard+energy-charts"}
{t(T.strommix_source_hybrid)}
{:else}
{t(T.strommix_source_energy_charts)}
{/if}
{t(T.strommix_source_resolution, {
time: fmtTime(live.measuredAtUnix),
})}
{t(T.strommix_conventional_explanation)}
</p>
</details>
{/if}
<!-- Footer: disclaimer + explanation link + stale flag -->
{#if disclaimerHtml || explanationHref || isStale}
<footer class="mt-3 text-xs text-neutral-600 flex flex-wrap items-start gap-x-4 gap-y-1">
{#if disclaimerHtml}
<div class="flex-1 min-w-0 prose prose-xs">{@html disclaimerHtml}</div>
{/if}
{#if explanationHref}
<a href={explanationHref} class="underline hover:no-underline">
Wie wird das berechnet?
</a>
{/if}
{#if isStale}
<span class="inline-flex items-center gap-1 text-amber-700">
<Icon icon="mdi:cloud-off-outline" class="size-3.5" aria-hidden="true" />
gespeicherte Daten (Upstream gerade unerreichbar)
</span>
{/if}
</footer>
{/if}
<!-- Footer: disclaimer + explanation link + stale flag -->
{#if disclaimerHtml || explanationHref || isStale}
<footer
class="mt-3 text-xs text-neutral-600 flex flex-wrap items-start gap-x-4 gap-y-1"
>
{#if disclaimerHtml}
<div class="flex-1 min-w-0 prose prose-xs">
{@html disclaimerHtml}
</div>
{/if}
{#if explanationHref}
<a href={explanationHref} class="underline hover:no-underline">
{t(T.strommix_how_calculated)}
</a>
{/if}
{#if isStale}
<span class="inline-flex items-center gap-1 text-amber-700">
<Icon
icon="mdi:cloud-off-outline"
class="size-3.5"
aria-hidden="true"
/>
{t(T.strommix_stored_data)}
</span>
{/if}
</footer>
{/if}
</div>
+36
View File
@@ -168,6 +168,42 @@ const TRANSLATION_KEYS = [
"post_action_copied",
"post_action_print",
"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;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];