diff --git a/site/countdown/main.js b/site/countdown/main.js index c51aa60..e5454b6 100644 --- a/site/countdown/main.js +++ b/site/countdown/main.js @@ -1,4 +1,5 @@ import { Countdown, integer, adjustTime } from './timer.mjs'; +import { SETTINGS_KEY, serializeSettings, parseSettings } from './settings.mjs'; const timer = new Countdown(); const minutes = document.querySelector('#minutes'); @@ -8,8 +9,17 @@ const startStop = document.querySelector('#start-stop'); const reset = document.querySelector('#reset'); const status = document.querySelector('#status'); const fields = [minutes, seconds]; +const title = document.title; let ticker; +function saveSettings() { + try { + localStorage.setItem(SETTINGS_KEY, serializeSettings(minutes.value, seconds.value)); + } catch { + // The timer still works when storage is disabled or full. + } +} + function resize(field) { field.style.width = `${Math.max(field === seconds ? 2 : 1, field.value.length)}ch`; field.dataset.valid = field.value; @@ -26,6 +36,7 @@ function render(updateFields = true) { resize(field); }); counter.classList.toggle('finished', timer.finished); + document.title = timer.finished ? `🔔 ${title}` : title; startStop.textContent = timer.state === 'running' ? 'Stop' : 'Start'; const message = timer.finished ? 'Countdown finished' : timer.state === 'running' ? 'Running' : @@ -39,6 +50,7 @@ function render(updateFields = true) { function edited() { timer.edit(minutes.value, seconds.value); + saveSettings(); render(false); } @@ -93,4 +105,12 @@ document.addEventListener('visibilitychange', () => { } }); -render(); +try { + const saved = parseSettings(localStorage.getItem(SETTINGS_KEY)); + minutes.value = saved.minutes; + seconds.value = saved.seconds; + timer.edit(minutes.value, seconds.value); +} catch { + // Storage may be unavailable; retain the default idle countdown. +} +render(false); diff --git a/site/countdown/settings.mjs b/site/countdown/settings.mjs new file mode 100644 index 0000000..6364301 --- /dev/null +++ b/site/countdown/settings.mjs @@ -0,0 +1,26 @@ +import { integer } from './timer.mjs'; + +export const SETTINGS_KEY = 'countdown-settings'; + +export function serializeSettings(minutes, seconds) { + return JSON.stringify({ + minutes: String(integer(minutes)), + seconds: String(integer(seconds)), + }); +} + +export function parseSettings(json) { + try { + const settings = JSON.parse(json); + if (typeof settings?.minutes === 'string' && /^\d+$/.test(settings.minutes) && + typeof settings?.seconds === 'string' && /^\d+$/.test(settings.seconds)) { + return { + minutes: String(integer(settings.minutes)), + seconds: String(integer(settings.seconds)).padStart(2, '0'), + }; + } + } catch { + // Missing or invalid storage is equivalent to a fresh countdown. + } + return { minutes: '0', seconds: '00' }; +} diff --git a/site/countdown/settings.test.mjs b/site/countdown/settings.test.mjs new file mode 100644 index 0000000..18dc06f --- /dev/null +++ b/site/countdown/settings.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { serializeSettings, parseSettings } from './settings.mjs'; + +test('settings store only entered minutes and seconds, not countdown state', () => { + assert.deepEqual(JSON.parse(serializeSettings('2', '90')), { + minutes: '2', seconds: '90', + }); + assert.deepEqual(parseSettings(serializeSettings('2', '90')), { + minutes: '2', seconds: '90', + }); +}); + +test('restored seconds have at least two digits and leading zeros are canonicalized', () => { + assert.deepEqual(parseSettings(serializeSettings('001', '005')), { + minutes: '1', seconds: '05', + }); +}); + +test('empty input is stored as zero', () => { + assert.deepEqual(parseSettings(serializeSettings('', '')), { + minutes: '0', seconds: '00', + }); +}); + +test('large entered integers round trip without losing precision', () => { + const minutes = '123456789012345678901234567890'; + const seconds = '987654321098765432109876543210'; + assert.deepEqual(parseSettings(serializeSettings(minutes, seconds)), { minutes, seconds }); +}); + +test('missing, malformed, or invalid settings restore the default countdown', () => { + for (const json of [ + null, '', 'invalid json', 'null', '[]', '{}', '42', + '{"minutes": 1, "seconds": 2}', + '{"minutes": "-1", "seconds": "00"}', + '{"minutes": "0", "seconds": "1.5"}', + '{"minutes": "0", "seconds": ""}', + '{"minutes": "0", "seconds": "1e2"}', + ]) { + assert.deepEqual(parseSettings(json), { minutes: '0', seconds: '00' }); + } +});