aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/island')
-rw-r--r--desktop/island/calendar/Cal.js155
-rw-r--r--desktop/island/calendar/test_cal.js124
2 files changed, 279 insertions, 0 deletions
diff --git a/desktop/island/calendar/Cal.js b/desktop/island/calendar/Cal.js
new file mode 100644
index 0000000..dc50b04
--- /dev/null
+++ b/desktop/island/calendar/Cal.js
@@ -0,0 +1,155 @@
+.pragma library
+
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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: "", 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.
+function fromEvent(ev) {
+ return { uid: ev.uid, 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 } };
+}
diff --git a/desktop/island/calendar/test_cal.js b/desktop/island/calendar/test_cal.js
new file mode 100644
index 0000000..d91966e
--- /dev/null
+++ b/desktop/island/calendar/test_cal.js
@@ -0,0 +1,124 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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.
+
+// Run: node desktop/island/calendar/test_cal.js
+// Cal.js is a QML library, so its .pragma line is stripped first.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Cal.js", "utf8").replace(/^\.pragma library$/m, "");
+const C = new Function(src + "\nreturn { pad, addDays, today, daysBetween, monthCells, shiftMonth," +
+ " lastDay, onDay, dayEvents, dots, timeLabel, validTime, stepTime, newDraft, fromEvent, pick," +
+ " toPayload };")();
+
+// Dates are plain YYYY-MM-DD strings, arithmetic in UTC so DST never moves a day.
+assert.strictEqual(C.addDays("2026-10-31", 1), "2026-11-01");
+assert.strictEqual(C.addDays("2026-03-01", -1), "2026-02-28");
+assert.strictEqual(C.addDays("2026-10-24", 2), "2026-10-26");
+assert.strictEqual(C.daysBetween("2026-10-30", "2026-11-02"), 3);
+assert.strictEqual(C.today(new Date(2026, 9, 13, 23, 59)), "2026-10-13");
+
+// Month cells: six weeks from Monday. October 2026 starts on a Thursday.
+const oct = C.monthCells(2026, 9);
+assert.strictEqual(oct.length, 42);
+assert.strictEqual(oct[0].iso, "2026-09-28");
+assert.strictEqual(oct[0].inMonth, false);
+assert.strictEqual(oct[3].iso, "2026-10-01");
+assert.strictEqual(oct[3].inMonth, true);
+assert.strictEqual(oct[3].day, 1);
+assert.strictEqual(oct[41].iso, "2026-11-08");
+// June 2026 starts on a Monday: no leading days.
+assert.strictEqual(C.monthCells(2026, 5)[0].iso, "2026-06-01");
+
+assert.deepStrictEqual(C.shiftMonth(2026, 11, 1), { y: 2027, m0: 0 });
+assert.deepStrictEqual(C.shiftMonth(2026, 0, -1), { y: 2025, m0: 11 });
+
+// Which days an event covers.
+const timed = { uid: "t", calendar: "home", allDay: false, start: "2026-10-15T14:30", end: "2026-10-15T15:30" };
+const night = { uid: "n", calendar: "home", allDay: false, start: "2026-10-15T22:00", end: "2026-10-16T00:00" };
+const late = { uid: "l", calendar: "home", allDay: false, start: "2026-10-15T23:00", end: "2026-10-16T01:00" };
+const trip = { uid: "a", calendar: "trips", allDay: true, start: "2026-10-14", end: "2026-10-16" };
+assert.strictEqual(C.lastDay(timed), "2026-10-15");
+assert.strictEqual(C.lastDay(night), "2026-10-15"); // ends at midnight: not on the 16th
+assert.strictEqual(C.lastDay(late), "2026-10-16");
+assert.strictEqual(C.lastDay(trip), "2026-10-16");
+assert.ok(C.onDay(trip, "2026-10-15") && !C.onDay(trip, "2026-10-17"));
+
+// The day's agenda: all-day first, then by start.
+const evs = [timed, late, trip, night];
+assert.deepStrictEqual(C.dayEvents(evs, "2026-10-15").map(e => e.uid), ["a", "t", "n", "l"]);
+assert.deepStrictEqual(C.dayEvents(evs, "2026-10-16").map(e => e.uid), ["a", "l"]);
+
+// Dots: one per calendar with something that day, in calendar order.
+const cals = [{ name: "home", color: "green" }, { name: "trips", color: "#ff8800" }];
+assert.deepStrictEqual(C.dots(evs, "2026-10-15", cals).map(c => c.name), ["home", "trips"]);
+assert.deepStrictEqual(C.dots(evs, "2026-10-14", cals).map(c => c.name), ["trips"]);
+assert.deepStrictEqual(C.dots(evs, "2026-10-20", cals), []);
+
+assert.strictEqual(C.timeLabel(trip, "2026-10-15"), "all day");
+assert.strictEqual(C.timeLabel(timed, "2026-10-15"), "14:30–15:30");
+assert.strictEqual(C.timeLabel(late, "2026-10-15"), "23:00–…");
+assert.strictEqual(C.timeLabel(late, "2026-10-16"), "…–01:00");
+
+// Times.
+assert.ok(C.validTime("09:05") && C.validTime("23:59"));
+assert.ok(!C.validTime("24:00") && !C.validTime("9:05") && !C.validTime("ab:cd"));
+assert.strictEqual(C.stepTime("14:30", 15), "14:45");
+assert.strictEqual(C.stepTime("23:50", 15), "00:05");
+assert.strictEqual(C.stepTime("00:05", -15), "23:50");
+assert.strictEqual(C.stepTime("bad", 15), "bad");
+
+// A new event: the next whole hour, one hour long, alarm 1h.
+const d = C.newDraft("2026-10-20", new Date(2026, 9, 13, 9, 20), "home");
+assert.strictEqual(d.startTime, "10:00");
+assert.strictEqual(d.endTime, "11:00");
+assert.strictEqual(d.startDay, "2026-10-20");
+assert.strictEqual(d.alarm, "1h");
+assert.strictEqual(d.calendar, "home");
+assert.strictEqual(C.newDraft("2026-10-20", new Date(2026, 9, 13, 23, 10), "home").startTime, "22:00");
+
+// From calctl's JSON.
+const fe = C.fromEvent({ uid: "u", calendar: "home", title: "Trip", allDay: true, start: "2026-10-14",
+ end: "2026-10-16", repeat: "none", until: "", alarm: "none", location: "", notes: "", override: false });
+assert.strictEqual(fe.startDay, "2026-10-14");
+assert.strictEqual(fe.endDay, "2026-10-16");
+assert.strictEqual(fe.allDay, true);
+assert.strictEqual(C.fromEvent(Object.assign({}, timed, { title: "x" })).startTime, "14:30");
+
+// Picking on the grid: a click moves the event and keeps its length in days;
+// shift-click sets the end, or the start when before it.
+const p1 = C.pick(fe, "2026-10-20", false);
+assert.strictEqual(p1.startDay, "2026-10-20");
+assert.strictEqual(p1.endDay, "2026-10-22");
+assert.strictEqual(fe.startDay, "2026-10-14"); // input untouched
+const p2 = C.pick(fe, "2026-10-18", true);
+assert.strictEqual(p2.startDay, "2026-10-14");
+assert.strictEqual(p2.endDay, "2026-10-18");
+const p3 = C.pick(fe, "2026-10-10", true);
+assert.strictEqual(p3.startDay, "2026-10-10");
+assert.strictEqual(p3.endDay, "2026-10-16");
+
+// To calctl's JSON.
+assert.strictEqual(C.toPayload(Object.assign({}, d, { title: " " })).error, "Title is empty");
+assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", startTime: "9:00" })).error, "Times are HH:MM");
+assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", endTime: "09:00" })).error, "End is before start");
+assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", repeat: "weekly", until: "2026-10-01" })).error,
+ "Until is before the start");
+const ok = C.toPayload(Object.assign({}, d, { title: " Gym ", repeat: "weekly", until: "2026-12-01" })).payload;
+assert.deepStrictEqual(ok, { calendar: "home", title: "Gym", allDay: false, start: "2026-10-20T10:00",
+ end: "2026-10-20T11:00", repeat: "weekly", until: "2026-12-01", alarm: "1h", location: "", notes: "" });
+const ad = C.toPayload(Object.assign({}, fe, { repeat: "none", until: "2026-12-01" })).payload;
+assert.strictEqual(ad.start, "2026-10-14");
+assert.strictEqual(ad.end, "2026-10-16");
+assert.strictEqual(ad.until, ""); // no repeat, no until
+
+console.log("test_cal: ok");