Compare commits

..

4 Commits

Author SHA1 Message Date
Peter Meier fc6c59bc10 fix(deadline-banner): count calendar days, not 24h windows
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 53s
daysLeft used Math.ceil(ms/24h) which counted partial 24h windows:
30h before a midnight event tomorrow showed "Noch 2 Tage" instead
of "Morgen". Compare midnight-to-midnight day-keys instead, same
approach as CalendarBlock relativeDays().

Also adds data-block="DeadlineBanner" identifier.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:45:22 +02:00
Peter Meier a1c59b549d feat(calendar): future-events accordion, ICS/Google export, multi-day
Right panel now shows all upcoming events as a card-style accordion
(date block + title + location/time chips visible, body collapsed).
Day-grouped with sticky headers + relative countdown.

- ICS download per event (RFC-5545, client-side blob)
- Google Calendar render-template link per event
- Maps link via location field (new schema field)
- Multi-day events via terminEnde (new schema field): grid spans,
  range "Mai / 14–16 / 2026" date block, expanded eventsByDay map
- Tag filter chips (new schema field) above calendar
- Past events <details> at list end, expanded if no future
- Live status badge: "läuft gerade" / "beginnt in 45 min"
  (60s tick interval)
- Today marker on calendar grid (outline + sr-only label)
- Auto-scroll next event into panel viewport on mount
- "Im Kalender anzeigen" jumps grid to event month
- Urgency-colored left border per event: now/today/tomorrow/week/
  later in fire/erde/himmel/stein
- Zebra striping + 4px separator between day-groups
- ARIA: aria-current=date, aria-pressed on day/tag buttons
- Print CSS: details auto-open, day-group break-inside: avoid
- Timezone fix: dayKey uses local Y/M/D not ISO string slice
- Empty-title fallback to "Termin"
- Side-by-side layout from md+ (was always stacked)

Extends CalendarItemData with terminEnde, location, tags
inline until OpenAPI regen catches up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:45:16 +02:00
Peter Meier 4790d571fd feat(strommix): hero variant, 24h sparkline, trend, polishing
- New "hero" variant between compact and full: pct + status +
  sparkline + trend, no sub-cards or accordion
- 24h Wind+Solar sparkline below hero pct, accent-colored
- Trend arrow vs previous hour with delta percentage points
- Polling pauses on tab hidden, refetches on visibility return
  if last fetch >5min old
- Skeleton loader replaces "Lade…" text (matches final layout)
- Retry button on error state (silent vs explicit fetch)
- Stale-flag now prominent red badge in header bar
- Stand-time wrapped in <time datetime title> for full-date hover
- loadMw=0 falls back to last hourly slot instead of red error
- Negative-price banner replaces standalone price card (dedup)
- Production breakdown grid: clean auto/1fr label-value alignment
- Print CSS: details auto-open, chevrons hidden
- All German strings moved to translation keys
- STROMMIX_DEFAULT_THRESHOLDS const as single source for buckets

Includes data-block="LiveStrommix" attribute for the new
identifier scheme + new RenewableSparkline.svelte component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:57 +02:00
Peter Meier 5a86ff701e feat(blocks): data-block PascalCase identifier on every CMS block
Adds data-block="PascalName" alongside existing data-block-type
snake_case so blocks can be located via [data-block="Calendar"] etc.
in DevTools. Snake-case attribute kept for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:40 +02:00
24 changed files with 1351 additions and 317 deletions
+16 -5
View File
@@ -337,9 +337,11 @@ export interface OrganisationsBlockData {
export interface LiveStrommixBlockData { export interface LiveStrommixBlockData {
_type?: "live_strommix"; _type?: "live_strommix";
_slug?: string; _slug?: string;
/** `full` = card with argument + breakdown disclosure. `compact` = single /** `full` = card with argument + breakdown disclosure + history accordion.
* horizontal strip for sidebar / homepage placement. Default: `full`. */ * `hero` = pct + status + sparkline + trend, no sub-cards or accordion.
variant?: "full" | "compact"; * `compact` = single horizontal strip for sidebar / homepage placement.
* Default: `full`. */
variant?: "full" | "hero" | "compact";
intro_headline?: string; intro_headline?: string;
intro_text?: string; intro_text?: string;
threshold_dunkelflaute?: number; threshold_dunkelflaute?: number;
@@ -380,8 +382,17 @@ export interface LiveStrommixBlockData {
layout?: BlockLayout; layout?: BlockLayout;
} }
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */ /** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time.
export type CalendarItemData = components["schemas"]["calendar_item"]; * Lokale Erweiterungen (terminEnde, location, tags) sind im RustyCMS-Schema
* ergänzt und werden bis zur OpenAPI-Regen hier inline mitgeführt. */
export type CalendarItemData = components["schemas"]["calendar_item"] & {
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
terminEnde?: string;
/** Optionale Veranstaltungsort-Beschreibung (Adresse oder Name). */
location?: string;
/** Optionale Tags zur Filterung. */
tags?: string[];
};
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */ /** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
export type CalendarBlockData = Omit< export type CalendarBlockData = Omit<
+146
View File
@@ -0,0 +1,146 @@
/**
* Helpers for "send calendar entry to my own calendar" flows.
*
* - `buildICS()` produces a single-event RFC-5545 string suitable for
* download. Apple Calendar, Outlook and Google all import it.
* - `googleCalUrl()` returns a deep link to Google Calendar's event-
* create form pre-filled with the event details.
* - `mapsUrl()` returns a Google Maps search URL for an address string.
*
* All client-only — no Node APIs touched. Safe in SSR contexts (the
* functions just return strings; the actual download is triggered by
* the caller via `URL.createObjectURL`).
*/
export type CalendarItemICS = {
title: string;
start: Date;
/** Optional end date. If omitted, default duration is 1 hour. */
end?: Date | null;
description?: string;
location?: string;
url?: string;
/** Stable identifier; falls back to start-timestamp + title hash. */
uid?: string;
};
/** Folds long lines per RFC-5545 §3.1 (75 octets max). Most clients
* tolerate longer lines but Outlook is strict — better safe. */
function foldLine(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
const chunk = line.slice(i, i + (i === 0 ? 75 : 74));
out.push((i === 0 ? "" : " ") + chunk);
i += i === 0 ? 75 : 74;
}
return out.join("\r\n");
}
/** Escape per RFC-5545 §3.3.11 — backslash, comma, semicolon, newline. */
function escapeText(s: string): string {
return s
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
/** UTC timestamp formatted as `YYYYMMDDTHHMMSSZ`. */
function fmtICSDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getUTCFullYear().toString() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
"Z"
);
}
function deterministicUid(title: string, start: Date): string {
let hash = 0;
for (let i = 0; i < title.length; i++) {
hash = (hash * 31 + title.charCodeAt(i)) | 0;
}
return `${start.getTime()}-${Math.abs(hash).toString(36)}@windwiderstand.de`;
}
export function buildICS(item: CalendarItemICS): string {
const start = item.start;
const end =
item.end ??
new Date(start.getTime() + 60 * 60 * 1000); // 1h default
const uid = item.uid ?? deterministicUid(item.title, start);
const now = new Date();
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//windwiderstand.de//calendar//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${fmtICSDate(now)}`,
`DTSTART:${fmtICSDate(start)}`,
`DTEND:${fmtICSDate(end)}`,
foldLine(`SUMMARY:${escapeText(item.title)}`),
];
if (item.description) {
lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
}
if (item.location) {
lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
}
if (item.url) {
lines.push(foldLine(`URL:${item.url}`));
}
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n");
}
/** Trigger a browser download of an ICS file. Caller should pass a
* filesystem-friendly filename; we still strip path separators
* defensively. No-op on the server (`window` undefined). */
export function downloadICS(item: CalendarItemICS, filename: string): void {
if (typeof window === "undefined") return;
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "event.ics";
const ics = buildICS(item);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Google-Calendar's render-template URL. Times must be UTC, in
* YYYYMMDDTHHMMSSZ format, joined by `/`. */
export function googleCalUrl(item: CalendarItemICS): string {
const start = item.start;
const end =
item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
const dates = `${fmtICSDate(start)}/${fmtICSDate(end)}`;
const params = new URLSearchParams({
action: "TEMPLATE",
text: item.title,
dates,
});
if (item.description) params.set("details", item.description);
if (item.location) params.set("location", item.location);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
/** Generic maps URL — Google Maps search by query. Safe with addresses
* or place names. */
export function mapsUrl(location: string): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(location)}`;
}
+636 -134
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { import type {
CalendarBlockData, CalendarBlockData,
@@ -6,12 +7,21 @@
} from "$lib/block-types"; } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import {
downloadICS,
googleCalUrl,
mapsUrl,
type CalendarItemICS,
} from "$lib/calendar-ics";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
type EventCardItem = CalendarItemData & { type EventCardItem = CalendarItemData & {
date: Date | null; start: Date;
end: Date | null;
timeStr: string; timeStr: string;
isMultiDay: boolean;
spanDayKeys: string[];
}; };
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props(); let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
@@ -22,31 +32,53 @@
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const items = $derived.by(() => { /** Wall-clock ticker for live countdown — refreshed every minute on
const raw = block.items ?? []; * the client. SSR uses Date.now() at render time; the first client
return raw * tick after hydration takes over. */
.filter( let nowMs = $state(Date.now());
(x): x is CalendarItemData => let tickTimer: ReturnType<typeof setInterval> | null = null;
typeof x === "object" &&
x !== null &&
"terminZeit" in x &&
"title" in x,
)
.map((x) => ({
...x,
date: parseDate(x.terminZeit),
timeStr: formatTime(x.terminZeit),
}))
.filter((x) => x.date)
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
});
function parseDate(iso: string): Date | null { /** UI state: filter chips (tag), past-events toggle, calendar view
* + selected day. */
let showPast = $state(false);
let activeTag = $state<string | null>(null);
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
function parseDate(iso: string | undefined | null): Date | null {
if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
return isNaN(d.getTime()) ? null : d; return isNaN(d.getTime()) ? null : d;
} }
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */ /** Calendar-day key in the *local* timezone — avoids ISO timezone
* drift when the upstream string lacks a `Z`/offset. */
function dayKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function spanKeys(start: Date, end: Date | null): string[] {
if (!end || end.getTime() <= start.getTime()) return [dayKey(start)];
const out: string[] = [];
const cur = new Date(start);
cur.setHours(12, 0, 0, 0);
const last = new Date(end);
last.setHours(12, 0, 0, 0);
while (cur.getTime() <= last.getTime()) {
out.push(dayKey(cur));
cur.setDate(cur.getDate() + 1);
}
return out;
}
function formatTime(d: Date | null): string {
if (!d) return "";
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
}
function getEventLinkHref(link: unknown): string { function getEventLinkHref(link: unknown): string {
if (typeof link === "string" && link) return link; if (typeof link === "string" && link) return link;
if (link && typeof link === "object") { if (link && typeof link === "object") {
@@ -58,33 +90,118 @@
return ""; return "";
} }
function formatTime(iso: string): string { /** Normalised, sorted item list. Filters out malformed entries. */
const d = new Date(iso); const items = $derived.by((): EventCardItem[] => {
if (isNaN(d.getTime())) return ""; const raw = block.items ?? [];
const h = d.getHours(); return raw
const m = d.getMinutes(); .filter(
if (h === 0 && m === 0) return ""; (x): x is CalendarItemData =>
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`; typeof x === "object" &&
} x !== null &&
"terminZeit" in x &&
"title" in x,
)
.map((x) => {
const start = parseDate(x.terminZeit);
const end = parseDate(x.terminEnde);
if (!start) return null;
const isMultiDay =
!!end && dayKey(end) !== dayKey(start);
return {
...x,
start,
end: isMultiDay ? end : null,
timeStr: formatTime(start),
isMultiDay,
spanDayKeys: spanKeys(start, isMultiDay ? end : null),
} satisfies EventCardItem;
})
.filter((x): x is EventCardItem => x !== null)
.sort((a, b) => a.start.getTime() - b.start.getTime());
});
function dayKey(d: Date): string { /** Distinct tag list across all items (case-insensitive de-dup,
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; * display in original casing of first occurrence). */
const allTags = $derived.by((): string[] => {
const seen = new Map<string, string>();
for (const it of items) {
for (const tag of it.tags ?? []) {
const key = tag.trim().toLowerCase();
if (!key) continue;
if (!seen.has(key)) seen.set(key, tag.trim());
} }
}
return [...seen.values()].sort((a, b) => a.localeCompare(b, "de"));
});
const filteredItems = $derived.by(() => {
if (!activeTag) return items;
const needle = activeTag.toLowerCase();
return items.filter((it) =>
(it.tags ?? []).some((t) => t.toLowerCase() === needle),
);
});
/** Map a day-key → events overlapping that day (multi-day events
* appear under each spanned day). */
const eventsByDay = $derived.by(() => { const eventsByDay = $derived.by(() => {
const map = new Map<string, typeof items>(); const map = new Map<string, EventCardItem[]>();
for (const item of items) { for (const ev of filteredItems) {
if (!item.date) continue; for (const k of ev.spanDayKeys) {
const key = dayKey(item.date); if (!map.has(k)) map.set(k, []);
if (!map.has(key)) map.set(key, []); map.get(k)!.push(ev);
map.get(key)!.push(item); }
} }
return map; return map;
}); });
let currentMonth = $state(new Date()); const startOfTodayMs = $derived.by(() => {
let selectedDay = $state<string | null>(null); const d = new Date(nowMs);
d.setHours(0, 0, 0, 0);
return d.getTime();
});
/** Future = anything whose end (or start, if no end) is today or
* later. So a multi-day event running today is still "future"
* until its last day passes. */
const futureEvents = $derived.by(() =>
filteredItems.filter((it) => {
const cutoff = (it.end ?? it.start).getTime();
return cutoff >= startOfTodayMs;
}),
);
const pastEvents = $derived.by(() =>
filteredItems
.filter((it) => {
const cutoff = (it.end ?? it.start).getTime();
return cutoff < startOfTodayMs;
})
.reverse(),
);
type EventGroup = { dayKey: string; date: Date; events: EventCardItem[] };
const futureEventsByDay = $derived.by((): EventGroup[] => {
const groups: EventGroup[] = [];
for (const ev of futureEvents) {
const key = dayKey(ev.start);
const last = groups[groups.length - 1];
if (last && last.dayKey === key) {
last.events.push(ev);
} else {
groups.push({ dayKey: key, date: ev.start, events: [ev] });
}
}
return groups;
});
const selectedDayEvents = $derived(
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
const todayKey = $derived.by(() => dayKey(new Date(nowMs)));
// ── Calendar-grid derived state ──────────────────────────────────
const year = $derived(currentMonth.getFullYear()); const year = $derived(currentMonth.getFullYear());
const month = $derived(currentMonth.getMonth()); const month = $derived(currentMonth.getMonth());
const monthLabel = $derived( const monthLabel = $derived(
@@ -93,7 +210,6 @@
year: "numeric", year: "numeric",
}), }),
); );
const daysInMonth = $derived.by(() => { const daysInMonth = $derived.by(() => {
const first = new Date(year, month, 1); const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0); const last = new Date(year, month + 1, 0);
@@ -101,7 +217,6 @@
const startWeekday = (first.getDay() + 6) % 7; const startWeekday = (first.getDay() + 6) % 7;
return { count, startWeekday, last }; return { count, startWeekday, last };
}); });
const calendarDays = $derived.by(() => { const calendarDays = $derived.by(() => {
const { count, startWeekday } = daysInMonth; const { count, startWeekday } = daysInMonth;
const empty = Array(startWeekday).fill(null); const empty = Array(startWeekday).fill(null);
@@ -112,19 +227,79 @@
return [...empty, ...days]; return [...empty, ...days];
}); });
const selectedDayEvents = $derived( // ── Formatters ───────────────────────────────────────────────────
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [], function fmtSummaryDate(d: Date): string {
); return d.toLocaleDateString("de-DE", {
weekday: "short",
/** Nächste 3 Termine ab heute zur Laufzeit gefiltert. */ day: "numeric",
const nextUpcomingEvents = $derived.by(() => { month: "short",
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
return items
.filter((item) => item.date && item.date.getTime() >= todayMs)
.slice(0, 3);
}); });
}
function fmtGroupHeader(d: Date): string {
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
}
function fmtRangeShort(start: Date, end: Date): string {
const sameMonth =
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear();
const fmtStart = start.toLocaleDateString("de-DE", {
day: "numeric",
...(sameMonth ? {} : { month: "short" }),
});
const fmtEnd = end.toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
year: end.getFullYear() !== start.getFullYear() ? "numeric" : undefined,
});
return t(T.calendar_multi_day_range, { from: fmtStart, to: fmtEnd });
}
/** Relative day diff for a target date vs. current `nowMs`. */
function relativeDays(d: Date): string {
const today = new Date(nowMs);
today.setHours(0, 0, 0, 0);
const target = new Date(d);
target.setHours(0, 0, 0, 0);
const diff = Math.round(
(target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000),
);
if (diff === 0) return t(T.calendar_today);
if (diff === 1) return t(T.calendar_tomorrow);
if (diff < 0) return "";
return t(T.calendar_in_days, { n: diff });
}
/** Live status badge text — "läuft gerade", "beginnt in 45 min",
* "beginnt in 3 h" — only meaningful for events within ~24h. */
function liveStatus(ev: EventCardItem): string {
const startMs = ev.start.getTime();
const endMs = (ev.end ?? ev.start).getTime();
const dayLengthMs =
ev.isMultiDay ? endMs - startMs : 24 * 60 * 60 * 1000;
if (nowMs >= startMs && nowMs <= endMs + (ev.isMultiDay ? 0 : dayLengthMs)) {
// Only flag "running" if event has a meaningful duration window
if (nowMs <= endMs || (ev.timeStr && nowMs <= startMs + 4 * 60 * 60 * 1000)) {
return t(T.calendar_running_now);
}
}
const diffMs = startMs - nowMs;
if (diffMs <= 0 || diffMs > 24 * 60 * 60 * 1000) return "";
const minutes = Math.round(diffMs / 60000);
if (minutes < 60) {
return t(T.calendar_starts_in_minutes, { n: Math.max(1, minutes) });
}
const hours = Math.round(minutes / 60);
return t(T.calendar_starts_in, { n: hours });
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
}
function prevMonth() { function prevMonth() {
currentMonth = new Date(year, month - 1); currentMonth = new Date(year, month - 1);
@@ -136,11 +311,58 @@
selectedDay = null; selectedDay = null;
} }
function selectDay(key: string) { /** "Show me this in the calendar" — used from accordion bodies for
selectedDay = selectedDay === key ? null : key; * events that may be in a different month than the current view. */
function jumpToEventInGrid(ev: EventCardItem) {
currentMonth = new Date(ev.start.getFullYear(), ev.start.getMonth(), 1);
selectedDay = dayKey(ev.start);
} }
const todayKey = $derived(dayKey(new Date())); /** Visual urgency-bucket for the colored left-border on each event
* card. "today" / "tomorrow" / "this-week" pull stronger accent
* colors so the eye lands on imminent dates first when scrolling
* the list. */
type Urgency = "now" | "today" | "tomorrow" | "week" | "later";
function urgencyOf(ev: EventCardItem): Urgency {
const startMs = ev.start.getTime();
const endMs = (ev.end ?? ev.start).getTime();
if (nowMs >= startMs && nowMs <= endMs + 4 * 60 * 60 * 1000) return "now";
const today = new Date(nowMs);
today.setHours(0, 0, 0, 0);
const target = new Date(ev.start);
target.setHours(0, 0, 0, 0);
const diffDays = Math.round(
(target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000),
);
if (diffDays <= 0) return "today";
if (diffDays === 1) return "tomorrow";
if (diffDays <= 7) return "week";
return "later";
}
function toICS(ev: EventCardItem): CalendarItemICS {
return {
title: ev.title || t(T.calendar_untitled),
start: ev.start,
end: ev.end ?? null,
description: ev.description ?? undefined,
location: ev.location ?? undefined,
url: getEventLinkHref(ev.link) || undefined,
};
}
function fileSlug(s: string): string {
return (
s
.toLowerCase()
.replace(/[äöüß]/g, (c) =>
c === "ä" ? "ae" : c === "ö" ? "oe" : c === "ü" ? "ue" : "ss",
)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "termin"
);
}
const weekdays = $derived([ const weekdays = $derived([
t(T.calendar_weekday_mo), t(T.calendar_weekday_mo),
@@ -151,83 +373,193 @@
t(T.calendar_weekday_sa), t(T.calendar_weekday_sa),
t(T.calendar_weekday_so), t(T.calendar_weekday_so),
]); ]);
let panelEl: HTMLElement | null = $state(null);
onMount(() => {
tickTimer = setInterval(() => {
nowMs = Date.now();
}, 60_000);
// After hydration, scroll the next-up event into view inside its
// panel (panel-local scroll, not page scroll — jumping the whole
// page when the block is below the fold would be hostile).
requestAnimationFrame(() => {
if (!panelEl) return;
const target = panelEl.querySelector<HTMLElement>(".event-item-next");
if (target) {
target.scrollIntoView({ block: "start", behavior: "auto" });
}
});
});
onDestroy(() => {
if (tickTimer) clearInterval(tickTimer);
});
</script> </script>
<div <div
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden" class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
data-block-type="calendar" data-block="Calendar" data-block-type="calendar"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#snippet eventCard(item: EventCardItem)} {#snippet eventAccordion(item: EventCardItem, openByDefault: boolean, isNext: boolean)}
{@const eventHref = getEventLinkHref(item.link)} {@const eventHref = getEventLinkHref(item.link)}
<li class="bg-himmel-100 p-2"> {@const live = liveStatus(item)}
<div class="flex gap-1"> {@const title = item.title || t(T.calendar_untitled)}
{#if item.date} {@const urgency = urgencyOf(item)}
<div class="text-xs text-stein-500 mb-0.5"> {@const hasBody = !!(
{item.date.toLocaleDateString("de-DE", { item.description ||
weekday: "short", eventHref ||
day: "numeric", item.location ||
month: "short", item.isMultiDay
})} )}
</div> <li
{#if item.timeStr} class="event-item {isNext ? 'event-item-next' : ''}"
<span class="text-xs text-stein-600">/</span> data-urgency={urgency}
{/if}
{/if}
{#if item.timeStr}
<div class="text-xs text-stein-600">
{item.timeStr}
{t(T.calendar_time_suffix)}
</div>
{/if}
</div>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
> >
{item.title} <details class="group" open={openByDefault}>
</a> <summary
class="cursor-pointer list-none p-3 flex items-start gap-3 hover:bg-himmel-50/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
>
<!-- Datum-Block: Wochentag/Tag/Monat in vertikalem Stack —
als Block scannbar, große Tag-Zahl als Anker. Bei Multi-
Day fällt das Block-Layout auf Range zurück. -->
{#if item.isMultiDay && item.end}
<div class="event-date-range shrink-0 w-20 text-center leading-tight">
<div class="text-[10px] uppercase tracking-wide text-stein-500">
{item.start.toLocaleDateString("de-DE", { month: "short" })}
</div>
<div class="text-base font-bold text-stein-900 tabular-nums">
{item.start.getDate()}{item.end.getDate()}
</div>
<div class="text-[10px] text-stein-500">
{item.start.getFullYear() === item.end.getFullYear()
? item.end.toLocaleDateString("de-DE", { month: "short" })
: item.end.getFullYear()}
</div>
</div>
{:else} {:else}
<div class="text-base font-medium text-stein-900">{item.title}</div> <div class="event-date-block shrink-0 w-14 text-center leading-tight">
<div class="text-[10px] uppercase tracking-wide text-stein-500">
{item.start.toLocaleDateString("de-DE", { weekday: "short" })}
</div>
<div class="text-2xl font-bold text-stein-900 tabular-nums">
{item.start.getDate()}
</div>
<div class="text-[10px] uppercase tracking-wide text-stein-500">
{item.start.toLocaleDateString("de-DE", { month: "short" })}
</div>
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<span class="text-sm font-semibold text-stein-900 leading-snug">
{title}
</span>
{#if live}
<span class="text-[10px] uppercase tracking-wide font-semibold text-fire-50 bg-fire-700 px-1.5 py-0.5 rounded-xs shrink-0">
{live}
</span>
{/if}
</div>
<div class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600">
{#if item.timeStr && !item.isMultiDay}
<span class="inline-flex items-center gap-1 tabular-nums">
<Icon icon="mdi:clock-outline" class="size-3" aria-hidden="true" />
{item.timeStr}
</span>
{/if}
{#if item.location}
<span class="inline-flex items-center gap-1 truncate max-w-[14rem]">
<Icon icon="mdi:map-marker-outline" class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">{item.location}</span>
</span>
{/if}
{#if item.tags && item.tags.length > 0}
{#each item.tags.slice(0, 2) as tg}
<span class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs">
{tg}
</span>
{/each}
{#if item.tags.length > 2}
<span class="text-[10px] text-stein-500">+{item.tags.length - 2}</span>
{/if}
{/if}
</div>
</div>
<Icon
icon="mdi:chevron-down"
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div class="px-3 pb-3 pt-0 pl-[4.75rem] text-sm text-stein-700 space-y-2">
<div class="text-[11px] uppercase tracking-wide text-stein-500 -mt-1">
{relativeDays(item.start)}
</div>
{#if item.location}
<a
href={mapsUrl(item.location)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-[12px] text-himmel-700 hover:underline"
>
<Icon icon="mdi:map-marker" class="size-4 shrink-0" aria-hidden="true" />
{t(T.calendar_open_maps)}
<Icon icon="mdi:open-in-new" class="size-3" aria-hidden="true" />
</a>
{/if} {/if}
{#if item.description} {#if item.description}
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3"> <p class="font-light text-stein-700 m-0! leading-relaxed">{item.description}</p>
{item.description} {/if}
</p> {#if item.tags && item.tags.length > 2}
<div class="flex flex-wrap gap-1">
{#each item.tags as tg}
<span class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs">
{tg}
</span>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-2 pt-1 text-xs">
{#if eventHref} {#if eventHref}
<a <a
href={eventHref} href={eventHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700" class="inline-flex items-center gap-1 rounded-xs bg-himmel-700 text-himmel-50 px-2 py-1 font-medium hover:bg-himmel-800"
> >
{t(T.calendar_more_info)} {t(T.calendar_more_info)}
<Icon <Icon icon="mdi:open-in-new" class="size-3.5" aria-hidden="true" />
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a> </a>
{/if} {/if}
{:else if eventHref} <button
type="button"
onclick={() => downloadICS(toICS(item), fileSlug(title))}
class="inline-flex items-center gap-1 rounded-xs border border-stein-300 px-2 py-1 text-stein-700 hover:bg-stein-100"
>
<Icon icon="mdi:download" class="size-3.5" aria-hidden="true" />
{t(T.calendar_download_ics)}
</button>
<a <a
href={eventHref} href={googleCalUrl(toICS(item))}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700" class="inline-flex items-center gap-1 rounded-xs border border-stein-300 px-2 py-1 text-stein-700 hover:bg-stein-100"
> >
{t(T.calendar_more_info)} <Icon icon="mdi:google" class="size-3.5" aria-hidden="true" />
<Icon {t(T.calendar_add_to_google)}
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a> </a>
{/if} <button
type="button"
onclick={() => jumpToEventInGrid(item)}
class="inline-flex items-center gap-1 rounded-xs border border-stein-300 px-2 py-1 text-stein-700 hover:bg-stein-100"
>
<Icon icon="mdi:calendar-search" class="size-3.5" aria-hidden="true" />
{t(T.calendar_jump_to_month)}
</button>
</div>
</div>
</details>
</li> </li>
{/snippet} {/snippet}
@@ -237,7 +569,35 @@
</h3> </h3>
{/if} {/if}
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0"> {#if allTags.length > 0}
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn
mindestens ein Termin Tags hat — sonst Lärm. -->
<div class="flex flex-wrap gap-1.5 items-center px-4 py-2 border-b border-stein-200 bg-stein-50">
<span class="text-[11px] uppercase tracking-wide text-stein-500">
{t(T.calendar_filter_by_tag)}
</span>
<button
type="button"
onclick={() => (activeTag = null)}
class="text-[11px] px-2 py-0.5 rounded-xs border {!activeTag ? 'bg-himmel-700 text-himmel-50 border-himmel-700' : 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={!activeTag}
>
{t(T.calendar_all_tags)}
</button>
{#each allTags as tag}
<button
type="button"
onclick={() => (activeTag = activeTag === tag ? null : tag)}
class="text-[11px] px-2 py-0.5 rounded-xs border {activeTag === tag ? 'bg-himmel-700 text-himmel-50 border-himmel-700' : 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={activeTag === tag}
>
{tag}
</button>
{/each}
</div>
{/if}
<div class="calendar-grid-wrapper grid w-full grid-cols-1 md:grid-cols-2 gap-0">
<!-- Links: Kalender --> <!-- Links: Kalender -->
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm"> <div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
<div class="flex items-center justify-between gap-2 mb-4"> <div class="flex items-center justify-between gap-2 mb-4">
@@ -273,9 +633,10 @@
{:else} {:else}
{@const key = dayKey(d)} {@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month} {@const isCurrentMonth = d.getMonth() === month}
{@const events = eventsByDay.get(key) ?? []} {@const dayEvents = eventsByDay.get(key) ?? []}
{@const hasEvents = events.length > 0} {@const hasEvents = dayEvents.length > 0}
{@const isSelected = selectedDay === key} {@const isSelected = selectedDay === key}
{@const isToday = key === todayKey}
{@const isFuture = key >= todayKey} {@const isFuture = key >= todayKey}
{#if hasEvents} {#if hasEvents}
<button <button
@@ -284,24 +645,31 @@
? 'text-himmel-100 hover:bg-himmel-700' ? 'text-himmel-100 hover:bg-himmel-700'
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected : 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
? 'ring-2 ring-himmel-400 bg-himmel-600' ? 'ring-2 ring-himmel-400 bg-himmel-600'
: ''}" : ''} {isToday ? 'today-cell' : ''}"
onclick={() => selectDay(key)} onclick={() => selectDay(key)}
aria-pressed={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label="{d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}, {dayEvents.length} {t(T.calendar_event_count, { n: dayEvents.length }).replace(/\d+\s*/, '')}"
> >
<span class="text-xs">{d.getDate()}</span> <span class="text-xs">{d.getDate()}</span>
<span <span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}" class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
aria-hidden="true" aria-hidden="true"
> >
{events.length} {dayEvents.length}
</span> </span>
</button> </button>
{:else} {:else}
<div <div
class="relative aspect-square rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth class="relative aspect-square rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
? 'text-himmel-100' ? 'text-himmel-100'
: 'text-himmel-100 opacity-50'}" : 'text-himmel-100 opacity-50'} {isToday ? 'today-cell' : ''}"
aria-current={isToday ? "date" : undefined}
> >
<span class="text-xs">{d.getDate()}</span> <span class="text-xs">{d.getDate()}</span>
{#if isToday}
<span class="sr-only">{t(T.calendar_today_marker)}</span>
{/if}
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -309,41 +677,175 @@
</div> </div>
</div> </div>
<!-- Rechts: Events --> <!-- Rechts: alle zukünftigen Termine als Accordion. Datum + Headline
<div class="calendar-events-wrapper min-h-0"> im summary, Beschreibung + Aktionen im body. Kalender-Klick
filtert auf einen Tag; ohne Filter zeigt Liste alles ab heute,
gruppiert nach Tag mit großzügigem header. -->
<div class="calendar-events-wrapper min-h-0 flex flex-col" bind:this={panelEl}>
<div <div
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden" class="calendar-events-panel border-t md:border-t-0 md:border-l border-stein-200 bg-stein-0 flex flex-col overflow-hidden flex-1 min-h-0"
> >
{#if items.length === 0} {#if items.length === 0}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p> <p class="text-sm text-stein-500 p-4">{t(T.calendar_no_events)}</p>
{:else if selectedDay} {:else if selectedDay}
<p class="text-xs font-medium text-stein-500 mb-2"> <div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-stein-200 bg-himmel-50">
<div class="text-xs font-medium text-stein-700 truncate">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", { {new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
weekday: "long", weekday: "long",
day: "numeric", day: "numeric",
month: "long", month: "long",
})} })}
</p> </div>
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"> <button
{#each selectedDayEvents as item} type="button"
{@render eventCard(item)} onclick={() => (selectedDay = null)}
{/each} class="inline-flex items-center gap-1 text-[11px] text-himmel-700 hover:text-himmel-900 hover:underline shrink-0"
</ul>
{:else if nextUpcomingEvents.length > 0}
<p class="text-xs uppercase font-medium text-stein-500">
{t(T.calendar_next_events)}
</p>
<ul
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
> >
{#each nextUpcomingEvents as item} <Icon icon="mdi:close" class="size-3.5" aria-hidden="true" />
{@render eventCard(item)} {t(T.calendar_clear_filter)}
</button>
</div>
<ul class="m-0! p-0! list-none flex-1 min-h-0 overflow-auto">
{#each selectedDayEvents as item}
{@render eventAccordion(item, true, false)}
{/each} {/each}
</ul> </ul>
{:else if futureEventsByDay.length > 0}
<div class="flex items-baseline justify-between gap-2 px-3 py-2 border-b border-stein-200">
<h4 class="text-xs font-medium text-stein-500 m-0!">
{t(T.calendar_all_upcoming)}
</h4>
<span class="text-[11px] text-stein-500 tabular-nums">
{t(T.calendar_event_count, { n: futureEvents.length })}
</span>
</div>
<ul class="m-0! p-0! list-none flex-1 min-h-0 overflow-auto">
{#each futureEventsByDay as group, gIdx}
<li class="day-group">
<div class="px-3 py-1 bg-himmel-50/60 text-[11px] uppercase tracking-wide font-medium text-stein-600 sticky top-0 z-10 backdrop-blur-sm">
{fmtGroupHeader(group.date)}
<span class="ml-2 text-stein-500 normal-case tracking-normal lowercase">· {relativeDays(group.date)}</span>
</div>
<ul class="m-0! p-0! list-none">
{#each group.events as item, eIdx}
{@render eventAccordion(item, gIdx === 0, gIdx === 0 && eIdx === 0)}
{/each}
</ul>
</li>
{/each}
</ul>
{#if pastEvents.length > 0}
<details class="border-t border-stein-200 bg-stein-50 group/past">
<summary class="cursor-pointer list-none px-3 py-2 text-xs text-stein-600 hover:bg-stein-100 inline-flex items-center gap-1.5">
<Icon icon="mdi:chevron-right" class="size-3.5 transition-transform group-open/past:rotate-90" aria-hidden="true" />
<span class="font-medium uppercase tracking-wide">{t(T.calendar_past_events)}</span>
<span class="text-stein-500 tabular-nums">· {pastEvents.length}</span>
</summary>
<ul class="m-0! p-0! list-none max-h-72 overflow-auto opacity-80">
{#each pastEvents as item}
{@render eventAccordion(item, false, false)}
{/each}
</ul>
</details>
{/if}
{:else if pastEvents.length > 0}
<!-- Nur Vergangene da, keine Zukunft → past-Liste expanded
damit Panel nicht leer wirkt. -->
<details open class="bg-stein-50 group/past">
<summary class="cursor-pointer list-none px-3 py-2 text-xs text-stein-600 hover:bg-stein-100 inline-flex items-center gap-1.5 border-b border-stein-200">
<Icon icon="mdi:chevron-right" class="size-3.5 transition-transform group-open/past:rotate-90" aria-hidden="true" />
<span class="font-medium uppercase tracking-wide">{t(T.calendar_past_events)}</span>
<span class="text-stein-500 tabular-nums">· {pastEvents.length}</span>
</summary>
<ul class="m-0! p-0! list-none flex-1 min-h-0 overflow-auto opacity-80">
{#each pastEvents as item}
{@render eventAccordion(item, false, false)}
{/each}
</ul>
</details>
{:else} {:else}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p> <p class="text-sm text-stein-500 p-4">{t(T.calendar_no_future_events)}</p>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style>
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
erkennbar sind. */
.calendar-block .today-cell {
outline: 2px solid var(--color-wald-400, #84e0a4);
outline-offset: -2px;
}
/* Event-Rows visuell trennen: Card-Optik mit Urgency-Akzent links,
Zebra-Stripe ab dem zweiten Eintrag, deutlicher Separator
zwischen Tagesgruppen. So lassen sich Termine optisch klar
auseinanderhalten ohne komplette Kacheloptik. */
.calendar-block .event-item {
position: relative;
border-left: 4px solid transparent;
background: var(--color-stein-0, #fff);
transition: background-color 150ms ease;
}
.calendar-block .event-item + .event-item {
border-top: 1px solid var(--color-stein-200, #e0dcd5);
}
/* Zebra: jeder zweite Termin in einer Tagesgruppe leicht abgedunkelt
— sauber per :nth-of-type, ohne dass Day-Group-Header einen Slot
verbraucht (event-item ist eigene <li>-Sequenz). */
.calendar-block .event-item:nth-of-type(even) {
background: var(--color-stein-50, #f7f5f1);
}
.calendar-block .event-item:hover {
background: var(--color-himmel-50, #eef4f8);
}
/* Urgency-Akzent: 4px farbiger Strich links. Alphabetische
Wichtigkeit: now (rot stark) → today (rot) → tomorrow (warm) →
week (kühl) → later (neutral). */
.calendar-block .event-item[data-urgency="now"] {
border-left-color: var(--color-fire-700, #b91c1c);
background: var(--color-fire-50, #fef2f2);
}
.calendar-block .event-item[data-urgency="today"] {
border-left-color: var(--color-fire-500, #ef4444);
}
.calendar-block .event-item[data-urgency="tomorrow"] {
border-left-color: var(--color-erde-500, #c97a3b);
}
.calendar-block .event-item[data-urgency="week"] {
border-left-color: var(--color-himmel-500, #4798c4);
}
.calendar-block .event-item[data-urgency="later"] {
border-left-color: var(--color-stein-300, #c4bfb4);
}
/* Next-up: kräftigere linke Kante + leichter himmel-Tint, plus
ein subtiler ring damit der Eintrag beim Scroll-into-view
visuell anspringt. */
.calendar-block .event-item.event-item-next {
border-left-width: 6px;
box-shadow: inset 4px 0 0 var(--color-himmel-100, #d8e6ef);
}
/* Day-Group: dickerer Separator + etwas Luft zwischen Gruppen. */
.calendar-block .day-group + .day-group {
border-top: 4px solid var(--color-stein-100, #ede8df);
}
/* Print: alle <details> geöffnet, Chevrons aus, Akzent-Farben in
Graustufen damit der Druck lesbar bleibt. */
@media print {
.calendar-block details > *:not(summary) {
display: block !important;
}
.calendar-block details > summary :global(svg) {
display: none;
}
.calendar-block .day-group {
break-inside: avoid;
}
.calendar-block .calendar-events-panel {
overflow: visible !important;
max-height: none !important;
}
}
</style>
@@ -61,11 +61,20 @@
}); });
}); });
/** Kalendertage zwischen heute und Ziel — über Mitternacht-zu-
* Mitternacht-Diff, nicht roh per ms. Sonst zählt z. B. 30 h vor
* Termin als „2 Tage" obwohl der Termin morgen ist. */
const daysLeft = $derived.by(() => { const daysLeft = $derived.by(() => {
if (!dateObj) return null; if (!dateObj) return null;
const ms = dateObj.getTime() - Date.now(); const today = new Date();
if (ms < 0) return null; today.setHours(0, 0, 0, 0);
return Math.ceil(ms / (1000 * 60 * 60 * 24)); const target = new Date(dateObj);
target.setHours(0, 0, 0, 0);
const diffDays = Math.round(
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays < 0) return null;
return diffDays;
}); });
const countdownStr = $derived.by(() => { const countdownStr = $derived.by(() => {
@@ -103,7 +112,7 @@
{#if hasContent && !dismissed} {#if hasContent && !dismissed}
<div <div
class="deadline-banner {layoutClasses}" class="deadline-banner {layoutClasses}"
data-block-type="deadline_banner" data-block="DeadlineBanner" data-block-type="deadline_banner"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
<div <div
+1 -1
View File
@@ -106,7 +106,7 @@
); );
</script> </script>
<div class="files-block {layoutClasses}" data-block-type="files" data-block-slug={block._slug}> <div class="files-block {layoutClasses}" data-block="Files" data-block-type="files" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline}
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3> <h3 class="mb-3 text-lg font-semibold">{block.headline}</h3>
{/if} {/if}
@@ -15,7 +15,7 @@
const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"); const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
</script> </script>
<div class={layoutClasses} data-block-type="headline" data-block-slug={block._slug}> <div class={layoutClasses} data-block="Headline" data-block-type="headline" data-block-slug={block._slug}>
<svelte:element <svelte:element
this={Tag} this={Tag}
class={alignClass} class={alignClass}
+1 -1
View File
@@ -9,7 +9,7 @@
<div <div
class="{layoutClasses} content-html" class="{layoutClasses} content-html"
data-block-type="html" data-block="Html" data-block-type="html"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{@html block.html ?? ""} {@html block.html ?? ""}
+1 -1
View File
@@ -32,7 +32,7 @@
} }
</script> </script>
<div class={layoutClasses} data-block-type="iframe" data-block-slug={block._slug}> <div class={layoutClasses} data-block="Iframe" data-block-type="iframe" data-block-slug={block._slug}>
{#if src} {#if src}
<div class="relative aspect-video w-full overflow-hidden rounded-xs bg-stein-100"> <div class="relative aspect-video w-full overflow-hidden rounded-xs bg-stein-100">
<iframe <iframe
+1 -1
View File
@@ -40,7 +40,7 @@
); );
</script> </script>
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}> <div class={layoutClasses} data-block="Image" data-block-type="image" data-block-slug={block._slug}>
{#if rawSrc} {#if rawSrc}
<figure class="max-w-[400px]"> <figure class="max-w-[400px]">
<RustyImage <RustyImage
@@ -155,7 +155,7 @@
<svelte:window onkeydown={onModalKey} /> <svelte:window onkeydown={onModalKey} />
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}> <div class={layoutClasses} data-block="ImageGallery" data-block-type="image_gallery" data-block-slug={block._slug}>
{#if descriptionHtml} {#if descriptionHtml}
<div class="markdown max-w-none prose prose-zinc mb-4"> <div class="markdown max-w-none prose prose-zinc mb-4">
{@html descriptionHtml} {@html descriptionHtml}
@@ -28,7 +28,7 @@
); );
</script> </script>
<div class="link-list {layoutClasses}" data-block-type="link_list" data-block-slug={block._slug}> <div class="link-list {layoutClasses}" data-block="LinkList" data-block-type="link_list" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline}
<h3 class="mb-3 text-sm font-semibold tracking-wide uppercase">{block.headline}</h3> <h3 class="mb-3 text-sm font-semibold tracking-wide uppercase">{block.headline}</h3>
{/if} {/if}
@@ -24,7 +24,7 @@
<div <div
class={layoutClasses} class={layoutClasses}
data-block-type="markdown" data-block="Markdown" data-block-type="markdown"
data-markdown-id={block.name} data-markdown-id={block.name}
data-block-slug={block._slug} data-block-slug={block._slug}
> >
@@ -82,7 +82,7 @@
<div <div
class="{layoutClasses} content-opnform w-[calc(100%+2rem)] max-w-none -mx-4 min-w-0 overflow-visible border border-stein-200 lg:rounded-lg px-0 py-3 md:w-[calc(100%+3rem)] md:-mx-6 md:px-4 md:py-4" class="{layoutClasses} content-opnform w-[calc(100%+2rem)] max-w-none -mx-4 min-w-0 overflow-visible border border-stein-200 lg:rounded-lg px-0 py-3 md:w-[calc(100%+3rem)] md:-mx-6 md:px-4 md:py-4"
data-block-type="opnform" data-block="OpnForm" data-block-type="opnform"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#if iframeSrc} {#if iframeSrc}
@@ -129,7 +129,7 @@
</Card> </Card>
{/snippet} {/snippet}
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}> <div class={layoutClasses} data-block="Organisations" data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline}
<h3 <h3
class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"} class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}
@@ -40,7 +40,7 @@
{#if hasContent} {#if hasContent}
<div <div
class="post-overview mb-4 {layoutClasses} {className}" class="post-overview mb-4 {layoutClasses} {className}"
data-block-type="post_overview" data-block="PostOverview" data-block-type="post_overview"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#if block.headline} {#if block.headline}
+1 -1
View File
@@ -39,7 +39,7 @@
{:else} {:else}
<div <div
class={layoutClasses} class={layoutClasses}
data-block-type="quote" data-block="Quote" data-block-type="quote"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{@render quoteMarkup()} {@render quoteMarkup()}
@@ -68,7 +68,7 @@
<div <div
class="quote-carousel {layoutClasses}" class="quote-carousel {layoutClasses}"
data-block-type="quote_carousel" data-block="QuoteCarousel" data-block-type="quote_carousel"
data-block-slug={block._slug} data-block-slug={block._slug}
onmouseenter={() => (paused = true)} onmouseenter={() => (paused = true)}
onmouseleave={() => (paused = false)} onmouseleave={() => (paused = false)}
@@ -160,7 +160,7 @@
<div <div
class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}" class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}"
data-block-type="searchable_text" data-block="SearchableText" data-block-type="searchable_text"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
<div class="p-4 md:p-6"> <div class="p-4 md:p-6">
+348 -116
View File
@@ -4,6 +4,7 @@
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { LiveStrommixBlockData } from "$lib/block-types"; import type { LiveStrommixBlockData } from "$lib/block-types";
import type { StrommixResponse } from "$lib/strommix"; import type { StrommixResponse } from "$lib/strommix";
import { STROMMIX_DEFAULT_THRESHOLDS } from "$lib/strommix";
import { useTranslate, T } from "$lib/translations"; import { useTranslate, T } from "$lib/translations";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
@@ -12,20 +13,30 @@
import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte"; import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte";
import NegPriceCounter from "./strommix/NegPriceCounter.svelte"; import NegPriceCounter from "./strommix/NegPriceCounter.svelte";
import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte"; import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte";
import RenewableSparkline from "./strommix/RenewableSparkline.svelte";
let { block }: { block: LiveStrommixBlockData } = $props(); let { block }: { block: LiveStrommixBlockData } = $props();
const t = useTranslate(); const t = useTranslate();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const isCompact = $derived(block.variant === "compact"); const isCompact = $derived(block.variant === "compact");
const isHero = $derived(block.variant === "hero");
/** Variants without sub-cards / accordion / details. Hero & compact
* keep things minimal; full renders the entire dashboard. */
const isMinimal = $derived(isCompact || isHero);
// Live data + lifecycle // Live data + lifecycle
let live = $state<StrommixResponse | null>(null); let live = $state<StrommixResponse | null>(null);
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let isLoading = $state(false);
let lastFetchAt = $state<number | null>(null); let lastFetchAt = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
async function loadLive() { /** Fetch live snapshot. `silent` keeps the existing data on screen
* during a background re-poll; only the first load and explicit
* retries flip `isLoading` so the skeleton appears. */
async function loadLive(silent = false) {
if (!silent) isLoading = true;
try { try {
const res = await fetch("/api/strommix", { cache: "no-store" }); const res = await fetch("/api/strommix", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -34,35 +45,115 @@
loadError = null; loadError = null;
} catch (e) { } catch (e) {
loadError = e instanceof Error ? e.message : String(e); loadError = e instanceof Error ? e.message : String(e);
} finally {
if (!silent) isLoading = false;
} }
} }
/** Re-poll every 5 minutes while the document is visible. Pause on
* hidden tab so embedded/idle widgets don't burn API quota. On show
* trigger an immediate silent refresh if the last fetch is stale
* (>5 min old) so the visitor sees current data, not whatever was
* on screen when they switched away. */
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => loadLive(true), 5 * 60 * 1000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleVisibility() {
if (typeof document === "undefined") return;
if (document.hidden) {
stopPolling();
return;
}
startPolling();
const stale = !lastFetchAt || Date.now() - lastFetchAt > 5 * 60 * 1000;
if (stale) loadLive(true);
}
onMount(() => { onMount(() => {
loadLive(); loadLive();
pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min startPolling();
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", handleVisibility);
}
}); });
onDestroy(() => { onDestroy(() => {
if (pollTimer) clearInterval(pollTimer); stopPolling();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibility);
}
}); });
/** Current Last in MW. Falls back to the most recent history slot
* if the live snapshot lost its load value (SMARD load filter
* occasionally lags behind production). Keeps the % live instead of
* showing the red "Lastdaten fehlen" banner for the brief lag
* window. */
const effectiveLoadMw = $derived.by(() => {
if (!live) return 0;
if (live.loadMw > 0) return live.loadMw;
const history = live.todayHourly;
if (!history || history.length === 0) return 0;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].loadMw > 0) return history[i].loadMw;
}
return 0;
});
const usingLoadFallback = $derived(
!!live && live.loadMw <= 0 && effectiveLoadMw > 0,
);
// Derived numbers // Derived numbers
const renewablePct = $derived.by(() => { const renewablePct = $derived.by(() => {
if (!live || live.loadMw <= 0) return null; if (!live || effectiveLoadMw <= 0) return null;
return (live.weatherRenewableMw / live.loadMw) * 100; return (live.weatherRenewableMw / effectiveLoadMw) * 100;
}); });
type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch"; type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch";
const bucket = $derived.by((): Bucket | null => { const bucket = $derived.by((): Bucket | null => {
if (renewablePct == null) return null; if (renewablePct == null) return null;
const t1 = block.threshold_dunkelflaute ?? 15; const t1 = block.threshold_dunkelflaute ?? STROMMIX_DEFAULT_THRESHOLDS.dunkelflaute;
const t2 = block.threshold_niedrig ?? 35; const t2 = block.threshold_niedrig ?? STROMMIX_DEFAULT_THRESHOLDS.niedrig;
const t3 = block.threshold_mittel ?? 60; const t3 = block.threshold_mittel ?? STROMMIX_DEFAULT_THRESHOLDS.mittel;
if (renewablePct < t1) return "dunkelflaute"; if (renewablePct < t1) return "dunkelflaute";
if (renewablePct < t2) return "niedrig"; if (renewablePct < t2) return "niedrig";
if (renewablePct < t3) return "mittel"; if (renewablePct < t3) return "mittel";
return "hoch"; return "hoch";
}); });
/** Compare current % to the slot one hour back in `todayHourly` to
* give a quick "is it climbing or dropping?" cue alongside the
* status label. Threshold ≥0.5 %-pt to filter flat noise. */
type Trend = { direction: "up" | "down" | "flat"; deltaPct: number };
const trend = $derived.by((): Trend | null => {
if (renewablePct == null || !live?.todayHourly) return null;
const slots = live.todayHourly;
if (slots.length < 2) return null;
const nowUnix = live.measuredAtUnix ?? slots[slots.length - 1].unix;
const target = nowUnix - 3600;
let prev: typeof slots[number] | null = null;
for (let i = slots.length - 1; i >= 0; i--) {
if (slots[i].unix <= target && slots[i].loadMw > 0) {
prev = slots[i];
break;
}
}
if (!prev) return null;
const prevPct = (prev.weatherRenewableMw / prev.loadMw) * 100;
const delta = renewablePct - prevPct;
if (Math.abs(delta) < 0.5) return { direction: "flat", deltaPct: 0 };
return {
direction: delta > 0 ? "up" : "down",
deltaPct: Math.abs(delta),
};
});
const statusLabel = $derived.by(() => { const statusLabel = $derived.by(() => {
if (!bucket) return ""; if (!bucket) return "";
const labels: Record<Bucket, string | undefined> = { const labels: Record<Bucket, string | undefined> = {
@@ -145,6 +236,22 @@
minute: "2-digit", minute: "2-digit",
}); });
} }
function fmtDateTimeAttr(unix: number | null): string {
if (!unix) return "";
return new Date(unix * 1000).toISOString();
}
function fmtDateTimeFull(unix: number | null): string {
if (!unix) return "";
const d = new Date(unix * 1000);
return d.toLocaleString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** Cross-border data is considered stale (and worth flagging) when its /** Cross-border data is considered stale (and worth flagging) when its
* timestamp is more than 30 min behind the production-snapshot. cbpf * timestamp is more than 30 min behind the production-snapshot. cbpf
@@ -230,10 +337,29 @@
if (!live) return null; if (!live) return null;
return live.netFlowGw >= 0 ? "Export" : "Import"; return live.netFlowGw >= 0 ? "Export" : "Import";
}); });
/** History-Panels (Residuallast, Dunkelflaute, NegPreis, Flow,
* Capacity-Factor) sind sekundärer Kontext. Im Akkordeon gebündelt
* damit die Hauptkennzahlen oben dominieren — aufklappbar wenn
* Leser mehr Tiefe will. */
const hasHistoryPanels = $derived.by(() => {
if (!live || isMinimal) return false;
return !!(
(block.installed_wind_onshore_gw || block.installed_solar_gw) ||
(live.todayHourly && live.todayHourly.length > 0) ||
live.lastDunkelflaute ||
(live.worstDunkelflauten && live.worstDunkelflauten.length > 0) ||
live.negPriceHoursYtd != null ||
typeof block.einsman_costs_ytd_eur_mio === "number" ||
live.crossborderTodayGwh ||
live.netCrossborderGwhYtd != null
);
});
</script> </script>
<div <div
class="live-strommix {layoutClasses}" class="live-strommix {layoutClasses}"
data-block="LiveStrommix"
data-block-type="live_strommix" data-block-type="live_strommix"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
@@ -303,7 +429,7 @@
Source-Badge zeigt klar woher die zahlen kommen ohne dass Source-Badge zeigt klar woher die zahlen kommen ohne dass
man die Detail-Aufklappkachel öffnen muss. --> man die Detail-Aufklappkachel öffnen muss. -->
<div <div
class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-2 border-b border-current/10 text-xs font-semibold {accentClasses.label}" class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-1.5 border-b border-current/10 text-xs font-semibold {accentClasses.label}"
> >
<span class="inline-flex items-center gap-1.5 uppercase tracking-wide"> <span class="inline-flex items-center gap-1.5 uppercase tracking-wide">
<Icon <Icon
@@ -315,6 +441,12 @@
</span> </span>
{#if live} {#if live}
<span class="inline-flex items-center gap-2 normal-case font-normal"> <span class="inline-flex items-center gap-2 normal-case font-normal">
{#if isStale}
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide bg-fire-700 text-fire-50">
<Icon icon="mdi:cloud-off-outline" class="size-3" aria-hidden="true" />
{t(T.strommix_stale_badge)}
</span>
{:else}
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}"> <span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" /> <Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
{#if live.dataSource === "smard"} {#if live.dataSource === "smard"}
@@ -325,26 +457,47 @@
Energy-Charts Energy-Charts
{/if} {/if}
</span> </span>
{/if}
{#if live.measuredAtUnix} {#if live.measuredAtUnix}
<span> <time
datetime={fmtDateTimeAttr(live.measuredAtUnix)}
title={fmtDateTimeFull(live.measuredAtUnix)}
>
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })} {t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
</span> </time>
{/if} {/if}
</span> </span>
{/if} {/if}
</div> </div>
<!-- Main: percentage + status --> <!-- Main: percentage + status -->
{#if loadError} {#if loadError && !live}
<div class="p-6 text-sm text-fire-700"> <div class="p-6 text-sm text-fire-700 flex flex-wrap items-center gap-3">
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0" aria-hidden="true" />
<span class="flex-1 min-w-0">
{t(T.strommix_load_error, { error: loadError })} {t(T.strommix_load_error, { error: loadError })}
</span>
<button
type="button"
class="inline-flex items-center gap-1 rounded-xs border border-fire-300 bg-fire-100 px-3 py-1 text-xs font-semibold text-fire-800 hover:bg-fire-200 disabled:opacity-50"
onclick={() => loadLive()}
disabled={isLoading}
>
<Icon icon="mdi:refresh" class="size-3.5 {isLoading ? 'animate-spin' : ''}" aria-hidden="true" />
{t(T.strommix_retry)}
</button>
</div> </div>
{:else if !live} {:else if !live}
<div class="p-6 text-center text-stein-500 text-sm"> <!-- Skeleton: matches the final layout's footprint so the
{t(T.strommix_loading)} % drop-in doesn't shift the rest of the page. -->
<div class="p-4 sm:p-6 text-center" aria-busy="true" aria-live="polite">
<div class="strommix-skel mx-auto h-16 sm:h-20 w-44 sm:w-56 rounded-xs bg-stein-200/70"></div>
<div class="strommix-skel mx-auto mt-3 h-3 w-40 rounded-xs bg-stein-200/60"></div>
<div class="strommix-skel mx-auto mt-2 h-4 w-32 rounded-xs bg-stein-200/60"></div>
<span class="sr-only">{t(T.strommix_loading)}</span>
</div> </div>
{:else} {:else}
<div class="p-6 sm:p-8 text-center"> <div class="p-4 sm:p-6 text-center">
{#if renewablePct != null} {#if renewablePct != null}
<!-- Key change keys the % display so a fresh fetch <!-- Key change keys the % display so a fresh fetch
triggers a brief CSS fade-in transition. The triggers a brief CSS fade-in transition. The
@@ -352,26 +505,47 @@
supports it. --> supports it. -->
{#key live.measuredAtUnix} {#key live.measuredAtUnix}
<div <div
class="strommix-pct text-7xl sm:text-8xl font-bold tabular-nums leading-none {accentClasses.pct}" class="strommix-pct text-5xl sm:text-7xl font-bold tabular-nums leading-none {accentClasses.pct}"
> >
{Math.round(renewablePct)}% {Math.round(renewablePct)}%
</div> </div>
{/key} {/key}
<div class="mt-2 text-sm sm:text-base text-stein-700"> <div class="mt-1.5 text-sm text-stein-700">
{t(T.strommix_wind_solar_share)} {t(T.strommix_wind_solar_share)}
</div> </div>
<div <div
class="mt-3 text-lg font-semibold {accentClasses.label}" class="mt-2 text-base font-semibold {accentClasses.label}"
> >
{statusLabel} {statusLabel}
</div> </div>
{#if argumentHtml} {#if trend}
<div class="mt-1 text-[11px] text-stein-500 tabular-nums">
{#if trend.direction === "up"}
{t(T.strommix_trend_up, { delta: trend.deltaPct.toFixed(1).replace(".", ",") })}
{:else if trend.direction === "down"}
{t(T.strommix_trend_down, { delta: trend.deltaPct.toFixed(1).replace(".", ",") })}
{:else}
{t(T.strommix_trend_flat)}
{/if}
</div>
{/if}
{#if live.todayHourly && live.todayHourly.length >= 2}
<div class="mt-2 {accentClasses.label}">
<RenewableSparkline points={live.todayHourly} accent="currentColor" />
</div>
{/if}
{#if argumentHtml && !isHero}
<div <div
class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1" class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1"
> >
{@html argumentHtml} {@html argumentHtml}
</div> </div>
{/if} {/if}
{#if usingLoadFallback}
<div class="mt-2 text-[11px] text-stein-500 italic">
{t(T.strommix_load_missing)}
</div>
{/if}
{:else} {:else}
<!-- Production data arrived but load is missing → no percentage, <!-- Production data arrived but load is missing → no percentage,
but we still show what we have so the widget isn't blank. --> but we still show what we have so the widget isn't blank. -->
@@ -386,21 +560,23 @@
<!-- Context cards: konventionell + saldo, je eigenes box-block. <!-- Context cards: konventionell + saldo, je eigenes box-block.
Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt- Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt-
karte mit %-zahl optisch dominiert. --> karte mit %-zahl optisch dominiert. Hero-Variante zeigt nur
{#if live} Hauptzahl + Sparkline; sub-cards würden den minimalen Charakter
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2"> brechen. -->
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm"> {#if live && !isHero}
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="rounded-xs border border-stein-200 bg-white px-4 py-2.5 text-sm shadow-sm">
<div class="text-xs text-stein-600 uppercase tracking-wide"> <div class="text-xs text-stein-600 uppercase tracking-wide">
{t(T.strommix_conventional_now)} {t(T.strommix_conventional_now)}
</div> </div>
<div class="text-2xl font-semibold tabular-nums text-stein-900"> <div class="text-xl font-semibold tabular-nums text-stein-900">
{fmtMw(live.conventionalMw)} {fmtMw(live.conventionalMw)}
</div> </div>
<div class="text-[11px] text-stein-500 mt-1"> <div class="text-[11px] text-stein-500 mt-0.5">
{t(T.strommix_conventional_formula)} {t(T.strommix_conventional_formula)}
</div> </div>
</div> </div>
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm"> <div class="rounded-xs border border-stein-200 bg-white px-4 py-2.5 text-sm shadow-sm">
<div class="text-xs text-stein-600 uppercase tracking-wide"> <div class="text-xs text-stein-600 uppercase tracking-wide">
{importDirection === "Import" {importDirection === "Import"
? t(T.strommix_import) ? t(T.strommix_import)
@@ -408,16 +584,21 @@
? t(T.strommix_export) ? t(T.strommix_export)
: t(T.strommix_saldo)} : t(T.strommix_saldo)}
</div> </div>
<div class="text-2xl font-semibold tabular-nums text-stein-900"> <div class="text-xl font-semibold tabular-nums text-stein-900">
{#if importDirection === "Import"}{/if} {#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if} {#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)} {fmtGw(live.netFlowGw)}
</div> </div>
{#if live.crossborderMeasuredAtUnix} {#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-stein-500 mt-1"> <div class="text-[11px] text-stein-500 mt-0.5">
<time
datetime={fmtDateTimeAttr(live.crossborderMeasuredAtUnix)}
title={fmtDateTimeFull(live.crossborderMeasuredAtUnix)}
>
{t(T.strommix_stand, { {t(T.strommix_stand, {
time: fmtTime(live.crossborderMeasuredAtUnix), time: fmtTime(live.crossborderMeasuredAtUnix),
})} })}
</time>
{#if cbStale}<span class="text-fire-700"> {#if cbStale}<span class="text-fire-700">
· {t(T.strommix_delayed)}</span · {t(T.strommix_delayed)}</span
>{/if} >{/if}
@@ -426,10 +607,50 @@
</div> </div>
</div> </div>
<!-- Price card — separater block, full-width damit der vergleich {#if (live.priceDeEurMwh ?? 0) < 0}
DE/FR + slot-info nicht in den 2-spaltigen flow oben gepresst <!-- Negativpreis-warnung als eigener prominenter banner statt
werden muss. --> inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
<div class="mt-3 rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm"> aussagekräftiger marktzustand der nicht versteckt werden
sollte. Ersetzt die separate Price-Card komplett, weil
derselbe Preis sonst zweimal genannt würde. -->
<div class="mt-2 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-2.5 text-sm shadow-sm">
<Icon
icon="mdi:alert-circle"
class="size-6 shrink-0 text-fire-700"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
<span class="text-base font-bold text-fire-800">
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
</span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-xs text-fire-900/70">
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-fire-900/60">
· <time
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
</span>
{/if}
</div>
{#if showDisclaimer}
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
{@html disclaimerHtml}
</div>
{/if}
</div>
</div>
{:else}
<!-- Price card — separater block, full-width damit DE/FR-
Vergleich + Slot-Info sauber nebeneinander stehen. Bei
Negativpreisen unterdrückt; der Negativbanner oben zeigt
dann denselben Preis prominenter. -->
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2 text-sm shadow-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1"> <div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold"> <span class="font-semibold">
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })} {t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
@@ -441,42 +662,73 @@
{/if} {/if}
{#if live.priceMeasuredAtUnix} {#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-stein-500"> <span class="text-[11px] text-stein-500">
· {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })} · <time
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
</span> </span>
{/if} {/if}
</div> </div>
</div> </div>
{/if}
{/if}
<!-- Negativpreis-warnung als eigener prominenter banner statt <!-- History-Panels gebündelt im Akkordeon: Residuallast,
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr Dunkelflaute, Negativpreise, Cross-Border, Capacity-Factor.
aussagekräftiger marktzustand der nicht versteckt werden Default zu — Hauptkennzahlen oben sollen dominieren. -->
sollte. --> {#if live && hasHistoryPanels}
{#if (live.priceDeEurMwh ?? 0) < 0} <details class="strommix-history mt-3 group">
<div class="mt-3 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-3 text-sm shadow-sm"> <summary class="cursor-pointer rounded-xs border border-stein-200 bg-white px-4 py-2 text-sm font-semibold text-stein-700 shadow-sm hover:bg-stein-50 inline-flex items-center gap-2">
<Icon <Icon icon="mdi:chevron-right" class="size-4 transition-transform group-open:rotate-90" aria-hidden="true" />
icon="mdi:alert-circle" {t(T.strommix_history_summary)}
class="size-6 shrink-0 text-fire-700" </summary>
aria-hidden="true"
{#if live.todayHourly && live.todayHourly.length > 0}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<h4 class="text-sm font-semibold text-stein-800">
{t(T.strommix_residual_today_title)}
</h4>
<p class="mt-0.5 text-[11px] text-stein-600">
{t(T.strommix_residual_today_desc)}
</p>
<div class="mt-2">
<ResidualLoadChart points={live.todayHourly} />
</div>
</div>
{/if}
{#if live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0)}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<DunkelflauteCounter
last={live.lastDunkelflaute}
worst={live.worstDunkelflauten}
/> />
<div class="min-w-0">
<div class="text-base font-bold text-fire-800">
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
</div> </div>
{#if showDisclaimer}
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
{@html disclaimerHtml}
</div>
{/if}
</div>
</div>
{/if}
{/if} {/if}
<!-- Capacity-factor bars: live vs. installiert. Nur full-Variante; in {#if live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number"}
compact wäre's optisch zu dicht. Renders nur, wenn Stammdaten <div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
gesetzt sind. --> <NegPriceCounter
{#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)} negHoursYtd={live.negPriceHoursYtd}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm"> einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
einsmanAsOf={block.einsman_costs_as_of}
/>
</div>
{/if}
{#if live.crossborderTodayGwh || live.netCrossborderGwhYtd != null}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<FlowBalanceTodayYtd
today={live.crossborderTodayGwh}
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
: null}
/>
</div>
{/if}
{#if block.installed_wind_onshore_gw || block.installed_solar_gw}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<CapacityFactorBars <CapacityFactorBars
windOnshoreLiveMw={live.windOnshoreMw} windOnshoreLiveMw={live.windOnshoreMw}
windOffshoreLiveMw={live.windOffshoreMw} windOffshoreLiveMw={live.windOffshoreMw}
@@ -488,72 +740,26 @@
/> />
</div> </div>
{/if} {/if}
</details>
<!-- Residuallast-Kurve heute: Last minus Wind+Solar. Nur full-Variante,
und nur wenn der Aggregator Historien-Daten geliefert hat (CMS mit
history-mode-Schema im Hintergrund — sonst null). -->
{#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<h4 class="text-sm font-semibold text-stein-800">
Residuallast heute
</h4>
<p class="mt-0.5 text-[11px] text-stein-600">
Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke
füllen konventionelle Kraftwerke und Importe.
</p>
<div class="mt-2">
<ResidualLoadChart points={live.todayHourly} />
</div>
</div>
{/if}
<!-- Dunkelflaute-Counter: letzte Phase + Top-5 Liste. Nur full-Variante.
Renders nur, wenn Aggregator History-Daten + qualifying runs hat. -->
{#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<DunkelflauteCounter
last={live.lastDunkelflaute}
worst={live.worstDunkelflauten}
/>
</div>
{/if}
<!-- Negativpreis- + EinsMan-Counter. Nur full. Renders wenn entweder
History-Aggregat oder hand-gepflegte EinsMan-Zahl vorhanden. -->
{#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<NegPriceCounter
negHoursYtd={live.negPriceHoursYtd}
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
einsmanAsOf={block.einsman_costs_as_of}
/>
</div>
{/if}
<!-- Cross-Border-Bilanz: heute + YTD pro Land. Nur full, nur wenn
History-Daten da sind. -->
{#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<FlowBalanceTodayYtd
today={live.crossborderTodayGwh}
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
: null}
/>
</div>
{/if} {/if}
<!-- 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). --> kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot).
{#if live} Hero-Variante kapselt den minimalen Modus — Detail-Aufklapper würde
das brechen. -->
{#if live && !isHero}
<details class="mt-3 text-xs"> <details class="mt-3 text-xs">
<summary <summary
class="cursor-pointer text-stein-600 hover:text-stein-900" class="cursor-pointer text-stein-600 hover:text-stein-900"
> >
{t(T.strommix_check_values)} {t(T.strommix_check_values)}
</summary> </summary>
<!-- grid: label/value-Paare als zwei klar getrennte Spalten
(auto / 1fr) statt grid-cols-2 — sonst alterniert das
Mapping pro Zelle und wirkt unruhig. Auf sm doppelt: 2
Paare nebeneinander. -->
<div <div
class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-xs border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-3" class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-xs border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-[auto_1fr_auto_1fr] sm:gap-x-4"
> >
<div class="text-stein-500"> <div class="text-stein-500">
{t(T.strommix_field_wind_onshore)} {t(T.strommix_field_wind_onshore)}
@@ -664,4 +870,30 @@
.live-strommix [class*="border-stein"] { .live-strommix [class*="border-stein"] {
transition: background-color 350ms ease, border-color 350ms ease, color 350ms ease; transition: background-color 350ms ease, border-color 350ms ease, color 350ms ease;
} }
/* Skeleton placeholder pulse — matches widget rhythm so the
loading state doesn't feel like a hang. */
.strommix-skel {
animation: strommix-skel-pulse 1400ms ease-in-out infinite;
}
@keyframes strommix-skel-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.strommix-skel { animation: none; }
}
/* Print: open all <details> children so the printed page shows
the full picture, not just the summary chevron. The chevron
itself is hidden — it's only meaningful interactively. */
@media print {
.live-strommix details > *:not(summary) {
display: block !important;
}
.live-strommix details > summary {
list-style: none;
}
.live-strommix details > summary > :global(svg) {
display: none;
}
}
</style> </style>
@@ -84,7 +84,7 @@
const hasEmbed = $derived(embedUrl != null); const hasEmbed = $derived(embedUrl != null);
</script> </script>
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}> <div class={layoutClasses} data-block="YoutubeVideo" data-block-type="youtube_video" data-block-slug={block._slug}>
{#if hasEmbed} {#if hasEmbed}
<div class="aspect-video w-full overflow-hidden rounded-xs bg-stein-100"> <div class="aspect-video w-full overflow-hidden rounded-xs bg-stein-100">
<iframe <iframe
@@ -145,7 +145,7 @@
<div <div
class={layoutClasses} class={layoutClasses}
data-block-type="youtube_video_gallery" data-block="YoutubeVideoGallery" data-block-type="youtube_video_gallery"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#if block.title} {#if block.title}
@@ -0,0 +1,91 @@
<script lang="ts">
import type { ResidualLoadPoint } from "$lib/strommix";
import { useTranslate, T } from "$lib/translations";
/** Daily per-slot data — the sparkline plots `weatherRenewableMw / loadMw`
* per slot. Compact, decorative, sits below the hero %. */
let {
points,
accent = "currentColor",
}: { points: ResidualLoadPoint[]; accent?: string } = $props();
const t = useTranslate();
const W = 240;
const H = 36;
const PAD = 2;
const series = $derived.by(() => {
return points
.filter((p) => p.loadMw > 0)
.map((p) => ({
unix: p.unix,
pct: (p.weatherRenewableMw / p.loadMw) * 100,
}));
});
const range = $derived.by(() => {
if (series.length === 0) return { minX: 0, maxX: 1, minY: 0, maxY: 100 };
const xs = series.map((s) => s.unix);
const ys = series.map((s) => s.pct);
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.max(0, Math.min(...ys) - 5),
maxY: Math.min(100, Math.max(...ys) + 5),
};
});
function xOf(unix: number): number {
const span = range.maxX - range.minX || 1;
return PAD + ((unix - range.minX) / span) * (W - 2 * PAD);
}
function yOf(pct: number): number {
const span = range.maxY - range.minY || 1;
return PAD + (1 - (pct - range.minY) / span) * (H - 2 * PAD);
}
const linePath = $derived.by(() => {
if (series.length < 2) return "";
return series
.map(
(s, i) => `${i === 0 ? "M" : "L"} ${xOf(s.unix).toFixed(1)} ${yOf(s.pct).toFixed(1)}`,
)
.join(" ");
});
const areaPath = $derived.by(() => {
if (series.length < 2) return "";
const top = linePath;
const last = series[series.length - 1];
const first = series[0];
return `${top} L ${xOf(last.unix).toFixed(1)} ${(H - PAD).toFixed(1)} L ${xOf(first.unix).toFixed(1)} ${(H - PAD).toFixed(1)} Z`;
});
const ariaLabel = $derived.by(() => {
if (series.length === 0) return "";
return t(T.strommix_sparkline_aria, {
from: Math.round(series[0].pct),
to: Math.round(series[series.length - 1].pct),
});
});
</script>
{#if series.length >= 2}
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="strommix-sparkline w-full max-w-[240px] h-9 mx-auto"
role="img"
aria-label={ariaLabel}
>
<path d={areaPath} fill={accent} fill-opacity="0.15" />
<path
d={linePath}
stroke={accent}
stroke-width="1.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
+10
View File
@@ -6,6 +6,16 @@
* consumes it. * consumes it.
*/ */
/** Default percentage thresholds (Wind+Solar / Last × 100) for bucket
* classification. Single source of truth both the CMS schema
* (`live_strommix.json5`) and the block (`StrommixBlock.svelte`) read
* these. Editor can override per-instance via `threshold_*` fields. */
export const STROMMIX_DEFAULT_THRESHOLDS = {
dunkelflaute: 15,
niedrig: 35,
mittel: 60,
} as const;
export type StrommixResponse = { export type StrommixResponse = {
// Production-snapshot timestamp (Unix sec) — used as the "anchor" for // Production-snapshot timestamp (Unix sec) — used as the "anchor" for
// the rest of the values. Cross-border + prices may have their own, // the rest of the values. Cross-border + prices may have their own,
+33
View File
@@ -114,6 +114,28 @@ const TRANSLATION_KEYS = [
"calendar_time_suffix", "calendar_time_suffix",
"calendar_select_day", "calendar_select_day",
"calendar_next_events", "calendar_next_events",
"calendar_all_upcoming",
"calendar_today",
"calendar_tomorrow",
"calendar_in_days", // {{n}}
"calendar_no_future_events",
"calendar_clear_filter",
"calendar_event_count", // {{n}}
"calendar_add_to_google",
"calendar_download_ics",
"calendar_open_maps",
"calendar_show_past",
"calendar_hide_past",
"calendar_past_events",
"calendar_filter_by_tag",
"calendar_all_tags",
"calendar_multi_day_range", // {{from}}, {{to}}
"calendar_starts_in", // {{n}}
"calendar_starts_in_minutes", // {{n}}
"calendar_running_now",
"calendar_jump_to_month",
"calendar_today_marker",
"calendar_untitled",
"calendar_show_more", "calendar_show_more",
"calendar_show_less", "calendar_show_less",
"post_overview_all", "post_overview_all",
@@ -207,6 +229,17 @@ const TRANSLATION_KEYS = [
// Compact variant for homepage / sidebar. // Compact variant for homepage / sidebar.
"strommix_compact_label", // {{pct}}, {{status}} "strommix_compact_label", // {{pct}}, {{status}}
"strommix_compact_more", "strommix_compact_more",
// Akkordeon + history-panel headings.
"strommix_history_summary",
"strommix_residual_today_title",
"strommix_residual_today_desc",
// Loading / error UX.
"strommix_retry",
"strommix_trend_up", // {{delta}}
"strommix_trend_down", // {{delta}}
"strommix_trend_flat",
"strommix_sparkline_aria", // {{from}}, {{to}}
"strommix_stale_badge",
] as const; ] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number]; export type TranslationKey = (typeof TRANSLATION_KEYS)[number];