'use strict'; const ical = require('node-ical'); // ponytail: pre-6am folded into "morning" — personal calendars rarely have events there const SEGMENT_RANGES = [[0, 12], [12, 18], [18, 24]]; // color used for a leading emoji that isn't in tagsConfig const UNKNOWN_EMOJI_COLOR = 'blue'; const EMOJI_RE = /\p{Extended_Pictographic}/u; const grapheme = new Intl.Segmenter('en', { granularity: 'grapheme' }); // leading emoji, capped at 2, tolerating one space between them ("✈️ 💚 Title" and "✈️💚 Title" both count) function leadingEmojis(text, max = 2) { const segs = [...grapheme.segment(text.trimStart())].map((s) => s.segment); const result = []; let i = 0; while (result.length < max && i < segs.length && EMOJI_RE.test(segs[i])) { result.push(segs[i]); i++; if (segs[i] === ' ' && EMOJI_RE.test(segs[i + 1])) i++; } return result; } function dateStrToUTCNoon(dateStr) { const [y, m, d] = dateStr.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d, 12)); } function addDays(dateStr, n) { const dt = dateStrToUTCNoon(dateStr); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10); } function localParts(date, tz) { // ponytail: no TZID means "floating" time per RFC 5545 - interpret in the // system's local zone (matches how node-ical encodes floating DATE values), // not UTC. A real tz (incl. "Etc/UTC" for Z-suffixed timestamps) is an // absolute instant - always display it in Amsterdam, not whatever zone // the source event happened to carry. const fmt = new Intl.DateTimeFormat('en-CA', { ...(tz ? { timeZone: 'Europe/Amsterdam' } : {}), year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value])); let hour = Number(parts.hour); if (hour === 24) hour = 0; // ICU quirk: midnight can format as "24" return { dateStr: `${parts.year}-${parts.month}-${parts.day}`, hour: hour + Number(parts.minute) / 60, }; } function formatHour(hour) { const h = Math.floor(hour); const m = Math.round((hour - h) * 60); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } function segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay) { const dayStartHour = isFullDay ? 0 : (dayStr === startLocal.dateStr ? startLocal.hour : 0); const dayEndHour = isFullDay ? 24 : (dayStr === lastDayStr ? endLocal.hour : 24); if (isFullDay) return { lit: [0, 1, 2], start: dayStartHour, end: dayEndHour }; const lit = []; SEGMENT_RANGES.forEach(([segStart, segEnd], i) => { if (dayStartHour < segEnd && dayEndHour > segStart) lit.push(i); }); if (lit.length === 0) { const idx = SEGMENT_RANGES.findIndex(([, segEnd]) => dayStartHour < segEnd); lit.push(idx === -1 ? 2 : idx); } return { lit, start: dayStartHour, end: dayEndHour }; } // two tagged events sharing a day overlap if their local-hour spans intersect function hasOverlap(intervals) { for (let i = 0; i < intervals.length; i++) { for (let j = i + 1; j < intervals.length; j++) { if (intervals[i].start < intervals[j].end && intervals[j].start < intervals[i].end) return true; } } return false; } function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) { const parsed = ical.sync.parseICS(icsText); const rangeStart = dateStrToUTCNoon(rangeStartStr); const rangeEnd = dateStrToUTCNoon(rangeEndStr); const days = {}; const getDay = (dateStr) => { if (!days[dateStr]) days[dateStr] = { segments: [null, null, null], events: [], taggedIntervals: [] }; return days[dateStr]; }; for (const event of Object.values(parsed)) { if (event.type !== 'VEVENT' || !event.start) continue; let instances; try { instances = ical.expandRecurringEvent(event, { from: rangeStart, to: rangeEnd, expandOngoing: true }); } catch { continue; // ponytail: skip a malformed event rather than fail the whole import } for (const inst of instances) { if (!inst.start) continue; const isFullDay = !!(inst.isFullDay || inst.start.dateOnly); const summary = inst.summary || '(untitled)'; const colors = leadingEmojis(summary).map((e) => (tagsConfig[e] ? tagsConfig[e].color : UNKNOWN_EMOJI_COLOR)); const color = colors.length === 2 ? colors : colors[0] || null; // "nc" as a standalone word anywhere in the title marks a conflict as resolved const noConflict = /\bnc\b/i.test(summary); const startLocal = localParts(inst.start, inst.start.tz); const endInstant = isFullDay ? new Date((inst.end || inst.start).getTime() - 1) : inst.end || inst.start; const endLocal = localParts(endInstant, (inst.end && inst.end.tz) || inst.start.tz); const lastDayStr = endLocal.dateStr < startLocal.dateStr ? startLocal.dateStr : endLocal.dateStr; let dayStr = startLocal.dateStr; while (true) { if (dayStr >= rangeStartStr && dayStr <= rangeEndStr) { const day = getDay(dayStr); const { lit, start, end } = segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay); for (const segIdx of lit) { if (color) day.segments[segIdx] = color; else if (!day.segments[segIdx]) day.segments[segIdx] = 'muted'; } if (color && !noConflict) day.taggedIntervals.push({ start, end }); day.events.push({ hour: dayStr === startLocal.dateStr ? startLocal.hour : 0, isFullDay, text: summary, }); } if (dayStr === lastDayStr) break; dayStr = addDays(dayStr, 1); } } } const result = {}; for (const [dateStr, day] of Object.entries(days)) { const lines = day.events .sort((a, b) => a.hour - b.hour) .map((e) => `${e.isFullDay ? 'All day' : formatHour(e.hour)} ${e.text}`); result[dateStr] = { segments: day.segments, tooltip: lines.join('\n'), conflict: hasOverlap(day.taggedIntervals) }; } return result; } module.exports = { buildOverview };