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;