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:
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user