From 7e78af5d42e610165c8003270ca3c62388722c01 Mon Sep 17 00:00:00 2001 From: Riley Sheehan Date: Thu, 12 Nov 2026 20:48:47 -0600 Subject: [PATCH] Gingham 0.3.0: screen time --- README.md | 2 + dist/app.js | 446 +++++++++++++++++++++++++++++++++++++-- dist/index.html | 11 + dist/style.css | 72 +++++++ dist/tokens.css | 4 +- households.js | 5 +- package.json | 4 +- releases/v0.3.0.md | 9 + rewards.js | 291 +++++++++++++++++++++++++ server.js | 74 ++++++- setup.js | 13 +- test/fixtures.js | 32 +++ test/rewards.test.js | 396 ++++++++++++++++++++++++++++++++++ test/shots.cjs | 47 ++++- test/wall-harness.js | 4 +- test/wall-screen.test.js | 313 +++++++++++++++++++++++++++ test/wall.test.js | 3 +- 17 files changed, 1689 insertions(+), 37 deletions(-) create mode 100644 releases/v0.3.0.md create mode 100644 rewards.js create mode 100644 test/rewards.test.js create mode 100644 test/wall-screen.test.js diff --git a/README.md b/README.md index b13fde1..51d0a01 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ copy holds that copy's secrets. Tasks or Microsoft To Do (those two need an app registered for the server). Add at the wall or from a phone; check things off with a moment to undo. A young child's list has big rows and a picture on each. - **Photos** from an iCloud shared album, straight from a phone, or from a folder. +- **Screen time.** A child's minutes for the day in a ring, with a timer that stops at zero and chimes; adding or + taking away time takes the household PIN. Off until a grown-up turns it on. - **Text sized for the room.** Smaller, Standard or Larger, for the rows and the lists; the clock and the layout stay as they are. - **It behaves itself.** Brightness follows sunrise and sunset. After a while it drifts to photos and comes back. After a diff --git a/dist/app.js b/dist/app.js index 6e55a87..47ded8a 100644 --- a/dist/app.js +++ b/dist/app.js @@ -136,9 +136,10 @@ loadHousehold(); } setInterval(firstRunStep, 5000); - function post(url, body, done) { + function post(url, body, done, headers) { var xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.timeout = 20000; xhr.setRequestHeader('X-Gingham', '1'); xhr.setRequestHeader('Content-Type', 'application/json'); + for (var h in headers || {}) xhr.setRequestHeader(h, headers[h]); xhr.onload = function () { var data = null; try { data = JSON.parse(xhr.responseText); } catch (e) {} done(xhr.status, data || {}); }; xhr.onerror = xhr.ontimeout = function () { done(0, {}); }; xhr.send(JSON.stringify(body || {})); @@ -175,10 +176,11 @@ function checkVersion(v) { if (!v) return; if (version === null) { version = v; return; } - if (v !== version && !undo && !dialogOpen() && Date.now() - lastTouch > 10000) location.reload(); + if (v !== version && !undo && !screenUndo && !dialogOpen() && Date.now() - lastTouch > 10000) location.reload(); } // Any sheet or dialog up means someone is in the middle of something: a half-typed grocery item, a PIN, a day's chores. - function dialogOpen() { return !$('event-dialog').hidden || !$('day-dialog').hidden || !$('manage-dialog').hidden || !$('add-dialog').hidden || !$('legend-dialog').hidden; } + // Screen time unlocked is the same: a grown-up is at the wall, behind the PIN. + function dialogOpen() { return !$('event-dialog').hidden || !$('day-dialog').hidden || !$('manage-dialog').hidden || !$('add-dialog').hidden || !$('legend-dialog').hidden || !!manage; } /* ---------- Sky ---------- */ var SKY = { @@ -1054,6 +1056,14 @@ // alone. The open list keeps its name. if (mode !== 'list:' + project) named.push({ tab: b, rank: style.person ? 0 : GLYPHS[style.icon] && style.icon !== 'list' ? 1 : 2 }); }); + // Screen time is a view like a list, with its glyph and no count: a count is work to be done, and minutes left are not. + if (screenPerson()) { + var st = node('button', 'list-tab'); st.appendChild(glyphNode(TIMER_GLYPH)); st.appendChild(node('span', 'label', 'Screen time')); + st.setAttribute('aria-pressed', String(mode === 'screen')); + st.onclick = function () { setMode(mode === 'screen' ? 'calendar' : 'screen'); }; + tabs.appendChild(st); + if (mode !== 'screen') named.push({ tab: st, rank: 1 }); + } // Photos is a view like the lists, so it lives in the same row with its count. if (photos.length) { var p = node('button', '', 'Photos'); @@ -1137,15 +1147,18 @@ $('status-weather').textContent = weather ? 'Open-Meteo, updated ' + since(weather.fetchedAt) + (weather.stale ? ', not updating now' : '') : 'Not loaded yet'; $('status-photos').textContent = !photoInfo.configured ? 'No album connected' : !photos.length ? 'The album is empty' : photos.length + ' photos' + (photoInfo.syncedAt ? ', updated ' + since(photoInfo.syncedAt) : '') + (photoInfo.error ? ', can’t reach the album now' : ''); } - function render() { paintSky(); renderNow(); renderToday(); renderLegend(); renderRail(); renderStrip(); renderTabs(); renderList(); renderNotice(); if (sheetDay && !$('day-dialog').hidden) fillDay(); } + function render() { paintSky(); renderNow(); renderToday(); renderLegend(); renderRail(); renderStrip(); renderTabs(); renderList(); renderScreen(); renderNotice(); if (sheetDay && !$('day-dialog').hidden) fillDay(); } /* ---------- Modes ---------- */ function setMode(next) { if (next === 'photos' && !photos.length) next = 'calendar'; + if (next === 'screen' && !screenPerson() && !manage) next = 'calendar'; var leaving = mode; mode = next; + // Leaving screen time locks it: unlocked is for the grown-up standing at it, not for whoever comes next. + if (leaving === 'screen' && mode !== 'screen') lockScreen(); $('view-calendar').hidden = mode !== 'calendar'; $('view-list').hidden = mode.indexOf('list:') !== 0; - $('view-photos').hidden = mode !== 'photos'; $('view-settings').hidden = mode !== 'settings'; + $('view-photos').hidden = mode !== 'photos'; $('view-settings').hidden = mode !== 'settings'; $('view-screen').hidden = mode !== 'screen'; $('settings-button').setAttribute('aria-pressed', String(mode === 'settings')); if (mode === 'settings') { fadeIfScrolls(document.querySelector('.settings-card')); if (leaving !== 'settings') { loadUpdates(); pollInstall(); showUpdateIfDue(); } } if (mode === 'photos' && leaving !== 'photos') startSlides(); @@ -1177,7 +1190,7 @@ box.appendChild(b); }); })(groups[i]); - renderRepair(); + renderRepair(); renderScreenSetting(); } // Fixing the frame at the wall, so nobody needs a computer for the usual trouble. Reloading always works; restarting // the browser or the frame needs Fully Kiosk's website integration (Settings → Advanced Web Settings), and those @@ -1348,6 +1361,394 @@ }); } + /* ---------- Screen time ---------- */ + // A person's minutes for today (rewards.js on the server, DESIGN.md → Screen time): a ring with what is left, the day's + // sum, its record, and a timer anyone may start or stop. Adding, taking away, removing from the record and who has it + // need the household PIN: the server hands this page a token for it, kept in this variable and nowhere else (no cookie, + // no storage, no address), and refuses every such change without one. A record, not a monitor: it counts only while the + // timer is on, and says so. Time is the server's, so a frame with a wrong clock still counts right. + var screen = null; // /api/rewards: {day, now, people, pinSet} + var screenAt = 0; // Date.now() when it arrived; the server's time since is screen.now plus what has passed here + var screenProblem = false; // the last ask failed: show what we had, said so, with its controls off + var manage = null; // {token} while a grown-up has screen time unlocked + var screenUndo = null; // {run, timer}: the Undo in the toast, for a screen time change + var manageChoice = { sign: 1, minutes: 10, reason: null }, setupAllowance = 30, confirmOff = null; + var screenName = null; // the name being typed to turn it on, kept across a redraw + var REASONS = { chores: 'Chores', reading: 'Reading', helping: 'Helping', other: 'Other' }; + var TIMER_GLYPH = ['M12 21a8 8 0 1 0 0-16a8 8 0 1 0 0 16z', 'M12 9v4l2.5 2', 'M10 2.5h4']; + var MINUS = '−'; + // The person the wall draws. One for now; the ring is a component so that more can stand side by side later. + function screenPerson() { var on = screen ? screen.people.filter(function (p) { return p.on; }) : []; return on[0] || null; } + function passed() { return screen ? Math.max(0, Date.now() - screenAt) : 0; } + // What is left and used, in seconds, counted on from the server's answer while a timer runs, and never past zero: there + // the server stops the timer. + function live(p) { var run = p.timer ? Math.min(Math.max(0, p.left), Math.floor(passed() / 1000)) : 0; return { left: p.left - run, used: p.used + run }; } + // When time ran out, as an instant on the server's clock, while nothing is left: the stop time's up made (the newest + // timer in the record), or, in the moment before the server's answer says so, where a running timer reached zero. + // Zero when the day simply has no time left. + function upAt(p, now) { + if (now.left > 0) return 0; + if (p.timer) return screen.now + Math.max(0, p.left) * 1000; + for (var i = 0; i < p.entries.length; i++) if (p.entries[i].kind === 'timer' && !p.entries[i].removed) return p.entries[i].timeUp ? p.entries[i].at : 0; + return 0; + } + function timerRunning() { var p = screenPerson(); return !!(p && p.timer); } + function mins(n) { return n + ' min'; } + function signed(n) { return n > 0 ? '+' + mins(n) : n < 0 ? MINUS + mins(-n) : mins(0); } + function usedText(seconds) { return seconds > 0 && seconds < 30 ? 'under a minute' : mins(Math.round(seconds / 60)); } + function entryLabel(e) { + if (e.kind === 'timer') return 'Timer'; + if (e.kind === 'earn') return 'Earned for ' + (REASONS[e.reason] || 'Other').toLowerCase(); + return e.minutes > 0 ? 'Added' : 'Taken away'; + } + function entryAmount(e) { return e.kind === 'timer' ? (e.seconds < 30 ? 'under a minute' : MINUS + mins(Math.round(e.seconds / 60))) : signed(e.minutes); } + function glyphNode(paths) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'glyph'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); + paths.forEach(function (d) { var p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', d); svg.appendChild(p); }); + return svg; + } + // The person's mark: their own list's colour, if they have one, as everywhere else on the wall. + function screenMark(name) { var l = listOf(name), a = monogram(name, l.person ? l.color : ''); a.setAttribute('aria-hidden', 'true'); return a; } + + function loadRewards() { + get('/api/rewards', applyRewards, function () { screenProblem = true; renderScreen(); }); + } + function applyRewards(data) { + if (!data || !data.people) return; + var hadTab = !!screenPerson(); + screen = data; screenAt = Date.now(); screenProblem = false; + heardTimeUp(); + if (mode === 'screen' && !screenPerson() && !manage) setMode('calendar'); + else { if (hadTab !== !!screenPerson()) renderTabs(); renderScreen(); renderScreenSetting(); } + } + // A change, with the manage token when it needs one. A 403 that says "locked" means the token is gone (two quiet minutes, + // or the server restarted): the page forgets it too and says so. + function screenPost(action, body, done, guarded) { + var headers = guarded && manage ? { 'X-Gingham-Manage': manage.token } : null; + post('/api/rewards/' + action, body, function (status, data) { + if (status === 200) { applyRewards(data); if (done) done(data); return; } + if (status === 403 && data.locked) { manage = null; renderScreen(); toast('Locked. Enter the household PIN to change screen time.', false); return; } + toast(data.error || 'Can’t reach the frame’s server. Nothing changed.', false); + if (status === 0) { screenProblem = true; renderScreen(); } + }, headers); + } + function screenToast(text, undoFn) { + if (undo) commit(); + dropScreenUndo(); + toast(text, !!undoFn); + if (!undoFn) return; + screenUndo = { run: function () { dropScreenUndo(); $('toast').hidden = true; undoFn(); }, timer: setTimeout(function () { screenUndo = null; $('toast').hidden = true; }, UNDO_MS) }; + } + function dropScreenUndo() { if (screenUndo) { clearTimeout(screenUndo.timer); screenUndo = null; } } + + function startTimer(p) { + screenPost('start', { person: p.id }, function (data) { + if (data.changed) screenToast('Timer on for ' + p.name, function () { screenPost('cancel-start', { person: p.id }); }); + }); + } + function stopTimer(p) { + screenPost('stop', { person: p.id }, function (data) { + var entry = data.changed && data.changed.entry, made = entry && personIn(data, p.id).entries.filter(function (e) { return e.id === entry; })[0]; + if (made) screenToast('Timer off. ' + usedText(made.seconds).replace(/^u/, 'U') + ' used', function () { screenPost('resume', { person: p.id, entry: entry }); }); + }); + } + function personIn(data, id) { return data.people.filter(function (x) { return x.id === id; })[0] || { entries: [] }; } + function adjustTime(p) { + var minutes = manageChoice.sign * manageChoice.minutes, reason = manageChoice.sign > 0 ? manageChoice.reason : null; + screenPost('adjust', { person: p.id, minutes: minutes, reason: reason }, function (data) { + var entry = data.changed && data.changed.entry; + manageChoice.reason = null; renderScreen(); + screenToast((minutes > 0 ? 'Added ' + mins(minutes) : 'Took away ' + mins(-minutes)) + (reason ? ' for ' + REASONS[reason].toLowerCase() : ''), entry && function () { screenPost('remove', { person: p.id, entry: entry }, null, true); }); + }, true); + } + function removeEntry(p, e) { + screenPost('remove', { person: p.id, entry: e.id }, function () { + screenToast('Removed ' + entryLabel(e).charAt(0).toLowerCase() + entryLabel(e).slice(1) + ', ' + entryAmount(e), function () { screenPost('restore', { person: p.id, entry: e.id }, null, true); }); + }, true); + } + function restoreEntry(p, e) { screenPost('restore', { person: p.id, entry: e.id }, function () { screenToast('Put back ' + entryAmount(e), null); }, true); } + function setAllowance(p, value) { + screenPost('person', { id: p.id, allowance: value }, function () { screenToast('From tomorrow, each day starts at ' + mins(value), null); }, true); + } + function turnOff(p) { + screenPost('person', { id: p.id, on: false }, function () { screenToast('Screen time is off for ' + p.name + '. Its record is kept.', null); }, true); + } + function turnOn(body) { + screenPost('person', body, function () { screenToast('Screen time is on', null); }, true); + } + + // Time's up, heard (MER-2089). The server stops the timer at zero whether or not anything plays; the chime is for the + // person in the room. This page chimes when it sees its timer reach zero, or when it loads within two minutes of a + // stop that time's up made. Each timer is chimed for once, by its start, and never one from before. + var chimed = {}, askedAt = null, UP_CHIME_MS = 120000; + function timeUp(p, from) { + if (chimed[from]) return; + chimed[from] = true; + chime(function (played) { screenToast(played ? 'Time’s up for ' + p.name : 'Time’s up. This frame couldn’t play its chime.', null); }); + } + // Once a second: a running timer at zero here is asked about once, so the server's stop arrives without a polling loop. + function watchTimeUp() { + var p = screenPerson(); + if (!p || !p.timer || live(p).left > 0 || askedAt === p.timer.since) return; + askedAt = p.timer.since; + timeUp(p, p.timer.since); + loadRewards(); + } + function heardTimeUp() { + screen.people.forEach(function (p) { + if (p.on) p.entries.forEach(function (e) { if (e.timeUp && !e.removed && screen.now - e.at <= UP_CHIME_MS) timeUp(p, e.from); }); + }); + } + // Three soft rising tones, twice, about three seconds in all, made here with Web Audio: no file to fetch. `done(true)` + // once it plays, and `done(false)` when this page can't make a sound: no Web Audio, a context the browser keeps + // suspended (its resume() never settling is the same), or an error. + var audio = null, CHIME = [523.25, 659.25, 783.99]; + function chime(done) { + var said = false, Context = window.AudioContext || window.webkitAudioContext; + function answer(played) { if (!said) { said = true; done(played); } } + function play() { + try { + var t0 = audio.currentTime + 0.05; + for (var pass = 0; pass < 2; pass++) for (var i = 0; i < CHIME.length; i++) { + var at = t0 + pass * 1.5 + i * 0.4, tone = audio.createOscillator(), gain = audio.createGain(); + tone.type = 'sine'; tone.frequency.value = CHIME[i]; + gain.gain.setValueAtTime(0.0001, at); gain.gain.exponentialRampToValueAtTime(0.25, at + 0.03); gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.7); + tone.connect(gain); gain.connect(audio.destination); tone.start(at); tone.stop(at + 0.75); + } + answer(true); + } catch (e) { answer(false); } + } + try { + if (!Context) return answer(false); + if (!audio) audio = new Context(); + if (audio.state === 'running') return play(); + var resumed = audio.resume ? audio.resume() : null; + if (!resumed || !resumed.then) return audio.state === 'running' ? play() : answer(false); + resumed.then(function () { if (audio.state === 'running') play(); else answer(false); }, function () { answer(false); }); + setTimeout(function chimeWait() { answer(false); }, 1500); + } catch (e) { answer(false); } + } + function testChime() { chime(function (played) { if (!played) toast('This frame couldn’t play its chime. Check that its sound is on.', false); }); } + + // The household's PIN, on the keypad the phone's way in uses. With no PIN set, the wall says where to set one and offers + // no other way in. + function openScreenPin() { + if (manage) { setMode('screen'); return; } + openKeypad('screen'); + $('manage-eyebrow').textContent = 'Screen time'; + if (screen && !screen.pinSet) { + $('manage-title').textContent = 'No household PIN yet'; + $('manage-note').textContent = 'Set a household PIN on the setup page to manage screen time here.'; + $('keypad').hidden = true; $('pin-dots').hidden = true; + } + } + function unlockScreen() { + if (pinSoFar.length < 4) { $('manage-note').textContent = 'A PIN is at least four digits.'; return; } + post('/api/rewards/unlock', { pin: pinSoFar }, function (status, data) { + pinSoFar = ''; drawPin(); + if (status !== 200 || !data.token) { $('manage-note').textContent = data.error || 'Can’t reach the frame’s server. Try again.'; return; } + manage = { token: data.token }; confirmOff = null; + closeManage(); setMode('screen'); + }); + } + function lockScreen() { + if (!manage) return; + post('/api/rewards/lock', {}, function () {}, { 'X-Gingham-Manage': manage.token }); + manage = null; confirmOff = null; + if (mode === 'screen' && !screenPerson()) setMode('calendar'); else renderScreen(); + } + + function screenButton(cls, label, action, disabled) { + var b = node('button', cls, label); b.onclick = action; if (disabled) b.disabled = true; return b; + } + function segmented(choices, current, pick, disabled) { + var box = node('div', 'choices'); + choices.forEach(function (c) { + var b = node('button', '', c[1]); b.setAttribute('aria-pressed', String(current === c[0])); b.disabled = !!disabled; + b.onclick = function () { pick(c[0]); renderScreen(); }; box.appendChild(b); + }); + return box; + } + // A count of seconds as a timer shows it: "24:37", "0:42", "1:04:37". Never rounded up, so it never shows a second + // that is not there. + function countText(seconds) { + var s = Math.max(0, Math.floor(seconds)), h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60; + return (h ? h + ':' + pad(m) : String(m)) + ':' + pad(s % 60); + } + // What the ring says, from one instant: a running timer counts down in seconds, and at zero it is time's up, with + // nothing counted past it; stopped on a whole minute it is the big minutes, and stopped between minutes it keeps the + // seconds that are left. + function readout(p, now) { + if (upAt(p, now)) return { up: true }; + if (p.timer || now.left > 0 && now.left % 60) return { number: countText(now.left), unit: 'left' }; + return { number: String(Math.max(0, Math.floor(now.left / 60))), unit: 'min left' }; + } + // The readout is sized by how many characters it has, so "24:37" fits the ring's inner circle as "25" does. + function numberClass(text) { return 'ring-number' + (text.length > 2 ? ' n' + Math.min(text.length, 7) : ''); } + // The ring: the arc is what is left of the day's total seconds (allowance, earned and added). The number is the glance + // layer; what it counts is said under it in words. While a timer runs, tickRing moves both once a second. + var ringParts = null; // {arc, number, total}: the nodes a tick changes, and nothing else + var RING_C = 2 * Math.PI * 88; + function arcAt(arc, left, total) { + var share = total > 0 ? Math.max(0, Math.min(1, left / (total * 60))) : 0; + arc.setAttribute('stroke-dashoffset', (RING_C * (1 - share)).toFixed(2)); + // An empty arc would still draw its round end as a dot. + arc.setAttribute('visibility', share > 0 ? 'visible' : 'hidden'); + } + function ringNode(p, now) { + var total = p.allowance + p.earned + p.adjusted, say = readout(p, now), minutes = Math.floor(Math.max(0, now.left) / 60); + var box = node('div', 'ring'), NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('viewBox', '0 0 200 200'); svg.setAttribute('aria-hidden', 'true'); + var track = document.createElementNS(NS, 'circle'); track.setAttribute('class', 'ring-track'); track.setAttribute('cx', '100'); track.setAttribute('cy', '100'); track.setAttribute('r', '88'); svg.appendChild(track); + var arc = document.createElementNS(NS, 'circle'); arc.setAttribute('class', 'ring-arc'); arc.setAttribute('cx', '100'); arc.setAttribute('cy', '100'); arc.setAttribute('r', '88'); + arc.setAttribute('stroke-dasharray', RING_C.toFixed(2)); arc.setAttribute('transform', 'rotate(-90 100 100)'); arcAt(arc, now.left, total); + svg.appendChild(arc); + box.appendChild(svg); + var inside = node('div', 'ring-text'), number = null; + // The label is the minute, said once a minute (renderScreen redraws on it); the seconds are for the eye only. + if (say.up) { + inside.appendChild(node('p', 'ring-up', 'Time’s up')); + box.setAttribute('aria-label', 'Time’s up. No time left of ' + total + ' today'); + } else { + number = inside.appendChild(node('p', numberClass(say.number), say.number)); + inside.appendChild(node('p', 'ring-unit', say.unit)); + var spoken = minutes + (minutes === 1 ? ' minute' : ' minutes'), secs = Math.max(0, now.left) % 60; + if (!p.timer && secs) spoken += ' ' + secs + (secs === 1 ? ' second' : ' seconds'); + box.setAttribute('aria-label', spoken + ' left of ' + total + ' today'); + } + box.setAttribute('role', 'img'); + box.appendChild(inside); + ringParts = p.timer && !say.up ? { arc: arc, number: number, total: total } : null; + return box; + } + // Once a second while a timer runs: the arc's offset and the number's text, in place. No button, focus, scroll or + // typed name is touched, and nothing is announced: the ring's label moves with the minute, in renderScreen. + function tickRing(p, now) { + if (!ringParts) return; + var say = readout(p, now); + arcAt(ringParts.arc, now.left, ringParts.total); + if (!say.up && ringParts.number) { + ringParts.number.firstChild.data = say.number; + if (ringParts.number.className !== numberClass(say.number)) ringParts.number.className = numberClass(say.number); + } + } + function sumNode(p, now) { + var dl = node('dl', 'screen-sum'); + [['Daily', mins(p.allowance)], ['Earned', signed(p.earned)], ['Added or taken', signed(p.adjusted)], ['Used', usedText(now.used)]].forEach(function (row) { + var d = node('div'); d.appendChild(node('dt', '', row[0])); d.appendChild(node('dd', '', row[1])); dl.appendChild(d); + }); + return dl; + } + function recordNode(p, unlocked) { + var box = node('div', 'screen-record'), rows = []; + if (p.timer) rows.push({ running: true, from: p.timer.since }); + p.entries.forEach(function (e) { rows.push(e); }); + if (!rows.length) { box.appendChild(node('p', 'screen-empty', 'Nothing yet today')); return box; } + rows.forEach(function (e) { + var row = node('div', 'screen-row' + (e.removed ? ' removed' : '')); + row.appendChild(node('span', 'when', clockTime(houseTime(e.kind === 'timer' || e.running ? e.from : e.at)))); + var what = node('span', 'what'); what.appendChild(node('span', 'label', e.running ? 'Timer' : entryLabel(e))); + if (e.removed) what.appendChild(node('span', 'meta', 'Removed ' + clockTime(houseTime(e.removed)))); + row.appendChild(what); + row.appendChild(node('span', 'amount', e.running ? 'running' : entryAmount(e))); + if (unlocked && !e.running) row.appendChild(screenButton('quiet', e.removed ? 'Put back' : 'Remove', function () { if (e.removed) restoreEntry(p, e); else removeEntry(p, e); }, screenProblem)); + box.appendChild(row); + }); + return box; + } + // Unlocked: add or take away, with an optional reason for time earned; the allowance days start with; and off. + function manageNode(p) { + var box = node('div', 'screen-manage-panel'), off = screenProblem; + box.appendChild(segmented([[1, 'Add'], [-1, 'Take away']], manageChoice.sign, function (v) { manageChoice.sign = v; if (v < 0) manageChoice.reason = null; }, off)); + box.appendChild(segmented([[5, '5'], [10, '10'], [15, '15'], [30, '30']].map(function (c) { return [c[0], c[1] + ' min']; }), manageChoice.minutes, function (v) { manageChoice.minutes = v; }, off)); + if (manageChoice.sign > 0) box.appendChild(segmented([['chores', 'Chores'], ['reading', 'Reading'], ['helping', 'Helping'], ['other', 'Other']], manageChoice.reason, function (v) { manageChoice.reason = manageChoice.reason === v ? null : v; }, off)); + box.appendChild(screenButton('screen-go', (manageChoice.sign > 0 ? 'Add ' : 'Take away ') + mins(manageChoice.minutes), function () { adjustTime(p); }, off)); + var daily = node('div', 'screen-daily'); + daily.appendChild(node('p', '', 'Each day')); + daily.appendChild(screenButton('step', MINUS, function () { setAllowance(p, p.nextAllowance - 5); }, off || p.nextAllowance <= 0)).setAttribute('aria-label', 'Five minutes less each day'); + daily.appendChild(node('span', 'setting-value', mins(p.nextAllowance))); + daily.appendChild(screenButton('step', '+', function () { setAllowance(p, p.nextAllowance + 5); }, off || p.nextAllowance >= 240)).setAttribute('aria-label', 'Five minutes more each day'); + if (p.nextAllowance !== p.allowance) daily.appendChild(node('span', 'setting-hint', 'From tomorrow')); + box.appendChild(daily); + var offButton = screenButton('quiet screen-off', confirmOff === p.id ? 'Tap again to turn off' : 'Turn off screen time', function () { + if (confirmOff === p.id) { confirmOff = null; turnOff(p); return; } + confirmOff = p.id; renderScreen(); setTimeout(function () { if (confirmOff === p.id) { confirmOff = null; renderScreen(); } }, 6000); + }, off); + offButton.setAttribute('aria-pressed', String(confirmOff === p.id)); + box.appendChild(offButton); + return box; + } + // Unlocked with nobody on: whose it is and how long their days are. Someone turned off before comes back with their record. + function setupNode(typed) { + var box = node('div', 'screen-setup'); + box.appendChild(node('h2', '', 'Turn on screen time')); + box.appendChild(node('p', 'screen-setup-note', 'A daily allowance and a timer, kept here on the wall. It counts only while someone starts its timer; it never measures a device.')); + var before = screen ? screen.people.filter(function (p) { return !p.on; }) : []; + if (before.length) { + var again = node('div', 'screen-again'); + before.forEach(function (p) { var b = screenButton('quiet', '', function () { turnOn({ id: p.id, on: true }); }, screenProblem); b.appendChild(screenMark(p.name)); b.appendChild(node('span', '', 'Turn on for ' + p.name)); again.appendChild(b); }); + box.appendChild(again); + } + var row = node('div', 'add-row'), input = screenName = node('input'); input.id = 'screen-name'; input.type = 'text'; input.maxLength = 30; input.setAttribute('autocomplete', 'off'); input.setAttribute('autocapitalize', 'words'); input.setAttribute('aria-label', 'Their name'); input.placeholder = 'Their name'; + input.value = typed || ''; + row.appendChild(input); box.appendChild(row); + var daily = node('div', 'screen-daily'); + daily.appendChild(node('p', '', 'Each day')); + daily.appendChild(screenButton('step', MINUS, function () { setupAllowance = Math.max(0, setupAllowance - 5); renderScreen(); }, setupAllowance <= 0)).setAttribute('aria-label', 'Five minutes less each day'); + daily.appendChild(node('span', 'setting-value', mins(setupAllowance))); + daily.appendChild(screenButton('step', '+', function () { setupAllowance = Math.min(240, setupAllowance + 5); renderScreen(); }, setupAllowance >= 240)).setAttribute('aria-label', 'Five minutes more each day'); + box.appendChild(daily); + box.appendChild(screenButton('screen-go', 'Turn on', function () { + var name = input.value.replace(/^\s+|\s+$/g, ''); + if (!name) { input.focus(); return; } + turnOn({ name: name, allowance: setupAllowance }); + }, screenProblem)); + return box; + } + // Drawn whole whenever what it shows changes: a new answer, a lock, and once a minute while a timer runs (the clock's + // own once-a-second timer asks, and nothing is redrawn until the minute moves, or time runs out). Between those, the + // same tick steps the ring and its seconds in place (tickRing). Nothing here animates. + var screenShown = ''; + function renderScreen(force) { + if (mode !== 'screen') { screenShown = ''; ringParts = null; return; } + var p = screenPerson(), card = $('screen-card'), now = p ? live(p) : null; + var key = JSON.stringify([screenAt, screenProblem, !!manage, manageChoice, setupAllowance, confirmOff, now && Math.floor(now.left / 60), !!(p && p.timer && now.left <= 0), now && Math.round(now.used / 60), prefs.clock]); + $('screen-manage').textContent = manage ? 'Lock' : 'Manage'; + $('screen-manage').disabled = screenProblem && !manage; + $('screen-note').textContent = screenProblem && screen ? 'Can’t reach the frame’s server. Showing the balance from ' + since(screen.now) + '.' : 'Counts only while the timer is on'; + if (!force && key === screenShown) { tickRing(p, now); return; } + screenShown = key; ringParts = null; + var keepScroll = card.querySelector('.screen-record'), scroll = keepScroll ? keepScroll.scrollTop : 0, typed = screenName ? screenName.value : ''; + screenName = null; + card.textContent = ''; card.className = 'screen-card'; + if (!p) { if (manage) { card.className = 'screen-card hug'; card.appendChild(setupNode(typed)); } return; } + var who = node('div', 'screen-person'); + var name = node('p', 'screen-who'); name.appendChild(screenMark(p.name)); name.appendChild(node('span', '', p.name)); who.appendChild(name); + who.appendChild(ringNode(p, now)); + var up = upAt(p, now), empty = now.left <= 0 && (!p.timer || !!up); + who.appendChild(node('p', 'screen-line', up ? 'Timer stopped at ' + clockTime(houseTime(up)) : p.timer ? 'Timer on since ' + clockTime(houseTime(p.timer.since)) : empty ? 'No time left today' : '')); + var act = node('div', 'screen-actions'); + act.appendChild(p.timer && !up ? screenButton('screen-go stop', 'Stop', function () { stopTimer(p); }, screenProblem) : screenButton('screen-go', 'Start', function () { startTimer(p); }, screenProblem || empty)); + if (empty) act.appendChild(node('p', 'screen-hint', 'A grown-up can add time')); + who.appendChild(act); + card.appendChild(who); + var side = node('div', 'screen-side'); + if (manage) side.appendChild(manageNode(p)); + else side.appendChild(sumNode(p, now)); + side.appendChild(node('h2', 'eyebrow', 'Record')); + var record = recordNode(p, !!manage); side.appendChild(record); + card.appendChild(side); + record.scrollTop = scroll; fadeIfScrolls(record); + record.addEventListener('scroll', function () { fadeIfScrolls(record); }, { passive: true }); + } + function renderScreenSetting() { + var on = screen ? screen.people.filter(function (p) { return p.on; }) : [], box = $('screen-open'); + $('screen-who').textContent = !screen ? 'Not loaded yet' : on.length ? on.map(function (p) { return p.name; }).join(', ') : 'Nobody yet'; + box.textContent = ''; + if (screen) box.appendChild(screenButton('', on.length ? 'Manage' : 'Set up', openScreenPin, screenProblem)); + $('screen-chime-row').hidden = !on.length; + } + // Adding to a list at the wall, whichever kind of list it is. The dialog sits at the top of the screen so the // keyboard, which takes the bottom half of a wall display, never covers what is being typed; it stays open, // because a grocery list is rarely one thing. @@ -1371,8 +1772,10 @@ // slides up over a wall display. var pinSoFar = ''; function drawPin() { var dots = ''; for (var i = 0; i < pinSoFar.length; i++) dots += '●'; $('pin-dots').textContent = dots || ' '; } - function openManage() { + // The same keypad unlocks screen time (openScreenPin): `purpose` says which door OK knocks on. + function openKeypad(purpose) { pinSoFar = ''; drawPin(); + $('manage-eyebrow').textContent = 'Manage from a phone'; $('manage-title').textContent = 'Enter the household PIN'; $('manage-note').textContent = 'Set on your household’s setup page.'; $('keypad').hidden = false; $('pin-dots').hidden = false; $('manage-code').hidden = true; $('manage-qr').hidden = true; $('manage-typed').hidden = true; var pad = $('keypad'); pad.textContent = ''; @@ -1380,7 +1783,7 @@ var b = node('button', key === 'OK' ? 'key go' : 'key', key); b.onclick = function () { if (key === '⌫') pinSoFar = pinSoFar.slice(0, -1); - else if (key === 'OK') { askForCode(); return; } + else if (key === 'OK') { if (purpose === 'screen') unlockScreen(); else askForCode(); return; } else if (pinSoFar.length < 8) pinSoFar += key; drawPin(); }; @@ -1388,6 +1791,7 @@ }); $('manage-dialog').hidden = false; } + function openManage() { openKeypad('phone'); } function askForCode() { if (pinSoFar.length < 4) { $('manage-note').textContent = 'A PIN is at least four digits.'; return; } post('/api/owner-code', { pin: pinSoFar }, function (status, data) { @@ -1434,6 +1838,8 @@ function loadSettings() { get('/api/settings', function (data) { if (data.rest) { prefs = withDefaults(data); applyPrefs(); } }, function () {}); } // Where the frame settles when no one is using it: the week on a morning if that is set, otherwise the chosen view. function restingView() { + // A running timer holds its view, as a list holds the screen while cooking: the time left stays readable across the room. + if (timerRunning()) return 'screen'; var hour = now().getHours(), morning = prefs.mornings && hour >= 4 && hour < prefs.mornings; return prefs.rest === 'photos' && photos.length && !morning && !query.nophotos ? 'photos' : 'calendar'; } @@ -1444,6 +1850,7 @@ function toggleTask(task) { if (pending[task.id]) { if (undo && undo.task.id === task.id) cancelUndo(); return; } if (undo) commit(); + dropScreenUndo(); pending[task.id] = true; undo = { task: task, timer: setTimeout(commit, UNDO_MS) }; if (listOf(task.project).kid) cheer(); @@ -1608,7 +2015,9 @@ $('previous').onclick = function () { if (page > 0) { page--; renderStrip(); } }; $('next').onclick = pageNext; $('back-today').onclick = function () { page = 0; renderStrip(); }; - $('toast-undo').onclick = function () { if (undo) cancelUndo(); }; + $('toast-undo').onclick = function () { if (undo) cancelUndo(); else if (screenUndo) screenUndo.run(); }; + $('screen-manage').onclick = function () { if (manage) lockScreen(); else openScreenPin(); }; + $('screen-chime').onclick = testChime; $('close-dialog').onclick = closeEvent; $('close-legend').onclick = closeLegend; $('legend-dialog').onclick = function (e) { if (e.target === $('legend-dialog')) closeLegend(); }; @@ -1687,19 +2096,22 @@ var lastMinute = -1; setInterval(function () { - var t = now(), idle = Date.now() - lastTouch, busy = dialogOpen() || !!undo || installing(); - tickSky(); + var t = now(), idle = Date.now() - lastTouch, busy = dialogOpen() || !!undo || !!screenUndo || installing(); + tickSky(); watchTimeUp(); if (t.getMinutes() !== lastMinute) { lastMinute = t.getMinutes(); if (loadedDay && loadedDay !== iso(t)) loadCalendar(); // midnight: slide the whole window forward + if (screen && screen.day !== iso(t)) loadRewards(); // and a new day of screen time, which the server starts render(); } + renderScreen(); // Quiet for two minutes: a list, Settings or a paged-ahead week goes back to this week. Quiet for the time set in // Settings: the resting view (photos, or the week on a morning), which also follows the morning hours as they pass. // An open day or event used to suspend every timer, so the frame could sit on a dialog all night. - if (idle > HOME_AFTER_MS && dialogOpen()) { closeEvent(); closeDay(); closeManage(); closeAdd(); closeLegend(); busy = !!undo || installing(); } - var onList = mode.indexOf('list:') === 0, hold = onList ? LIST_AFTER_MS : HOME_AFTER_MS; - if (!busy && idle > hold && mode !== 'photos' && (mode !== 'calendar' || page !== 0)) { page = 0; setMode('calendar'); } + if (idle > HOME_AFTER_MS && dialogOpen()) { closeEvent(); closeDay(); closeManage(); closeAdd(); closeLegend(); lockScreen(); busy = !!undo || !!screenUndo || installing(); } + // While a timer runs, "home" is screen time rather than this week. + var onList = mode.indexOf('list:') === 0, hold = onList ? LIST_AFTER_MS : HOME_AFTER_MS, home = timerRunning() ? 'screen' : 'calendar'; + if (!busy && idle > hold && mode !== 'photos' && (home === 'screen' ? mode !== 'screen' : mode !== 'calendar' || page !== 0)) { page = 0; setMode(home); } if (!busy && idle > Math.max(prefs.restAfter * 60000, onList ? LIST_AFTER_MS : 0) && (mode !== restingView() || page !== 0)) { page = 0; setMode(restingView()); } // Photo mode goes bare a few seconds after the last touch, however it was reached: nobody wants a shrunken photo // with a dock under it, and the dock is only there to leave by. A touch brings it back. Night: dim rather than @@ -1717,8 +2129,8 @@ if (query.tab) { mode = 'list:' + query.tab; $('view-calendar').hidden = true; $('view-list').hidden = false; } // Layout is measured to fit whole items, so measure again once the web fonts have arrived. if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { render(); }); - renderSettings(); render(); loadHousehold(); loadSettings(); loadCalendar(); loadTasks(); loadWeather(); loadPhotos(); loadUpdates(); pollInstall(); - setInterval(loadUpdates, 600000); setInterval(loadHousehold, 600000); setInterval(loadCalendar, 60000); setInterval(loadTasks, 60000); setInterval(loadWeather, 900000); setInterval(loadPhotos, 60000); setInterval(loadSettings, 60000); - window.addEventListener('online', function () { loadCalendar(); loadTasks(); loadWeather(); }); + renderSettings(); render(); loadHousehold(); loadSettings(); loadCalendar(); loadTasks(); loadWeather(); loadPhotos(); loadUpdates(); loadRewards(); pollInstall(); + setInterval(loadUpdates, 600000); setInterval(loadHousehold, 600000); setInterval(loadCalendar, 60000); setInterval(loadTasks, 60000); setInterval(loadWeather, 900000); setInterval(loadPhotos, 60000); setInterval(loadSettings, 60000); setInterval(loadRewards, 60000); + window.addEventListener('online', function () { loadCalendar(); loadTasks(); loadWeather(); loadRewards(); }); document.addEventListener('visibilitychange', function () { if (!document.hidden) { loadCalendar(); loadTasks(); } }); })(); diff --git a/dist/index.html b/dist/index.html index 817826d..44303e4 100644 --- a/dist/index.html +++ b/dist/index.html @@ -70,6 +70,11 @@

+ + +
+

Screen time

+

Who has it

+ +

A daily allowance and a timer, kept on the wall. Turning it on and changing time take the household PIN.

+

Appearance

Calendar

diff --git a/dist/style.css b/dist/style.css index 2d18f4d..181d5e9 100644 --- a/dist/style.css +++ b/dist/style.css @@ -569,6 +569,78 @@ button.item:active{opacity:.6} .dialog.manage .qr-plate:not([hidden])+.pair-code{font-size:3rem;margin:1rem 0 .3rem} .dialog .wide{margin-top:2.25rem;height:4rem;font-size:1.5rem} +/* ---------- Screen time: a ring for what is left today ---------- + One card like the lists and Settings. Left, centred: the person, the ring and the one control a passing child needs. + Right, read up close: the day's sum and its record, or, unlocked behind the PIN, the grown-up's controls. The ring is the + glance layer (Bricolage, the clock's scale); every row and button is Inter. While a timer runs app.js steps the ring + and its seconds once a second, as the clock steps; nothing here animates. */ +.screen-card{flex:1;min-height:0;display:flex;background:var(--card);border-radius:1.5rem;box-shadow:0 .1rem .2rem var(--shade);padding:2.5rem 2.75rem} +.screen-card.hug{justify-content:center} +.screen-person{flex:0 0 31rem;display:flex;flex-direction:column;align-items:center;margin-right:3.75rem} +.screen-who{display:flex;align-items:center;font:600 calc(1.9rem*var(--ts))/1.2 var(--text)} +.screen-who .avatar{width:2.8rem;height:2.8rem;font-size:1.5rem;margin-right:.9rem} +.ring{position:relative;flex:none;width:30rem;height:30rem;margin-top:1.75rem} +.ring svg{width:100%;height:100%} +.ring-track{fill:none;stroke:var(--line);stroke-width:14} +.ring-arc{fill:none;stroke:var(--accent);stroke-width:14;stroke-linecap:round} +.ring-text{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center} +.ring-number{font:500 10.5rem/1 var(--display);letter-spacing:-.045em;font-variation-settings:"opsz" 96;font-variant-numeric:tabular-nums;margin-top:-.75rem} +/* A timer's seconds ("0:42", "24:37", "1:04:37") at the size that keeps each inside the ring's inner circle with 1.5rem + clear (measured in Chrome at 1920 × 1080); the weight and optical size stay the clock's. */ +.ring-number.n4{font-size:10rem} +.ring-number.n5{font-size:8.2rem} +.ring-number.n7{font-size:6.4rem} +.ring-unit{font:500 calc(1.8rem*var(--ts))/1.2 var(--text);color:var(--ink-2);margin-top:.5rem} +.ring-up{font:600 4.4rem/1.05 var(--display);letter-spacing:-.025em;font-variation-settings:"opsz" 72} +.screen-line{min-height:2.2rem;margin-top:1.4rem;font:500 calc(1.6rem*var(--ts))/1.35 var(--text);color:var(--ink-2);font-variant-numeric:tabular-nums;text-align:center} +.screen-actions{display:flex;flex-direction:column;align-items:center;margin-top:1.2rem} +.screen-go{height:5rem;min-width:20rem;padding:0 2.5rem;border-radius:2.5rem;background:var(--accent);color:var(--on-accent);font:600 1.9rem/1 var(--text);text-align:center} +.screen-go.stop{background:var(--ink);color:var(--surface)} +.screen-go:disabled{background:var(--press);color:var(--ink-2)} +.screen-go:active:not(:disabled){opacity:.85} +.screen-hint{margin-top:.8rem;font-size:calc(1.4rem*var(--ts));color:var(--ink-2)} +.screen-side{flex:1;min-width:0;display:flex;flex-direction:column} +.screen-side .eyebrow{flex:none;margin-bottom:.4rem} +.screen-sum{flex:none;margin:0 0 2.25rem} +.screen-sum div{display:flex;align-items:baseline;padding:.85rem 0;border-bottom:.0625rem solid var(--line)} +.screen-sum dt{flex:1;font-size:calc(1.7rem*var(--ts));font-weight:500} +.screen-sum dd{margin:0;font-size:calc(1.7rem*var(--ts));font-variant-numeric:tabular-nums} +.screen-record{flex:1;min-height:0;overflow-y:auto;scrollbar-width:none} +.screen-record::-webkit-scrollbar{display:none} +.screen-record.has-more{-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 4.5rem),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 4.5rem),transparent)} +.screen-row{display:flex;align-items:center;min-height:calc(3.9rem*var(--ts));padding:.35rem 0;border-bottom:.0625rem solid var(--line);font-size:calc(1.5rem*var(--ts))} +.screen-row:last-child{border-bottom:0} +.screen-row .when{flex:0 0 8.5rem;color:var(--ink-2);font-variant-numeric:tabular-nums} +.screen-row .what{flex:1;min-width:0} +.screen-row .meta{display:block;font-size:calc(1.25rem*var(--ts));color:var(--ink-2)} +.screen-row .amount{flex:none;margin-left:1rem;font-weight:600;font-variant-numeric:tabular-nums} +.screen-row.removed .label,.screen-row.removed .amount{text-decoration:line-through;color:var(--ink-2);font-weight:400} +.screen-row .quiet{flex:none;margin:0 -1.25rem 0 .5rem} +.screen-empty{padding:.9rem 0;font-size:calc(1.5rem*var(--ts));color:var(--ink-2)} +/* Unlocked. The segments are Settings' own, sharing the column's width. */ +.screen-manage-panel{flex:none;margin-bottom:1.75rem} +.screen-manage-panel .choices{margin-bottom:.8rem} +.screen-manage-panel .choices button{flex:1;min-width:0;padding:0 .6rem} +.screen-manage-panel .screen-go{display:block;width:100%;margin-top:.2rem} +.screen-daily{display:flex;align-items:center;margin-top:1.2rem} +.screen-daily p{flex:1;font-size:calc(1.6rem*var(--ts));font-weight:500} +.step{flex:none;width:3.5rem;height:3.5rem;border-radius:50%;background:var(--press);text-align:center;font:500 2rem/1 var(--text)} +.step:active:not(:disabled){background:var(--line)} +.step:disabled{opacity:.4} +.screen-daily .setting-value{min-width:7.5rem;text-align:center} +.screen-daily .setting-hint{margin-left:1rem} +.screen-off{display:block;margin:.9rem 0 0 -1.25rem} +.screen-off[aria-pressed=true]{color:var(--bad)} +/* Turning it on: whose it is and how long their days are, near the top so the keyboard never covers the name. */ +.screen-setup{width:46rem;align-self:flex-start;margin-top:.5rem} +.screen-setup h2{font:600 3rem/1.1 var(--display);letter-spacing:-.02em;font-variation-settings:"opsz" 40} +.screen-setup-note{margin-top:.9rem;font-size:calc(1.6rem*var(--ts));line-height:1.4;color:var(--ink-2)} +.screen-again{display:flex;flex-wrap:wrap;margin-top:1.5rem} +.screen-again .quiet{display:flex;align-items:center;background:var(--press);color:var(--ink);margin:0 .8rem .8rem 0} +.screen-again .avatar{margin-right:.7rem} +.screen-setup .add-row{margin-top:1.75rem} +.screen-setup .screen-go{display:block;width:100%;margin-top:1.6rem} + /* Photos is a card like the week and the lists: the photo whole, over a blurred copy of itself. */ .photo-card{flex:1;min-height:0;position:relative;overflow:hidden;border-radius:1.5rem;background:#000;box-shadow:0 .1rem .2rem var(--shade);cursor:pointer} /* By day the fill is the photo's own colours, a little dimmed so the photo stays the brightest thing in the card; at .55 a diff --git a/dist/tokens.css b/dist/tokens.css index 12c8484..7c23941 100644 --- a/dist/tokens.css +++ b/dist/tokens.css @@ -41,8 +41,8 @@ } /* Light. --selected, --shade, --tint, --star, --rain-ink (the rain chance as text) and --mono-ink (the letter of a - monogram with no colour of its own, on --ink-2) are the wall's; --bad and --good are the phone's (the wall has no - error colour). The phone's card shadow is its own, rgba(16,38,63,.08) in setup.css, not --shade. */ + monogram with no colour of its own, on --ink-2) are the wall's; --good is the phone's; --bad is the phone's errors and, + on the wall, only screen time's "Tap again to turn off", always in words. The phone's card shadow is its own, rgba(16,38,63,.08) in setup.css, not --shade. */ :root,.theme-light{ --surface:var(--mist-100);--card:var(--mist-0);--ink:var(--navy-800);--ink-2:var(--navy-600);--line:var(--mist-300); --accent:var(--blue-600);--on-accent:var(--mist-0);--press:var(--mist-200); diff --git a/households.js b/households.js index 586babe..a3f2838 100644 --- a/households.js +++ b/households.js @@ -1,6 +1,6 @@ // One deployment serves several households. Each is a folder under data/households// holding everything that is // theirs: sources.json (calendars, lists, album, people, place), credentials.json (their Google and Todoist -// authorizations), settings.json, photos/ and cache/. Nothing is shared between folders, so a bug that mixes +// authorizations), settings.json, rewards.json (screen time, once someone turns it on), photos/ and cache/. Nothing is shared between folders, so a bug that mixes // households has to cross a directory boundary to do it. const fs = require('node:fs'); const path = require('node:path'); @@ -17,6 +17,7 @@ const {createMicrosoft} = require('./microsoft'); const {createCalDav} = require('./caldav'); const {createHomeAssistant} = require('./homeassistant'); const {createGoogleTasks} = require('./googletasks'); +const {createRewards} = require('./rewards'); const validId = id => /^[a-z0-9][a-z0-9-]{0,39}$/.test(id || ''); @@ -91,6 +92,8 @@ function createHouseholds({root = path.join(__dirname, 'data/households'), log = }, calendar: service.calendar, tasks: service.tasks, closeTask: service.closeTask, addTask: service.addTask, lists: local, settings, + // Screen time (rewards.js), in the household's own time zone. No file until someone turns it on. + rewards: createRewards({file: path.join(dir, 'rewards.json'), zone: () => household.place().timezone}), // The album's photos and the ones added from a phone, as one set. Added ones come first: whoever just sent // a picture is standing there waiting to see it. photoManifest() { diff --git a/package.json b/package.json index 803940f..fc2f656 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "gingham", - "version": "0.2.1", + "version": "0.3.0", "private": true, "license": "AGPL-3.0-only", "description": "A family wall display for any Android tablet or photo frame: calendar, lists, weather and photos, self-hosted.", "scripts": { "start": "node server.js", - "check": "node --check server.js && node --check integrations.js && node --check dist/app.js && node --check api-client.js && node --check album.js && node --check households.js && node --check grants.js && node --check admin.js && node --check zone.js && node --check ics.js && node --check safe-fetch.js && node --check settings.js && node --check vault.js && node --check setup.js && node --check lists.js && node --check mdns.js && node --check folder.js && node --check countdowns.js && node --check microsoft.js && node --check caldav.js && node --check homeassistant.js && node --check googletasks.js && node --check uploads.js && node --check admin-api.js && node --check dist/setup-page.js && node --check dist/lists-page.js && node --check dist/admin-page.js && node --check dist/sw.js && node --check dist/photos-page.js && node --check dist/qr.js && node --check updates.js", + "check": "node --check server.js && node --check integrations.js && node --check dist/app.js && node --check api-client.js && node --check album.js && node --check households.js && node --check grants.js && node --check admin.js && node --check zone.js && node --check ics.js && node --check safe-fetch.js && node --check settings.js && node --check rewards.js && node --check vault.js && node --check setup.js && node --check lists.js && node --check mdns.js && node --check folder.js && node --check countdowns.js && node --check microsoft.js && node --check caldav.js && node --check homeassistant.js && node --check googletasks.js && node --check uploads.js && node --check admin-api.js && node --check dist/setup-page.js && node --check dist/lists-page.js && node --check dist/admin-page.js && node --check dist/sw.js && node --check dist/photos-page.js && node --check dist/qr.js && node --check updates.js", "test": "node --test test/*.test.js" } } diff --git a/releases/v0.3.0.md b/releases/v0.3.0.md new file mode 100644 index 0000000..7d8fd02 --- /dev/null +++ b/releases/v0.3.0.md @@ -0,0 +1,9 @@ +- Screen time: a child's minutes for the day in a ring on the wall, counting down in minutes and seconds. +- A timer anyone can start and stop. It stops by itself when the time runs out, and the frame plays a short chime. +- Adding or taking away time, and changing the daily allowance, take the household PIN. +- Every change goes in the day's record, and starting or stopping the timer can be undone for a few seconds. + +Screen time is off until a grown-up turns it on in Settings → Screen time, which needs a household PIN set on the +setup page first. It keeps time only while its timer runs; it never measures a device. On a frame that runs the app, +install this one from Settings → Updates: it updates in place and keeps the frame paired. Calendars, lists, photos and +settings from 0.2.1 carry over as they are. diff --git a/rewards.js b/rewards.js new file mode 100644 index 0000000..945f0a1 --- /dev/null +++ b/rewards.js @@ -0,0 +1,291 @@ +// Screen time: a person's minutes for today, kept on the server (rewards.json in the household's folder) so a reload, a +// restart or a power cut loses nothing. It is a record, not a monitor: it never measures a device. The timer counts only +// while someone has started it, and every stop is written into the day's record. +// +// Each day starts from that person's daily allowance; time a grown-up adds (earned, or simply added) and takes away is an +// entry, and so is each stop of the timer. What is left is always worked out from the day's entries, never stored, so the +// record and the number on the wall cannot disagree. Nothing carries over from one day to the next. +// +// Time is the server's, in the household's own zone (zone.js): the frame has no clock battery and has booted into 2009, +// so a timer's start, how long it ran and where a day ends are never asked of the frame. A timer stops by itself when +// the time left runs out, charged exactly to zero; more time is a grown-up's to add, and then someone starts it again. A +// timer still running at midnight is stopped at midnight and charged to the day it ran in. Both are worked out on the +// next read from any frame, so it holds with every frame asleep, off or reloading. +// +// `unit` is stored so that a later reward that is not minutes can be added without moving anyone's history. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const {dayIn, midnightIn} = require('./zone'); + +const UNIT = 'min'; +const KEEP_DAYS = 60; // about two months of records; the wall shows today +const MAX_PEOPLE = 8, MAX_ENTRIES = 300; // per household, per person per day +const ALLOWANCE = {min: 0, max: 240, step: 5, fallback: 30}; +const CHANGE_MAX = 240; // the most added or taken away in one entry +const REASONS = ['chores', 'reading', 'helping', 'other']; +const UNDO_MS = 15000; // a timer's start or stop can be taken back this long, without the PIN +const fail = (status, message) => Object.assign(Error(message), {status}); +const text = (value, max) => String(value == null ? '' : value).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max); +const nextDay = day => new Date(Date.parse(day + 'T12:00:00Z') + 86400000).toISOString().slice(0, 10); +const validAllowance = n => Number.isInteger(n) && n >= ALLOWANCE.min && n <= ALLOWANCE.max && n % ALLOWANCE.step === 0; +const newId = () => crypto.randomBytes(6).toString('hex'); + +// What a file says, kept only where it makes sense: anything else is dropped rather than trusted. +function cleanEntry(e) { + if (!e || typeof e !== 'object' || typeof e.id !== 'string' || !Number.isFinite(Date.parse(e.at))) return null; + const out = {id: e.id.slice(0, 20), at: new Date(e.at).toISOString(), kind: e.kind, by: typeof e.by === 'string' ? e.by.slice(0, 40) : ''}; + if (e.kind === 'earn' || e.kind === 'adjust') { + if (!Number.isInteger(e.minutes) || !e.minutes || Math.abs(e.minutes) > CHANGE_MAX) return null; + out.minutes = e.minutes; + if (REASONS.includes(e.reason)) out.reason = e.reason; + } else if (e.kind === 'timer') { + if (!Number.isInteger(e.seconds) || e.seconds < 0 || !Number.isFinite(Date.parse(e.from))) return null; + out.seconds = e.seconds; out.from = new Date(e.from).toISOString(); + } else if (e.kind === 'remove') { + if (typeof e.target !== 'string') return null; + out.target = e.target.slice(0, 20); + } else return null; + return out; +} +function cleanDay(d, fallback) { + const out = {allowance: validAllowance(d && d.allowance) ? d.allowance : fallback, entries: [], timer: null}; + if (d && Array.isArray(d.entries)) out.entries = d.entries.map(cleanEntry).filter(Boolean).slice(-MAX_ENTRIES); + if (d && d.timer && Number.isFinite(Date.parse(d.timer.since))) out.timer = {since: new Date(d.timer.since).toISOString(), by: typeof d.timer.by === 'string' ? d.timer.by.slice(0, 40) : ''}; + return out; +} +function cleanFile(input) { + const out = {version: 1, unit: UNIT, people: []}; + for (const p of input && Array.isArray(input.people) ? input.people.slice(0, MAX_PEOPLE) : []) { + if (!p || typeof p.id !== 'string' || !/^[a-z0-9]{1,20}$/.test(p.id)) continue; + const name = text(p.name, 30); + if (!name) continue; + const allowance = validAllowance(p.allowance) ? p.allowance : ALLOWANCE.fallback, days = {}; + for (const [day, d] of Object.entries(p.days && typeof p.days === 'object' ? p.days : {})) if (/^\d{4}-\d\d-\d\d$/.test(day)) days[day] = cleanDay(d, allowance); + out.people.push({id: p.id, name, on: p.on === true, allowance, days}); + } + return out; +} + +// Which entries still count: a removal cancels its target, unless the removal was itself removed (an undo). +function standing(entries) { + const byTarget = {}; + for (const e of entries) if (e.kind === 'remove') (byTarget[e.target] = byTarget[e.target] || []).push(e); + const memo = {}; + const counts = e => { if (e.id in memo) return memo[e.id]; memo[e.id] = true; memo[e.id] = !(byTarget[e.id] || []).some(counts); return memo[e.id]; }; + return e => counts(e); +} +// The day's entries that count, added up: minutes given, and seconds used by timers that have stopped. +function totals(day) { + const counts = standing(day.entries); + let earned = 0, adjusted = 0, used = 0; + for (const e of day.entries) { + if (!counts(e)) continue; + if (e.kind === 'earn') earned += e.minutes; + else if (e.kind === 'adjust') adjusted += e.minutes; + else if (e.kind === 'timer') used += e.seconds; + } + return {earned, adjusted, used, total: (day.allowance + earned + adjusted) * 60}; +} +// The instant a running timer reaches zero: its start, plus what was left when it started. +function timeUpAt(day) { const t = totals(day); return Date.parse(day.timer.since) + Math.max(0, t.total - t.used) * 1000; } +// The day as the wall shows it, at instant `at`. Seconds, so a timer is charged exactly; the wall rounds for people. A +// running timer never counts past what was left, even in the moment before settle closes it. +function summary(day, at) { + const t = totals(day); + const running = day.timer ? Math.min(Math.max(0, t.total - t.used), Math.max(0, Math.floor((at - Date.parse(day.timer.since)) / 1000))) : 0; + const used = t.used + running; + return {allowance: day.allowance, earned: t.earned, adjusted: t.adjusted, used, left: t.total - used}; +} + +// `zone()` is the household's time zone, asked every time, since its owner may change it. `persist: false` keeps +// everything in memory (design review), starting from `initial`. +function createRewards({file, persist = true, initial = null, now = () => Date.now(), zone = () => 'UTC'} = {}) { + let memory = cleanFile(initial); + function raw() { + if (!persist) return JSON.parse(JSON.stringify(memory)); + try { return cleanFile(JSON.parse(fs.readFileSync(file, 'utf8'))); } + catch (e) { if (e.code !== 'ENOENT') throw fail(503, 'Can’t read the screen time record right now.'); return cleanFile(null); } + } + function write(value) { + if (!persist) { memory = value; return; } + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file + '.tmp', JSON.stringify(value, null, 2) + '\n', {mode: 0o600}); + fs.renameSync(file + '.tmp', file); + } + // A timer stops where its time ran out ("time-up", charged exactly to zero) or where its day ended ("midnight"), + // whichever came first, and is charged to the day it ran in. Old days go. Says whether anything changed, so a plain + // read writes nothing on an ordinary minute. After a change, `notBefore` is that change's instant: time taken away + // from a running timer can put its end in the past, and it then stops now, charged every second it ran. + function settle(all, at, notBefore = 0) { + const tz = zone(), oldest = dayIn(tz, at - KEEP_DAYS * 86400000); + let changed = false; + for (const p of all.people) for (const day of Object.keys(p.days)) { + const d = p.days[day]; + if (day < oldest) { delete p.days[day]; changed = true; continue; } + if (!d.timer) continue; + const up = timeUpAt(d), midnight = Date.parse(midnightIn(tz, nextDay(day))), end = Math.max(notBefore, Math.min(up, midnight)); + if (end > at) continue; + const since = Date.parse(d.timer.since); + d.entries.push({id: newId(), at: new Date(end).toISOString(), kind: 'timer', seconds: Math.max(0, Math.floor((end - since) / 1000)), from: d.timer.since, by: up <= midnight ? 'time-up' : 'midnight'}); + d.timer = null; changed = true; + } + return changed; + } + const todayOf = (p, at) => { const day = dayIn(zone(), at); if (!p.days[day]) p.days[day] = {allowance: p.allowance, entries: [], timer: null}; return p.days[day]; }; + function personIn(all, id) { const p = all.people.find(x => x.id === id); if (!p) throw fail(404, 'That person has no screen time here.'); return p; } + function view(all, at) { + const day = dayIn(zone(), at); + return {unit: all.unit, day, now: at, people: all.people.map(p => { + const d = p.days[day] || {allowance: p.allowance, entries: [], timer: null}, s = summary(d, at), counts = standing(d.entries); + const removedAt = {}; + for (const e of d.entries) if (e.kind === 'remove' && counts(e)) removedAt[e.target] = Date.parse(e.at); + const entries = d.entries.filter(e => e.kind !== 'remove').map(e => ({id: e.id, at: Date.parse(e.at), kind: e.kind, + ...(e.kind === 'timer' ? {seconds: e.seconds, from: Date.parse(e.from)} : {minutes: e.minutes}), ...(e.by === 'time-up' ? {timeUp: true} : {}), ...(e.reason ? {reason: e.reason} : {}), + ...(e.id in removedAt ? {removed: removedAt[e.id]} : {})})).reverse(); + return {id: p.id, name: p.name, on: p.on, nextAllowance: p.allowance, ...s, timer: d.timer ? {since: Date.parse(d.timer.since)} : null, entries}; + })}; + } + function read() { + const at = now(), all = raw(); + if (settle(all, at)) write(all); + return view(all, at); + } + // Every change: read, settle, change, write whole, answer with the day as it now is. `result` is what the change made, + // for the wall's Undo. + function change(fn) { + const at = now(), all = raw(); + settle(all, at); + const result = fn(all, at); + settle(all, at, at); + write(all); + return {...view(all, at), ...(result ? {changed: result} : {})}; + } + function push(d, entry) { if (d.entries.length >= MAX_ENTRIES) throw fail(409, 'Today’s record is full.'); d.entries.push(entry); return entry.id; } + + return { + read, + // Anyone at the frame may start or stop the timer; the record shows every stop. + start: (id, by) => change((all, at) => { + const p = personIn(all, id), d = todayOf(p, at); + if (!p.on) throw fail(404, 'That person has no screen time here.'); + if (d.timer) return null; + if (summary(d, at).left <= 0) throw fail(409, 'No time left today. A grown-up can add time.'); + d.timer = {since: new Date(at).toISOString(), by: String(by || '')}; + return {kind: 'start'}; + }), + stop: (id, by) => change((all, at) => { + const p = personIn(all, id), d = todayOf(p, at); + if (!d.timer) return null; + const since = Date.parse(d.timer.since); + const entry = push(d, {id: newId(), at: new Date(at).toISOString(), kind: 'timer', seconds: Math.max(0, Math.floor((at - since) / 1000)), from: d.timer.since, by: String(by || '')}); + d.timer = null; + return {kind: 'stop', entry}; + }), + // Undo of a start, for a few seconds: as if it never ran. Later, it is a stop like any other. + cancelStart: id => change((all, at) => { + const p = personIn(all, id), d = todayOf(p, at); + if (!d.timer || at - Date.parse(d.timer.since) > UNDO_MS) throw fail(409, 'That can’t be undone now. Stop the timer instead.'); + d.timer = null; + return {kind: 'cancel'}; + }), + // Undo of a stop, for a few seconds: the timer carries on from when it started, so every second is still charged. A + // timer that time's up stopped, or that would have run out since its stop, stays stopped. + resume: (id, entryId) => change((all, at) => { + const p = personIn(all, id), d = todayOf(p, at), last = d.entries[d.entries.length - 1]; + if (d.timer || !last || last.id !== entryId || last.kind !== 'timer' || at - Date.parse(last.at) > UNDO_MS) throw fail(409, 'That can’t be undone now.'); + const upNow = 'Time’s up. A grown-up can add time.'; + if (last.by === 'time-up') throw fail(409, upNow); + d.entries.pop(); + d.timer = {since: last.from, by: last.by}; + if (timeUpAt(d) <= at) throw fail(409, upNow); + return {kind: 'resume'}; + }), + // A grown-up's change, behind the PIN (server.js checks the manage token): time added, earned (added with a reason) + // or taken away. + adjust: (id, minutes, reason, by) => change((all, at) => { + if (!Number.isInteger(minutes) || !minutes || Math.abs(minutes) > CHANGE_MAX) throw fail(400, 'Add or take away 1 to ' + CHANGE_MAX + ' minutes.'); + if (reason != null && !(minutes > 0 && REASONS.includes(reason))) throw fail(400, 'A reason goes with time added: ' + REASONS.join(', ') + '.'); + const p = personIn(all, id), d = todayOf(p, at); + const entry = push(d, {id: newId(), at: new Date(at).toISOString(), kind: reason ? 'earn' : 'adjust', minutes, ...(reason ? {reason} : {}), by: String(by || '')}); + return {kind: 'adjust', entry}; + }), + // Any of today's entries, or a removal (which puts its entry back). The removal is an entry of its own. + remove: (id, target, by) => change((all, at) => { + const p = personIn(all, id), d = todayOf(p, at), found = d.entries.find(e => e.id === target); + if (!found) throw fail(404, 'That is not in today’s record.'); + const counts = standing(d.entries); + if (!counts(found)) throw fail(409, 'That is already removed.'); + const entry = push(d, {id: newId(), at: new Date(at).toISOString(), kind: 'remove', target, by: String(by || '')}); + return {kind: 'remove', entry}; + }), + // The undo of a removal: the removal is removed, so its entry counts again. Found by the entry it removed. + restore: (id, target, by) => change((all, at) => { + const p = personIn(all, id), d = todayOf(p, at), counts = standing(d.entries); + const removal = d.entries.filter(e => e.kind === 'remove' && e.target === target && counts(e)).pop(); + if (!removal) throw fail(409, 'That is not removed.'); + push(d, {id: newId(), at: new Date(at).toISOString(), kind: 'remove', target: removal.id, by: String(by || '')}); + return {kind: 'restore'}; + }), + // Someone new, or a change to someone: on or off, their name, and the allowance their days start with. A new allowance + // starts tomorrow: today keeps the one it started with (and changes through an entry). Turning someone off stops + // their timer first, so nothing runs where nobody can see it. Their record stays. + person: ({id, name, on, allowance}, by) => change((all, at) => { + if (allowance !== undefined && !validAllowance(allowance)) throw fail(400, 'A daily allowance is 0 to ' + ALLOWANCE.max + ' minutes, in steps of ' + ALLOWANCE.step + '.'); + if (on !== undefined && typeof on !== 'boolean') throw fail(400, 'Send on as true or false.'); + let p; + if (id === undefined) { + const named = text(name, 30); + if (!named) throw fail(400, 'Give them a name.'); + if (all.people.length >= MAX_PEOPLE) throw fail(409, 'Screen time is kept for at most ' + MAX_PEOPLE + ' people.'); + p = {id: newId(), name: named, on: on !== false, allowance: allowance === undefined ? ALLOWANCE.fallback : allowance, days: {}}; + all.people.push(p); + return {kind: 'person', id: p.id}; + } + p = personIn(all, String(id)); + const d = todayOf(p, at); + if (name !== undefined) { const named = text(name, 30); if (!named) throw fail(400, 'Give them a name.'); p.name = named; } + if (allowance !== undefined) p.allowance = allowance; + if (on === false && d.timer) { + push(d, {id: newId(), at: new Date(at).toISOString(), kind: 'timer', seconds: Math.max(0, Math.floor((at - Date.parse(d.timer.since)) / 1000)), from: d.timer.since, by: String(by || '')}); + d.timer = null; + } + if (on !== undefined) p.on = on; + return {kind: 'person', id: p.id}; + }) + }; +} + +// Who may change time, held in this server's memory only: a long random token handed to a frame for the household's PIN, +// bound to that frame and household, forgotten after two minutes without use, and gone at Lock or a restart. Only its +// hash is kept, so not even a dump of this process's memory poses as a frame that is unlocked. +const IDLE_MS = 120000, MAX_TOKENS = 500; +function createManageTokens({now = () => Date.now()} = {}) { + const held = new Map(); + const hash = token => crypto.createHash('sha256').update(String(token)).digest('hex'); + const prune = at => { for (const [k, t] of held) if (at - t.used > IDLE_MS) held.delete(k); }; + return { + IDLE_MS, + issue(household, grant) { + const at = now(); prune(at); + if (held.size >= MAX_TOKENS) held.delete(held.keys().next().value); + const token = crypto.randomBytes(32).toString('base64url'); + held.set(hash(token), {household, grant, used: at}); + return token; + }, + // True for a token this server gave this frame of this household, used within the last two minutes; a use renews it. + check(token, household, grant) { + if (typeof token !== 'string' || token.length < 20 || token.length > 100) return false; + const at = now(), key = hash(token), t = held.get(key); + if (!t) return false; + if (at - t.used > IDLE_MS) { held.delete(key); return false; } + if (t.household !== household || t.grant !== grant) return false; + t.used = at; + return true; + }, + revoke(token) { if (typeof token === 'string') held.delete(hash(token)); }, + size: () => held.size + }; +} + +module.exports = {createRewards, createManageTokens, summary, standing, cleanFile, REASONS, ALLOWANCE, UNDO_MS, KEEP_DAYS, UNIT}; diff --git a/server.js b/server.js index 6f95c58..ba9b9c4 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,8 @@ const {createHouseholds} = require('./households'); const {createGrants, mayManage} = require('./grants'); const {createSetup} = require('./setup'); const {createSettings} = require('./settings'); +const {createRewards, createManageTokens} = require('./rewards'); +const {checkPin} = require('./vault'); const {dayIn} = require('./zone'); const {upcoming} = require('./countdowns'); const http = require('node:http'); @@ -22,6 +24,13 @@ const fixture = process.env.FRAME_FIXTURE ? require('./test/fixtures')[process.e if (process.env.FRAME_FIXTURE && !fixture) throw Error('Unknown fixture ' + process.env.FRAME_FIXTURE); // FRAME_FIXTURE_PLAY=https://example.com/ gives the fixture household a play page, to try the long press on the clock. const fixtureSettings = createSettings({persist: false, initial: process.env.FRAME_FIXTURE_PLAY ? {playPage: {url: process.env.FRAME_FIXTURE_PLAY}} : {}}); +// The fixture's screen time is kept in memory and runs on the fixture's own clock (its `rewards.clock`, 5:40 PM on the +// fixtures' Wednesday unless it says), so every state can be photographed the same way each time. Its household PIN is +// whatever FRAME_FIXTURE_PIN_HASH says (vault.js hashPin's form): test/shots.cjs makes one up for each run, so no PIN is +// ever written in a file. Without it, the fixture is a household that has not set a PIN. A fixture whose rewards are +// `still` stops its clock there, so a photograph of a running timer shows the very second the fixture names. +const fixtureClock = (base => { const started = Date.now(); return fixture && fixture.rewards && fixture.rewards.still ? () => base : () => base + Date.now() - started; })(Date.parse((fixture && fixture.rewards && fixture.rewards.clock) || '2026-09-23T17:40:00-05:00')); +const fixtureRewards = fixture ? createRewards({persist: false, initial: fixture.rewards ? fixture.rewards.data : null, now: fixtureClock, zone: () => fixturePlace.timezone}) : null; // Whether a newer Gingham is out (updates.js): asked of GitHub once shortly after start and then once a day, for the // whole server, unless Settings or GINGHAM_UPDATE_CHECK=off says not to. Design review never asks GitHub; it is given a @@ -72,6 +81,21 @@ const onFly = !!process.env.FLY_APP_NAME, trustProxy = process.env.FRAME_TRUST_P const clientAddress = req => String((onFly && req.headers['fly-client-ip']) || (trustProxy && String(req.headers['x-forwarded-for'] || '').split(',').pop().trim()) || req.socket.remoteAddress || '').replace(/^::ffff:/, ''); const spent = new Map(), pinLocks = new Map(); +// Wrong PINs are counted per household, whichever door they were tried at (a code for a phone, or unlocking screen time): +// five, then a wait of fifteen minutes that doubles each time, up to a day. One count, so two doors never double the guesses. +function pinWait(id) { + const lock = pinLocks.get(id); + if (!lock || Date.now() >= lock.until) return ''; + const wait = Math.ceil((lock.until - Date.now()) / 60000); + return 'Too many wrong PINs. Try again in ' + wait + (wait === 1 ? ' minute.' : ' minutes.'); +} +function pinWrong(id) { + const lock = pinLocks.get(id) || {fails: 0, until: 0, locks: 0}; + lock.fails++; + if (lock.fails >= 5) { lock.until = Date.now() + Math.min(24 * 3600000, 15 * 60000 * 2 ** lock.locks); lock.locks++; lock.fails = 0; } + pinLocks.set(id, lock); +} +const manageTokens = createManageTokens(); const hasOwner = household => grants.list().some(g => g.household === household && g.scope === 'owner' && !g.revokedAt); // One household's connection is one IPv6 /64 (a phone picks a new address in it every day), so that is one ration. const rationKey = address => { if (!net.isIPv6(address)) return address; const g = groups(address); return g ? g.slice(0, 4).map(n => n.toString(16)).join(':') + '::/64' : address; }; @@ -198,15 +222,15 @@ async function serve(req,res){ if (req.method !== 'POST' || !fromOurPages(req)) return json(res,405,{error:'Use POST'}); const asking = whoIs(req); if (!asking.grant || !asking.household) return json(res,401,{error:'This frame is not set up yet.'}); - const lock = pinLocks.get(asking.household.id) || {fails: 0, until: 0, locks: 0}; - if (Date.now() < lock.until) { const wait = Math.ceil((lock.until - Date.now()) / 60000); return json(res,429,{error:'Too many wrong PINs. Try again in ' + wait + (wait === 1 ? ' minute.' : ' minutes.')}); } + const wait = pinWait(asking.household.id); + if (wait) return json(res,429,{error:wait}); let pin = ''; try { pin = String(JSON.parse(await readBody(req)).pin || ''); } catch(e) {} // The very first owner: asked for by the tablet's own screen, while nobody looks after the household, there is // no PIN to ask for and nobody who could have set one. The code still has to be read off that screen. if (asking.grant.id === 'local' && !hasOwner(asking.household.id)) return json(res,200,grants.startClaim(asking.household.id)); try { const made = setup.ownerCode(asking.household, pin); pinLocks.delete(asking.household.id); return json(res,200,made); } catch(e) { - if (e.status === 403) { lock.fails++; if (lock.fails >= 5) { lock.until = Date.now() + Math.min(24 * 3600000, 15 * 60000 * 2 ** lock.locks); lock.locks++; lock.fails = 0; } pinLocks.set(asking.household.id, lock); } + if (e.status === 403) pinWrong(asking.household.id); return json(res, e.status || 500, {error: e.status ? e.message : 'That did not work. Try again.'}); } } @@ -302,6 +326,50 @@ async function serve(req,res){ if (!result.ok) return json(res,502,{...status,error:'Couldn’t reach GitHub. The daily check will try again.'}); return json(res,200,status); } + // Screen time (rewards.js). Any frame of the household sees it, and any paired one may start or stop its timer. Every + // other change (adding or taking away time, removing from the record, who has it and their allowance) needs a manage + // token in X-Gingham-Manage, and only the household's PIN gets one: the guard is here, not in the wall's buttons. The + // PIN is checked once, against its hash, and is never kept, logged or sent back; the token lives in this server's + // memory and in a variable on the frame's page, nowhere else (DESIGN.md → Screen time). + if (pathname === '/api/rewards' || pathname.startsWith('/api/rewards/')) { + const rewards = fixture ? fixtureRewards : home.rewards, householdId = fixture ? 'fixture' : home.id; + const failed = e => json(res, e.status || 500, {error: e.status ? e.message : 'Can’t change screen time right now. Try again.'}); + if (pathname === '/api/rewards') { + if (req.method !== 'GET') return json(res,405,{error:'Use GET'}); + try { return json(res,200,{...rewards.read(), pinSet: fixture ? !!process.env.FRAME_FIXTURE_PIN_HASH : setup.hasPin(home)}); } catch(e) { return failed(e); } + } + if (req.method !== 'POST' || !fromOurPages(req)) return json(res,405,{error:'Use POST'}); + const grant = fixture ? {id: 'fixture'} : who.grant; + if (!grant || !(grant.id === 'local' || grant.id === 'fixture' || (grant.scope !== 'admin' && grant.household === householdId))) return json(res,403,{error:'Pair this frame first.'}); + if (rationed(req,'rewards',120,60000)) return json(res,429,{error:'Slow down'}); + let body = {}; try { body = JSON.parse(await readBody(req) || '{}'); } catch(e) { return json(res,400,{error:'Send JSON'}); } + if (!body || typeof body !== 'object' || Array.isArray(body)) body = {}; + const action = pathname.slice('/api/rewards/'.length), token = String(req.headers['x-gingham-manage'] || ''); + if (action === 'unlock') { + const wait = pinWait(householdId); + if (wait) return json(res,429,{error:wait}); + const pin = String(body.pin == null ? '' : body.pin), none = 'Set a household PIN on the setup page to manage screen time here.'; + try { + if (!fixture) setup.checkHouseholdPin(home, pin, none); + else if (!process.env.FRAME_FIXTURE_PIN_HASH) throw Object.assign(Error(none), {status: 409}); + else if (!checkPin(pin, process.env.FRAME_FIXTURE_PIN_HASH)) throw Object.assign(Error('That is not the PIN.'), {status: 403}); + } catch(e) { if (e.status === 403) pinWrong(householdId); return failed(e); } + pinLocks.delete(householdId); + return json(res,200,{token: manageTokens.issue(householdId, grant.id), expiresIn: manageTokens.IDLE_MS / 1000}); + } + if (action === 'lock') { manageTokens.revoke(token); return json(res,200,{ok:true}); } + const person = String(body.person || ''), entry = String(body.entry || ''), by = grant.id; + const open = {start: () => rewards.start(person, by), stop: () => rewards.stop(person, by), 'cancel-start': () => rewards.cancelStart(person), resume: () => rewards.resume(person, entry)}; + const guarded = { + adjust: () => rewards.adjust(person, body.minutes, body.reason == null ? null : body.reason, by), + remove: () => rewards.remove(person, entry, by), restore: () => rewards.restore(person, entry, by), + person: () => rewards.person({id: body.id == null ? undefined : String(body.id), name: body.name, on: body.on, allowance: body.allowance}, by) + }; + const run = open[action] || guarded[action]; + if (!run) return json(res,404,{error:'Not found'}); + if (guarded[action] && !manageTokens.check(token, householdId, grant.id)) return json(res,403,{error:'Locked. Enter the household PIN again.', locked:true}); + try { return json(res,200,run()); } catch(e) { return failed(e); } + } if (fixture && pathname.startsWith('/api/')) { if (/\/close$/.test(pathname)) return json(res,200,{ok:true}); if (pathname === '/api/household') return json(res,200,{name:'Household',timezone:fixturePlace.timezone,place:fixturePlace.label,country:process.env.FRAME_FIXTURE_COUNTRY||fixturePlace.country,countdowns:fixture.countdowns||[]}); diff --git a/setup.js b/setup.js index fe9ac4d..6e21071 100644 --- a/setup.js +++ b/setup.js @@ -311,13 +311,18 @@ function createSetup({households, grants, feed = safeFetchText, fetchImpl = fetc return page(200, 'Google Tasks is connected.', back + '#google-connected'); } catch (e) { return page(e.status && e.status < 500 ? e.status : 502, e.status ? e.message : 'Google didn’t finish signing in. Try again.'); } } - // Asked by a paired frame: a code that lets a phone in, if the household has a PIN and this is it. - function ownerCode(home, pin) { + // The household's PIN, checked: a 409 when it has none, a 403 when this is not it. Never says anything about the PIN. + function checkHouseholdPin(home, pin, none = 'Set a frame PIN on the setup page first.') { const stored = (held(home).frame || {}).pin; - if (!stored) throw fail(409, 'Set a frame PIN on the setup page first.'); + if (!stored) throw fail(409, none); if (!checkPin(pin, stored)) throw fail(403, 'That is not the PIN.'); + } + const hasPin = home => !!(held(home).frame || {}).pin; + // Asked by a paired frame: a code that lets a phone in, if the household has a PIN and this is it. + function ownerCode(home, pin) { + checkHouseholdPin(home, pin); return grants.startClaim(home.id); } - return {handle, describe, ownerCode, oauthReturn}; + return {handle, describe, ownerCode, oauthReturn, checkHouseholdPin, hasPin}; } module.exports = {createSetup}; diff --git a/test/fixtures.js b/test/fixtures.js index 555fd58..0092a4e 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -97,3 +97,35 @@ module.exports = { }; // The packed day again, with tonight's dinner written as a chore, the way a household writes "Verb: object". module.exports.dinner = {...module.exports.stress, tasks: {...module.exports.stress.tasks, tasks: [task('Chores', 'Dinner: tacos al pastor', '2026-09-23'), ...stressTasks]}}; +// Screen time (rewards.js) for June, the Ashbys' youngest, on the fixtures' Wednesday at 5:40 PM: a 30-minute day, 15 minutes +// earned, 5 taken away and a ten-minute timer, so 30 are left. The other states change only the timer: one running since +// 5:25 PM, one started at 5 PM that time's up stopped at 5:30 (the server stops it on reading), and a day used up. "screen-newday" is five past midnight, after a timer that +// was still running at midnight. The server keeps these in memory on the fixture's own clock (server.js). +const st = (time, day = '09-23') => '2026-' + day + 'T' + time + ':00-05:00'; +const juneDay = extra => ({allowance: 30, timer: null, ...extra, entries: [ + {id: 'e1', at: st('15:00'), kind: 'earn', minutes: 10, reason: 'chores'}, + {id: 'e2', at: st('15:30'), kind: 'earn', minutes: 5, reason: 'reading'}, + {id: 'e3', at: st('16:00'), kind: 'adjust', minutes: -5}, + {id: 'e4', at: st('16:20'), kind: 'timer', seconds: 600, from: st('16:10')}, + ...((extra && extra.entries) || [])]}); +const screenTime = (day, clock) => ({clock, data: {people: [{id: 'june', name: 'June', on: true, allowance: 30, days: day}]}}); +module.exports.screen = {...module.exports.stress, rewards: screenTime({'2026-09-23': juneDay()})}; +module.exports['screen-running'] = {...module.exports.stress, rewards: screenTime({'2026-09-23': juneDay({timer: {since: st('17:25')}})})}; +module.exports['screen-over'] = {...module.exports.stress, rewards: screenTime({'2026-09-23': juneDay({timer: {since: st('17:00')}})})}; +module.exports['screen-none'] = {...module.exports.stress, rewards: screenTime({'2026-09-23': juneDay({entries: [{id: 'e5', at: st('17:10'), kind: 'timer', seconds: 1800, from: st('16:40')}]})})}; +// A day the build before time's up let run past its total: a 50-minute timer against 40, kept as it was recorded. +module.exports['screen-oldover'] = {...module.exports.stress, rewards: screenTime({'2026-09-23': juneDay({entries: [{id: 'e5', at: st('17:31'), kind: 'timer', seconds: 3000, from: st('16:41')}]})})}; +module.exports['screen-newday'] = {...module.exports.stress, rewards: screenTime({'2026-09-23': juneDay({timer: {since: st('23:40')}})}, st('00:05', '09-24'))}; +// The seconds on the ring (MER-2089), each on a still clock at 5:40 PM: a timer on for 30:00, 24:37, 0:42 and, with 35 +// minutes more earned, 1:04:37 left; one that time's up stopped 1:12 ago (so a page loading now chimes); and stopped +// at 24:37 and on a whole 25. +const sec = time => '2026-09-23T' + time + '-05:00'; +const stillDay = (extra, entries = []) => ({...module.exports.stress, rewards: {still: true, data: {people: [{id: 'june', name: 'June', on: true, allowance: 30, days: {'2026-09-23': {...juneDay({...extra, entries}), allowance: 30}}}]}}}); +const earnedMore = [{id: 'e6', at: st('17:00'), kind: 'earn', minutes: 35, reason: 'chores'}]; +module.exports['screen-s3000'] = stillDay({timer: {since: sec('17:40:00')}}); +module.exports['screen-s2437'] = stillDay({timer: {since: sec('17:34:37')}}); +module.exports['screen-s0042'] = stillDay({timer: {since: sec('17:10:42')}}); +module.exports['screen-s10437'] = stillDay({timer: {since: sec('17:39:37')}}, earnedMore); +module.exports['screen-sover'] = stillDay({timer: {since: sec('17:08:48')}}); +module.exports['screen-stop2437'] = stillDay({}, [{id: 'e7', at: st('17:30'), kind: 'timer', seconds: 323, from: sec('17:24:37')}]); +module.exports['screen-stop25'] = stillDay({}, [{id: 'e7', at: st('17:30'), kind: 'timer', seconds: 300, from: st('17:25')}]); diff --git a/test/rewards.test.js b/test/rewards.test.js new file mode 100644 index 0000000..d58f252 --- /dev/null +++ b/test/rewards.test.js @@ -0,0 +1,396 @@ +// Screen time (rewards.js and its routes in server.js): the arithmetic of a day, where a day ends, a timer that outlives +// a reload, a restart or midnight, and the PIN guard at the server. The PIN in these tests is made up for each run. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const {once} = require('node:events'); +const {createRewards, createManageTokens, UNDO_MS} = require('../rewards'); +const {createGrants} = require('../grants'); +const {startServer, makeData, writeHousehold} = require('./server-harness'); + +const CHICAGO = 'America/Chicago'; +const at = text => Date.parse(text); +// A household's rewards on a clock the test moves. +function clocked(start, options = {}) { + const clock = {t: at(start)}; + const rewards = createRewards({persist: false, now: () => clock.t, zone: () => CHICAGO, ...options}); + return {rewards, clock, advance: minutes => { clock.t += minutes * 60000; }}; +} +const made = view => view.changed.id; +const june = view => view.people[0]; +const madePin = () => String(crypto.randomInt(10000000, 100000000)); + +test('A day is its allowance, plus what was earned and added, less what was taken and used', () => { + const {rewards, advance} = clocked('2026-09-23T15:00:00-05:00'); + const id = made(rewards.person({name: 'June', allowance: 30})); + let day = june(rewards.read()); + assert.deepEqual([day.allowance, day.earned, day.adjusted, day.used, day.left], [30, 0, 0, 0, 1800]); + rewards.adjust(id, 10, 'chores', 'g'); + rewards.adjust(id, 5, null, 'g'); + rewards.adjust(id, -5, null, 'g'); + rewards.start(id, 'g'); advance(12); rewards.stop(id, 'g'); + day = june(rewards.read()); + assert.deepEqual([day.earned, day.adjusted, day.used, day.left], [10, 0, 720, (30 + 10) * 60 - 720]); + assert.deepEqual(day.entries.map(e => e.kind), ['timer', 'adjust', 'adjust', 'earn'], 'newest first'); + assert.equal(day.entries[0].seconds, 720); + // What a change may be. + for (const [minutes, reason] of [[0, null], [241, null], [1.5, null], [-5, 'chores'], [5, 'bribery'], ['5', null]]) + assert.throws(() => rewards.adjust(id, minutes, reason, 'g'), e => e.status === 400, JSON.stringify([minutes, reason])); + assert.throws(() => rewards.adjust('nobody', 5, null, 'g'), e => e.status === 404); + assert.throws(() => rewards.person({name: ' '}), e => e.status === 400); + assert.throws(() => rewards.person({id, allowance: 33}), e => e.status === 400, 'allowances go in fives'); +}); + +test('The timer charges by the second, starts only with time left, and starts once', () => { + const {rewards, clock, advance} = clocked('2026-09-23T16:00:00-05:00'); + const id = made(rewards.person({name: 'June', allowance: 5})); + rewards.start(id, 'g'); + const since = june(rewards.read()).timer.since; + advance(2); + assert.equal(rewards.start(id, 'g').changed, undefined, 'a second start while one runs changes nothing'); + assert.equal(june(rewards.read()).timer.since, since); + advance(2); clock.t += 30000; + rewards.stop(id, 'g'); + let day = june(rewards.read()); + assert.deepEqual([day.timer, day.used, day.left], [null, 270, 30]); + rewards.start(id, 'g'); clock.t += 30000; + rewards.stop(id, 'g'); + day = june(rewards.read()); + assert.deepEqual([day.used, day.left], [300, 0]); + assert.throws(() => rewards.start(id, 'g'), e => e.status === 409 && /No time left/.test(e.message)); + assert.equal(rewards.stop(id, 'g').changed, undefined, 'stopping a stopped timer changes nothing'); +}); + +// Time's up (MER-2089): once the time runs out the timer stops, and more time is added behind the PIN. The server stops +// it, charged exactly to zero, whoever is or is not looking. +const timeUps = day => day.entries.filter(e => e.timeUp); +test('A timer stops by itself when time runs out, charged exactly to zero, even with every frame away', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rewards-')), 'rewards.json'); + const clock = {t: at('2026-09-23T17:00:00-05:00')}, open = () => createRewards({file, now: () => clock.t, zone: () => CHICAGO}); + const id = made(open().person({name: 'June', allowance: 30})); + open().start(id, 'g'); + clock.t += 20 * 60000; + assert.deepEqual([june(open().read()).left, june(open().read()).used], [600, 1200], 'still running with ten minutes left'); + clock.t += 20 * 60000; // nobody looked between 17:20 and 17:40 + assert.ok(JSON.parse(fs.readFileSync(file, 'utf8')).people[0].days['2026-09-23'].timer, 'on file it still runs until someone reads it'); + const day = june(open().read()); + assert.deepEqual([day.timer, day.used, day.left], [null, 1800, 0], 'no minute past zero is counted'); + assert.deepEqual(timeUps(day).map(e => [e.seconds, e.at, e.from]), [[1800, at('2026-09-23T17:30:00-05:00'), at('2026-09-23T17:00:00-05:00')]]); + const saved = JSON.parse(fs.readFileSync(file, 'utf8')).people[0].days['2026-09-23']; + assert.deepEqual([saved.timer, saved.entries.length, saved.entries[0].by], [null, 1, 'time-up'], 'one entry, written once'); + clock.t += 60000; open().read(); + assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).people[0].days['2026-09-23'].entries.length, 1, 'a later read adds nothing'); + // Mid-timer, the moment before anything is written, the day still never goes below zero. + const {rewards, clock: c2} = clocked('2026-09-23T17:00:00-05:00'); + const pid = made(rewards.person({name: 'Theo', allowance: 5})); + rewards.start(pid, 'g'); c2.t += 9 * 60000; + assert.equal(june(rewards.read()).left, 0); +}); + +test('Stop just before zero charges what ran; just after, time’s up already stopped it, and two stops make one entry', () => { + let {rewards, clock} = clocked('2026-09-23T17:00:00-05:00'); + let id = made(rewards.person({name: 'June', allowance: 5})); + rewards.start(id, 'g'); clock.t += 299000; + rewards.stop(id, 'g'); + let day = june(rewards.read()); + assert.deepEqual([day.used, day.left, timeUps(day).length], [299, 1, 0], 'a second before zero: a stop like any other'); + ({rewards, clock} = clocked('2026-09-23T17:00:00-05:00')); + id = made(rewards.person({name: 'June', allowance: 5})); + rewards.start(id, 'g'); clock.t += 301000; + const first = rewards.stop(id, 'g'), second = rewards.stop(id, 'g'); + assert.deepEqual([first.changed, second.changed], [undefined, undefined], 'a second after zero, Stop finds it stopped'); + day = june(rewards.read()); + assert.deepEqual([day.used, day.left, day.entries.length, timeUps(day)[0].seconds], [300, 0, 1, 300]); + // Two frames stopping together, before zero: one entry. + ({rewards, clock} = clocked('2026-09-23T17:00:00-05:00')); + id = made(rewards.person({name: 'June', allowance: 5})); + rewards.start(id, 'g'); clock.t += 120000; + assert.equal(rewards.stop(id, 'a').changed.kind, 'stop'); + assert.equal(rewards.stop(id, 'b').changed, undefined); + assert.equal(june(rewards.read()).entries.length, 1); +}); + +test('Time added while it runs moves its end later; time taken away brings it earlier, or stops it now', () => { + let {rewards, clock} = clocked('2026-09-23T17:00:00-05:00'); + let id = made(rewards.person({name: 'June', allowance: 10})); + rewards.start(id, 'g'); clock.t += 5 * 60000; + rewards.adjust(id, 10, 'chores', 'g'); + clock.t += 10 * 60000; + assert.deepEqual([june(rewards.read()).timer !== null, june(rewards.read()).left], [true, 300], 'it runs on into the added time'); + clock.t += 10 * 60000; + let day = june(rewards.read()); + assert.deepEqual([day.timer, day.used, timeUps(day)[0].at], [null, 1200, at('2026-09-23T17:20:00-05:00')]); + // Taken away, still with time left: it stops at the new, earlier end. + ({rewards, clock} = clocked('2026-09-23T17:00:00-05:00')); + id = made(rewards.person({name: 'June', allowance: 30})); + rewards.start(id, 'g'); clock.t += 5 * 60000; + rewards.adjust(id, -15, null, 'g'); + clock.t += 20 * 60000; + day = june(rewards.read()); + assert.deepEqual([day.used, day.left, timeUps(day)[0].at], [900, 0, at('2026-09-23T17:15:00-05:00')]); + // Taken away past what is left: it stops at once, charged every second it ran, and the day is short by the rest. + ({rewards, clock} = clocked('2026-09-23T17:00:00-05:00')); + id = made(rewards.person({name: 'June', allowance: 10})); + rewards.start(id, 'g'); clock.t += 5 * 60000; + const view = rewards.adjust(id, -10, null, 'g'); + day = june(view); + assert.deepEqual([day.timer, day.used, day.left, timeUps(day)[0].at], [null, 300, -300, at('2026-09-23T17:05:00-05:00')]); + assert.throws(() => rewards.start(id, 'g'), e => e.status === 409); +}); + +test('Midnight before zero stops it at midnight; an undone stop never resumes past zero; old days stay as they are', () => { + const tz = process.env.TZ; + process.env.TZ = 'Europe/London'; + try { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rewards-')), 'rewards.json'); + const clock = {t: at('2026-09-23T23:50:00-05:00')}, kept = createRewards({file, now: () => clock.t, zone: () => CHICAGO}); + const id = made(kept.person({name: 'June', allowance: 30})); + kept.start(id, 'g'); clock.t += 60 * 60000; + const view = kept.read(); + assert.equal(view.day, '2026-09-24'); + assert.deepEqual([june(view).timer, june(view).left], [null, 1800]); + const before = JSON.parse(fs.readFileSync(file, 'utf8')).people[0].days['2026-09-23'].entries; + assert.deepEqual(before.map(e => [e.seconds, e.by]), [[600, 'midnight']], 'ten minutes, to midnight, with twenty still left'); + } finally { if (tz === undefined) delete process.env.TZ; else process.env.TZ = tz; } + // A stop five seconds before zero, undone ten seconds later, would run past zero: it stays stopped. + let {rewards, clock} = clocked('2026-09-23T17:00:00-05:00'); + let id = made(rewards.person({name: 'June', allowance: 5})); + rewards.start(id, 'g'); clock.t += 295000; + const stopped = rewards.stop(id, 'g').changed.entry; + clock.t += 10000; + assert.throws(() => rewards.resume(id, stopped), e => e.status === 409 && /Time’s up/.test(e.message)); + assert.deepEqual([june(rewards.read()).timer, june(rewards.read()).used], [null, 295], 'and nothing is charged past the stop'); + // The entry time's up made cannot be undone into a running timer. + ({rewards, clock} = clocked('2026-09-23T17:00:00-05:00')); + id = made(rewards.person({name: 'June', allowance: 5})); + rewards.start(id, 'g'); clock.t += 302000; + const up = timeUps(june(rewards.read()))[0]; + assert.throws(() => rewards.resume(id, up.id), e => e.status === 409 && /A grown-up can add time/.test(e.message)); + // A day the old build let run past its total keeps its record: nothing rewrites it. + const over = {people: [{id: 'june', name: 'June', on: true, allowance: 30, days: {'2026-09-23': {allowance: 30, timer: null, + entries: [{id: 'old1', at: '2026-09-23T23:11:51.000Z', kind: 'timer', seconds: 3054, from: '2026-09-23T22:20:57.000Z', by: 'f'}]}}}]}; + const kept = createRewards({persist: false, initial: over, now: () => at('2026-09-23T19:00:00-05:00'), zone: () => CHICAGO}); + const day = june(kept.read()); + assert.deepEqual([day.used, day.left, day.entries.map(e => [e.id, e.seconds, !!e.timeUp])], [3054, 1800 - 3054, [['old1', 3054, false]]]); +}); + +test('A day starts again at house midnight, and a timer running across it is charged to the day it ran in', () => { + // The process runs in London (as a container in UTC would), the household in Chicago. + const tz = process.env.TZ; + process.env.TZ = 'Europe/London'; + try { + const {rewards, advance} = clocked('2026-09-23T23:00:00-05:00'); + const id = made(rewards.person({name: 'June', allowance: 30})); + rewards.adjust(id, 15, 'reading', 'g'); + advance(40); rewards.start(id, 'g'); + let view = rewards.read(); + assert.equal(view.day, '2026-09-23', 'London is already on Thursday; the household is not'); + advance(30); // 12:10 AM in Chicago + view = rewards.read(); + assert.equal(view.day, '2026-09-24'); + assert.deepEqual([june(view).allowance, june(view).earned, june(view).used, june(view).left, june(view).timer], [30, 0, 0, 1800, null], 'nothing carries over, and no timer runs into the new day'); + // Yesterday's record, on file: the timer stopped at midnight, twenty minutes after it started. + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rewards-')), 'rewards.json'); + const clock = {t: at('2026-09-23T23:40:00-05:00')}; + const kept = createRewards({file, now: () => clock.t, zone: () => CHICAGO}); + const pid = made(kept.person({name: 'Theo'})); + kept.start(pid, 'g'); + clock.t = at('2026-09-24T00:10:00-05:00'); + kept.read(); + const saved = JSON.parse(fs.readFileSync(file, 'utf8')).people[0].days; + assert.equal(saved['2026-09-23'].timer, null); + assert.deepEqual(saved['2026-09-23'].entries.map(e => [e.kind, e.seconds, e.at]), [['timer', 1200, '2026-09-24T05:00:00.000Z']]); + assert.ok(!saved['2026-09-24'], 'a plain read starts no day on file'); + } finally { if (tz === undefined) delete process.env.TZ; else process.env.TZ = tz; } +}); + +test('A running timer survives a reload or a restart, since its start is on file', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rewards-')), 'rewards.json'); + const clock = {t: at('2026-09-23T17:00:00-05:00')}, open = () => createRewards({file, now: () => clock.t, zone: () => CHICAGO}); + const id = made(open().person({name: 'June'})); + open().start(id, 'g'); + clock.t += 25 * 60000; + const day = june(open().read()); + assert.equal(day.timer.since, at('2026-09-23T17:00:00-05:00')); + assert.equal(day.left, 300); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); +}); + +test('Undo: a start or a stop is taken back for a few seconds, and an entry is removed and put back, all on the record', () => { + const {rewards, clock, advance} = clocked('2026-09-23T15:00:00-05:00'); + const id = made(rewards.person({name: 'June'})); + rewards.start(id, 'g'); clock.t += 5000; + rewards.cancelStart(id); + assert.deepEqual([june(rewards.read()).timer, june(rewards.read()).entries.length], [null, 0], 'a start undone at once leaves nothing'); + rewards.start(id, 'g'); clock.t += UNDO_MS + 1000; + assert.throws(() => rewards.cancelStart(id), e => e.status === 409, 'later, it is a stop like any other'); + advance(10); + const stop = rewards.stop(id, 'g').changed.entry; + clock.t += 3000; + rewards.resume(id, stop); + let day = june(rewards.read()); + assert.ok(day.timer, 'the timer carries on'); + assert.equal(day.used, UNDO_MS / 1000 + 1 + 600 + 3, 'every second since it started is still charged'); + rewards.stop(id, 'g'); + const earned = rewards.adjust(id, 10, 'chores', 'g').changed.entry; + rewards.remove(id, earned, 'g'); + day = june(rewards.read()); + assert.equal(day.earned, 0); + assert.ok(day.entries.find(e => e.id === earned).removed, 'the removed entry stays in the record, marked'); + assert.throws(() => rewards.remove(id, earned, 'g'), e => e.status === 409); + rewards.restore(id, earned, 'g'); + day = june(rewards.read()); + assert.equal(day.earned, 10); + assert.ok(!day.entries.find(e => e.id === earned).removed); + assert.throws(() => rewards.remove(id, 'nothing', 'g'), e => e.status === 404); +}); + +test('A new allowance starts tomorrow; turning someone off stops their timer and keeps their record', () => { + const {rewards, advance} = clocked('2026-09-23T15:00:00-05:00'); + const id = made(rewards.person({name: 'June', allowance: 30})); + rewards.person({id, allowance: 45}); + let day = june(rewards.read()); + assert.deepEqual([day.allowance, day.nextAllowance], [30, 45]); + rewards.start(id, 'g'); advance(5); + rewards.person({id, on: false}); + day = june(rewards.read()); + assert.deepEqual([day.on, day.timer, day.used], [false, null, 300]); + assert.throws(() => rewards.start(id, 'g'), e => e.status === 404, 'off is off'); + advance(24 * 60); + rewards.person({id, on: true}); + assert.equal(june(rewards.read()).allowance, 45); +}); + +test('A manage token is good for two idle minutes, for one frame of one household, until it is locked', () => { + const clock = {t: 0}, tokens = createManageTokens({now: () => clock.t}); + const token = tokens.issue('alpha', 'frame-a'); + assert.ok(tokens.check(token, 'alpha', 'frame-a')); + assert.ok(!tokens.check(token, 'alpha', 'frame-b'), 'another frame'); + assert.ok(!tokens.check(token, 'beta', 'frame-a'), 'another household'); + clock.t += 110000; assert.ok(tokens.check(token, 'alpha', 'frame-a'), 'a use renews it'); + clock.t += 110000; assert.ok(tokens.check(token, 'alpha', 'frame-a')); + clock.t += 121000; assert.ok(!tokens.check(token, 'alpha', 'frame-a'), 'two quiet minutes and it is gone'); + const second = tokens.issue('alpha', 'frame-a'); + tokens.revoke(second); + assert.ok(!tokens.check(second, 'alpha', 'frame-a'), 'Lock'); + for (const forged of ['', 'x', 'x'.repeat(43), null, 42]) assert.ok(!tokens.check(forged, 'alpha', 'frame-a')); +}); + +// The server: what a frame may do without the PIN, what it may do with it, and what nobody may do. +test('The server guards every change of time behind the household PIN, shares its lockout, and never repeats the PIN', async () => { + const data = makeData('frame-rewards-'); + for (const id of ['alpha', 'beta']) fs.writeFileSync(path.join(writeHousehold(data, id, {name: id}), 'credentials.json'), '{}'); + fs.writeFileSync(path.join(data, 'households', 'alpha', 'settings.json'), JSON.stringify({rest: 'calendar'})); + const grants = createGrants({file: path.join(data, 'grants.json')}); + const frameA = grants.add({scope: 'frame', household: 'alpha', label: 'Kitchen'}).secret, frameA2 = grants.add({scope: 'frame', household: 'alpha', label: 'Hall'}).secret; + const frameB = grants.add({scope: 'frame', household: 'beta', label: 'Beta'}).secret, owner = grants.add({scope: 'owner', household: 'alpha', label: 'Phone'}).secret; + const pin = madePin(); + const s = await startServer({data, env: {FRAME_AUTH: 'required', TZ: 'Europe/London'}}); + const seen = []; + const call = async (pathname, {as = frameA, body, token, method = body === undefined && !token ? 'GET' : 'POST', header = true} = {}) => { + const response = await fetch(s.url + pathname, {method, headers: {cookie: 'frame=' + as, ...(header ? {'x-gingham': '1'} : {}), ...(token ? {'x-gingham-manage': token} : {})}, ...(method === 'POST' ? {body: JSON.stringify(body || {})} : {})}); + const text = await response.text(); + // Every answer but the one that hands the token over, which is the token's only way out. + if (pathname !== '/api/rewards/unlock' || response.status !== 200) seen.push(text); + let json = null; try { json = JSON.parse(text); } catch (e) {} + return {status: response.status, json}; + }; + try { + const home = path.join(data, 'households', 'alpha'); + // An older household: nothing about it changes, and no file appears for reading it. + const before = fs.readdirSync(home).sort(); + let got = await call('/api/rewards'); + assert.deepEqual([got.status, got.json.people, got.json.pinSet, got.json.unit], [200, [], false, 'min']); + assert.equal(got.json.day, new Intl.DateTimeFormat('en-CA', {timeZone: CHICAGO}).format(new Date()), 'the household\'s day, not the server\'s zone'); + assert.deepEqual(fs.readdirSync(home).sort(), before); + assert.equal((await call('/api/settings')).json.rest, 'calendar'); + + // No PIN yet: the wall says where to set one, and no PIN opens anything. + got = await call('/api/rewards/unlock', {body: {pin: '1234'}}); + assert.deepEqual([got.status, got.json.error], [409, 'Set a household PIN on the setup page to manage screen time here.']); + assert.equal((await call('/api/setup/pin', {as: owner, body: {pin}})).status, 200); + const files = () => Object.fromEntries(fs.readdirSync(home).filter(f => f !== 'rewards.json' && f !== 'cache').map(f => [f, fs.readFileSync(path.join(home, f))])); + const untouched = files(); + + // Without a token, nothing that changes time or who has it. + for (const action of ['adjust', 'remove', 'restore', 'person']) { + got = await call('/api/rewards/' + action, {body: {name: 'June', person: 'x', minutes: 10}}); + assert.deepEqual([got.status, got.json.locked], [403, true], action); + } + assert.equal((await call('/api/rewards/person', {body: {name: 'June'}, token: 'x'.repeat(43)})).status, 403, 'a made-up token'); + assert.equal((await call('/api/rewards/unlock', {body: {pin}, header: false})).status, 405, 'only from our pages'); + assert.equal((await call('/api/rewards/unlock', {body: {pin}, as: 'nobody'.repeat(5)})).status, 401, 'only a paired frame'); + + // The PIN opens it for this frame. + got = await call('/api/rewards/unlock', {body: {pin}}); + assert.equal(got.status, 200); + const token = got.json.token; + assert.match(token, /^[A-Za-z0-9_-]{40,}$/); + assert.equal(got.json.expiresIn, 120); + got = await call('/api/rewards/person', {body: {name: 'June', allowance: 30}, token}); + assert.equal(got.status, 200); + const june = got.json.people[0].id; + got = await call('/api/rewards/adjust', {body: {person: june, minutes: 10, reason: 'chores'}, token}); + assert.equal(got.json.people[0].left, 2400); + const earned = got.json.changed.entry; + assert.equal((await call('/api/rewards/adjust', {body: {person: june, minutes: 10}, token, as: frameA2})).status, 403, 'another frame cannot use this one\'s token'); + assert.equal((await call('/api/rewards/adjust', {body: {person: june, minutes: 10}, token, as: frameB})).status, 403, 'nor another household'); + assert.equal((await call('/api/rewards', {as: frameB})).json.people.length, 0, 'beta sees none of alpha\'s'); + + // The timer needs a paired frame of the household and nothing more. + got = await call('/api/rewards/start', {body: {person: june}, as: frameA2}); + assert.ok(got.json.people[0].timer); + assert.equal((await call('/api/rewards/stop', {body: {person: june}, as: frameB})).status, 404, 'beta has no such person'); + got = await call('/api/rewards/stop', {body: {person: june}, as: frameA2}); + assert.equal(got.json.people[0].entries[0].kind, 'timer'); + got = await call('/api/rewards/remove', {body: {person: june, entry: earned}, token}); + assert.equal(got.json.people[0].earned, 0); + + // Lock: the token is gone. + assert.equal((await call('/api/rewards/lock', {body: {}, token})).status, 200); + assert.equal((await call('/api/rewards/restore', {body: {person: june, entry: earned}, token})).status, 403); + + // Only rewards.json changed in the household's folder. + assert.deepEqual(files(), untouched, 'every other household file is byte for byte as it was'); + assert.ok(!fs.existsSync(path.join(data, 'households', 'beta', 'rewards.json'))); + + // Wrong PINs are one count with the phone's way in: three here and two there, and both doors are shut, even to the PIN. + for (let i = 0; i < 3; i++) assert.equal((await call('/api/rewards/unlock', {body: {pin: '0000'}})).status, 403); + for (let i = 0; i < 2; i++) assert.equal((await call('/api/owner-code', {body: {pin: '0000'}})).status, 403); + assert.equal((await call('/api/rewards/unlock', {body: {pin}})).status, 429); + assert.equal((await call('/api/owner-code', {body: {pin}})).status, 429); + assert.equal((await call('/api/rewards/unlock', {body: {pin}, as: frameB})).status, 409, 'another household keeps its own count'); + + // The PIN and the token appear in no answer, no file and no log. + const everything = [...seen, s.output()]; + (function walk(dir) { for (const e of fs.readdirSync(dir, {withFileTypes: true})) { const f = path.join(dir, e.name); if (e.isDirectory()) walk(f); else everything.push(fs.readFileSync(f, 'utf8')); } })(data); + for (const secret of [pin, token]) assert.ok(!everything.some(text => text.includes(secret)), 'never written or sent back'); + } finally { await s.stop(); } +}); + +test('A restart keeps a running timer, and forgets every manage token', async () => { + const data = makeData('frame-rewards-restart-'); + fs.writeFileSync(path.join(writeHousehold(data, 'home'), 'credentials.json'), '{}'); + const grants = createGrants({file: path.join(data, 'grants.json')}); + const frame = grants.add({scope: 'frame', household: 'home', label: 'Kitchen'}).secret, owner = grants.add({scope: 'owner', household: 'home', label: 'Phone'}).secret; + const pin = madePin(); + const post = (s, pathname, body, extra = {}) => fetch(s.url + pathname, {method: 'POST', headers: {cookie: 'frame=' + (extra.as || frame), 'x-gingham': '1', ...(extra.token ? {'x-gingham-manage': extra.token} : {})}, body: JSON.stringify(body)}).then(async r => ({status: r.status, json: await r.json()})); + let s = await startServer({data, env: {FRAME_AUTH: 'required'}}); + let token, june, since; + try { + await post(s, '/api/setup/pin', {pin}, {as: owner}); + token = (await post(s, '/api/rewards/unlock', {pin})).json.token; + june = (await post(s, '/api/rewards/person', {name: 'June'}, {token})).json.people[0].id; + since = (await post(s, '/api/rewards/start', {person: june})).json.people[0].timer.since; + } catch (e) { await s.stop(); throw e; } + s.server.kill(); await once(s.server, 'exit'); + s = await startServer({data, env: {FRAME_AUTH: 'required'}}); + try { + const view = await (await fetch(s.url + '/api/rewards', {headers: {cookie: 'frame=' + frame}})).json(); + assert.equal(view.people[0].timer.since, since); + assert.equal((await post(s, '/api/rewards/adjust', {person: june, minutes: 5}, {token})).status, 403, 'the old token died with the old process'); + } finally { await s.stop(); } +}); diff --git a/test/shots.cjs b/test/shots.cjs index 64528bf..5f5aeaf 100644 --- a/test/shots.cjs +++ b/test/shots.cjs @@ -13,7 +13,13 @@ settings, Larger text, and the week with the tablet itself in dark mode); each other one gets the week in day and night. The phone pages are shown with the first. --only take only the screenshots whose names match, e.g. --only 'week|phone-lists'. - --set "moments" takes the wall's moments of the day instead of the set above (20 screenshots, --fixtures ignored): + --set "screen" takes screen time's states (DESIGN.md → Screen time) by day and by night: off, resting, a timer + running, time's up (the timer stopped at zero), a day an older build let run over, Settings with Play the + chime, nothing left, a new day, the server out of reach, the PIN pad with and without a household + PIN, unlocked, a change with its Undo, and turning it on; then the ring's seconds: running at 30:00, 24:37, + 0:42 and 1:04:37, time's up 1:12 ago, stopped at 24:37 and at 25, and unlocked while running. Its PIN is made up for the run and handed to the + fixture server as a hash (FRAME_FIXTURE_PIN_HASH), so no PIN is ever written down. + "moments" takes the wall's moments of the day instead of the set above (20 screenshots, --fixtures ignored): the evening handoff, the day line from dawn to night, the moon through a month (and south of the equator), dinner by day and night, and birthdays in Today, the week, Later and the agenda. The sky follows each state's own clock. Its "evening" and "dinner" fixtures are newer than some checkouts; to take @@ -56,7 +62,7 @@ const [FIRST_PORT, LAST_PORT] = (args.ports || '4550-4599').split('-').map(Numbe const FIXTURES = (args.fixtures || 'stress,quiet,offline').split(',').filter(Boolean); const ONLY = args.only ? new RegExp(args.only) : null; const SET = args.set || 'default'; -if (!/^(default|moments)$/.test(SET)) { console.error('--set is "default" or "moments"'); process.exit(2); } +if (!/^(default|moments|screen)$/.test(SET)) { console.error('--set is "default", "moments" or "screen"'); process.exit(2); } if (!(FIRST_PORT > 0 && LAST_PORT >= FIRST_PORT)) { console.error('--ports is a range, like 4550-4599'); process.exit(2); } if (!fs.existsSync(path.join(ROOT, 'server.js'))) { console.error(ROOT + ' has no server.js'); process.exit(2); } fs.mkdirSync(OUT, {recursive: true}); @@ -71,10 +77,10 @@ const CHROME_FLAGS = ['--disable-gpu', '--disable-gpu-rasterization', '--disable let nextPort = FIRST_PORT; const running = new Set(); -async function fresh(fixture) { +async function fresh(fixture, env = {}) { if (nextPort > LAST_PORT) throw Error('Out of ports: widen --ports'); const port = nextPort++; - const srv = spawn(process.execPath, ['server.js'], {cwd: ROOT, env: {...process.env, FRAME_FIXTURE: fixture, PORT: String(port), HOST: '127.0.0.1'}, stdio: 'ignore'}); + const srv = spawn(process.execPath, ['server.js'], {cwd: ROOT, env: {...process.env, FRAME_FIXTURE: fixture, PORT: String(port), HOST: '127.0.0.1', ...env}, stdio: 'ignore'}); const gone = new Promise(resolve => srv.once('exit', resolve)); running.add(srv); const stop = async () => { if (srv.exitCode === null && srv.signalCode === null) srv.kill(); await gone; running.delete(srv); }; @@ -109,9 +115,9 @@ const openSetting = async (p, choice) => { await p.click('#settings-button'); aw // `sky` forces the sky ("day", "night"); left out, the sky follows `now` as it does on the wall. `south` places the // household south of the equator. -async function wall(browser, {fixture, now, sky, name, setup, scheme = 'light', south = false}) { +async function wall(browser, {fixture, now, sky, name, setup, scheme = 'light', south = false, env}) { if (ONLY && !ONLY.test(name)) return; - const server = await fresh(fixture); + const server = await fresh(fixture, env); try { const ctx = await contextFor(browser, {viewport: {width: 1920, height: 1080}, colorScheme: scheme}); if (south) await ctx.route(/^http:\/\/127\.0\.0\.1:\d+\/api\/household$/, async r => { const answer = await r.fetch(); r.fulfill({json: {...await answer.json(), south: true}}); }); @@ -158,10 +164,39 @@ async function moments(browser) { await shot('birthday-agenda', 'stress', '2026-09-23T17:40:00', {setup: p => openSetting(p, 'Agenda')}); } +// Screen time's states. The PIN is new for each run, typed on the wall's own keypad, and only its hash reaches the server. +async function screenStates(browser) { + const pin = String(require('node:crypto').randomInt(100000, 1000000)); + const env = {FRAME_FIXTURE_PIN_HASH: require(path.join(ROOT, 'vault.js')).hashPin(pin)}; + const tab = p => clickText(p, '.dock button', 'Screen time'); + const unlock = async p => { await p.click('#screen-manage'); await p.waitForTimeout(300); for (const d of pin) await p.locator('#keypad .key', {hasText: d}).first().click(); await p.locator('#keypad .key', {hasText: 'OK'}).click(); await p.waitForTimeout(600); }; + for (const [mode, sky] of [['day', 'day'], ['night', 'night']]) { + const at = {now: '2026-09-23T17:40:00', sky, env}, shot = (name, extra) => wall(browser, {...at, name: `screen-${name}-${mode}`, ...extra}); + await shot('off-settings', {fixture: 'stress', setup: p => p.click('#settings-button')}); + await shot('resting', {fixture: 'screen', setup: tab}); + await shot('running', {fixture: 'screen-running', setup: tab}); + await shot('timeup', {fixture: 'screen-over', setup: tab}); + await shot('oldover', {fixture: 'screen-oldover', setup: tab}); + await shot('chime-setting', {fixture: 'screen', setup: async p => { await p.click('#settings-button'); await p.waitForTimeout(300); await p.locator('#screen-chime').scrollIntoViewIfNeeded(); }}); + await shot('none', {fixture: 'screen-none', setup: tab}); + await shot('newday', {fixture: 'screen-newday', now: '2026-09-24T00:05:00', setup: tab}); + await shot('offline', {fixture: 'screen', setup: async p => { await tab(p); await p.route(/\/api\/rewards$/, r => r.abort()); await p.evaluate(() => window.dispatchEvent(new Event('online'))); }}); + await shot('pin', {fixture: 'screen', setup: async p => { await tab(p); await p.click('#screen-manage'); for (const d of '12') await p.locator('#keypad .key', {hasText: d}).first().click(); }}); + await shot('no-pin', {fixture: 'screen', env: {}, setup: async p => { await tab(p); await p.click('#screen-manage'); }}); + await shot('unlocked', {fixture: 'screen', setup: async p => { await tab(p); await unlock(p); await clickText(p, '.screen-manage-panel button', 'Chores'); }}); + await shot('changed', {fixture: 'screen', setup: async p => { await tab(p); await unlock(p); await clickText(p, '.screen-manage-panel button', 'Chores'); await p.click('.screen-manage-panel .screen-go'); }}); + // The seconds on the ring, each on a still clock (test/fixtures.js), so the shot shows the second it names. + for (const name of ['s3000', 's2437', 's0042', 's10437', 'sover', 'stop2437', 'stop25']) await shot('seconds-' + name, {fixture: 'screen-' + name, setup: tab}); + await shot('seconds-unlocked', {fixture: 'screen-s2437', setup: async p => { await tab(p); await unlock(p); }}); + await shot('setup', {fixture: 'stress', setup: async p => { await p.click('#settings-button'); await p.waitForTimeout(300); await p.click('#screen-open button'); for (const d of pin) await p.locator('#keypad .key', {hasText: d}).first().click(); await p.locator('#keypad .key', {hasText: 'OK'}).click(); await p.waitForTimeout(600); await p.fill('#screen-name', 'June'); }}); + } +} + (async () => { const browser = await chromium.launch({...(process.env.CHROME ? {executablePath: process.env.CHROME} : {channel: 'chrome'}), headless: true, args: CHROME_FLAGS}); try { if (SET === 'moments') { await moments(browser); return; } + if (SET === 'screen') { await screenStates(browser); return; } const [first, ...others] = FIXTURES; const times = [['day', '2026-09-23T17:40:00', 'day'], ['night', '2026-09-23T19:16:00', 'night']]; for (const [mode, now, sky] of times) { diff --git a/test/wall-harness.js b/test/wall-harness.js index 5112597..04f8d0f 100644 --- a/test/wall-harness.js +++ b/test/wall-harness.js @@ -199,7 +199,7 @@ function makeServer(fixture, overrides) { return server; } -function load({fixture = 'stress', now, clock, overrides, bridge, origin = 'http://127.0.0.1'} = {}) { +function load({fixture = 'stress', now, clock, overrides, bridge, audio, origin = 'http://127.0.0.1'} = {}) { const document = makeDocument(), server = makeServer(fixture, overrides), timers = []; // The device clock: real unless `clock` fixes it, and either way it can be moved on with `advance(ms)`. let fixed = clock ? Date.parse(clock) : null, offset = 0; @@ -222,6 +222,8 @@ function load({fixture = 'stress', now, clock, overrides, bridge, origin = 'http window.window = window; // A device bridge (`fully`): Fully Kiosk's, or Gingham's own app's, as a test fakes it. if (bridge) window.fully = bridge; + // Web Audio, when a test gives the page one (an AudioContext class it records); without it the page has none. + if (audio) window.AudioContext = audio; // The frame remembers its household's zone from the last start; so does this one. window.localStorage.setItem('frame.household', JSON.stringify({name: 'Household', timezone: 'America/Chicago', place: 'Austin'})); vm.createContext(window); diff --git a/test/wall-screen.test.js b/test/wall-screen.test.js new file mode 100644 index 0000000..cbbf5f1 --- /dev/null +++ b/test/wall-screen.test.js @@ -0,0 +1,313 @@ +// Screen time on the wall's own page (wall-harness.js), answered by the real rewards.js on the fixtures' clock: off until +// someone has it, the ring and its words, a running timer holding the wall, the PIN pad, and the token kept out of storage. +// The device is in London while the household is in Austin, as a Frameo resets itself to. +process.env.TZ = 'Europe/London'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fixtures = require('./fixtures'); +const {createRewards} = require('../rewards'); +const {load} = require('./wall-harness'); + +// What GET /api/rewards answers for a fixture: the real module, in memory, at the fixture's own instant. +function rewardsOf(name, extra = {}) { + const r = fixtures[name].rewards; + return {...createRewards({persist: false, initial: r.data, now: () => Date.parse(r.clock || '2026-09-23T17:40:00-05:00'), zone: () => 'America/Chicago'}).read(), pinSet: true, ...extra}; +} +const tab = w => w.$('tabs').children.find(b => b.textContent === 'Screen time'); +const open = w => { tab(w).click(); w.server.flush(); }; +const ring = w => w.$('screen-card').querySelector('.ring-text').textContent; + +test('Screen time is nowhere until someone has it, and then a tab with no count', () => { + let w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': {unit: 'min', day: '2026-09-23', now: Date.now(), people: [], pinSet: false}}}); + assert.equal(tab(w), undefined, 'off: no tab'); + assert.equal(w.$('screen-who').textContent, 'Nobody yet'); + w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen')}}); + assert.ok(tab(w)); + assert.equal(tab(w).querySelector('b'), null, 'minutes left are not work to be done'); + assert.equal(w.$('screen-who').textContent, 'June'); +}); + +test('The ring says what is left in Bricolage and in words, beside the day and its record', () => { + const w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen')}}); + open(w); + assert.equal(w.$('view-screen').hidden, false); + assert.equal(ring(w), '30min left'); + assert.equal(w.$('screen-card').querySelector('.ring').getAttribute('aria-label'), '30 minutes left of 40 today'); + assert.deepEqual(w.$('screen-card').querySelectorAll('.screen-sum div').map(d => d.textContent), ['Daily30 min', 'Earned+15 min', 'Added or taken−5 min', 'Used10 min']); + assert.deepEqual(w.$('screen-card').querySelectorAll('.screen-row').map(r => r.textContent), + ['4:10 PMTimer−10 min', '4 PMTaken away−5 min', '3:30 PMEarned for reading+5 min', '3 PMEarned for chores+10 min']); + assert.equal(w.$('screen-card').querySelector('.screen-go').textContent, 'Start'); + assert.equal(w.$('screen-note').textContent, 'Counts only while the timer is on'); +}); + +test('A day with no time left says so, and Start waits for a grown-up', () => { + const used = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen-none')}}); + open(used); + assert.equal(ring(used), '0min left'); + assert.equal(used.$('screen-card').querySelector('.screen-line').textContent, 'No time left today'); + const start = used.$('screen-card').querySelector('.screen-go'); + assert.deepEqual([start.textContent, start.disabled, used.$('screen-card').querySelector('.screen-hint').textContent], ['Start', true, 'A grown-up can add time']); +}); + +test('A running timer counts down on the frame and holds the wall instead of resting', () => { + const w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen-running'), '/api/settings': {rest: 'calendar', restAfter: 2}}}); + open(w); + assert.equal(ring(w), '15:00left'); + w.$('tab-calendar').click(); + assert.equal(w.$('view-screen').hidden, true); + w.advance(6 * 60000); + w.every(''); + assert.equal(w.$('view-screen').hidden, false, 'quiet, the wall goes back to the timer, not to the week'); + assert.equal(ring(w), '9:00left', 'counted from the server\'s answer, not the frame\'s clock'); +}); + +test('The PIN unlocks the grown-up’s controls; the token stays in the page and goes only in its header', () => { + const token = 'T'.repeat(43), sent = []; + const w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen'), 'POST /api/rewards/unlock': {token, expiresIn: 120}, 'POST /api/rewards/adjust': () => rewardsOf('screen')}}); + const XHR = w.server.XMLHttpRequest.prototype, setHeader = XHR.setRequestHeader; + XHR.setRequestHeader = function (name, value) { sent.push([this.url, name, value]); return setHeader.call(this, name, value); }; + open(w); + assert.equal(w.$('screen-card').querySelectorAll('.screen-row .quiet').length, 0, 'locked: nothing to remove'); + w.$('screen-manage').click(); + assert.equal(w.$('manage-eyebrow').textContent, 'Screen time'); + const key = k => w.$('keypad').children.find(b => b.textContent === k); + for (const d of '2580') key(d).click(); + key('OK').click(); w.server.flush(); + assert.equal(w.$('manage-dialog').hidden, true); + assert.equal(w.$('screen-manage').textContent, 'Lock'); + assert.ok(w.$('screen-card').querySelector('.screen-manage-panel')); + assert.equal(w.$('screen-card').querySelectorAll('.screen-row .quiet').length, 4, 'every entry can be removed'); + w.$('screen-card').querySelector('.screen-manage-panel .screen-go').click(); w.server.flush(); + assert.ok(sent.some(([url, name, value]) => url === '/api/rewards/adjust' && name === 'X-Gingham-Manage' && value === token)); + assert.ok(!sent.some(([url]) => url.includes(token)), 'never in an address'); + assert.ok(!JSON.stringify(w.window.localStorage).includes(token) && !w.document.cookie, 'never in storage'); + w.$('tab-calendar').click(); + assert.ok(sent.some(([url, name]) => url === '/api/rewards/lock' && name === 'X-Gingham-Manage'), 'leaving locks it'); + open(w); + assert.equal(w.$('screen-manage').textContent, 'Manage'); +}); + +test('With no household PIN, the wall says where to set one and offers no keypad', () => { + const w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen', {pinSet: false})}}); + open(w); + w.$('screen-manage').click(); + assert.equal(w.$('manage-title').textContent, 'No household PIN yet'); + assert.equal(w.$('manage-note').textContent, 'Set a household PIN on the setup page to manage screen time here.'); + assert.equal(w.$('keypad').hidden, true); +}); + +test('Out of reach, it shows the balance it had, says from when, and turns its controls off', () => { + const w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen')}}); + open(w); + w.server.answers['/api/rewards'] = undefined; + w.every('loadRewards'); + assert.equal(w.$('screen-note').textContent, 'Can’t reach the frame’s server. Showing the balance from 5:40 PM.'); + assert.equal(ring(w), '30min left'); + assert.equal(w.$('screen-card').querySelector('.screen-go').disabled, true); +}); + +// The seconds on the ring (MER-2089). The frame's clock is fixed and moved by the test, and so is the server's: a rewards +// module answering as server.js does, so a stop, a start and an undo are the real ones. +const CLOCK = '2026-09-23T17:40:00-05:00'; +function serverFor(name) { + let t = Date.parse(CLOCK); + const r = createRewards({persist: false, initial: fixtures[name].rewards.data, now: () => t, zone: () => 'America/Chicago'}); + const answer = out => ({...out, pinSet: true}); + let stopped = null; + return {move: ms => { t += ms; }, answers: { + '/api/rewards': () => answer(r.read()), + 'POST /api/rewards/start': () => answer(r.start('june', 'test')), 'POST /api/rewards/stop': () => { const out = r.stop('june', 'test'); stopped = out.changed.entry; return answer(out); }, + 'POST /api/rewards/resume': () => answer(r.resume('june', stopped)) + }}; +} +const card = (w, sel) => w.$('screen-card').querySelector(sel); +const label = w => card(w, '.ring').getAttribute('aria-label'); +// The clock's once-a-second timer, a second later on both clocks. +const second = (w, srv, n = 1) => { for (let i = 0; i < n; i++) { w.advance(1000); if (srv) srv.move(1000); w.every(''); } }; + +test('A running timer reads in minutes and seconds, from 1:04:37 down to 0:42, sized by its length', () => { + for (const [fixture, text, size] of [['screen-s3000', '30:00', 'n5'], ['screen-s2437', '24:37', 'n5'], ['screen-s0042', '0:42', 'n4'], ['screen-s10437', '1:04:37', 'n7']]) { + const w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: {'/api/rewards': rewardsOf(fixture)}}); + open(w); + assert.equal(ring(w), text + 'left', fixture); + assert.equal(card(w, '.ring-number').className, 'ring-number ' + size, fixture); + } + const w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: {'/api/rewards': rewardsOf('screen-s2437')}}); + open(w); + assert.equal(label(w), '24 minutes left of 40 today', 'the label is the minute, never the second'); +}); + +// Time's up (MER-2089): the server stops the timer at zero, and the page says so, once, with a chime when it can play one. +// Web Audio is a stand-in that records each tone it is asked to play: six tones are one chime. +function audioStub({state = 'running', resume} = {}) { + const tones = []; + class Context { + constructor() { this.state = state; this.currentTime = 0; this.destination = {}; Context.made++; } + resume() { return resume ? resume(this) : Promise.resolve(); } + createOscillator() { const o = {type: '', frequency: {value: 0}, connect() {}, start(at) { tones.push([o.frequency.value, at]); }, stop() {}}; return o; } + createGain() { return {gain: {setValueAtTime() {}, exponentialRampToValueAtTime() {}}, connect() {}}; } + } + Context.made = 0; Context.tones = tones; Context.chimes = () => tones.length / 6; + return Context; +} +const toastText = w => w.$('toast').hidden ? '' : w.$('toast-text').textContent; +const settle = () => new Promise(resolve => setImmediate(resolve)); + +test('At zero the timer is stopped: time’s up, when it stopped, nothing counted past it, and Start waits for a grown-up', () => { + const w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen-over')}}); + open(w); + assert.equal(ring(w), 'Time’s up'); + assert.equal(label(w), 'Time’s up. No time left of 40 today'); + assert.equal(card(w, '.screen-line').textContent, 'Timer stopped at 5:30 PM', 'where the time ran out, not when someone looked'); + const start = card(w, '.screen-go'); + assert.deepEqual([start.textContent, start.disabled, card(w, '.screen-hint').textContent], ['Start', true, 'A grown-up can add time']); + assert.equal(card(w, '.ring-arc').getAttribute('visibility'), 'hidden', 'the ring is empty, with no dot of a round end'); + assert.deepEqual(w.$('screen-card').querySelectorAll('.screen-sum div').map(d => d.textContent).slice(-1), ['Used40 min'], 'used is the day, not a minute more'); + assert.ok(!/over/.test(w.$('screen-card').textContent), 'no overtime anywhere'); + assert.equal(toastText(w), '', 'ten minutes ago: no chime and no toast'); +}); + +test('Crossing zero here: time’s up at once, one ask of the server, one chime, and nothing counts on', () => { + const srv = serverFor('screen-s0042'), audio = audioStub(); + let asked = 0; + const read = srv.answers['/api/rewards']; + srv.answers['/api/rewards'] = () => { asked++; return read(); }; + const w = load({now: '2026-09-23T17:40:00', clock: CLOCK, audio, overrides: srv.answers}); + open(w); + const before = asked; + second(w, srv, 41); + assert.equal(ring(w), '0:01left'); + assert.equal(audio.chimes(), 0); + second(w, srv); + assert.equal(ring(w), 'Time’s up'); + assert.equal(asked - before, 1, 'one ask, for the server’s stop'); + assert.equal(audio.chimes(), 1, 'one chime'); + assert.deepEqual(audio.tones.slice(0, 3).map(t => t[0]), [523.25, 659.25, 783.99], 'three rising tones'); + assert.equal(toastText(w), 'Time’s up for June'); + assert.equal(card(w, '.screen-line').textContent, 'Timer stopped at 5:40 PM'); + assert.equal(card(w, '.screen-go').disabled, true); + second(w, srv, 90); + assert.deepEqual([ring(w), asked - before, audio.chimes()], ['Time’s up', 1, 1], 'and stays: no ask, no chime, no count'); + assert.ok(!/over/.test(w.$('screen-card').textContent)); + w.every('loadRewards'); + assert.equal(audio.chimes(), 1, 'the minute’s read of the same stop chimes nothing'); +}); + +test('Loaded within two minutes of time’s up it chimes once; later, never', () => { + const audio = audioStub(); + const w = load({now: '2026-09-23T17:40:00', clock: CLOCK, audio, overrides: {'/api/rewards': rewardsOf('screen-sover')}}); + assert.equal(audio.chimes(), 1, 'stopped at 5:38:48, read at 5:40'); + open(w); + assert.equal(ring(w), 'Time’s up'); + w.every('loadRewards'); second(w, null, 5); + assert.equal(audio.chimes(), 1, 'once'); + const late = audioStub(); + load({now: '2026-09-23T17:40:00', audio: late, overrides: {'/api/rewards': rewardsOf('screen-over')}}); + assert.equal(late.chimes(), 0, 'stopped at 5:30: too long ago to ring for'); +}); + +test('Without sound the timer still stops, and the toast says the frame couldn’t play its chime', async () => { + // No Web Audio at all. + let w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: {'/api/rewards': rewardsOf('screen-sover')}}); + open(w); + assert.equal(toastText(w), 'Time’s up. This frame couldn’t play its chime.'); + assert.equal(ring(w), 'Time’s up'); + // A context the browser keeps suspended, and one whose resume() fails. + for (const resume of [ctx => Promise.resolve(), () => Promise.reject(Error('not allowed'))]) { + const audio = audioStub({state: 'suspended', resume}); + w = load({now: '2026-09-23T17:40:00', clock: CLOCK, audio, overrides: {'/api/rewards': rewardsOf('screen-sover')}}); + await settle(); + open(w); + assert.equal(audio.chimes(), 0); + assert.equal(toastText(w), 'Time’s up. This frame couldn’t play its chime.'); + assert.equal(card(w, '.screen-go').disabled, true, 'stopped all the same'); + } + // A context that comes up when resumed plays. + const audio = audioStub({state: 'suspended', resume: ctx => { ctx.state = 'running'; return Promise.resolve(); }}); + w = load({now: '2026-09-23T17:40:00', clock: CLOCK, audio, overrides: {'/api/rewards': rewardsOf('screen-sover')}}); + await settle(); + assert.equal(audio.chimes(), 1); + assert.equal(toastText(w), 'Time’s up for June'); +}); + +test('Settings plays the chime on request, once someone has screen time', () => { + let w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': {unit: 'min', day: '2026-09-23', now: Date.now(), people: [], pinSet: true}}}); + assert.equal(w.$('screen-chime-row').hidden, true, 'nobody has it: nothing to hear'); + const audio = audioStub(); + w = load({now: '2026-09-23T17:40:00', audio, overrides: {'/api/rewards': rewardsOf('screen')}}); + assert.equal(w.$('screen-chime-row').hidden, false); + assert.equal(w.$('screen-chime').textContent, 'Play the chime'); + w.$('screen-chime').click(); + assert.equal(audio.chimes(), 1); + assert.equal(toastText(w), '', 'the sound is the answer'); + w = load({now: '2026-09-23T17:40:00', overrides: {'/api/rewards': rewardsOf('screen')}}); + w.$('screen-chime').click(); + assert.equal(toastText(w), 'This frame couldn’t play its chime. Check that its sound is on.'); +}); + +test('A tick changes the ring and its number in place: the same Stop keeps focus, and nothing is announced', () => { + const w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: {'/api/rewards': rewardsOf('screen-s2437')}}); + open(w); + const stop = card(w, '.screen-go'), arc = card(w, '.ring-arc'), number = card(w, '.ring-number'), offsets = [arc.getAttribute('stroke-dashoffset')]; + stop.focus(); + for (const text of ['24:36', '24:35', '24:34']) { + second(w); + assert.equal(ring(w), text + 'left'); + assert.equal(card(w, '.screen-go'), stop, 'the same button'); + assert.equal(card(w, '.ring-number'), number, 'the same number'); + assert.equal(w.document.activeElement, stop, 'focus stays on Stop'); + offsets.push(arc.getAttribute('stroke-dashoffset')); + } + assert.equal(new Set(offsets).size, 4, 'the arc moves every second'); + assert.ok(Number(offsets[1]) > Number(offsets[0]), 'and shrinks'); + assert.equal(label(w), '24 minutes left of 40 today', 'its label waits for the minute'); + assert.equal(w.$('screen-card').querySelectorAll('[aria-live]').length, 0, 'no live region reads seconds'); + second(w, null, 35); + assert.equal(ring(w), '23:59left'); + assert.equal(label(w), '23 minutes left of 40 today', 'the minute moved: one redraw, one new label'); +}); + +test('Stopped between minutes it keeps the seconds, never rounded up; on a whole minute it is the big minutes', () => { + let w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: {'/api/rewards': rewardsOf('screen-stop2437')}}); + open(w); + assert.equal(ring(w), '24:37left'); + assert.equal(label(w), '24 minutes 37 seconds left of 40 today'); + w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: {'/api/rewards': rewardsOf('screen-stop25')}}); + open(w); + assert.equal(ring(w), '25min left'); + assert.equal(card(w, '.ring-number').className, 'ring-number'); +}); + +test('Stop and start again keep the seconds, and so does undoing a stop', () => { + const srv = serverFor('screen-s2437'), w = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: srv.answers}); + open(w); + second(w, srv, 3); + assert.equal(ring(w), '24:34left'); + card(w, '.screen-go').click(); w.server.flush(); + assert.equal(ring(w), '24:34left', 'stopped where it was'); + assert.equal(card(w, '.screen-go').textContent, 'Start'); + second(w, srv, 20); + assert.equal(ring(w), '24:34left', 'and stays there'); + card(w, '.screen-go').click(); w.server.flush(); + second(w, srv, 2); + assert.equal(ring(w), '24:32left', 'on again from the same second'); + card(w, '.screen-go').click(); w.server.flush(); + second(w, srv, 5); + w.$('toast-undo').click(); w.server.flush(); + assert.equal(ring(w), '24:27left', 'an undone stop charges every second since it started'); + second(w, srv); + assert.equal(ring(w), '24:26left'); +}); + +test('A reload in the middle of a minute shows the same second as the wall that was already on', () => { + const srv = serverFor('screen-s2437'), before = load({now: '2026-09-23T17:40:00', clock: CLOCK, overrides: srv.answers}); + open(before); + second(before, srv, 30); + assert.equal(ring(before), '24:07left'); + const after = load({now: '2026-09-23T17:40:30', clock: '2026-09-23T17:40:30-05:00', overrides: srv.answers}); + open(after); + assert.equal(ring(after), ring(before)); + second(before, srv); second(after); + assert.equal(ring(after), '24:06left'); + assert.equal(ring(before), '24:06left'); +}); diff --git a/test/wall.test.js b/test/wall.test.js index 93c7db4..5520929 100644 --- a/test/wall.test.js +++ b/test/wall.test.js @@ -719,7 +719,8 @@ test('Play page: the wall trusts only an https address, and Settings says nothin } const w = playWall(PLAY); w.$('settings-button').click(); w.server.flush(); - assert.doesNotMatch(w.document.body.textContent, /example\.com|play/i); + // Screen time's "Play the chime" is the one other "play" on the page. + assert.doesNotMatch(w.document.body.textContent.replace(/Play the chime/g, ''), /example\.com|play/i); }); test('Play page: the browser\'s own long press is held back only where there is a play page', () => {