diff --git a/src/lib/calendar-ics.ts b/src/lib/calendar-ics.ts
index 029d8db..5818fa0 100644
--- a/src/lib/calendar-ics.ts
+++ b/src/lib/calendar-ics.ts
@@ -122,6 +122,72 @@ export function downloadICS(item: CalendarItemICS, filename: string): void {
URL.revokeObjectURL(url);
}
+/** Builds a VCALENDAR with multiple VEVENTs — for feed/bulk export. */
+export function buildICSFeed(items: CalendarItemICS[], calName = "Windwiderstand Termine"): string {
+ const now = new Date();
+ const lines: string[] = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//windwiderstand.de//calendar//DE",
+ "CALSCALE:GREGORIAN",
+ "METHOD:PUBLISH",
+ foldLine(`X-WR-CALNAME:${escapeText(calName)}`),
+ "X-WR-TIMEZONE:Europe/Berlin",
+ "REFRESH-INTERVAL;VALUE=DURATION:PT1H",
+ "X-PUBLISHED-TTL:PT1H",
+ ];
+ for (const item of items) {
+ const start = item.start;
+ const end = item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
+ const uid = item.uid ?? deterministicUid(item.title, start);
+ lines.push(
+ "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");
+ }
+ lines.push("END:VCALENDAR");
+ return lines.join("\r\n");
+}
+
+/** Triggers a browser download of a multi-event ICS file. */
+export function downloadICSFeed(items: CalendarItemICS[], filename: string): void {
+ if (typeof window === "undefined") return;
+ const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "termine.ics";
+ const ics = buildICSFeed(items);
+ 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);
+}
+
+/** Deep-link to Google Calendar's pre-filled create form. */
+export function googleCalUrl(item: CalendarItemICS): string {
+ const fmt = (d: Date) =>
+ d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
+ const end = item.end ?? new Date(item.start.getTime() + 60 * 60 * 1000);
+ const params = new URLSearchParams({
+ action: "TEMPLATE",
+ text: item.title,
+ dates: `${fmt(item.start)}/${fmt(end)}`,
+ });
+ if (item.description) params.set("details", item.description);
+ if (item.location) params.set("location", item.location);
+ return `https://calendar.google.com/calendar/render?${params}`;
+}
+
/** OpenStreetMap search URL for an address or place name. */
export function mapsUrl(location: string): string {
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
diff --git a/src/lib/components/blocks/CalendarBlock.svelte b/src/lib/components/blocks/CalendarBlock.svelte
index fba8398..4cdb5f0 100644
--- a/src/lib/components/blocks/CalendarBlock.svelte
+++ b/src/lib/components/blocks/CalendarBlock.svelte
@@ -1,840 +1,1167 @@
+ const selectedDayEvents = $derived(
+ selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
+ );
-
- {#snippet eventAccordion(item: EventCardItem, openByDefault: boolean, isNext: boolean)}
- {@const eventHref = getEventLinkHref(item.link)}
- {@const live = liveStatus(item)}
- {@const title = item.title || t(T.calendar_untitled)}
- {@const urgency = urgencyOf(item)}
- {@const locText = locationText(item.location)}
- {@const hasBody = !!(
- item.description ||
- eventHref ||
- locText ||
- item.isMultiDay
- )}
-
-
-
-
- {#if item.isMultiDay && item.end}
-
-
- {item.start.toLocaleDateString("de-DE", { month: "short" })}
-
-
- {item.start.getDate()}–{item.end.getDate()}
-
-
- {item.start.getFullYear() === item.end.getFullYear()
- ? item.end.toLocaleDateString("de-DE", { month: "short" })
- : item.end.getFullYear()}
-
-
- {:else}
-
-
- {item.start.toLocaleDateString("de-DE", { weekday: "short" })}
-
-
- {item.start.getDate()}
-
-
- {item.start.toLocaleDateString("de-DE", { month: "short" })}
-
-
- {/if}
-
-
-
- {title}
-
- {#if live}
-
- {live}
-
- {/if}
-
-
- {#if item.timeStr && !item.isMultiDay}
-
-
- {item.timeStr}
-
- {/if}
- {#if locText}
-
-
- {locText}
-
- {/if}
- {#if item.tags && item.tags.length > 0}
- {#each item.tags.slice(0, 2) as tg}
-
- {tg}
-
- {/each}
- {#if item.tags.length > 2}
- +{item.tags.length - 2}
- {/if}
- {/if}
-
-
-
-
-
-
- {relativeDays(item.start)}
-
- {#if item.description}
-
{item.description}
- {/if}
- {#if item.tags && item.tags.length > 2}
-
- {#each item.tags as tg}
-
- {tg}
-
- {/each}
-
- {/if}
-
- {#if eventHref}
-
- {t(T.calendar_more_info)}
-
-
- {/if}
- {#if locText}
-
-
- {t(T.calendar_open_maps)}
-
- {/if}
-
-
-
-
-
-
- {/snippet}
+ const todayKey = $derived.by(() => dayKey(new Date(nowMs)));
- {#if block.title}
-
- {block.title}
-
- {/if}
+ // ── Calendar-grid derived state ──────────────────────────────────
+ const year = $derived(currentMonth.getFullYear());
+ const month = $derived(currentMonth.getMonth());
+ const monthLabel = $derived(
+ currentMonth.toLocaleDateString("de-DE", {
+ month: "long",
+ year: "numeric",
+ }),
+ );
+ const daysInMonth = $derived.by(() => {
+ const first = new Date(year, month, 1);
+ const last = new Date(year, month + 1, 0);
+ const count = last.getDate();
+ const startWeekday = (first.getDay() + 6) % 7;
+ return { count, startWeekday, last };
+ });
+ const calendarDays = $derived.by(() => {
+ const { count, startWeekday } = daysInMonth;
+ const empty = Array(startWeekday).fill(null);
+ const days = Array.from(
+ { length: count },
+ (_, i) => new Date(year, month, i + 1),
+ );
+ return [...empty, ...days];
+ });
- {#if allTags.length > 0}
-
-
-
- {t(T.calendar_filter_by_tag)}
-
-
- {#each allTags as tag}
-
- {/each}
-
- {/if}
-
-
-
-
-
-
- {monthLabel}
-
-
-
-
- {#each weekdays as w}
-
{w}
- {/each}
- {#each calendarDays as d}
- {#if d === null}
-
- {:else}
- {@const key = dayKey(d)}
- {@const isCurrentMonth = d.getMonth() === month}
- {@const dayEvents = eventsByDay.get(key) ?? []}
- {@const hasEvents = dayEvents.length > 0}
- {@const isSelected = selectedDay === key}
- {@const isToday = key === todayKey}
- {@const isFuture = key >= todayKey}
- {#if hasEvents}
-
- {:else}
-
- {d.getDate()}
- {#if isToday}
- {t(T.calendar_today_marker)}
- {/if}
-
- {/if}
- {/if}
- {/each}
-
-
-
-
-
-
- {#if items.length === 0}
-
{t(T.calendar_no_events)}
- {:else if selectedDay}
-
-
- {new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
+ // ── 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",
- })}
-
-
diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json
index ad34277..a6c6e80 100644
--- a/src/lib/iconify-mdi-subset.generated.json
+++ b/src/lib/iconify-mdi-subset.generated.json
@@ -72,9 +72,15 @@
"download": {
"body": "
"
},
+ "google": {
+ "body": "
"
+ },
"calendar-search": {
"body": "
"
},
+ "calendar-plus": {
+ "body": "
"
+ },
"arrow-right": {
"body": "
"
},
diff --git a/src/lib/translations.ts b/src/lib/translations.ts
index 6ad8e54..6d59791 100644
--- a/src/lib/translations.ts
+++ b/src/lib/translations.ts
@@ -137,6 +137,13 @@ const TRANSLATION_KEYS = [
"calendar_untitled",
"calendar_show_more",
"calendar_show_less",
+ "calendar_subscribe",
+ "calendar_subscribe_aria",
+ "calendar_export_all",
+ "calendar_add_to_google",
+ "calendar_share",
+ "calendar_share_copied",
+ "calendar_share_aria",
"post_overview_all",
"blog_search_label",
"blog_search_placeholder",
diff --git a/src/routes/api/kalender.ics/+server.ts b/src/routes/api/kalender.ics/+server.ts
new file mode 100644
index 0000000..d05c0c2
--- /dev/null
+++ b/src/routes/api/kalender.ics/+server.ts
@@ -0,0 +1,57 @@
+import type { RequestHandler } from '@sveltejs/kit';
+import { env } from '$env/dynamic/public';
+import { getCalendarItems } from '$lib/cms';
+import { buildICSFeed, type CalendarItemICS } from '$lib/calendar-ics';
+import type { CalendarItemData } from '$lib/block-types';
+
+function parseDate(iso: string | undefined | null): Date | null {
+ if (!iso) return null;
+ const d = new Date(iso);
+ return isNaN(d.getTime()) ? null : d;
+}
+
+function locationText(loc: unknown): string {
+ if (typeof loc === 'string') return loc;
+ if (loc && typeof loc === 'object') {
+ const t = (loc as { text?: unknown }).text;
+ if (typeof t === 'string') return t;
+ }
+ return '';
+}
+
+export const GET: RequestHandler = async () => {
+ const siteName = env.PUBLIC_SITE_NAME || 'Windwiderstand';
+
+ let rawItems: Awaited
> = [];
+ try {
+ rawItems = await getCalendarItems();
+ } catch {
+ rawItems = [];
+ }
+
+ const icsItems: CalendarItemICS[] = rawItems
+ .flatMap((raw) => {
+ const item = raw as unknown as CalendarItemData;
+ const start = parseDate(item.terminZeit);
+ if (!start) return [];
+ const end = parseDate(item.terminEnde ?? null) ?? undefined;
+ const entry: CalendarItemICS = {
+ title: item.title || 'Termin',
+ start,
+ end,
+ description: (item as { description?: string }).description ?? undefined,
+ location: locationText((item as { location?: unknown }).location) || undefined,
+ };
+ return [entry];
+ });
+
+ const body = buildICSFeed(icsItems, `${siteName} – Termine`);
+
+ return new Response(body, {
+ headers: {
+ 'Content-Type': 'text/calendar; charset=utf-8',
+ 'Content-Disposition': 'inline; filename="windwiderstand-termine.ics"',
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ });
+};