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:
+16
-5
@@ -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<
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user