From 9c47ad7c10aa5a04de738a5d41acc01cc0e5e059 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 13 Nov 2026 09:17:23 +0100 Subject: feat(calendar): click-to-pick event times, loose time typing The month grid was already the editor's date picker, but times could only be typed or wheeled. While a time field has focus the month now gives way to an hour grid and a :00/:15/:30/:45 row: an hour keeps the minutes, a minute moves on from start to end, then out of the fields. The picker replaces the grid rather than growing the editor, because the island's layer surface is a fixed 480px and the editor nearly fills it. The field keeps focus throughout, so typing and the wheel still work. Typed times are normalised: "9.30", "9:30", "930", "9" and "14h15" become HH:MM on leaving the field and again in toPayload, instead of failing with "Times are HH:MM". Impossible values still fail, unchanged. Co-Authored-By: Claude Opus 5.5 --- desktop/island/calendar/Cal.js | 11 ++++++ desktop/island/calendar/CalendarPanel.qml | 66 ++++++++++++++++++++++++++++++- desktop/island/calendar/EventEditor.qml | 28 +++++++++++-- desktop/island/calendar/test_cal.js | 10 ++++- 4 files changed, 108 insertions(+), 7 deletions(-) (limited to 'desktop') 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"); -- cgit v1.2.3