// ── Kalender Widget mit iCal-Unterstützung ──────────────────────────────────── import { useState, useEffect, useMemo } from 'react'; import { api, S } from './lib.js'; // iCal-Parser – unterstützt gefaltete Zeilen, TZID, VALUE=DATE function parseIcal(raw) { // 1. Gefaltete Zeilen (RFC 5545) zusammenführen const text = raw.replace(/\r\n[ \t]/g, '').replace(/\r/g, ''); // 2. Alle Properties als Map extrahieren function getProps(block) { // VALARM-Blöcke entfernen (haben eigene SUMMARY/DESCRIPTION die nicht zum Termin gehören) const stripped = block.replace(/BEGIN:VALARM[\s\S]*?END:VALARM/g, ''); const props = {}; for (const line of stripped.split('\n')) { const col = line.indexOf(':'); if (col < 0) continue; const namepart = line.slice(0, col).toUpperCase(); const val = line.slice(col + 1).trim(); // Basisname ohne Parameter const name = namepart.split(';')[0]; // TZID-Parameter extrahieren const tzidM = namepart.match(/TZID=([^;:]+)/); props[name] = { val, raw: namepart, tzid: tzidM?.[1] || null }; } return props; } // 3. Datum parsen – Ortszeit wenn TZID gesetzt, UTC wenn Z-Suffix function parseDate(prop, isEnd) { if (!prop) return null; const s = prop.val; if (!s) return null; const allDay = s.length === 8 || prop.raw.includes('VALUE=DATE'); if (allDay) { // DTEND bei all-day ist exklusiv → einen Tag abziehen const d = new Date(+s.slice(0,4), +s.slice(4,6)-1, +s.slice(6,8)); if (isEnd) d.setDate(d.getDate() - 1); return { date: d, allDay: true }; } const utc = s.endsWith('Z'); const y=+s.slice(0,4), mo=+s.slice(4,6)-1, d=+s.slice(6,8); const h=+s.slice(9,11)||0, mi=+s.slice(11,13)||0, sec=+s.slice(13,15)||0; const date = utc ? new Date(Date.UTC(y,mo,d,h,mi,sec)) : new Date(y,mo,d,h,mi,sec); // Ortszeit des Browsers (beste Annäherung) return { date, allDay: false }; } const events = []; const blocks = text.split('BEGIN:VEVENT'); for (let i = 1; i < blocks.length; i++) { const end = blocks[i].indexOf('END:VEVENT'); const block = end >= 0 ? blocks[i].slice(0, end) : blocks[i]; const p = getProps(block); const startObj = parseDate(p['DTSTART']); const endObj = parseDate(p['DTEND'], true) || startObj; if (!startObj || isNaN(startObj.date)) continue; events.push({ summary: (p['SUMMARY']?.val || '(kein Titel)').replace(/\\n/g,'\n').replace(/\\,/g,','), location: (p['LOCATION']?.val || '').replace(/\\n/g,'\n').replace(/\\,/g,','), dtstart: startObj.date, dtend: endObj.date, allDay: startObj.allDay, }); } return events; } function sameDay(a, b) { return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); } function inRange(event, day) { const d = new Date(day); d.setHours(0,0,0,0); const s = new Date(event.dtstart); s.setHours(0,0,0,0); const e = new Date(event.dtend); e.setHours(0,0,0,0); return d >= s && d <= e; } const WEEKDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So']; const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; export default function CalendarWidget() { const [isOpen, setIsOpen] = useState(false); const [today] = useState(new Date()); const [cur, setCur] = useState({ y: today.getFullYear(), m: today.getMonth() }); const [events, setEvents] = useState([]); // Feeds immer frisch aus localStorage lesen const getFeeds = () => { try { return JSON.parse(localStorage.getItem('dd_cal_feeds') || '[]'); } catch { return []; } }; const [feedCount, setFeedCount] = useState(0); // trigger re-render const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(new Date()); // default: heute const [errors, setErrors] = useState([]); // Load all feeds const loadFeeds = async (feedList) => { if (!feedList.length) { setEvents([]); setDebug(['Keine Feeds konfiguriert']); return; } setLoading(true); setErrors([]); const all = []; const errs = []; for (const f of feedList) { try { const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(f.url)}`, { headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` } }); if (res.ok) { const text = await res.text(); const evs = parseIcal(text).map(e => ({ ...e, color: f.color, feedName: f.name })); all.push(...evs); } else { const d = await res.json().catch(()=>({error:'Unbekannter Fehler'})); errs.push(`${f.name}: ${d.error}`); } } catch(e) { errs.push(`${f.name}: ${e.message}`); } } setEvents(all); setErrors(errs); setLoading(false); }; const refresh = () => { const f = getFeeds(); if (f.length > 0) loadFeeds(f); setFeedCount(f.length); }; useEffect(() => { if (isOpen) refresh(); }, [isOpen]); // Nächster oder aktueller Termin (für zugeklappte Ansicht) const nextEvent = useMemo(() => { const now = new Date(); const upcoming = events .filter(e => e.dtend >= now) .sort((a,b) => a.dtstart - b.dtstart); return upcoming[0] || null; }, [events]); // Calendar grid const days = useMemo(() => { const first = new Date(cur.y, cur.m, 1); const dow = (first.getDay() + 6) % 7; // 0=Mo const total = new Date(cur.y, cur.m+1, 0).getDate(); const grid = []; for (let i = 0; i < dow; i++) grid.push(null); for (let d = 1; d <= total; d++) grid.push(new Date(cur.y, cur.m, d)); return grid; }, [cur]); const eventsOnDay = (day) => day ? events.filter(e => inRange(e, day)) : []; const selectedEvents = selected ? events.filter(e => inRange(e, selected)) : []; const prevMonth = () => setCur(p => p.m === 0 ? { y:p.y-1, m:11 } : { y:p.y, m:p.m-1 }); const nextMonth = () => setCur(p => p.m === 11 ? { y:p.y+1, m:0 } : { y:p.y, m:p.m+1 }); return (
{/* Header */}
{isOpen && getFeeds().length > 0 && ( )}
{/* Nächster Termin auch im zugeklappten Zustand */} {!isOpen && nextEvent && (
{nextEvent.summary}
{nextEvent.allDay ? nextEvent.dtstart.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}) : `${nextEvent.dtstart.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'})} ${nextEvent.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}` }
)} {isOpen && (
{/* Fehler */} {errors.length > 0 && (
{errors.map((e,i) => (
✕ {e}
))}
)} {/* Monat Nav */}
{MONTHS[cur.m]} {cur.y} {(cur.m !== today.getMonth() || cur.y !== today.getFullYear()) && ( )}
{/* Kalender-Grid */}
=768 ? 'min(320px,50%)' : '100%' }}> {/* Wochentage */}
{WEEKDAYS.map(d => (
{d}
))}
{/* Tage */}
{days.map((day, i) => { const evs = eventsOnDay(day); const isToday = day && sameDay(day, today); const isSel = day && selected && sameDay(day, selected); return ( ); })}
{/* Ende Kalender-Grid */} {/* Termine – auf PC rechts, auf Mobile darunter */}
{/* Ausgewählter Tag Events */} {selected && (
{selected.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'})}
{selectedEvents.length === 0 ?
Keine Termine
: selectedEvents.map((e,i) => (
{e.summary}
{e.location &&
{e.location}
} {!e.allDay &&
{e.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})} {' – '}{e.dtend.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
}
{e.feedName}
)) }
)}
{/* Ende Termine */}
{/* Ende Flex-Layout */} {getFeeds().length === 0 && (
Noch keine Kalender abonniert. Füge Kalender in den Einstellungen hinzu.
)}
)}
); } // ── iCal-Einstellungen (für Einstellungen-Seite) ────────────────────────────── export function CalendarSettings({ toast }) { const [feeds, setFeeds] = useState(() => { try { return JSON.parse(localStorage.getItem('dd_cal_feeds') || '[]'); } catch { return []; } }); const [form, setForm] = useState({ name:'', url:'', color:'#4ecdc4' }); const [testing, setTesting] = useState(false); const save = (newFeeds) => { setFeeds(newFeeds); localStorage.setItem('dd_cal_feeds', JSON.stringify(newFeeds)); }; const add = async () => { if (!form.name.trim() || !form.url.trim()) { toast('Name und URL erforderlich', 'error'); return; } setTesting(true); try { const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(form.url)}`, { headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` } }); if (!res.ok) { const d=await res.json(); toast(d.error||'Feed-Fehler','error'); return; } save([...feeds, { ...form, id: Date.now() }]); setForm({ name:'', url:'', color:'#4ecdc4' }); toast('Kalender hinzugefügt ✓'); } catch(e) { toast(e.message,'error'); } finally { setTesting(false); } }; const remove = (id) => save(feeds.filter(f => f.id !== id)); const COLORS = ['#4ecdc4','#ff6b9d','#ffe66d','#6bcb77','#a78bfa','#60a5fa','#fb923c']; return (
setForm(f=>({...f,name:e.target.value}))} placeholder="z.B. Google Kalender" style={{ ...S.inp, fontSize:15, marginBottom:8 }}/> setForm(f=>({...f,url:e.target.value}))} placeholder="https://calendar.google.com/calendar/ical/…" style={{ ...S.inp, fontSize:13, marginBottom:8 }} autoCapitalize="none" autoCorrect="off"/>
{COLORS.map(col => (
{feeds.length > 0 && (
ABONNIERTE KALENDER
{feeds.map(f => (
{f.name}
{f.url}
))}
)}
); }