.pragma library // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // Pure date logic for the calendar panel. Days are "YYYY-MM-DD" strings and // times "HH:MM", the shapes calctl speaks; day arithmetic goes through UTC so // a DST change never shifts a day. Tested by test_cal.js under node. function pad(n) { return (n < 10 ? "0" : "") + n; } function fromUtc(t) { return t.getUTCFullYear() + "-" + pad(t.getUTCMonth() + 1) + "-" + pad(t.getUTCDate()); } function utc(day) { return Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10)); } function addDays(day, n) { return fromUtc(new Date(utc(day) + n * 86400000)); } function daysBetween(a, b) { return Math.round((utc(b) - utc(a)) / 86400000); } // The local calendar day of a JS Date. function today(now) { return now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()); } // 42 cells, six weeks from Monday (khal's firstweekday = 0). function monthCells(y, m0) { const first = new Date(Date.UTC(y, m0, 1)); const month = fromUtc(first).slice(0, 7); const lead = (first.getUTCDay() + 6) % 7; const start = addDays(fromUtc(first), -lead); const out = []; for (let i = 0; i < 42; i++) { const iso = addDays(start, i); out.push({ iso: iso, day: +iso.slice(8, 10), inMonth: iso.slice(0, 7) === month }); } return out; } function shiftMonth(y, m0, n) { const t = new Date(Date.UTC(y, m0 + n, 1)); return { y: t.getUTCFullYear(), m0: t.getUTCMonth() }; } // The last day an event covers. calctl gives all-day ends inclusive; a // timed event ending exactly at midnight does not reach the day it ends on. function lastDay(ev) { const endDay = ev.end.slice(0, 10); if (!ev.allDay && ev.end.slice(11, 16) === "00:00" && ev.end > ev.start) return addDays(endDay, -1); return endDay; } function onDay(ev, day) { return ev.start.slice(0, 10) <= day && lastDay(ev) >= day; } // The day's events, all-day first, then by start. function dayEvents(events, day) { return events.filter(e => onDay(e, day)).sort((a, b) => (b.allDay - a.allDay) || (a.start < b.start ? -1 : a.start > b.start ? 1 : 0)); } // The calendars with something on the day, in calendar order. function dots(events, day, calendars) { return calendars.filter(c => events.some(e => e.calendar === c.name && onDay(e, day))); } function timeLabel(ev, day) { if (ev.allDay) return "all day"; const s = ev.start.slice(0, 10) === day ? ev.start.slice(11, 16) : "…"; const e = ev.end.slice(0, 10) === day ? ev.end.slice(11, 16) : "…"; return s + "–" + e; } function validTime(s) { return /^([01]\d|2[0-3]):[0-5]\d$/.test(s); } // Step a time by minutes, wrapping within the day; an invalid time is left // for the user to fix rather than guessed at. function stepTime(s, minutes) { if (!validTime(s)) return s; const m = ((+s.slice(0, 2) * 60 + +s.slice(3, 5) + minutes) % 1440 + 1440) % 1440; return pad(Math.floor(m / 60)) + ":" + pad(m % 60); } // A new event on `day`: the next whole hour, one hour long, alarm 1h (the // most common alarm in the live calendars). Capped at 22:00 so the end stays // on the same day. function newDraft(day, now, calendar) { const h = Math.min(22, now.getHours() + 1); return { uid: "", source: "", calendar: calendar, title: "", allDay: false, startDay: day, endDay: day, startTime: pad(h) + ":00", endTime: pad(h + 1) + ":00", repeat: "none", until: "", alarm: "1h", location: "", notes: "", override: false }; } // calctl's event JSON as an editor draft. `source` is the calendar the event // lives in now; `calendar` is where the form wants it. function fromEvent(ev) { return { uid: ev.uid, source: ev.calendar, calendar: ev.calendar, title: ev.title, allDay: ev.allDay, startDay: ev.start.slice(0, 10), endDay: ev.end.slice(0, 10), startTime: ev.allDay ? "09:00" : ev.start.slice(11, 16), endTime: ev.allDay ? "10:00" : ev.end.slice(11, 16), repeat: ev.repeat, until: ev.until, alarm: ev.alarm, location: ev.location, notes: ev.notes, override: ev.override }; } // A click on the grid while editing. Plain: move the event, keeping its // length in days. Shift: set the end, or the start when before it. function pick(draft, day, shift) { const d = Object.assign({}, draft); if (!shift) { const len = daysBetween(d.startDay, d.endDay); d.startDay = day; d.endDay = addDays(day, len); } else if (day >= d.startDay) { d.endDay = day; } else { d.startDay = day; } return d; } // The draft as calctl's event JSON, or the first thing wrong with it. function toPayload(d) { if (!d.title.trim()) return { error: "Title is empty" }; if (!d.allDay && (!validTime(d.startTime) || !validTime(d.endTime))) return { error: "Times are HH:MM" }; const start = d.allDay ? d.startDay : d.startDay + "T" + d.startTime; const end = d.allDay ? d.endDay : d.endDay + "T" + d.endTime; if (end < start) return { error: "End is before start" }; const repeats = d.repeat !== "none" && d.repeat !== "custom"; if (repeats && d.until && d.until < d.startDay) return { error: "Until is before the start" }; return { payload: { calendar: d.calendar, title: d.title.trim(), allDay: d.allDay, start: start, end: end, repeat: d.repeat, until: repeats ? d.until : "", alarm: d.alarm, location: d.location, notes: d.notes } }; }