Local Node app that fetches a Google Calendar ICS URL server-side (to avoid browser CORS restrictions) and renders a two-year overview with each day split into morning/afternoon/evening segments, colored by emoji found at the start of event titles (configured in tags.json). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
142 lines
5.1 KiB
JavaScript
142 lines
5.1 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.
|
|
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
...(tz ? { timeZone: tz } : {}),
|
|
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) {
|
|
if (isFullDay) return [0, 1, 2];
|
|
const dayStartHour = dayStr === startLocal.dateStr ? startLocal.hour : 0;
|
|
const dayEndHour = dayStr === lastDayStr ? endLocal.hour : 24;
|
|
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;
|
|
}
|
|
|
|
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: [] };
|
|
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;
|
|
|
|
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 = 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';
|
|
}
|
|
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') };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
module.exports = { buildOverview };
|