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.
This commit is contained in:
ort
2026-08-16 11:29:22 -04:00
parent 8c7710d981
commit 1768ce7740
14 changed files with 357 additions and 36 deletions
+18
View File
@@ -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;
+14 -3
View File
@@ -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 <input type=number> 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);
}
});
+57 -4
View File
@@ -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) {
+69
View File
@@ -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.
}
}
+65 -1
View File
@@ -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<milestone key> }, 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);
+29 -1
View File
@@ -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;
}
</style>
</head>
<body>
@@ -64,6 +91,7 @@
</div>
<script src="/js/calendarRender.js"></script>
<script src="/js/chime.js"></script>
<script src="/js/kiosk.js"></script>
</body>