diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts
index 6091b58..5532604 100644
--- a/src/lib/block-types.ts
+++ b/src/lib/block-types.ts
@@ -362,6 +362,21 @@ export interface LiveStrommixBlockData {
disclaimer_threshold_eur_mwh?: number;
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
explanation_page?: string | { _slug?: string; title?: string };
+ // ---------------------------------------------------------------------
+ // Stammdaten (BNetzA Marktstammdatenregister, manuell gepflegt). Treiben
+ // den Live-vs-Installiert-Auslastungs-Bereich. Stand quartalsweise
+ // aktualisieren — die Werte bewegen sich um 1–3 GW pro Quartal.
+ // ---------------------------------------------------------------------
+ installed_wind_onshore_gw?: number;
+ installed_wind_offshore_gw?: number;
+ installed_solar_gw?: number;
+ /** Free-form Stand-Marker für die Stammdaten, z. B. "2025-Q4". */
+ installed_as_of?: string;
+ /** Optional: Hand-gepflegte EinsMan-Entschädigungssumme YTD (Mio €).
+ * BNetzA liefert das nur quartalsweise per PDF. Wenn nicht gesetzt,
+ * zeigt das Widget den Counter ohne Eurosumme. */
+ einsman_costs_ytd_eur_mio?: number;
+ einsman_costs_as_of?: string;
layout?: BlockLayout;
}
diff --git a/src/lib/components/blocks/StrommixBlock.svelte b/src/lib/components/blocks/StrommixBlock.svelte
index 93be455..92812a0 100644
--- a/src/lib/components/blocks/StrommixBlock.svelte
+++ b/src/lib/components/blocks/StrommixBlock.svelte
@@ -7,6 +7,11 @@
import { useTranslate, T } from "$lib/translations";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
+ import ResidualLoadChart from "./strommix/ResidualLoadChart.svelte";
+ import CapacityFactorBars from "./strommix/CapacityFactorBars.svelte";
+ import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte";
+ import NegPriceCounter from "./strommix/NegPriceCounter.svelte";
+ import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte";
let { block }: { block: LiveStrommixBlockData } = $props();
const t = useTranslate();
@@ -467,6 +472,77 @@
{/if}
{/if}
+
+ {#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)}
+
+
+
+ {/if}
+
+
+ {#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0}
+
+
+ Residuallast heute
+
+
+ Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke
+ füllen konventionelle Kraftwerke und Importe.
+
+
+
+
+
+ {/if}
+
+
+ {#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))}
+
+
+
+ {/if}
+
+
+ {#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")}
+
+
+
+ {/if}
+
+
+ {#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)}
+
+
+
+ {/if}
+
{#if live}
diff --git a/src/lib/components/blocks/strommix/CapacityFactorBars.svelte b/src/lib/components/blocks/strommix/CapacityFactorBars.svelte
new file mode 100644
index 0000000..93b6e94
--- /dev/null
+++ b/src/lib/components/blocks/strommix/CapacityFactorBars.svelte
@@ -0,0 +1,129 @@
+
+
+
+
+
+ Auslastung jetzt vs. installiert
+
+ {#if installedAsOf}
+
+ Installierte Leistung: Stand {installedAsOf},
+ Marktstammdatenregister
+
+ {/if}
+
+
+
+ {#each rows as row (row.label)}
+ {@const p = pct(row.liveMw, row.installedGw)}
+
+
+ {row.label}
+
+ {fmtGw(row.liveMw)}
+
+ / {row.installedGw.toLocaleString("de-DE", {
+ maximumFractionDigits: 1,
+ })} GW
+
+
+
+
+
+ {fmtPct(p)}
+
+ {Math.max(0, 100 - p).toLocaleString("de-DE", {
+ maximumFractionDigits: 0,
+ })} % nicht abgerufen
+
+
+
+ {/each}
+
+
diff --git a/src/lib/components/blocks/strommix/DunkelflauteCounter.svelte b/src/lib/components/blocks/strommix/DunkelflauteCounter.svelte
new file mode 100644
index 0000000..bc45d15
--- /dev/null
+++ b/src/lib/components/blocks/strommix/DunkelflauteCounter.svelte
@@ -0,0 +1,123 @@
+
+
+{#if hasLast || hasWorst}
+
+
+
+ {#if last}
+
+
+
+ Letzte Phase
+
+
+ {fmtAgo(last.endedHoursAgo)}
+
+
+
+
+ Beginn
+
+ {fmtDate(last.startUnix)}, {fmtTime(last.startUnix)}
+
+
+
+ Dauer
+
+ {fmtDuration(last.durationH)}
+
+
+
+ Tiefster Wert
+
+ {fmtPct(last.minPct)}
+
+
+
+
+ {/if}
+
+ {#if hasWorst && worst}
+
+
+ Längste Phasen
+
+
+ {#each worst as phase, i (phase.startUnix)}
+
+ #{i + 1}
+ {fmtDate(phase.startUnix)}
+
+ {fmtDuration(phase.durationH)}
+
+
+ min {fmtPct(phase.minPct)}
+
+
+ {/each}
+
+
+ {/if}
+
+{/if}
diff --git a/src/lib/components/blocks/strommix/FlowBalanceTodayYtd.svelte b/src/lib/components/blocks/strommix/FlowBalanceTodayYtd.svelte
new file mode 100644
index 0000000..fda00c1
--- /dev/null
+++ b/src/lib/components/blocks/strommix/FlowBalanceTodayYtd.svelte
@@ -0,0 +1,151 @@
+
+
+{#if visible}
+
+
+
+
+ {#if today}
+ {@const dir = direction(today.total)}
+
+
+ Heute
+
+
+
+ {fmtGwh(today.total)}
+
+ {dir.label}
+
+
+ {/if}
+ {#if ytd}
+ {@const dir = direction(ytd.total)}
+
+
+ {yearLabel} YTD
+
+
+
+ {fmtGwh(ytd.total)}
+
+ {dir.label}
+
+
+ {/if}
+
+
+ {#if ytd}
+
+
+ Aufschlüsselung nach Nachbarland (YTD)
+
+
+ {#each sortedRows(ytd.perCountry) as row (row.code)}
+ {@const isImport = row.gwh < 0}
+ {@const isExport = !isImport && row.gwh !== 0}
+
+
+ {COUNTRY_LABELS[row.code] ?? row.code}
+
+
+ {fmtGwh(row.gwh)}
+
+
+ {isImport ? "Import" : isExport ? "Export" : "·"}
+
+
+ {/each}
+
+
+ {/if}
+
+{/if}
diff --git a/src/lib/components/blocks/strommix/NegPriceCounter.svelte b/src/lib/components/blocks/strommix/NegPriceCounter.svelte
new file mode 100644
index 0000000..ae02cd9
--- /dev/null
+++ b/src/lib/components/blocks/strommix/NegPriceCounter.svelte
@@ -0,0 +1,75 @@
+
+
+{#if visible}
+
+
+
+
+ {#if showNeg && negHoursYtd != null}
+
+
+
+ {fmtNum(negHoursYtd)}
+
+ Stunden
+
+
+ mit negativem Day-Ahead-Preis
+ (DE-LU)
+
+
+ {/if}
+
+ {#if showEinsman && einsmanEurMio != null}
+
+
+
+ {fmtNum(einsmanEurMio, 0)}
+
+ Mio €
+
+
+ Entschädigung Abregelung (§ 13 EnWG, § 51 EEG)
+ {#if einsmanAsOf}
+
+ Stand {einsmanAsOf}
+
+ {/if}
+
+
+ {/if}
+
+
+{/if}
diff --git a/src/lib/components/blocks/strommix/ResidualLoadChart.svelte b/src/lib/components/blocks/strommix/ResidualLoadChart.svelte
new file mode 100644
index 0000000..41b4f59
--- /dev/null
+++ b/src/lib/components/blocks/strommix/ResidualLoadChart.svelte
@@ -0,0 +1,199 @@
+
+
+{#if points.length > 0}
+
+
+ {#each yTicks as t (t.mw)}
+
+
+ {t.label}
+
+ {/each}
+
+ {#if gapPath}
+
+ {/if}
+ {#if renewPath}
+
+ {/if}
+ {#if loadPath}
+
+ {/if}
+
+ {#each ticks as t (t.unix)}
+
+
+ {t.label}
+
+ {/each}
+
+
+
+
+ Last
+
+
+
+ Wind + Solar
+
+
+
+ Residuallast (Lücke)
+
+
+
+{/if}
diff --git a/src/lib/strommix.ts b/src/lib/strommix.ts
index 1f469c9..6269066 100644
--- a/src/lib/strommix.ts
+++ b/src/lib/strommix.ts
@@ -50,4 +50,62 @@ export type StrommixResponse = {
staleStrommix: boolean;
staleCrossborder: boolean;
stalePrice: boolean;
+
+ // ---------------------------------------------------------------------
+ // Historical aggregates (sourced from CMS `_history` endpoint over
+ // raw upstream snapshots; null = no data yet for this widget).
+ // ---------------------------------------------------------------------
+
+ /** Today's residual-load curve. One entry per upstream slot (typically
+ * 15 min). `loadMw - weatherRenewableMw` = the gap conventional plants
+ * + imports must fill. `null` when history backend is empty / disabled. */
+ todayHourly: ResidualLoadPoint[] | null;
+
+ /** Negative-price hours year-to-date (DE-LU, day-ahead). Counted by
+ * iterating history snapshots and summing 15-min slots whose price
+ * was below 0. */
+ negPriceHoursYtd: number | null;
+
+ /** Cross-border net balance YTD in GWh (positive = net export from DE,
+ * negative = net import). */
+ netCrossborderGwhYtd: number | null;
+
+ /** Per-country YTD balance, same sign convention as `netFlowGw`. */
+ crossborderByCountryGwhYtd: Record | null;
+
+ /** Cross-border balance for *today only*, GWh per country and total.
+ * Same sign convention. `null` when crossborder history is empty. */
+ crossborderTodayGwh: { total: number; perCountry: Record } | null;
+
+ /** Most recent contiguous run of `(wind+solar) / load < 10%`. `null`
+ * when no such run exists in retained history. `endedHoursAgo: 0`
+ * means the run is still ongoing (last slot is within the threshold). */
+ lastDunkelflaute: DunkelflautePhase | null;
+
+ /** Top 5 longest Dunkelflaute phases in retained history, newest first
+ * if equal length. Empty array when history exists but no qualifying
+ * phase found. `null` when history is unavailable. */
+ worstDunkelflauten: DunkelflautePhase[] | null;
+};
+
+/** One upstream slot in the residual-load series. */
+export type ResidualLoadPoint = {
+ /** Unix seconds at the start of the slot. */
+ unix: number;
+ /** Total electrical load on the German grid (MW). */
+ loadMw: number;
+ /** Wind onshore + wind offshore + solar (MW). */
+ weatherRenewableMw: number;
+};
+
+/** A contiguous low-renewables run, computed from history snapshots. */
+export type DunkelflautePhase = {
+ /** Unix sec of the first slot below the threshold. */
+ startUnix: number;
+ /** Run duration in hours (slot count × slot length). */
+ durationH: number;
+ /** Lowest Wind+Solar share observed during the run, percentage of load. */
+ minPct: number;
+ /** Hours since the run ended (0 = ongoing). */
+ endedHoursAgo: number;
};
diff --git a/src/routes/api/strommix/+server.ts b/src/routes/api/strommix/+server.ts
index 3063a94..a2fc321 100644
--- a/src/routes/api/strommix/+server.ts
+++ b/src/routes/api/strommix/+server.ts
@@ -23,7 +23,7 @@
import type { RequestHandler } from "./$types";
import { env } from "$env/dynamic/public";
-import type { StrommixResponse } from "$lib/strommix";
+import type { ResidualLoadPoint, StrommixResponse } from "$lib/strommix";
import { fetchSmardSnapshot } from "$lib/smard";
const CMS_BASE = (
@@ -147,6 +147,295 @@ type PriceArrayResp = {
price?: (number | null)[];
};
+/**
+ * One row from RustyCMS' `_history` endpoint. The `data` field carries the
+ * raw upstream JSON exactly as it was when persisted — projection happens
+ * client-side here so a future schema-mapping change cannot lose old data.
+ */
+type HistoryItem = { at: number; data: TData };
+type HistoryResponse = {
+ collection: string;
+ environment: string;
+ total: number;
+ returned: number;
+ items: HistoryItem[];
+};
+
+/**
+ * Generic helper: read a slice of an external collection's history.
+ * `from` / `to` are unix-seconds (the endpoint also accepts ISO strings,
+ * but unix avoids any TZ surprise on the client). Returns `null` when the
+ * upstream errors or has no rows yet — callers downgrade gracefully.
+ */
+async function fetchHistorySeries(
+ collection: string,
+ fromUnix: number,
+ toUnix: number,
+ fetcher: typeof fetch,
+ limit = 5000
+): Promise | null> {
+ try {
+ const url =
+ `${CMS_BASE}/api/content/${collection}/_history` +
+ `?from=${fromUnix}&to=${toUnix}&limit=${limit}`;
+ const res = await fetcher(url, { signal: AbortSignal.timeout(15_000) });
+ if (!res.ok) return null;
+ return (await res.json()) as HistoryResponse;
+ } catch {
+ return null;
+ }
+}
+
+/** Public-power upstream payload shape (as persisted in history). */
+type PublicPowerSnapshot = {
+ unix_seconds?: number[];
+ production_types?: PublicPowerSeries[];
+};
+
+/**
+ * Build today's residual-load curve from history snapshots. Each snapshot
+ * holds a window's worth of slots (4 × 15 min for a 1h chunk; 96 for a
+ * day-chunk backfill). We lay them on a Map keyed by `unix_seconds` so
+ * overlapping windows merge cleanly.
+ */
+function buildTodayHourly(snapshots: HistoryItem[]): ResidualLoadPoint[] {
+ const dayStartSec = (() => {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return Math.floor(d.getTime() / 1000);
+ })();
+
+ const merged = new Map();
+ for (const snap of snapshots) {
+ const ts = snap.data.unix_seconds ?? [];
+ const prod = snap.data.production_types ?? [];
+ const findData = (name: string): (number | null)[] =>
+ prod.find((p) => p.name === name)?.data ?? [];
+ const load = findData("Load");
+ const wOn = findData("Wind onshore");
+ const wOff = findData("Wind offshore");
+ const sol = findData("Solar");
+
+ for (let i = 0; i < ts.length; i++) {
+ const slot = ts[i];
+ if (slot < dayStartSec) continue;
+ const loadV = num(load[i]);
+ const renewV = num(wOn[i]) + num(wOff[i]) + num(sol[i]);
+ // Only keep slots with at least a load reading — empty future slots
+ // arrive as `null` from upstream and shouldn't appear on the chart.
+ if (load[i] == null) continue;
+ merged.set(slot, {
+ unix: slot,
+ loadMw: loadV,
+ weatherRenewableMw: renewV,
+ });
+ }
+ }
+ return Array.from(merged.values()).sort((a, b) => a.unix - b.unix);
+}
+
+/**
+ * Count negative-price slots in the price-history series. Energy-Charts
+ * delivers `price` arrays per snapshot; we sum slots with `price < 0`
+ * across all snapshots in the YTD window.
+ *
+ * Same caveat as `buildTodayHourly`: snapshots overlap by design, so we
+ * dedup by slot timestamp.
+ */
+function countNegPriceSlots(snapshots: HistoryItem[]): number {
+ const seen = new Set();
+ let count = 0;
+ for (const snap of snapshots) {
+ const ts = snap.data.unix_seconds ?? [];
+ const price = snap.data.price ?? [];
+ for (let i = 0; i < ts.length; i++) {
+ const slot = ts[i];
+ if (seen.has(slot)) continue;
+ seen.add(slot);
+ const p = price[i];
+ if (typeof p === "number" && p < 0) count += 1;
+ }
+ }
+ // Energy-Charts day-ahead is hourly: each "negative slot" = 1 negative hour.
+ return count;
+}
+
+/**
+ * Sum cross-border flows in `[fromUnix, +∞)` into a per-country total in
+ * GWh. Source values are GW per slot (typically 15 min) — multiplying by
+ * slot-duration in hours yields GWh. Slot duration is inferred from the
+ * gap between consecutive timestamps within a snapshot; defaults to 0.25h
+ * when only one slot is present.
+ *
+ * Slots are deduped across overlapping snapshots, so the same `fromUnix`
+ * cutoff can produce a today-only or YTD aggregate without double-counting.
+ */
+type CrossborderHistoryItem = HistoryItem<{
+ unix_seconds?: number[];
+ countries?: { name: string; data: (number | null)[] }[];
+}>;
+
+function aggregateCrossborderRange(
+ snapshots: CrossborderHistoryItem[],
+ fromUnix: number
+): { total: number; perCountry: Record } {
+ const slotDuration = new Map(); // slot → duration in hours
+ const byCountry: Record = {};
+ const counted = new Map>(); // country → slots already counted
+
+ const COUNTRY_CODE: Record = {
+ Austria: "AT",
+ Belgium: "BE",
+ "Czech Republic": "CZ",
+ Denmark: "DK",
+ France: "FR",
+ Luxembourg: "LU",
+ Netherlands: "NL",
+ Norway: "NO",
+ Poland: "PL",
+ Sweden: "SE",
+ Switzerland: "CH",
+ };
+
+ for (const snap of snapshots) {
+ const ts = snap.data.unix_seconds ?? [];
+ const slotH = ts.length > 1 ? (ts[1] - ts[0]) / 3600 : 0.25;
+ for (let i = 0; i < ts.length; i++) {
+ if (!slotDuration.has(ts[i])) slotDuration.set(ts[i], slotH);
+ }
+ for (const c of snap.data.countries ?? []) {
+ const code = COUNTRY_CODE[c.name];
+ if (!code) continue;
+ let seenForCountry = counted.get(code);
+ if (!seenForCountry) {
+ seenForCountry = new Set();
+ counted.set(code, seenForCountry);
+ }
+ let acc = byCountry[code] ?? 0;
+ for (let i = 0; i < ts.length; i++) {
+ const slot = ts[i];
+ if (slot < fromUnix) continue;
+ if (seenForCountry.has(slot)) continue;
+ const v = c.data[i];
+ if (typeof v !== "number" || !Number.isFinite(v)) continue;
+ const dur = slotDuration.get(slot) ?? slotH;
+ acc += v * dur;
+ seenForCountry.add(slot);
+ }
+ byCountry[code] = acc;
+ }
+ }
+ const total = Object.values(byCountry).reduce((a, b) => a + b, 0);
+ return { total, perCountry: byCountry };
+}
+
+/**
+ * Detect contiguous runs where Wind+Solar share of load drops below
+ * `thresholdPct` (e.g. 10 %). Operates on `strommix_history_de` snapshots:
+ * extracts per-slot load + wind+solar, dedups by slot, sorts, then walks
+ * the timeline.
+ *
+ * Returns the *latest* run plus the top `topN` longest runs (ties broken
+ * by recency). Empty list when history exists but no qualifying run.
+ */
+type SlotSample = { unix: number; load: number; renew: number; pct: number };
+
+function extractSlotSamples(
+ snapshots: HistoryItem[]
+): SlotSample[] {
+ const merged = new Map();
+ for (const snap of snapshots) {
+ const ts = snap.data.unix_seconds ?? [];
+ const prod = snap.data.production_types ?? [];
+ const find = (n: string) => prod.find((p) => p.name === n)?.data ?? [];
+ const load = find("Load");
+ const wOn = find("Wind onshore");
+ const wOff = find("Wind offshore");
+ const sol = find("Solar");
+ for (let i = 0; i < ts.length; i++) {
+ const lv = load[i];
+ if (typeof lv !== "number" || !Number.isFinite(lv) || lv <= 0) continue;
+ // Don't overwrite — first non-null wins per slot.
+ if (merged.has(ts[i])) continue;
+ const r = num(wOn[i]) + num(wOff[i]) + num(sol[i]);
+ merged.set(ts[i], { load: lv, renew: r });
+ }
+ }
+ const out: SlotSample[] = [];
+ for (const [unix, { load, renew }] of merged) {
+ out.push({ unix, load, renew, pct: (renew / load) * 100 });
+ }
+ out.sort((a, b) => a.unix - b.unix);
+ return out;
+}
+
+function detectDunkelflauten(
+ snapshots: HistoryItem[],
+ thresholdPct: number,
+ minDurationH: number,
+ topN: number,
+ nowUnix: number
+): { last: ReturnType | null; worst: ReturnType[] } {
+ const samples = extractSlotSamples(snapshots);
+ if (samples.length === 0) {
+ return { last: null, worst: [] };
+ }
+
+ // Infer slot length from first two samples; default 15 min.
+ const slotH = samples.length > 1 ? (samples[1].unix - samples[0].unix) / 3600 : 0.25;
+
+ type Run = { startUnix: number; endUnix: number; minPct: number; durationH: number };
+ const runs: Run[] = [];
+ let current: Run | null = null;
+ for (const s of samples) {
+ if (s.pct < thresholdPct) {
+ if (current == null) {
+ current = { startUnix: s.unix, endUnix: s.unix, minPct: s.pct, durationH: slotH };
+ } else {
+ current.endUnix = s.unix;
+ current.minPct = Math.min(current.minPct, s.pct);
+ current.durationH += slotH;
+ }
+ } else if (current != null) {
+ runs.push(current);
+ current = null;
+ }
+ }
+ if (current != null) runs.push(current);
+
+ const qualifying = runs.filter((r) => r.durationH >= minDurationH);
+ if (qualifying.length === 0) {
+ return { last: null, worst: [] };
+ }
+
+ const last = qualifying[qualifying.length - 1];
+ const worst = qualifying
+ .slice()
+ .sort((a, b) => b.durationH - a.durationH || b.startUnix - a.startUnix)
+ .slice(0, topN);
+
+ return {
+ last: toPhase(last, nowUnix, slotH),
+ worst: worst.map((r) => toPhase(r, nowUnix, slotH)),
+ };
+}
+
+function toPhase(
+ r: { startUnix: number; endUnix: number; minPct: number; durationH: number },
+ nowUnix: number,
+ slotH: number
+) {
+ // Run "ended" at endUnix + slotH (exclusive end of last slot).
+ const endPlusSlot = r.endUnix + slotH * 3600;
+ const endedHoursAgo = Math.max(0, (nowUnix - endPlusSlot) / 3600);
+ return {
+ startUnix: r.startUnix,
+ durationH: r.durationH,
+ minPct: r.minPct,
+ endedHoursAgo,
+ };
+}
+
/**
* Pick the day-ahead price slot whose start ≤ now. Energy-Charts
* returns 96 slots covering the day; the last cell is typically a
@@ -187,7 +476,29 @@ async function fetchPriceAtNow(
}
export const GET: RequestHandler = async ({ fetch }) => {
- const [smardRaw, energyCharts, cross, priceDe, priceFr] = await Promise.all([
+ const nowSecAtRequest = Math.floor(Date.now() / 1000);
+ const dayStartSec = (() => {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return Math.floor(d.getTime() / 1000);
+ })();
+ const yearStartSec = (() => {
+ const d = new Date();
+ d.setMonth(0, 1);
+ d.setHours(0, 0, 0, 0);
+ return Math.floor(d.getTime() / 1000);
+ })();
+
+ const [
+ smardRaw,
+ energyCharts,
+ cross,
+ priceDe,
+ priceFr,
+ histStrommix,
+ histPriceDe,
+ histCrossborder,
+ ] = await Promise.all([
fetchSmardSnapshot(fetch),
// Energy-Charts /public_power direct (with explicit time range —
// upstream now returns 404 without start/end). Used as per-field
@@ -197,6 +508,26 @@ export const GET: RequestHandler = async ({ fetch }) => {
fetchOne("crossborder_de", fetch),
fetchPriceAtNow("DE-LU", fetch),
fetchPriceAtNow("FR", fetch),
+ // History reads — null when CMS has no history backend yet, so the
+ // widget gracefully renders without the historical panels.
+ fetchHistorySeries(
+ "strommix_history_de",
+ dayStartSec,
+ nowSecAtRequest,
+ fetch
+ ),
+ fetchHistorySeries(
+ "price_de_history",
+ yearStartSec,
+ nowSecAtRequest,
+ fetch
+ ),
+ fetchHistorySeries<{ unix_seconds?: number[]; countries?: { name: string; data: (number | null)[] }[] }>(
+ "crossborder_history_de",
+ yearStartSec,
+ nowSecAtRequest,
+ fetch
+ ),
]);
// SMARD aligned-min picks the earliest "latest" across filters. If
@@ -206,7 +537,7 @@ export const GET: RequestHandler = async ({ fetch }) => {
// when it is older than 90 min so the aggregator falls back to
// Energy-Charts entirely. 90 min lets typical 30–60 min SMARD lag
// still win.
- const nowSec = Math.floor(Date.now() / 1000);
+ const nowSec = nowSecAtRequest;
const smard =
smardRaw && nowSec - smardRaw.measuredAtUnix < 90 * 60 ? smardRaw : null;
@@ -265,6 +596,30 @@ export const GET: RequestHandler = async ({ fetch }) => {
};
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
+ // Historical aggregates derived from CMS `_history`. `null` when the
+ // backing collection is unavailable (e.g. CMS not yet upgraded with
+ // history-mode schemas) — widget renders without the affected panels.
+ const todayHourly =
+ histStrommix && histStrommix.items.length > 0
+ ? buildTodayHourly(histStrommix.items)
+ : null;
+ const negPriceHoursYtd = histPriceDe ? countNegPriceSlots(histPriceDe.items) : null;
+ const crossborderYtd = histCrossborder
+ ? aggregateCrossborderRange(histCrossborder.items, yearStartSec)
+ : null;
+ const crossborderToday = histCrossborder
+ ? aggregateCrossborderRange(histCrossborder.items, dayStartSec)
+ : null;
+
+ // Dunkelflaute detection — tunables match the existing widget thresholds:
+ // < 10 % renewable share, runs of at least 1 h. Thresholds intentionally
+ // hard-coded here, not driven by block schema, since they're a fixed
+ // *fact* about the data (not editorial choice).
+ const dunkelflaute =
+ histStrommix && histStrommix.items.length > 0
+ ? detectDunkelflauten(histStrommix.items, 10, 1, 5, nowSecAtRequest)
+ : null;
+
const body: StrommixResponse = {
measuredAtUnix: smard?.measuredAtUnix ?? ec.measured_at_unix ?? null,
dataSource: !smard ? "energy-charts" : usedFallback ? "smard+energy-charts" : "smard",
@@ -289,6 +644,15 @@ export const GET: RequestHandler = async ({ fetch }) => {
staleStrommix: smard == null && !energyCharts.data,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,
+ todayHourly,
+ negPriceHoursYtd,
+ netCrossborderGwhYtd: crossborderYtd?.total ?? null,
+ crossborderByCountryGwhYtd: crossborderYtd?.perCountry ?? null,
+ crossborderTodayGwh: crossborderToday
+ ? { total: crossborderToday.total, perCountry: crossborderToday.perCountry }
+ : null,
+ lastDunkelflaute: dunkelflaute?.last ?? null,
+ worstDunkelflauten: dunkelflaute?.worst ?? null,
};
return new Response(JSON.stringify(body), {