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;