aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/island/calendar/Cal.js11
-rw-r--r--desktop/island/calendar/CalendarPanel.qml66
-rw-r--r--desktop/island/calendar/EventEditor.qml28
-rw-r--r--desktop/island/calendar/test_cal.js10
4 files changed, 108 insertions, 7 deletions
diff --git a/desktop/island/calendar/Cal.js b/desktop/island/calendar/Cal.js
index d8dcf80..6cf492d 100644
--- a/desktop/island/calendar/Cal.js
+++ b/desktop/island/calendar/Cal.js
@@ -94,9 +94,19 @@ function validTime(s) {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(s);
}
+// Loose typing as HH:MM: "9.30", "9:30", "930" and "9" mean what they look
+// like. Anything else comes back unchanged, for validTime to reject.
+function normTime(s) {
+ const m = /^\s*(\d{1,2})(?:[:.,h ]?(\d{2}))?\s*$/.exec(s);
+ if (!m) return s;
+ const t = pad(+m[1]) + ":" + (m[2] || "00");
+ return validTime(t) ? t : 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) {
+ s = normTime(s);
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);
@@ -141,6 +151,7 @@ function pick(draft, day, shift) {
// The draft as calctl's event JSON, or the first thing wrong with it.
function toPayload(d) {
+ d = Object.assign({}, d, { startTime: normTime(d.startTime), endTime: normTime(d.endTime) });
if (!d.title.trim()) return { error: "Title is empty" };
if (!d.allDay && (!validTime(d.startTime) || !validTime(d.endTime)))
return { error: "Times are HH:MM" };
diff --git a/desktop/island/calendar/CalendarPanel.qml b/desktop/island/calendar/CalendarPanel.qml
index 7d42f7d..4a0a011 100644
--- a/desktop/island/calendar/CalendarPanel.qml
+++ b/desktop/island/calendar/CalendarPanel.qml
@@ -31,6 +31,7 @@ Item {
readonly property var cells: Cal.monthCells(viewY, viewM0)
readonly property real gridWidth: 252
+ readonly property bool pickingTime: panel.editing && editor.timeField !== ""
implicitHeight: Math.max(left.implicitHeight, right.implicitHeight)
@@ -113,12 +114,14 @@ Item {
Text {
anchors.verticalCenter: parent.verticalCenter
- text: Qt.locale().standaloneMonthName(panel.viewM0) + " " + panel.viewY
+ text: panel.pickingTime ? (editor.timeField === "startTime" ? "Start" : "End") + " time"
+ : Qt.locale().standaloneMonthName(panel.viewM0) + " " + panel.viewY
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
}
Row {
anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ visible: !panel.pickingTime
spacing: 12
Repeater {
model: [{ t: "‹", n: -1 }, { t: "›", n: 1 }]
@@ -140,6 +143,7 @@ Item {
Grid {
columns: 7
spacing: 2
+ visible: !panel.pickingTime
Repeater {
model: [0, 1, 2, 3, 4, 5, 6]
@@ -208,6 +212,66 @@ Item {
}
}
}
+ // While a time field has focus: hours, then minutes. Typing still
+ // works, the field keeps the focus.
+ Grid {
+ id: hours
+ readonly property string cur: panel.pickingTime ? Cal.normTime(editor.draft[editor.timeField]) : ""
+ visible: panel.pickingTime
+ columns: 6
+ spacing: 2
+
+ Repeater {
+ model: 24
+ delegate: TimeCell {
+ required property int index
+ width: (panel.gridWidth - 10) / 6
+ label: Cal.pad(index)
+ on: hours.cur.slice(0, 3) === label + ":"
+ onClicked: editor.pickTime(index, null)
+ }
+ }
+ }
+ Row {
+ id: minutes
+ readonly property string cur: panel.pickingTime ? Cal.normTime(editor.draft[editor.timeField]) : ""
+ visible: panel.pickingTime
+ spacing: 2
+
+ Repeater {
+ model: ["00", "15", "30", "45"]
+ delegate: TimeCell {
+ required property string modelData
+ width: (panel.gridWidth - 6) / 4
+ label: ":" + modelData
+ on: minutes.cur.slice(2) === label
+ onClicked: editor.pickTime(null, modelData)
+ }
+ }
+ }
+ }
+
+ component TimeCell: Rectangle {
+ property string label: ""
+ property bool on: false
+ signal clicked()
+ height: 32
+ radius: 8
+ color: on ? Qt.alpha(Theme.accent, 0.35)
+ : cellHover.containsMouse ? Qt.alpha(Theme.surface, 0.8) : "transparent"
+ Text {
+ anchors.centerIn: parent
+ text: parent.label
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ MouseArea {
+ id: cellHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: parent.clicked()
+ }
}
// --- right: agenda or editor -------------------------------------------
diff --git a/desktop/island/calendar/EventEditor.qml b/desktop/island/calendar/EventEditor.qml
index 510c844..41815e3 100644
--- a/desktop/island/calendar/EventEditor.qml
+++ b/desktop/island/calendar/EventEditor.qml
@@ -24,6 +24,10 @@ Column {
property bool confirmDelete: false
property bool busy: false
property string err: ""
+ // The time field with focus, if any: the panel swaps the month for an
+ // hour and minute picker while one has it.
+ readonly property string timeField: startTime.input.activeFocus ? "startTime"
+ : endTime.input.activeFocus ? "endTime" : ""
signal done()
@@ -49,6 +53,22 @@ Column {
ed.draft = d;
}
+ function setTime(key, value) {
+ (key === "startTime" ? startTime : endTime).input.text = value;
+ ed.set(key, value);
+ }
+
+ // A click in the time picker: an hour keeps the minutes, a minute keeps
+ // the hour and moves on, start to end, end out of the fields.
+ function pickTime(h, m) {
+ const key = ed.timeField;
+ if (!key) return;
+ const cur = Cal.normTime(ed.draft[key]);
+ const old = Cal.validTime(cur) ? cur : "09:00";
+ ed.setTime(key, (h !== null ? Cal.pad(h) : old.slice(0, 2)) + ":" + (m !== null ? m : old.slice(3, 5)));
+ if (m !== null) (key === "startTime" ? endTime.input : ed).forceActiveFocus();
+ }
+
function pick(day, shift) {
if (ed.pickUntil) {
ed.set("until", day);
@@ -176,6 +196,7 @@ Column {
width: 62
visible: !ed.draft.allDay
input.onTextEdited: ed.set("startTime", input.text)
+ input.onEditingFinished: if (Cal.normTime(input.text) !== input.text) ed.setTime("startTime", Cal.normTime(input.text))
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
@@ -183,8 +204,7 @@ Column {
onWheel: wheel => {
acc += wheel.angleDelta.y;
if (Math.abs(acc) < 120) return;
- parent.input.text = Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15);
- ed.set("startTime", parent.input.text);
+ ed.setTime("startTime", Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15));
acc = 0;
}
}
@@ -200,6 +220,7 @@ Column {
width: 62
visible: !ed.draft.allDay
input.onTextEdited: ed.set("endTime", input.text)
+ input.onEditingFinished: if (Cal.normTime(input.text) !== input.text) ed.setTime("endTime", Cal.normTime(input.text))
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
@@ -207,8 +228,7 @@ Column {
onWheel: wheel => {
acc += wheel.angleDelta.y;
if (Math.abs(acc) < 120) return;
- parent.input.text = Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15);
- ed.set("endTime", parent.input.text);
+ ed.setTime("endTime", Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15));
acc = 0;
}
}
diff --git a/desktop/island/calendar/test_cal.js b/desktop/island/calendar/test_cal.js
index 9d0d147..de3d1c7 100644
--- a/desktop/island/calendar/test_cal.js
+++ b/desktop/island/calendar/test_cal.js
@@ -17,7 +17,7 @@ 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," +
+ " lastDay, onDay, dayEvents, dots, timeLabel, validTime, normTime, stepTime, newDraft, fromEvent, pick," +
" toPayload };")();
// Dates are plain YYYY-MM-DD strings, arithmetic in UTC so DST never moves a day.
@@ -76,6 +76,11 @@ 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");
+assert.strictEqual(C.stepTime("9.30", 15), "09:45");
+for (const [i, o] of [["9.30", "09:30"], ["9:30", "09:30"], ["930", "09:30"], ["1230", "12:30"],
+ ["9", "09:00"], [" 14h15 ", "14:15"], ["09:05", "09:05"],
+ ["24.00", "24.00"], ["9.7", "9.7"], ["ab", "ab"], ["", ""]])
+ assert.strictEqual(C.normTime(i), o, i);
// 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");
@@ -111,7 +116,8 @@ 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", startTime: "9:00" })).payload.start.slice(11), "09:00");
+assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", startTime: "25: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");