type TimeRange = { start: Date; end: Date }; type CacheEntry = { expiresAt: number; ranges: TimeRange[] }; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const MAX_RANGE_DAYS = 365; // guard against unbounded events const globalCache = (globalThis as any).__icalCache || new Map(); (globalThis as any).__icalCache = globalCache; function parseDateValue(raw: string): Date | null { if (!raw) return null; // Format examples: 20250101, 20250101T120000Z, 20250101T120000 if (/^\d{8}$/.test(raw)) { const year = Number(raw.slice(0, 4)); const month = Number(raw.slice(4, 6)) - 1; const day = Number(raw.slice(6, 8)); return new Date(Date.UTC(year, month, day)); } const parsed = new Date(raw); return Number.isNaN(parsed.getTime()) ? null : parsed; } function normalizeRange(startRaw: string | null, endRaw: string | null): TimeRange | null { const start = startRaw ? parseDateValue(startRaw) : null; let end = endRaw ? parseDateValue(endRaw) : null; if (!start) return null; if (!end) { end = new Date(start); end.setUTCDate(end.getUTCDate() + 1); } // Clamp absurdly long ranges const maxEnd = new Date(start); maxEnd.setUTCDate(maxEnd.getUTCDate() + MAX_RANGE_DAYS); if (end > maxEnd) end = maxEnd; if (end <= start) return null; return { start, end }; } function parseIcs(text: string): TimeRange[] { const lines = text.split(/\r?\n/); const ranges: TimeRange[] = []; let current: { dtstart?: string; dtend?: string; status?: string } | null = null; for (const rawLine of lines) { const line = rawLine.trim(); if (line === 'BEGIN:VEVENT') { current = {}; continue; } if (line === 'END:VEVENT') { if (current && current.status !== 'CANCELLED') { const range = normalizeRange(current.dtstart || null, current.dtend || null); if (range) ranges.push(range); } current = null; continue; } if (!current) continue; if (line.startsWith('DTSTART')) { const [, value] = line.split(':'); current.dtstart = value; } else if (line.startsWith('DTEND')) { const [, value] = line.split(':'); current.dtend = value; } else if (line.startsWith('STATUS')) { const [, value] = line.split(':'); current.status = value; } } return ranges; } async function fetchCalendarUrl(url: string, forceRefresh = false): Promise { const cached = globalCache.get(url) as CacheEntry | undefined; const now = Date.now(); if (!forceRefresh && cached && cached.expiresAt > now) { return cached.ranges; } try { const res = await fetch(url, { headers: { 'User-Agent': 'lomavuokraus-ical/1.0' }, cache: 'no-store' }); if (!res.ok) throw new Error(`Fetch failed (${res.status})`); const text = await res.text(); const ranges = parseIcs(text); globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges }); return ranges; } catch (err) { console.error('Failed to fetch calendar', url, err); globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges: [] }); return []; } } export async function getCalendarRanges(urls: string[], opts?: { forceRefresh?: boolean }): Promise { const forceRefresh = Boolean(opts?.forceRefresh); const unique = Array.from(new Set(urls.filter(Boolean))); if (!unique.length) return []; const results = await Promise.all(unique.map((u) => fetchCalendarUrl(u, forceRefresh))); return results.flat(); } export function isRangeAvailable(ranges: TimeRange[], start: Date, end: Date): boolean { for (const r of ranges) { if (r.start < end && r.end > start) { return false; } } return true; } export function expandBlockedDates(ranges: TimeRange[], from: Date, to: Date): string[] { const days: string[] = []; const cursor = new Date(from); while (cursor <= to) { const next = new Date(cursor); next.setUTCDate(next.getUTCDate() + 1); if (!isRangeAvailable(ranges, cursor, next)) { days.push(cursor.toISOString().slice(0, 10)); } cursor.setUTCDate(cursor.getUTCDate() + 1); } return days; }