feat(blocks): live_strommix widget for energy-charts data
Editor-controlled live-strommix card that surfaces what share of
electricity demand is currently covered by weather-dependent
renewables (Wind+Solar / Load), plus the conventional generation that
fills the gap, the cross-border saldo, and the day-ahead price (DE/FR
side-by-side). Status bucket + label + argument come from the CMS so
editors can iterate copy without a deploy.
- LiveStrommixBlockData type next to the other block-types.
- New `/api/strommix` aggregator route bundles four CMS external
collections (strommix_de, crossborder_de, price_de, price_fr) into a
single payload, computes the renewable + conventional sums and the
cross-border saldo (Σ flow_xx_gw, sign convention: + = export from
DE), surfaces stale-on-error flags from the CMS response headers.
Cache-control 5min so a single node only hits energy-charts once
per window.
- StrommixBlock.svelte renders the card: percentage, bucket label,
bucket argument (markdown), context row (conventional + saldo with
↑/↓ arrow), price row with optional FR comparison + negative-price
badge, footer with disclaimer + "Wie wird das berechnet?" link +
stale-data hint when the CMS served fallback. Re-polls every 5min
client-side; bucket thresholds + labels + arguments come from the
block instance so multiple placements can carry different copy.
- ContentRows.svelte dispatcher branch on `_type === "live_strommix"`.
Wired against `cms_content/_types/core/live_strommix.json5` (added in
the rustycms repo today). content_layout.row{1,2,3}Content already
allow the new block-type via the same commit on the CMS side.
This commit is contained in:
@@ -331,6 +331,32 @@ export interface OrganisationsBlockData {
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Live-Strommix-Widget (_type: "live_strommix"). Editorial-Konfig + Platzierungs-Instanz.
|
||||
* Fetched live data from /api/strommix (aggregator) and renders status bucket
|
||||
* derived from `(wind_onshore + wind_offshore + solar) / load * 100`. */
|
||||
export interface LiveStrommixBlockData {
|
||||
_type?: "live_strommix";
|
||||
_slug?: string;
|
||||
intro_headline?: string;
|
||||
intro_text?: string;
|
||||
threshold_dunkelflaute?: number;
|
||||
threshold_niedrig?: number;
|
||||
threshold_mittel?: number;
|
||||
label_dunkelflaute?: string;
|
||||
label_niedrig?: string;
|
||||
label_mittel?: string;
|
||||
label_hoch?: string;
|
||||
argument_dunkelflaute?: string;
|
||||
argument_niedrig?: string;
|
||||
argument_mittel?: string;
|
||||
argument_hoch?: string;
|
||||
enable_france_comparison?: boolean;
|
||||
disclaimer?: string;
|
||||
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
|
||||
explanation_page?: string | { _slug?: string; title?: string };
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time. */
|
||||
export type CalendarItemData = components["schemas"]["calendar_item"];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
||||
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
||||
import StrommixBlock from "./blocks/StrommixBlock.svelte";
|
||||
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
||||
import type { BlockLayout } from "$lib/block-layout";
|
||||
import type { Translations } from "$lib/translations";
|
||||
@@ -41,6 +42,7 @@
|
||||
OrganisationsBlockData,
|
||||
OpnFormBlockData,
|
||||
DeadlineBannerBlockData,
|
||||
LiveStrommixBlockData,
|
||||
} from "$lib/block-types";
|
||||
|
||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||
@@ -140,6 +142,8 @@
|
||||
<OpnFormBlock block={item as OpnFormBlockData} />
|
||||
{:else if blockType(item) === "deadline_banner"}
|
||||
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
|
||||
{:else if blockType(item) === "live_strommix"}
|
||||
<StrommixBlock block={item as LiveStrommixBlockData} />
|
||||
{:else}
|
||||
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Unbekannter Block: <code>{blockType(item)}</code>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<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";
|
||||
|
||||
let { block }: { block: LiveStrommixBlockData } = $props();
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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";
|
||||
});
|
||||
|
||||
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 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);
|
||||
|
||||
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 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}
|
||||
>
|
||||
{#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>
|
||||
|
||||
<!-- 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 if renewablePct == null}
|
||||
<div class="p-6 text-sm text-amber-700">Keine gültigen Live-Werte verfügbar.</div>
|
||||
{:else}
|
||||
<div class="p-6 text-center">
|
||||
<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}
|
||||
</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>
|
||||
<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>
|
||||
</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.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>
|
||||
|
||||
<!-- 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}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Shared types for the Strommix-Live-Widget.
|
||||
*
|
||||
* The aggregator route at `/api/strommix` builds this payload from the
|
||||
* four CMS external collections; the `StrommixBlock.svelte` component
|
||||
* consumes it.
|
||||
*/
|
||||
|
||||
export type StrommixResponse = {
|
||||
measuredAtUnix: number | null;
|
||||
// Production (MW)
|
||||
windOnshoreMw: number;
|
||||
windOffshoreMw: number;
|
||||
solarMw: number;
|
||||
biomassMw: number;
|
||||
hydroMw: number;
|
||||
ligniteMw: number;
|
||||
hardCoalMw: number;
|
||||
gasMw: number;
|
||||
nuclearMw: number;
|
||||
// Aggregates (MW)
|
||||
loadMw: number;
|
||||
weatherRenewableMw: number;
|
||||
conventionalMw: number;
|
||||
// Cross-border (GW; positive = net export from DE, negative = import)
|
||||
netFlowGw: number;
|
||||
flowsByCountry: Record<string, number>;
|
||||
// Prices (EUR/MWh; null if upstream unavailable)
|
||||
priceDeEurMwh: number | null;
|
||||
priceFrEurMwh: number | null;
|
||||
// Stale flags surfaced from the CMS' `x-rustycms-external-stale` header
|
||||
staleStrommix: boolean;
|
||||
staleCrossborder: boolean;
|
||||
stalePrice: boolean;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Aggregator route for the Strommix-Live-Widget.
|
||||
*
|
||||
* Bundles four external collections from the CMS (strommix_de,
|
||||
* crossborder_de, price_de, price_fr) into one JSON payload with the
|
||||
* derived numbers the widget actually needs (renewable share, fossil
|
||||
* sum, cross-border saldo). Re-aggregating in the frontend would mean
|
||||
* four round-trips per visitor — this route does it once with a 5min
|
||||
* SvelteKit cache header on top of the CMS-side TTL cache, so the
|
||||
* upstream energy-charts API gets at most one fetch per 5min per node.
|
||||
*
|
||||
* Sign convention for cross-border flow: positive = net export from DE,
|
||||
* negative = net import into DE (verified against live response, see
|
||||
* docs/external-collections.md notes).
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import type { StrommixResponse } from "$lib/strommix";
|
||||
|
||||
const CMS_BASE = (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
"http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
type StrommixRaw = {
|
||||
wind_onshore_mw?: number | null;
|
||||
wind_offshore_mw?: number | null;
|
||||
solar_mw?: number | null;
|
||||
biomass_mw?: number | null;
|
||||
hydro_mw?: number | null;
|
||||
lignite_mw?: number | null;
|
||||
hard_coal_mw?: number | null;
|
||||
gas_mw?: number | null;
|
||||
nuclear_mw?: number | null;
|
||||
load_mw?: number | null;
|
||||
measured_at_unix?: number | null;
|
||||
};
|
||||
|
||||
type CrossborderRaw = {
|
||||
flow_at_gw?: number | null;
|
||||
flow_be_gw?: number | null;
|
||||
flow_ch_gw?: number | null;
|
||||
flow_cz_gw?: number | null;
|
||||
flow_dk_gw?: number | null;
|
||||
flow_fr_gw?: number | null;
|
||||
flow_lu_gw?: number | null;
|
||||
flow_nl_gw?: number | null;
|
||||
flow_no_gw?: number | null;
|
||||
flow_pl_gw?: number | null;
|
||||
flow_se_gw?: number | null;
|
||||
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;
|
||||
}
|
||||
|
||||
async function fetchOne<T>(
|
||||
collection: string,
|
||||
fetcher: typeof fetch
|
||||
): Promise<{ data: T | null; stale: boolean }> {
|
||||
try {
|
||||
const res = await fetcher(
|
||||
`${CMS_BASE}/api/content/${collection}/current`,
|
||||
{ signal: AbortSignal.timeout(8_000) }
|
||||
);
|
||||
if (!res.ok) return { data: null, stale: false };
|
||||
const stale = res.headers.get("x-rustycms-external-stale") === "1";
|
||||
const data = (await res.json()) as T;
|
||||
return { data, stale };
|
||||
} catch {
|
||||
return { data: null, stale: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ fetch }) => {
|
||||
const [strommix, cross, priceDe, priceFr] = await Promise.all([
|
||||
fetchOne<StrommixRaw>("strommix_de", fetch),
|
||||
fetchOne<CrossborderRaw>("crossborder_de", fetch),
|
||||
fetchOne<PriceRaw>("price_de", fetch),
|
||||
fetchOne<PriceRaw>("price_fr", fetch),
|
||||
]);
|
||||
|
||||
const s = strommix.data ?? {};
|
||||
const c = cross.data ?? {};
|
||||
|
||||
const weatherRenewableMw = num(s.wind_onshore_mw) + num(s.wind_offshore_mw) + num(s.solar_mw);
|
||||
const conventionalMw =
|
||||
num(s.lignite_mw) + num(s.hard_coal_mw) + num(s.gas_mw) + num(s.nuclear_mw);
|
||||
|
||||
const flowsByCountry: Record<string, number> = {
|
||||
AT: num(c.flow_at_gw),
|
||||
BE: num(c.flow_be_gw),
|
||||
CH: num(c.flow_ch_gw),
|
||||
CZ: num(c.flow_cz_gw),
|
||||
DK: num(c.flow_dk_gw),
|
||||
FR: num(c.flow_fr_gw),
|
||||
LU: num(c.flow_lu_gw),
|
||||
NL: num(c.flow_nl_gw),
|
||||
NO: num(c.flow_no_gw),
|
||||
PL: num(c.flow_pl_gw),
|
||||
SE: num(c.flow_se_gw),
|
||||
};
|
||||
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
|
||||
|
||||
const body: StrommixResponse = {
|
||||
measuredAtUnix: s.measured_at_unix ?? null,
|
||||
windOnshoreMw: num(s.wind_onshore_mw),
|
||||
windOffshoreMw: num(s.wind_offshore_mw),
|
||||
solarMw: num(s.solar_mw),
|
||||
biomassMw: num(s.biomass_mw),
|
||||
hydroMw: num(s.hydro_mw),
|
||||
ligniteMw: num(s.lignite_mw),
|
||||
hardCoalMw: num(s.hard_coal_mw),
|
||||
gasMw: num(s.gas_mw),
|
||||
nuclearMw: num(s.nuclear_mw),
|
||||
loadMw: num(s.load_mw),
|
||||
weatherRenewableMw,
|
||||
conventionalMw,
|
||||
netFlowGw,
|
||||
flowsByCountry,
|
||||
priceDeEurMwh: priceDe.data?.price_eur_mwh ?? null,
|
||||
priceFrEurMwh: priceFr.data?.price_eur_mwh ?? null,
|
||||
staleStrommix: strommix.stale,
|
||||
staleCrossborder: cross.stale,
|
||||
stalePrice: priceDe.stale || priceFr.stale,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// 5min: matches the CMS-side TTL on strommix + crossborder.
|
||||
// Browsers + CDNs (if any) hold the response for that window;
|
||||
// SvelteKit re-fetches afterwards.
|
||||
"cache-control": "public, max-age=300, s-maxage=300",
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user