feat(calendar): future-events accordion, ICS/Google export, multi-day

Right panel now shows all upcoming events as a card-style accordion
(date block + title + location/time chips visible, body collapsed).
Day-grouped with sticky headers + relative countdown.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-07 15:45:16 +02:00
parent 4790d571fd
commit a1c59b549d
3 changed files with 815 additions and 156 deletions
+16 -5
View File
@@ -337,9 +337,11 @@ export interface OrganisationsBlockData {
export interface LiveStrommixBlockData {
_type?: "live_strommix";
_slug?: string;
/** `full` = card with argument + breakdown disclosure. `compact` = single
* horizontal strip for sidebar / homepage placement. Default: `full`. */
variant?: "full" | "compact";
/** `full` = card with argument + breakdown disclosure + history accordion.
* `hero` = pct + status + sparkline + trend, no sub-cards or accordion.
* `compact` = single horizontal strip for sidebar / homepage placement.
* Default: `full`. */
variant?: "full" | "hero" | "compact";
intro_headline?: string;
intro_text?: string;
threshold_dunkelflaute?: number;
@@ -380,8 +382,17 @@ export interface LiveStrommixBlockData {
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time.
* 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). */
export type CalendarBlockData = Omit<
+146
View File
@@ -0,0 +1,146 @@
/**
* Helpers for "send calendar entry to my own calendar" flows.
*
* - `buildICS()` produces a single-event RFC-5545 string suitable for
* download. Apple Calendar, Outlook and Google all import it.
* - `googleCalUrl()` returns a deep link to Google Calendar's event-
* create form pre-filled with the event details.
* - `mapsUrl()` returns a Google Maps search URL for an address string.
*
* All client-only — no Node APIs touched. Safe in SSR contexts (the
* functions just return strings; the actual download is triggered by
* the caller via `URL.createObjectURL`).
*/
export type CalendarItemICS = {
title: string;
start: Date;
/** Optional end date. If omitted, default duration is 1 hour. */
end?: Date | null;
description?: string;
location?: string;
url?: string;
/** Stable identifier; falls back to start-timestamp + title hash. */
uid?: string;
};
/** Folds long lines per RFC-5545 §3.1 (75 octets max). Most clients
* tolerate longer lines but Outlook is strict — better safe. */
function foldLine(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
const chunk = line.slice(i, i + (i === 0 ? 75 : 74));
out.push((i === 0 ? "" : " ") + chunk);
i += i === 0 ? 75 : 74;
}
return out.join("\r\n");
}
/** Escape per RFC-5545 §3.3.11 — backslash, comma, semicolon, newline. */
function escapeText(s: string): string {
return s
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
/** UTC timestamp formatted as `YYYYMMDDTHHMMSSZ`. */
function fmtICSDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getUTCFullYear().toString() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
"Z"
);
}
function deterministicUid(title: string, start: Date): string {
let hash = 0;
for (let i = 0; i < title.length; i++) {
hash = (hash * 31 + title.charCodeAt(i)) | 0;
}
return `${start.getTime()}-${Math.abs(hash).toString(36)}@windwiderstand.de`;
}
export function buildICS(item: CalendarItemICS): string {
const start = item.start;
const end =
item.end ??
new Date(start.getTime() + 60 * 60 * 1000); // 1h default
const uid = item.uid ?? deterministicUid(item.title, start);
const now = new Date();
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//windwiderstand.de//calendar//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${fmtICSDate(now)}`,
`DTSTART:${fmtICSDate(start)}`,
`DTEND:${fmtICSDate(end)}`,
foldLine(`SUMMARY:${escapeText(item.title)}`),
];
if (item.description) {
lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
}
if (item.location) {
lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
}
if (item.url) {
lines.push(foldLine(`URL:${item.url}`));
}
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n");
}
/** Trigger a browser download of an ICS file. Caller should pass a
* filesystem-friendly filename; we still strip path separators
* defensively. No-op on the server (`window` undefined). */
export function downloadICS(item: CalendarItemICS, filename: string): void {
if (typeof window === "undefined") return;
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "event.ics";
const ics = buildICS(item);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Google-Calendar's render-template URL. Times must be UTC, in
* YYYYMMDDTHHMMSSZ format, joined by `/`. */
export function googleCalUrl(item: CalendarItemICS): string {
const start = item.start;
const end =
item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
const dates = `${fmtICSDate(start)}/${fmtICSDate(end)}`;
const params = new URLSearchParams({
action: "TEMPLATE",
text: item.title,
dates,
});
if (item.description) params.set("details", item.description);
if (item.location) params.set("location", item.location);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
/** Generic maps URL — Google Maps search by query. Safe with addresses
* or place names. */
export function mapsUrl(location: string): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(location)}`;
}
+653 -151
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type {
CalendarBlockData,
@@ -6,12 +7,21 @@
} from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import {
downloadICS,
googleCalUrl,
mapsUrl,
type CalendarItemICS,
} from "$lib/calendar-ics";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
type EventCardItem = CalendarItemData & {
date: Date | null;
start: Date;
end: Date | null;
timeStr: string;
isMultiDay: boolean;
spanDayKeys: string[];
};
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
@@ -22,31 +32,53 @@
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const items = $derived.by(() => {
const raw = block.items ?? [];
return raw
.filter(
(x): x is CalendarItemData =>
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));
});
/** Wall-clock ticker for live countdown — refreshed every minute on
* the client. SSR uses Date.now() at render time; the first client
* tick after hydration takes over. */
let nowMs = $state(Date.now());
let tickTimer: ReturnType<typeof setInterval> | null = null;
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);
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 {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object") {
@@ -58,33 +90,118 @@
return "";
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) 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`;
}
/** Normalised, sorted item list. Filters out malformed entries. */
const items = $derived.by((): EventCardItem[] => {
const raw = block.items ?? [];
return raw
.filter(
(x): x is CalendarItemData =>
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 {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Distinct tag list across all items (case-insensitive de-dup,
* 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 map = new Map<string, typeof items>();
for (const item of items) {
if (!item.date) continue;
const key = dayKey(item.date);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
const map = new Map<string, EventCardItem[]>();
for (const ev of filteredItems) {
for (const k of ev.spanDayKeys) {
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(ev);
}
}
return map;
});
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
const startOfTodayMs = $derived.by(() => {
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 month = $derived(currentMonth.getMonth());
const monthLabel = $derived(
@@ -93,7 +210,6 @@
year: "numeric",
}),
);
const daysInMonth = $derived.by(() => {
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
@@ -101,7 +217,6 @@
const startWeekday = (first.getDay() + 6) % 7;
return { count, startWeekday, last };
});
const calendarDays = $derived.by(() => {
const { count, startWeekday } = daysInMonth;
const empty = Array(startWeekday).fill(null);
@@ -112,19 +227,79 @@
return [...empty, ...days];
});
const selectedDayEvents = $derived(
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
// ── Formatters ───────────────────────────────────────────────────
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. */
const nextUpcomingEvents = $derived.by(() => {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
return items
.filter((item) => item.date && item.date.getTime() >= todayMs)
.slice(0, 3);
});
/** Relative day diff for a target date vs. current `nowMs`. */
function relativeDays(d: Date): string {
const today = new Date(nowMs);
today.setHours(0, 0, 0, 0);
const target = new Date(d);
target.setHours(0, 0, 0, 0);
const diff = Math.round(
(target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000),
);
if (diff === 0) return t(T.calendar_today);
if (diff === 1) return t(T.calendar_tomorrow);
if (diff < 0) return "";
return t(T.calendar_in_days, { n: diff });
}
/** Live status badge text — "läuft gerade", "beginnt in 45 min",
* "beginnt in 3 h" — only meaningful for events within ~24h. */
function liveStatus(ev: EventCardItem): string {
const startMs = ev.start.getTime();
const endMs = (ev.end ?? ev.start).getTime();
const dayLengthMs =
ev.isMultiDay ? endMs - startMs : 24 * 60 * 60 * 1000;
if (nowMs >= startMs && nowMs <= endMs + (ev.isMultiDay ? 0 : dayLengthMs)) {
// Only flag "running" if event has a meaningful duration window
if (nowMs <= endMs || (ev.timeStr && nowMs <= startMs + 4 * 60 * 60 * 1000)) {
return t(T.calendar_running_now);
}
}
const diffMs = startMs - nowMs;
if (diffMs <= 0 || diffMs > 24 * 60 * 60 * 1000) return "";
const minutes = Math.round(diffMs / 60000);
if (minutes < 60) {
return t(T.calendar_starts_in_minutes, { n: Math.max(1, minutes) });
}
const hours = Math.round(minutes / 60);
return t(T.calendar_starts_in, { n: hours });
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
}
function prevMonth() {
currentMonth = new Date(year, month - 1);
@@ -136,11 +311,58 @@
selectedDay = null;
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
/** "Show me this in the calendar" — used from accordion bodies for
* 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([
t(T.calendar_weekday_mo),
@@ -151,83 +373,193 @@
t(T.calendar_weekday_sa),
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>
<div
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}
>
{#snippet eventCard(item: EventCardItem)}
{#snippet eventAccordion(item: EventCardItem, openByDefault: boolean, isNext: boolean)}
{@const eventHref = getEventLinkHref(item.link)}
<li class="bg-himmel-100 p-2">
<div class="flex gap-1">
{#if item.date}
<div class="text-xs text-stein-500 mb-0.5">
{item.date.toLocaleDateString("de-DE", {
weekday: "short",
day: "numeric",
month: "short",
})}
</div>
{#if item.timeStr}
<span class="text-xs text-stein-600">/</span>
{@const live = liveStatus(item)}
{@const title = item.title || t(T.calendar_untitled)}
{@const urgency = urgencyOf(item)}
{@const hasBody = !!(
item.description ||
eventHref ||
item.location ||
item.isMultiDay
)}
<li
class="event-item {isNext ? 'event-item-next' : ''}"
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 item.timeStr}
<div class="text-xs text-stein-600">
{item.timeStr}
{t(T.calendar_time_suffix)}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<span class="text-sm font-semibold text-stein-900 leading-snug">
{title}
</span>
{#if live}
<span class="text-[10px] uppercase tracking-wide font-semibold text-fire-50 bg-fire-700 px-1.5 py-0.5 rounded-xs shrink-0">
{live}
</span>
{/if}
</div>
<div class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600">
{#if item.timeStr && !item.isMultiDay}
<span class="inline-flex items-center gap-1 tabular-nums">
<Icon icon="mdi:clock-outline" class="size-3" aria-hidden="true" />
{item.timeStr}
</span>
{/if}
{#if item.location}
<span class="inline-flex items-center gap-1 truncate max-w-[14rem]">
<Icon icon="mdi:map-marker-outline" class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">{item.location}</span>
</span>
{/if}
{#if item.tags && item.tags.length > 0}
{#each item.tags.slice(0, 2) as tg}
<span class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs">
{tg}
</span>
{/each}
{#if item.tags.length > 2}
<span class="text-[10px] text-stein-500">+{item.tags.length - 2}</span>
{/if}
{/if}
</div>
</div>
{/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="mdi:open-in-new"
class="size-4"
icon="mdi:chevron-down"
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</a>
{/if}
</summary>
<div class="px-3 pb-3 pt-0 pl-[4.75rem] text-sm text-stein-700 space-y-2">
<div class="text-[11px] uppercase tracking-wide text-stein-500 -mt-1">
{relativeDays(item.start)}
</div>
{#if item.location}
<a
href={mapsUrl(item.location)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-[12px] text-himmel-700 hover:underline"
>
<Icon icon="mdi:map-marker" class="size-4 shrink-0" aria-hidden="true" />
{t(T.calendar_open_maps)}
<Icon icon="mdi:open-in-new" class="size-3" aria-hidden="true" />
</a>
{/if}
{#if 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>
{/snippet}
@@ -237,7 +569,35 @@
</h3>
{/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 -->
<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">
@@ -273,9 +633,10 @@
{:else}
{@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const events = eventsByDay.get(key) ?? []}
{@const hasEvents = events.length > 0}
{@const dayEvents = eventsByDay.get(key) ?? []}
{@const hasEvents = dayEvents.length > 0}
{@const isSelected = selectedDay === key}
{@const isToday = key === todayKey}
{@const isFuture = key >= todayKey}
{#if hasEvents}
<button
@@ -284,24 +645,31 @@
? '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
? 'ring-2 ring-himmel-400 bg-himmel-600'
: ''}"
: ''} {isToday ? 'today-cell' : ''}"
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="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"
>
{events.length}
{dayEvents.length}
</span>
</button>
{:else}
<div
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 opacity-50'}"
: 'text-himmel-100 opacity-50'} {isToday ? 'today-cell' : ''}"
aria-current={isToday ? "date" : undefined}
>
<span class="text-xs">{d.getDate()}</span>
{#if isToday}
<span class="sr-only">{t(T.calendar_today_marker)}</span>
{/if}
</div>
{/if}
{/if}
@@ -309,41 +677,175 @@
</div>
</div>
<!-- Rechts: Events -->
<div class="calendar-events-wrapper min-h-0">
<!-- Rechts: alle zukünftigen Termine als Accordion. Datum + Headline
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
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}
<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}
<p class="text-xs font-medium text-stein-500 mb-2">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
weekday: "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 class="flex items-center justify-between gap-2 px-3 py-2 border-b border-stein-200 bg-himmel-50">
<div class="text-xs font-medium text-stein-700 truncate">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
})}
</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}
{@render eventCard(item)}
{@render eventAccordion(item, true, false)}
{/each}
</ul>
{:else if nextUpcomingEvents.length > 0}
<p class="text-xs uppercase font-medium text-stein-500">
{t(T.calendar_next_events)}
</p>
<ul
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
>
{#each nextUpcomingEvents as item}
{@render eventCard(item)}
{:else if futureEventsByDay.length > 0}
<div class="flex items-baseline justify-between gap-2 px-3 py-2 border-b border-stein-200">
<h4 class="text-xs font-medium text-stein-500 m-0!">
{t(T.calendar_all_upcoming)}
</h4>
<span class="text-[11px] text-stein-500 tabular-nums">
{t(T.calendar_event_count, { n: futureEvents.length })}
</span>
</div>
<ul class="m-0! p-0! list-none flex-1 min-h-0 overflow-auto">
{#each futureEventsByDay as group, gIdx}
<li class="day-group">
<div class="px-3 py-1 bg-himmel-50/60 text-[11px] uppercase tracking-wide font-medium text-stein-600 sticky top-0 z-10 backdrop-blur-sm">
{fmtGroupHeader(group.date)}
<span class="ml-2 text-stein-500 normal-case tracking-normal lowercase">· {relativeDays(group.date)}</span>
</div>
<ul class="m-0! p-0! list-none">
{#each group.events as item, eIdx}
{@render eventAccordion(item, gIdx === 0, gIdx === 0 && eIdx === 0)}
{/each}
</ul>
</li>
{/each}
</ul>
{#if pastEvents.length > 0}
<details class="border-t border-stein-200 bg-stein-50 group/past">
<summary class="cursor-pointer list-none px-3 py-2 text-xs text-stein-600 hover:bg-stein-100 inline-flex items-center gap-1.5">
<Icon icon="mdi:chevron-right" class="size-3.5 transition-transform group-open/past:rotate-90" aria-hidden="true" />
<span class="font-medium uppercase tracking-wide">{t(T.calendar_past_events)}</span>
<span class="text-stein-500 tabular-nums">· {pastEvents.length}</span>
</summary>
<ul class="m-0! p-0! list-none max-h-72 overflow-auto opacity-80">
{#each pastEvents as item}
{@render eventAccordion(item, false, false)}
{/each}
</ul>
</details>
{/if}
{:else if pastEvents.length > 0}
<!-- Nur Vergangene da, keine Zukunft → past-Liste expanded
damit Panel nicht leer wirkt. -->
<details open class="bg-stein-50 group/past">
<summary class="cursor-pointer list-none px-3 py-2 text-xs text-stein-600 hover:bg-stein-100 inline-flex items-center gap-1.5 border-b border-stein-200">
<Icon icon="mdi:chevron-right" class="size-3.5 transition-transform group-open/past:rotate-90" aria-hidden="true" />
<span class="font-medium uppercase tracking-wide">{t(T.calendar_past_events)}</span>
<span class="text-stein-500 tabular-nums">· {pastEvents.length}</span>
</summary>
<ul class="m-0! p-0! list-none flex-1 min-h-0 overflow-auto opacity-80">
{#each pastEvents as item}
{@render eventAccordion(item, false, false)}
{/each}
</ul>
</details>
{:else}
<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}
</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>