From 1768ce7740e8d9228df5ce6f2fe5e5285c21ab6c Mon Sep 17 00:00:00 2001 From: ort Date: Sun, 16 Aug 2026 11:29:22 -0400 Subject: [PATCH] Add per-task timers with milestone audio for ADHD-friendly time awareness Every task now has a duration (10 min default) and a child-initiated start: a play icon on the kiosk, alongside the checkbox, begins a server-anchored countdown for that specific task. Deliberately not a beep-every-minute alarm -- audio is milestone-only (start/halfway/almost-done/time's-up), since frequent interrupting alerts can backfire for ADHD kids rather than help. Countdown is drift-free (recomputed each tick from the fixed started_at anchor, never decremented), audio is synthesized client-side via Web Audio (zero external assets, matching this app's existing hand-rolled-PNG-icon philosophy), and the child's own start tap is what unlocks audio playback for the rest of the session on iOS. Two real bugs caught and fixed during design, before they shipped: - Marking a task done now clears started_at (both the kiosk and parent routes) -- without this, unchecking a finished task later would resurrect a stale timer and instantly show "time's up" on a task the child hasn't touched today. - The parent editor's poll-pause guard only covered contenteditable fields; a plain number input for duration would have been silently unprotected from being clobbered by an incoming 4s poll mid-edit. Extended the guard to cover it, verified live that the DOM and focus both survive a poll while the field is focused. Schema: calendar_tasks gains duration_minutes (NOT NULL DEFAULT 10) and started_at (nullable). Verified against a simulated copy of the real production schema/data that the static DEFAULT backfills every existing row automatically (no business-logic backfill needed, unlike is_admin), and that repeated boots stay idempotent. Duplicate-calendar carries durations forward but always resets started_at, verified end-to-end. Kiosk countdown/milestone-firing verified live in-browser end-to-end: real timer start, halfway/almost-done/done firing at the correct proportional thresholds (not fixed minutes, so it scales from ~1min to 20+min tasks), and a mid-countdown page reload resuming at the correct remaining time with already-passed milestones backfilled silently rather than replayed. --- public/css/shared.css | 18 ++++++++++ public/js/calendar.js | 17 +++++++-- public/js/calendarRender.js | 61 +++++++++++++++++++++++++++++--- public/js/chime.js | 69 +++++++++++++++++++++++++++++++++++++ public/js/kiosk.js | 66 ++++++++++++++++++++++++++++++++++- public/kiosk.html | 30 +++++++++++++++- src/db/migrate.js | 2 ++ src/db/schema.sql | 2 ++ src/lib/calendarHydrate.js | 4 ++- src/lib/calendarSeed.js | 13 +++---- src/lib/defaults.js | 12 ++++++- src/lib/validate.js | 8 ++++- src/routes/calendars.js | 53 ++++++++++++++++++++-------- src/routes/kiosk.js | 38 +++++++++++++++++--- 14 files changed, 357 insertions(+), 36 deletions(-) create mode 100644 public/js/chime.js diff --git a/public/css/shared.css b/public/css/shared.css index 1134ea7..998d0ad 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -211,6 +211,24 @@ button.tool:disabled{ opacity: .5; cursor: default; transform:none; } } .del:hover{ color: var(--danger); } +.duration-input{ + width: 34px; + border: none; + background: none; + color: var(--ink-soft); + font-family: 'Nunito', sans-serif; + font-weight: 700; + font-size: 0.75rem; + text-align: right; + padding: 0 2px; + opacity: 0.55; + flex: none; + -moz-appearance: textfield; +} +.duration-input::-webkit-outer-spin-button, +.duration-input::-webkit-inner-spin-button{ -webkit-appearance: none; margin: 0; } +.duration-input:hover, .duration-input:focus{ opacity: 1; background: rgba(255,255,255,0.7); border-radius: 4px; } + .add-task{ background: none; border: none; diff --git a/public/js/calendar.js b/public/js/calendar.js index 8c99663..f7c07f7 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -26,6 +26,7 @@ const handlers = { return task; }, onDeleteTask: (taskId) => api.del(`/api/calendars/${calendarId}/tasks/${taskId}`).catch(() => showToast('Could not delete')), + onDurationEdit: (taskId, durationMinutes) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { durationMinutes }).catch(() => showToast('Could not save')), }; function todayName() { @@ -121,14 +122,24 @@ childNameEl.addEventListener('input', debouncedChildNameSave); // Polling keeps this view in sync with kiosk checkbox taps (and the other // parent's edits) without a manual reload. Paused while the parent is mid-edit // in any contenteditable field, so an incoming poll can't yank their cursor. +function isEditableTarget(el) { + // isContentEditable covers task/label/title/child-name text; duration is a + // plain instead (isContentEditable is always false for + // form fields), so it needs its own explicit check — a narrow class match + // rather than a blanket tag check, so unrelated inputs elsewhere (e.g. the + // period-toggle checkboxes, which commit instantly and don't need this) + // aren't also paused. + return !!el && (el.isContentEditable || el.classList.contains('duration-input')); +} + let isEditingFocused = false; document.addEventListener('focusin', (e) => { - if (e.target.isContentEditable) isEditingFocused = true; + if (isEditableTarget(e.target)) isEditingFocused = true; }); document.addEventListener('focusout', (e) => { - if (e.target.isContentEditable) { + if (isEditableTarget(e.target)) { setTimeout(() => { - isEditingFocused = !!(document.activeElement && document.activeElement.isContentEditable); + isEditingFocused = isEditableTarget(document.activeElement); }, 0); } }); diff --git a/public/js/calendarRender.js b/public/js/calendarRender.js index 55079ae..dde3984 100644 --- a/public/js/calendarRender.js +++ b/public/js/calendarRender.js @@ -20,6 +20,10 @@ function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handler const currentLabels = {}; calendar.blocks.forEach((b) => { currentLabels[b.key] = b.label; }); + // Kiosk-only: tasks with a running timer, for kiosk.js's tick loop to + // update in place every 250ms without a full re-render. + const activeTimers = []; + function syncLabel(key, sourceEl) { document.querySelectorAll(`.label[data-block="${key}"]`).forEach((el) => { if (el !== sourceEl) el.textContent = sourceEl.textContent; @@ -49,15 +53,48 @@ function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handler row.appendChild(cb); row.appendChild(span); + let startBtn = null; if (!editable) { // Kiosk mode: the 24px checkbox alone is a poor touch target, so the - // whole row toggles it. Skip when the tap landed on the checkbox - // itself — it already handles its own toggle+change natively. + // whole row toggles it. Skip when the tap landed on the checkbox or + // the start-timer button — those handle themselves. row.addEventListener('click', (e) => { - if (e.target === cb) return; + if (e.target === cb || e.target === startBtn) return; cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }); + + if (!task.done) { + startBtn = document.createElement('button'); + startBtn.className = 'timer-start'; + startBtn.textContent = '▶'; + startBtn.setAttribute('aria-label', 'Start timer'); + startBtn.onclick = () => handlers.onStartTimer(task.id); + row.appendChild(startBtn); + } + + if (task.startedAt && !task.done) { + const timer = document.createElement('div'); + timer.className = 'task-timer'; + const track = document.createElement('div'); + track.className = 'timer-track'; + const fill = document.createElement('div'); + fill.className = 'timer-fill'; + track.appendChild(fill); + const label = document.createElement('span'); + label.className = 'timer-label'; + timer.appendChild(track); + timer.appendChild(label); + row.appendChild(timer); + + activeTimers.push({ + taskId: task.id, + startedAt: task.startedAt, + durationMinutes: task.durationMinutes, + fillEl: fill, + labelEl: label, + }); + } } if (editable) { @@ -73,6 +110,22 @@ function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handler if (!span.textContent.trim()) span.textContent = task.text; }); + const durationInput = document.createElement('input'); + durationInput.type = 'number'; + durationInput.className = 'duration-input'; + durationInput.min = '1'; + durationInput.max = '180'; + durationInput.value = task.durationMinutes; + durationInput.title = 'Minutes'; + const debouncedDuration = debounce(() => { + const value = parseInt(durationInput.value, 10); + if (Number.isInteger(value) && value >= 1 && value <= 180) { + handlers.onDurationEdit(task.id, value); + } + }, 500); + durationInput.addEventListener('input', debouncedDuration); + row.appendChild(durationInput); + const del = document.createElement('button'); del.className = 'del'; del.textContent = '✕'; @@ -161,7 +214,7 @@ function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handler WEEKDAYS.forEach((d) => weekdayBoard.appendChild(makeDayCard(d))); WEEKEND.forEach((d) => weekendWrap.appendChild(makeDayCard(d))); - return { currentLabels }; + return { currentLabels, activeTimers }; } function renderLegend(legendEl, calendar) { diff --git a/public/js/chime.js b/public/js/chime.js new file mode 100644 index 0000000..4061f7d --- /dev/null +++ b/public/js/chime.js @@ -0,0 +1,69 @@ +// Zero external audio assets — synthesized tones via Web Audio, matching +// this app's existing "generate what we need with code" approach (the PWA +// icons are a hand-rolled PNG encoder for the same reason). One lazily +// created AudioContext, unlocked by the first real tap (the kiosk's start +// button) so later milestone sounds triggered by a setInterval — with no +// gesture of their own — are still allowed to play for the rest of the +// page's lifetime on iOS. + +let audioCtx = null; + +function getAudioContext() { + if (!audioCtx) { + const Ctor = window.AudioContext || window.webkitAudioContext; + audioCtx = new Ctor(); + } + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + return audioCtx; +} + +function playNote(ctx, { frequency, startTime, duration, peakGain, type }) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(frequency, startTime); + + gain.gain.setValueAtTime(0, startTime); + gain.gain.linearRampToValueAtTime(peakGain, startTime + 0.015); + gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(startTime); + osc.stop(startTime + duration + 0.02); +} + +const CHIMES = { + started: [{ frequency: 660, type: 'sine', duration: 0.12, peakGain: 0.15 }], + halfway: [ + { frequency: 523, type: 'sine', duration: 0.11, peakGain: 0.2 }, + { frequency: 659, type: 'sine', duration: 0.11, peakGain: 0.2 }, + ], + almostDone: [ + { frequency: 784, type: 'triangle', duration: 0.11, peakGain: 0.22 }, + { frequency: 659, type: 'triangle', duration: 0.11, peakGain: 0.22 }, + ], + done: [ + { frequency: 523, type: 'sine', duration: 0.14, peakGain: 0.25 }, + { frequency: 659, type: 'sine', duration: 0.14, peakGain: 0.25 }, + { frequency: 784, type: 'sine', duration: 0.14, peakGain: 0.25 }, + ], +}; + +function playChime(name) { + const notes = CHIMES[name]; + if (!notes) return; + try { + const ctx = getAudioContext(); + let t = ctx.currentTime; + const gap = 0.03; + notes.forEach((note) => { + playNote(ctx, { ...note, startTime: t }); + t += note.duration + gap; + }); + } catch { + // Audio unavailable/blocked — timers still work visually without it. + } +} diff --git a/public/js/kiosk.js b/public/js/kiosk.js index b21f3d5..acd837d 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -17,11 +17,72 @@ async function apiPatch(path, body) { if (!res.ok) throw new Error('request failed'); return res.json(); } +async function apiPost(path) { + const res = await fetch(path, { method: 'POST' }); + if (!res.ok) throw new Error('request failed'); + return res.json(); +} const handlers = { onToggleDone: (taskId, done) => apiPatch(`/api/kiosk/${token}/tasks/${taskId}`, { done }).catch(() => {}), + onStartTimer: (taskId) => { + // Played synchronously inside this real click handler — this is what + // unlocks audio for the rest of the page session on iOS. + playChime('started'); + apiPost(`/api/kiosk/${token}/tasks/${taskId}/start`).catch(() => {}); + }, }; +let activeTimers = []; +// taskId -> { startedAt, fired: Set }, in-memory only, reset +// on page load — audio is "while actively watching," not a durable log. +const milestoneState = new Map(); + +const MILESTONES = [ + { key: 'halfway', at: 0.5 }, + { key: 'almostDone', at: 0.85 }, + { key: 'done', at: 1.0 }, +]; + +function formatRemaining(seconds) { + const s = Math.max(0, Math.round(seconds)); + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, '0')}`; +} + +function tickTimers() { + const now = Date.now(); + activeTimers.forEach((timer) => { + const totalSec = timer.durationMinutes * 60; + const elapsedSec = (now - Date.parse(timer.startedAt)) / 1000; + const fraction = Math.min(1, Math.max(0, elapsedSec / totalSec)); + const remainingSec = totalSec - elapsedSec; + + timer.fillEl.style.width = `${fraction * 100}%`; + timer.fillEl.classList.toggle('milestone-almostDone', fraction >= 0.85 && fraction < 1); + timer.fillEl.classList.toggle('milestone-done', fraction >= 1); + timer.labelEl.textContent = fraction >= 1 ? "Time's up!" : formatRemaining(remainingSec); + + let state = milestoneState.get(timer.taskId); + if (!state || state.startedAt !== timer.startedAt) { + // First time seeing this timer (page load, or a fresh restart with a + // new anchor) — backfill already-passed milestones without sound, so + // a reload mid-countdown doesn't replay history. + state = { startedAt: timer.startedAt, fired: new Set() }; + MILESTONES.forEach((m) => { if (fraction >= m.at) state.fired.add(m.key); }); + milestoneState.set(timer.taskId, state); + } + + MILESTONES.forEach((m) => { + if (fraction >= m.at && !state.fired.has(m.key)) { + state.fired.add(m.key); + playChime(m.key); + } + }); + }); +} + function todayName() { return new Date().toLocaleDateString('en-US', { weekday: 'long' }); } @@ -30,7 +91,7 @@ function draw(calendar) { document.getElementById('childName').textContent = calendar.childName || ''; document.getElementById('calTitle').textContent = calendar.title || ''; - renderCalendar({ + const result = renderCalendar({ calendar, editable: false, weekdayBoard, @@ -38,6 +99,8 @@ function draw(calendar) { handlers, todayName: todayName(), }); + activeTimers = result.activeTimers; + tickTimers(); weekendWrap.classList.toggle('hidden', !calendar.showWeekend); } @@ -59,3 +122,4 @@ async function poll() { poll(); setInterval(poll, 4000); +setInterval(tickTimers, 250); diff --git a/public/kiosk.html b/public/kiosk.html index ee69c24..52e0d17 100644 --- a/public/kiosk.html +++ b/public/kiosk.html @@ -43,8 +43,35 @@ /* 24px checkbox alone is well under Apple's 44pt touch-target minimum — make the whole row tappable and give the checkbox itself more room. */ - .task.kiosk{ cursor: pointer; padding: 8px 4px; } + .task.kiosk{ cursor: pointer; padding: 8px 4px; flex-wrap: wrap; } .task.kiosk input[type=checkbox]{ width: 32px; height: 32px; } + + .timer-start{ + border: none; background: none; cursor: pointer; flex: none; + font-size: 1.1rem; line-height: 1; color: var(--ink-soft); padding: 4px 8px; + } + .timer-start:hover{ color: var(--ink); } + + /* flex-basis:100% on a flex-wrap row forces the timer bar onto its own + line below the checkbox/text/button, instead of squeezing in beside them. */ + .task-timer{ + flex-basis: 100%; + display: flex; align-items: center; gap: 8px; + margin: 6px 0 2px 40px; + } + .timer-track{ + flex: 1; height: 6px; border-radius: 3px; + background: rgba(46,52,70,0.1); overflow: hidden; + } + .timer-fill{ + height: 100%; width: 0%; background: var(--ink-soft); border-radius: 3px; + } + .timer-fill.milestone-almostDone{ background: var(--morning); } + .timer-fill.milestone-done{ background: var(--danger); } + .timer-label{ + font-size: 0.78rem; font-weight: 800; color: var(--ink-soft); + white-space: nowrap; min-width: 42px; text-align: right; + } @@ -64,6 +91,7 @@ + diff --git a/src/db/migrate.js b/src/db/migrate.js index 6a57811..5f0c798 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -8,6 +8,8 @@ function migrate(db) { db.exec(schema); ensureColumn(db, 'parents', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); backfillAdmins(db); + ensureColumn(db, 'calendar_tasks', 'duration_minutes', 'INTEGER NOT NULL DEFAULT 10'); + ensureColumn(db, 'calendar_tasks', 'started_at', 'TEXT'); } module.exports = { migrate }; diff --git a/src/db/schema.sql b/src/db/schema.sql index 6dadbdc..ed3ace1 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -63,6 +63,8 @@ CREATE TABLE IF NOT EXISTS calendar_tasks ( block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')), text TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, + duration_minutes INTEGER NOT NULL DEFAULT 10, + started_at TEXT, sort_order INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) diff --git a/src/lib/calendarHydrate.js b/src/lib/calendarHydrate.js index 41fb468..0c0b6f4 100644 --- a/src/lib/calendarHydrate.js +++ b/src/lib/calendarHydrate.js @@ -5,7 +5,7 @@ const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?'); const getChildStmt = db.prepare('SELECT id, name FROM children WHERE id = ?'); const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?'); const getTasksStmt = db.prepare( - 'SELECT id, day_of_week, block_key, text, done, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order, id' + 'SELECT id, day_of_week, block_key, text, done, sort_order, duration_minutes, started_at FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order, id' ); // Shared shape consumed by the parent editor, the kiosk view, and the poll endpoint. @@ -32,6 +32,8 @@ function hydrateCalendar(calendarId) { text: t.text, done: !!t.done, sortOrder: t.sort_order, + durationMinutes: t.duration_minutes, + startedAt: t.started_at || null, }); }); diff --git a/src/lib/calendarSeed.js b/src/lib/calendarSeed.js index 47ee58d..8c3c80e 100644 --- a/src/lib/calendarSeed.js +++ b/src/lib/calendarSeed.js @@ -1,5 +1,5 @@ const db = require('../db'); -const { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, ALL_DAYS } = require('./defaults'); +const { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, ALL_DAYS, DEFAULT_DURATION_MINUTES } = require('./defaults'); const { hydrateCalendar } = require('./calendarHydrate'); const insertCalendarStmt = db.prepare( @@ -10,11 +10,11 @@ const insertBlockStmt = db.prepare( 'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?)' ); const insertTaskStmt = db.prepare( - 'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order, duration_minutes) VALUES (?, ?, ?, ?, ?, ?)' ); const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?'); const getTasksStmt = db.prepare( - 'SELECT day_of_week, block_key, text, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order' + 'SELECT day_of_week, block_key, text, sort_order, duration_minutes FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order' ); const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?'); @@ -25,7 +25,7 @@ function createBlankCalendar(childId, title, weekStartDate) { insertBlockStmt.run(calendarId, key, DEFAULT_BLOCK_LABELS[key]); DEFAULT_TASKS[key].forEach((text, i) => { ALL_DAYS.forEach((day) => { - insertTaskStmt.run(calendarId, day, key, text, i); + insertTaskStmt.run(calendarId, day, key, text, i, DEFAULT_DURATION_MINUTES); }); }); }); @@ -41,9 +41,10 @@ function createDuplicateCalendar(sourceCalendarId, childId, title, weekStartDate getBlocksStmt.all(sourceCalendarId).forEach((b) => { insertBlockStmt.run(calendarId, b.block_key, b.label); }); - // done intentionally not copied — new week starts unchecked (column default is 0) + // done and started_at intentionally not copied — new week starts fresh + // (both columns default to their "not started/not done" state on insert) getTasksStmt.all(sourceCalendarId).forEach((t) => { - insertTaskStmt.run(calendarId, t.day_of_week, t.block_key, t.text, t.sort_order); + insertTaskStmt.run(calendarId, t.day_of_week, t.block_key, t.text, t.sort_order, t.duration_minutes); }); return hydrateCalendar(calendarId); diff --git a/src/lib/defaults.js b/src/lib/defaults.js index f2360be..2d8f998 100644 --- a/src/lib/defaults.js +++ b/src/lib/defaults.js @@ -18,4 +18,14 @@ const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; const WEEKEND = ['Saturday', 'Sunday']; const ALL_DAYS = [...WEEKDAYS, ...WEEKEND]; -module.exports = { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, WEEKDAYS, WEEKEND, ALL_DAYS }; +const DEFAULT_DURATION_MINUTES = 10; + +module.exports = { + BLOCK_KEYS, + DEFAULT_BLOCK_LABELS, + DEFAULT_TASKS, + WEEKDAYS, + WEEKEND, + ALL_DAYS, + DEFAULT_DURATION_MINUTES, +}; diff --git a/src/lib/validate.js b/src/lib/validate.js index ea78208..f6bbd94 100644 --- a/src/lib/validate.js +++ b/src/lib/validate.js @@ -2,6 +2,10 @@ function isNonEmptyString(value, maxLength) { return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; } +function isValidDuration(value) { + return Number.isInteger(value) && value >= LIMITS.DURATION_MIN && value <= LIMITS.DURATION_MAX; +} + const LIMITS = { NAME: 100, EMAIL: 254, @@ -10,6 +14,8 @@ const LIMITS = { TITLE: 200, LABEL: 60, TASK_TEXT: 300, + DURATION_MIN: 1, + DURATION_MAX: 180, }; -module.exports = { isNonEmptyString, LIMITS }; +module.exports = { isNonEmptyString, isValidDuration, LIMITS }; diff --git a/src/routes/calendars.js b/src/routes/calendars.js index 4f3a895..fe5d937 100644 --- a/src/routes/calendars.js +++ b/src/routes/calendars.js @@ -4,8 +4,8 @@ const requireAuth = require('../middleware/requireAuth'); const { loadOwnedCalendar } = require('../middleware/requireHousehold'); const { hydrateCalendar } = require('../lib/calendarHydrate'); const touchCalendar = require('../lib/touchCalendar'); -const { BLOCK_KEYS, ALL_DAYS } = require('../lib/defaults'); -const { isNonEmptyString, LIMITS } = require('../lib/validate'); +const { BLOCK_KEYS, ALL_DAYS, DEFAULT_DURATION_MINUTES } = require('../lib/defaults'); +const { isNonEmptyString, isValidDuration, LIMITS } = require('../lib/validate'); const router = express.Router(); router.use(requireAuth); @@ -19,18 +19,26 @@ const upsertBlockStmt = db.prepare( 'ON CONFLICT(calendar_id, block_key) DO UPDATE SET label = excluded.label' ); const insertTaskStmt = db.prepare( - 'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order, duration_minutes) VALUES (?, ?, ?, ?, ?, ?)' ); const maxSortOrderStmt = db.prepare( 'SELECT COALESCE(MAX(sort_order), -1) AS maxOrder FROM calendar_tasks WHERE calendar_id = ? AND day_of_week = ? AND block_key = ?' ); const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?'); -const updateTaskStmt = db.prepare( - 'UPDATE calendar_tasks SET text = COALESCE(?, text), done = COALESCE(?, done), sort_order = COALESCE(?, sort_order), ' + - "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" -); +// started_at clears whenever done is explicitly set to true in this same +// call (same reasoning as the kiosk route) — the CASE reuses the bound +// `done` param, so it's a no-op unless this update is actually marking the +// task done (NULL/0 both fall through to the ELSE branch, keeping started_at). +const updateTaskStmt = db.prepare(` + UPDATE calendar_tasks + SET text = COALESCE(?, text), done = COALESCE(?, done), sort_order = COALESCE(?, sort_order), + duration_minutes = COALESCE(?, duration_minutes), + started_at = CASE WHEN ? = 1 THEN NULL ELSE started_at END, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? +`); const deleteTaskStmt = db.prepare('DELETE FROM calendar_tasks WHERE id = ?'); -const resetChecksStmt = db.prepare('UPDATE calendar_tasks SET done = 0 WHERE calendar_id = ?'); +const resetChecksStmt = db.prepare('UPDATE calendar_tasks SET done = 0, started_at = NULL WHERE calendar_id = ?'); router.get('/:id', (req, res) => { const calendar = loadOwnedCalendar(req, res, req.params.id); @@ -96,18 +104,26 @@ router.post('/:id/tasks', (req, res) => { const calendar = loadOwnedCalendar(req, res, req.params.id); if (!calendar) return; - const { dayOfWeek, blockKey, text } = req.body || {}; + const { dayOfWeek, blockKey, text, durationMinutes } = req.body || {}; if (!ALL_DAYS.includes(dayOfWeek)) return res.status(400).json({ error: 'Invalid day' }); if (!BLOCK_KEYS.includes(blockKey)) return res.status(400).json({ error: 'Invalid block' }); if (!isNonEmptyString(text, LIMITS.TASK_TEXT)) return res.status(400).json({ error: 'Text is required' }); + if (durationMinutes !== undefined && !isValidDuration(durationMinutes)) { + return res.status(400).json({ error: `Duration must be ${LIMITS.DURATION_MIN}-${LIMITS.DURATION_MAX} minutes` }); + } const nextOrder = maxSortOrderStmt.get(calendar.id, dayOfWeek, blockKey).maxOrder + 1; - const taskId = insertTaskStmt.run(calendar.id, dayOfWeek, blockKey, text.trim(), nextOrder).lastInsertRowid; + const duration = isValidDuration(durationMinutes) ? durationMinutes : DEFAULT_DURATION_MINUTES; + const taskId = insertTaskStmt.run(calendar.id, dayOfWeek, blockKey, text.trim(), nextOrder, duration).lastInsertRowid; touchCalendar(calendar.id); const task = getTaskStmt.get(taskId); res.status(201).json({ - task: { id: task.id, text: task.text, done: !!task.done, sortOrder: task.sort_order, dayOfWeek, blockKey }, + task: { + id: task.id, text: task.text, done: !!task.done, sortOrder: task.sort_order, + durationMinutes: task.duration_minutes, startedAt: task.started_at || null, + dayOfWeek, blockKey, + }, }); }); @@ -120,22 +136,31 @@ router.patch('/:id/tasks/:taskId', (req, res) => { return res.status(404).json({ error: 'Task not found' }); } - const { text, done, sortOrder } = req.body || {}; + const { text, done, sortOrder, durationMinutes } = req.body || {}; if (text !== undefined && !isNonEmptyString(text, LIMITS.TASK_TEXT)) { return res.status(400).json({ error: 'Text cannot be empty' }); } + if (durationMinutes !== undefined && !isValidDuration(durationMinutes)) { + return res.status(400).json({ error: `Duration must be ${LIMITS.DURATION_MIN}-${LIMITS.DURATION_MAX} minutes` }); + } + const doneParam = done !== undefined ? (done ? 1 : 0) : null; updateTaskStmt.run( text !== undefined ? text.trim() : null, - done !== undefined ? (done ? 1 : 0) : null, + doneParam, sortOrder !== undefined ? sortOrder : null, + isValidDuration(durationMinutes) ? durationMinutes : null, + doneParam, task.id ); touchCalendar(calendar.id); const updated = getTaskStmt.get(task.id); res.json({ - task: { id: updated.id, text: updated.text, done: !!updated.done, sortOrder: updated.sort_order }, + task: { + id: updated.id, text: updated.text, done: !!updated.done, sortOrder: updated.sort_order, + durationMinutes: updated.duration_minutes, startedAt: updated.started_at || null, + }, }); }); diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js index ba23a9c..4e896ac 100644 --- a/src/routes/kiosk.js +++ b/src/routes/kiosk.js @@ -9,9 +9,20 @@ const router = express.Router({ mergeParams: true }); router.use(resolveKiosk); const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?'); -const setDoneStmt = db.prepare( - "UPDATE calendar_tasks SET done = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" -); +// Clears started_at whenever a task is marked done — otherwise unchecking a +// finished task later would resurrect a stale/expired started_at and show +// "time's up" on a task the child hasn't touched today. +const setDoneStmt = db.prepare(` + UPDATE calendar_tasks + SET done = ?, started_at = CASE WHEN ? = 1 THEN NULL ELSE started_at END, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? +`); +const startTaskStmt = db.prepare(` + UPDATE calendar_tasks + SET started_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? +`); // Deliberately only two routes exist on this router: read the active calendar, // and toggle a task's `done` flag. No create/delete/text-edit/label routes are @@ -59,7 +70,7 @@ router.patch('/tasks/:taskId', (req, res) => { const wasDone = !!task.done; - setDoneStmt.run(done ? 1 : 0, task.id); + setDoneStmt.run(done ? 1 : 0, done ? 1 : 0, task.id); touchCalendar(task.calendar_id); res.json({ task: { id: task.id, done } }); @@ -75,4 +86,23 @@ router.patch('/tasks/:taskId', (req, res) => { } }); +router.post('/tasks/:taskId/start', (req, res) => { + if (!req.child.active_calendar_id) { + return res.status(404).json({ error: 'No active calendar for this child' }); + } + + const task = getTaskStmt.get(req.params.taskId); + if (!task || task.calendar_id !== req.child.active_calendar_id) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Always overwrites, even if already running — tapping start again just + // restarts the countdown. Forgiving of mis-taps, no "already running" guard. + startTaskStmt.run(task.id); + touchCalendar(task.calendar_id); + + const updated = getTaskStmt.get(task.id); + res.json({ task: { id: task.id, startedAt: updated.started_at } }); +}); + module.exports = router;