lomavuokraus/lib/calendar.ts
2025-11-27 12:44:42 +02:00

128 lines
3.9 KiB
TypeScript

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<string, CacheEntry>();
(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): Promise<TimeRange[]> {
const cached = globalCache.get(url) as CacheEntry | undefined;
const now = Date.now();
if (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[]): Promise<TimeRange[]> {
const unique = Array.from(new Set(urls.filter(Boolean)));
if (!unique.length) return [];
const results = await Promise.all(unique.map(fetchCalendarUrl));
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;
}