Files
2026-07-17 20:23:52 +02:00

157 lines
6.0 KiB
JavaScript

'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 };