fix(strommix): full i18n + drop hardcoded grid constraints
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:
@@ -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.5–2 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.5–2 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 & 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: {fmtTime(live.measuredAtUnix)} 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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user