diff --git a/src/lib/components/blocks/StrommixBlock.svelte b/src/lib/components/blocks/StrommixBlock.svelte
index c63ddfd..efc0435 100644
--- a/src/lib/components/blocks/StrommixBlock.svelte
+++ b/src/lib/components/blocks/StrommixBlock.svelte
@@ -99,6 +99,15 @@
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"
@@ -198,6 +207,7 @@
Konventionell jetzt
{fmtMw(live.conventionalMw)}
+
Braunkohle + Steinkohle + Erdgas + Kernkraft
@@ -208,6 +218,12 @@
{#if importDirection === "Export"}↑{/if}
{fmtGw(live.netFlowGw)}
+ {#if live.crossborderMeasuredAtUnix}
+
+ Stand: {fmtTime(live.crossborderMeasuredAtUnix)} Uhr
+ {#if cbStale} · verzögert{/if}
+
+ {/if}
@@ -218,6 +234,11 @@
{#if enableFr && live.priceFrEurMwh != null}
(Frankreich: {fmtPrice(live.priceFrEurMwh)})
{/if}
+ {#if live.priceMeasuredAtUnix}
+
+ · Slot {fmtTime(live.priceMeasuredAtUnix)}
+
+ {/if}
{#if (live.priceDeEurMwh ?? 0) < 0}
diff --git a/src/lib/strommix.ts b/src/lib/strommix.ts
index b5a69ef..9e98adc 100644
--- a/src/lib/strommix.ts
+++ b/src/lib/strommix.ts
@@ -7,6 +7,9 @@
*/
export type StrommixResponse = {
+ // Production-snapshot timestamp (Unix sec) — used as the "anchor" for
+ // the rest of the values. Cross-border + prices may have their own,
+ // earlier or later, timestamps.
measuredAtUnix: number | null;
// Production (MW)
windOnshoreMw: number;
@@ -24,10 +27,18 @@ export type StrommixResponse = {
conventionalMw: number;
// Cross-border (GW; positive = net export from DE, negative = import)
netFlowGw: number;
+ /** When was the cross-border saldo last reported by upstream? cbpf
+ * typically lags 1–2 h behind real time. Widget compares to
+ * `measuredAtUnix` to decide whether to show "Stand HH:MM". */
+ crossborderMeasuredAtUnix: number | null;
flowsByCountry: Record;
- // Prices (EUR/MWh; null if upstream unavailable)
+ // Prices (EUR/MWh; null if upstream unavailable). Picked from the
+ // 15-min day-ahead slot whose interval contains "now", not the last
+ // non-null cell of the array.
priceDeEurMwh: number | null;
priceFrEurMwh: number | null;
+ /** Unix sec of the price-slot start that produced `priceDeEurMwh`. */
+ priceMeasuredAtUnix: number | null;
// Stale flags surfaced from the CMS' `x-rustycms-external-stale` header
staleStrommix: boolean;
staleCrossborder: boolean;
diff --git a/src/routes/api/strommix/+server.ts b/src/routes/api/strommix/+server.ts
index 4c92385..be01b03 100644
--- a/src/routes/api/strommix/+server.ts
+++ b/src/routes/api/strommix/+server.ts
@@ -53,11 +53,6 @@ type CrossborderRaw = {
measured_at_unix?: number | null;
};
-type PriceRaw = {
- price_eur_mwh?: number | null;
- measured_at_unix?: number | null;
-};
-
function num(x: unknown): number {
return typeof x === "number" && Number.isFinite(x) ? x : 0;
}
@@ -80,12 +75,62 @@ async function fetchOne(
}
}
+/**
+ * Day-ahead prices come as a 96-slot array (15-min intervals over 24 h)
+ * and the CMS' `last_non_null` transform always picks the LAST known
+ * slot — typically a slot in the future, not the one covering "now".
+ * Bypass the CMS for prices and pick the slot whose start time is the
+ * largest one ≤ now. Returns null when the upstream lacks a usable
+ * value.
+ */
+type PriceArrayResp = {
+ unix_seconds?: number[];
+ price?: (number | null)[];
+};
+
+async function fetchPriceAtNow(
+ bzn: string,
+ fetcher: typeof fetch
+): Promise<{ price: number | null; slotUnix: number | null; stale: boolean }> {
+ try {
+ const res = await fetcher(
+ `https://api.energy-charts.info/price?bzn=${encodeURIComponent(bzn)}`,
+ { signal: AbortSignal.timeout(8_000) }
+ );
+ if (!res.ok) return { price: null, slotUnix: null, stale: false };
+ const data = (await res.json()) as PriceArrayResp;
+ const times = data.unix_seconds ?? [];
+ const prices = data.price ?? [];
+ if (times.length === 0 || prices.length === 0) {
+ return { price: null, slotUnix: null, stale: false };
+ }
+ const nowSec = Math.floor(Date.now() / 1000);
+ let pickIdx = -1;
+ for (let i = 0; i < times.length; i++) {
+ if (times[i] <= nowSec) pickIdx = i;
+ else break;
+ }
+ if (pickIdx < 0) return { price: null, slotUnix: null, stale: false };
+ const value = prices[pickIdx];
+ return {
+ price: typeof value === "number" && Number.isFinite(value) ? value : null,
+ slotUnix: times[pickIdx] ?? null,
+ stale: false,
+ };
+ } catch {
+ return { price: null, slotUnix: null, stale: true };
+ }
+}
+
export const GET: RequestHandler = async ({ fetch }) => {
const [strommix, cross, priceDe, priceFr] = await Promise.all([
fetchOne("strommix_de", fetch),
fetchOne("crossborder_de", fetch),
- fetchOne("price_de", fetch),
- fetchOne("price_fr", fetch),
+ // Prices fetched directly so we can index by "now" instead of
+ // taking the last (potentially future) slot the CMS' last_non_null
+ // transform would pick.
+ fetchPriceAtNow("DE-LU", fetch),
+ fetchPriceAtNow("FR", fetch),
]);
const s = strommix.data ?? {};
@@ -125,9 +170,11 @@ export const GET: RequestHandler = async ({ fetch }) => {
weatherRenewableMw,
conventionalMw,
netFlowGw,
+ crossborderMeasuredAtUnix: c.measured_at_unix ?? null,
flowsByCountry,
- priceDeEurMwh: priceDe.data?.price_eur_mwh ?? null,
- priceFrEurMwh: priceFr.data?.price_eur_mwh ?? null,
+ priceDeEurMwh: priceDe.price,
+ priceFrEurMwh: priceFr.price,
+ priceMeasuredAtUnix: priceDe.slotUnix,
staleStrommix: strommix.stale,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,