Compare commits
4 Commits
b633ed2d2b
...
fc6c59bc10
| Author | SHA1 | Date | |
|---|---|---|---|
| fc6c59bc10 | |||
| a1c59b549d | |||
| 4790d571fd | |||
| 5a86ff701e |
+16
-5
@@ -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<
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
/** Nächste 3 Termine ab heute – zur Laufzeit gefiltert. */
|
/** Relative day diff for a target date vs. current `nowMs`. */
|
||||||
const nextUpcomingEvents = $derived.by(() => {
|
function relativeDays(d: Date): string {
|
||||||
const startOfToday = new Date();
|
const today = new Date(nowMs);
|
||||||
startOfToday.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const todayMs = startOfToday.getTime();
|
const target = new Date(d);
|
||||||
return items
|
target.setHours(0, 0, 0, 0);
|
||||||
.filter((item) => item.date && item.date.getTime() >= todayMs)
|
const diff = Math.round(
|
||||||
.slice(0, 3);
|
(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}
|
||||||
|
>
|
||||||
|
<details class="group" open={openByDefault}>
|
||||||
|
<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}
|
||||||
|
<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}
|
{/if}
|
||||||
{/if}
|
<div class="flex-1 min-w-0">
|
||||||
{#if item.timeStr}
|
<div class="flex items-baseline gap-2 flex-wrap">
|
||||||
<div class="text-xs text-stein-600">
|
<span class="text-sm font-semibold text-stein-900 leading-snug">
|
||||||
{item.timeStr}
|
{title}
|
||||||
{t(T.calendar_time_suffix)}
|
</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>
|
</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}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="text-base font-medium text-stein-900">{item.title}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if item.description}
|
|
||||||
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
{#if eventHref}
|
|
||||||
<a
|
|
||||||
href={eventHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
|
||||||
>
|
|
||||||
{t(T.calendar_more_info)}
|
|
||||||
<Icon
|
|
||||||
icon="mdi:open-in-new"
|
|
||||||
class="size-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{:else if eventHref}
|
|
||||||
<a
|
|
||||||
href={eventHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
|
||||||
>
|
|
||||||
{t(T.calendar_more_info)}
|
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:open-in-new"
|
icon="mdi:chevron-down"
|
||||||
class="size-4"
|
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open:rotate-180"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</a>
|
</summary>
|
||||||
{/if}
|
<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 item.description}
|
||||||
|
<p class="font-light text-stein-700 m-0! leading-relaxed">{item.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#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}
|
||||||
|
<a
|
||||||
|
href={eventHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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)}
|
||||||
|
<Icon icon="mdi:open-in-new" class="size-3.5" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<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
|
||||||
|
href={googleCalUrl(toICS(item))}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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:google" class="size-3.5" aria-hidden="true" />
|
||||||
|
{t(T.calendar_add_to_google)}
|
||||||
|
</a>
|
||||||
|
<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">
|
||||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
<div class="text-xs font-medium text-stein-700 truncate">
|
||||||
weekday: "long",
|
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
||||||
day: "numeric",
|
weekday: "long",
|
||||||
month: "long",
|
day: "numeric",
|
||||||
})}
|
month: "long",
|
||||||
</p>
|
})}
|
||||||
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (selectedDay = null)}
|
||||||
|
class="inline-flex items-center gap-1 text-[11px] text-himmel-700 hover:text-himmel-900 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:close" class="size-3.5" aria-hidden="true" />
|
||||||
|
{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}
|
{#each selectedDayEvents as item}
|
||||||
{@render eventCard(item)}
|
{@render eventAccordion(item, true, false)}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if nextUpcomingEvents.length > 0}
|
{:else if futureEventsByDay.length > 0}
|
||||||
<p class="text-xs uppercase font-medium text-stein-500">
|
<div class="flex items-baseline justify-between gap-2 px-3 py-2 border-b border-stein-200">
|
||||||
{t(T.calendar_next_events)}
|
<h4 class="text-xs font-medium text-stein-500 m-0!">
|
||||||
</p>
|
{t(T.calendar_all_upcoming)}
|
||||||
<ul
|
</h4>
|
||||||
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
|
<span class="text-[11px] text-stein-500 tabular-nums">
|
||||||
>
|
{t(T.calendar_event_count, { n: futureEvents.length })}
|
||||||
{#each nextUpcomingEvents as item}
|
</span>
|
||||||
{@render eventCard(item)}
|
</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}
|
{/each}
|
||||||
</ul>
|
</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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 ?? ""}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,36 +441,63 @@
|
|||||||
</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">
|
||||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
|
{#if isStale}
|
||||||
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
|
<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">
|
||||||
{#if live.dataSource === "smard"}
|
<Icon icon="mdi:cloud-off-outline" class="size-3" aria-hidden="true" />
|
||||||
SMARD
|
{t(T.strommix_stale_badge)}
|
||||||
{:else if live.dataSource === "smard+energy-charts"}
|
|
||||||
SMARD + EC
|
|
||||||
{:else}
|
|
||||||
Energy-Charts
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if live.measuredAtUnix}
|
|
||||||
<span>
|
|
||||||
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
|
|
||||||
</span>
|
</span>
|
||||||
|
{:else}
|
||||||
|
<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" />
|
||||||
|
{#if live.dataSource === "smard"}
|
||||||
|
SMARD
|
||||||
|
{:else if live.dataSource === "smard+energy-charts"}
|
||||||
|
SMARD + EC
|
||||||
|
{:else}
|
||||||
|
Energy-Charts
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if live.measuredAtUnix}
|
||||||
|
<time
|
||||||
|
datetime={fmtDateTimeAttr(live.measuredAtUnix)}
|
||||||
|
title={fmtDateTimeFull(live.measuredAtUnix)}
|
||||||
|
>
|
||||||
|
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
|
||||||
|
</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">
|
||||||
{t(T.strommix_load_error, { error: loadError })}
|
<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 })}
|
||||||
|
</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">
|
||||||
{t(T.strommix_stand, {
|
<time
|
||||||
time: fmtTime(live.crossborderMeasuredAtUnix),
|
datetime={fmtDateTimeAttr(live.crossborderMeasuredAtUnix)}
|
||||||
})}
|
title={fmtDateTimeFull(live.crossborderMeasuredAtUnix)}
|
||||||
|
>
|
||||||
|
{t(T.strommix_stand, {
|
||||||
|
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,41 +607,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price card — separater block, full-width damit der vergleich
|
|
||||||
DE/FR + slot-info nicht in den 2-spaltigen flow oben gepresst
|
|
||||||
werden muss. -->
|
|
||||||
<div class="mt-3 rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
|
|
||||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
|
|
||||||
</span>
|
|
||||||
{#if enableFr && live.priceFrEurMwh != null}
|
|
||||||
<span class="text-stein-600">
|
|
||||||
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if live.priceMeasuredAtUnix}
|
|
||||||
<span class="text-[11px] text-stein-500">
|
|
||||||
· {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Negativpreis-warnung als eigener prominenter banner statt
|
|
||||||
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
|
|
||||||
aussagekräftiger marktzustand der nicht versteckt werden
|
|
||||||
sollte. -->
|
|
||||||
{#if (live.priceDeEurMwh ?? 0) < 0}
|
{#if (live.priceDeEurMwh ?? 0) < 0}
|
||||||
<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">
|
<!-- Negativpreis-warnung als eigener prominenter banner statt
|
||||||
|
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
|
||||||
|
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
|
||||||
icon="mdi:alert-circle"
|
icon="mdi:alert-circle"
|
||||||
class="size-6 shrink-0 text-fire-700"
|
class="size-6 shrink-0 text-fire-700"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="text-base font-bold text-fire-800">
|
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
|
||||||
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
|
<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>
|
</div>
|
||||||
{#if showDisclaimer}
|
{#if showDisclaimer}
|
||||||
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
|
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
|
||||||
@@ -469,91 +645,121 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
|
||||||
|
</span>
|
||||||
|
{#if enableFr && live.priceFrEurMwh != null}
|
||||||
|
<span class="text-stein-600">
|
||||||
|
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if live.priceMeasuredAtUnix}
|
||||||
|
<span class="text-[11px] text-stein-500">
|
||||||
|
· <time
|
||||||
|
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
|
||||||
|
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
|
||||||
|
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Capacity-factor bars: live vs. installiert. Nur full-Variante; in
|
<!-- History-Panels gebündelt im Akkordeon: Residuallast,
|
||||||
compact wäre's optisch zu dicht. Renders nur, wenn Stammdaten
|
Dunkelflaute, Negativpreise, Cross-Border, Capacity-Factor.
|
||||||
gesetzt sind. -->
|
Default zu — Hauptkennzahlen oben sollen dominieren. -->
|
||||||
{#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)}
|
{#if live && hasHistoryPanels}
|
||||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
<details class="strommix-history mt-3 group">
|
||||||
<CapacityFactorBars
|
<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">
|
||||||
windOnshoreLiveMw={live.windOnshoreMw}
|
<Icon icon="mdi:chevron-right" class="size-4 transition-transform group-open:rotate-90" aria-hidden="true" />
|
||||||
windOffshoreLiveMw={live.windOffshoreMw}
|
{t(T.strommix_history_summary)}
|
||||||
solarLiveMw={live.solarMw}
|
</summary>
|
||||||
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
|
|
||||||
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
|
|
||||||
installedSolarGw={block.installed_solar_gw ?? 0}
|
|
||||||
installedAsOf={block.installed_as_of}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Residuallast-Kurve heute: Last minus Wind+Solar. Nur full-Variante,
|
{#if live.todayHourly && live.todayHourly.length > 0}
|
||||||
und nur wenn der Aggregator Historien-Daten geliefert hat (CMS mit
|
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||||
history-mode-Schema im Hintergrund — sonst null). -->
|
<h4 class="text-sm font-semibold text-stein-800">
|
||||||
{#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0}
|
{t(T.strommix_residual_today_title)}
|
||||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
</h4>
|
||||||
<h4 class="text-sm font-semibold text-stein-800">
|
<p class="mt-0.5 text-[11px] text-stein-600">
|
||||||
Residuallast heute
|
{t(T.strommix_residual_today_desc)}
|
||||||
</h4>
|
</p>
|
||||||
<p class="mt-0.5 text-[11px] text-stein-600">
|
<div class="mt-2">
|
||||||
Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke
|
<ResidualLoadChart points={live.todayHourly} />
|
||||||
füllen konventionelle Kraftwerke und Importe.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<div class="mt-2">
|
{/if}
|
||||||
<ResidualLoadChart points={live.todayHourly} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Dunkelflaute-Counter: letzte Phase + Top-5 Liste. Nur full-Variante.
|
{#if live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0)}
|
||||||
Renders nur, wenn Aggregator History-Daten + qualifying runs hat. -->
|
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||||
{#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))}
|
<DunkelflauteCounter
|
||||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
last={live.lastDunkelflaute}
|
||||||
<DunkelflauteCounter
|
worst={live.worstDunkelflauten}
|
||||||
last={live.lastDunkelflaute}
|
/>
|
||||||
worst={live.worstDunkelflauten}
|
</div>
|
||||||
/>
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Negativpreis- + EinsMan-Counter. Nur full. Renders wenn entweder
|
{#if live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number"}
|
||||||
History-Aggregat oder hand-gepflegte EinsMan-Zahl vorhanden. -->
|
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||||
{#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")}
|
<NegPriceCounter
|
||||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
negHoursYtd={live.negPriceHoursYtd}
|
||||||
<NegPriceCounter
|
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
|
||||||
negHoursYtd={live.negPriceHoursYtd}
|
einsmanAsOf={block.einsman_costs_as_of}
|
||||||
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
|
/>
|
||||||
einsmanAsOf={block.einsman_costs_as_of}
|
</div>
|
||||||
/>
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Cross-Border-Bilanz: heute + YTD pro Land. Nur full, nur wenn
|
{#if live.crossborderTodayGwh || live.netCrossborderGwhYtd != null}
|
||||||
History-Daten da sind. -->
|
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||||
{#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)}
|
<FlowBalanceTodayYtd
|
||||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
today={live.crossborderTodayGwh}
|
||||||
<FlowBalanceTodayYtd
|
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
|
||||||
today={live.crossborderTodayGwh}
|
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
|
||||||
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
|
: null}
|
||||||
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
|
/>
|
||||||
: null}
|
</div>
|
||||||
/>
|
{/if}
|
||||||
</div>
|
|
||||||
|
{#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
|
||||||
|
windOnshoreLiveMw={live.windOnshoreMw}
|
||||||
|
windOffshoreLiveMw={live.windOffshoreMw}
|
||||||
|
solarLiveMw={live.solarMw}
|
||||||
|
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
|
||||||
|
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
|
||||||
|
installedSolarGw={block.installed_solar_gw ?? 0}
|
||||||
|
installedAsOf={block.installed_as_of}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
{/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}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user