diff --git a/site/countdown/index.html b/site/countdown/index.html new file mode 100644 index 0000000..02c52e4 --- /dev/null +++ b/site/countdown/index.html @@ -0,0 +1,38 @@ + + + + + + + Countdown Timer - Tools + + + + + +
+
+

Countdown Timer

+

Set a time and count down

+
+
+
+
+
+ + + +
+
+ + +
+ Idle +
+
+ + diff --git a/site/countdown/main.js b/site/countdown/main.js new file mode 100644 index 0000000..9c99b30 --- /dev/null +++ b/site/countdown/main.js @@ -0,0 +1,94 @@ +import { Countdown, integer } from './timer.mjs'; + +const timer = new Countdown(); +const minutes = document.querySelector('#minutes'); +const seconds = document.querySelector('#seconds'); +const counter = document.querySelector('.counter'); +const startStop = document.querySelector('#start-stop'); +const reset = document.querySelector('#reset'); +const status = document.querySelector('#status'); +const fields = [minutes, seconds]; +let ticker; + +function resize(field) { + field.style.width = `${Math.max(field === seconds ? 2 : 1, field.value.length)}ch`; + field.dataset.valid = field.value; +} + +function render(updateFields = true) { + if (updateFields) { + const display = timer.display(); + minutes.value = display.minutes; + seconds.value = display.seconds; + } + fields.forEach(field => { + field.readOnly = timer.state === 'running'; + resize(field); + }); + counter.classList.toggle('finished', timer.finished); + startStop.textContent = timer.state === 'running' ? 'Stop' : 'Start'; + const message = timer.finished ? 'Countdown finished' : + timer.state === 'running' ? 'Running' : + timer.state === 'paused' ? 'Paused' : 'Idle'; + if (status.textContent !== message) status.textContent = message; + if (timer.state !== 'running') { + clearInterval(ticker); + ticker = undefined; + } +} + +function edited() { + timer.edit(minutes.value, seconds.value); + render(false); +} + +for (const field of fields) { + resize(field); + field.addEventListener('input', () => { + if (field.readOnly || !/^\d*$/.test(field.value)) { + field.value = field.dataset.valid; + return; + } + edited(); + }); + field.addEventListener('keydown', event => { + if (field.readOnly || !['ArrowUp', 'ArrowDown'].includes(event.key)) return; + event.preventDefault(); + const step = event.shiftKey ? 10n : 1n; + const value = integer(field.value) + (event.key === 'ArrowUp' ? step : -step); + field.value = String(value < 0n ? 0n : value); + edited(); + }); + field.addEventListener('blur', () => { + if (field.readOnly) return; + field.value = String(integer(field.value)); + if (field === seconds) field.value = field.value.padStart(2, '0'); + resize(field); + }); +} + +startStop.addEventListener('click', () => { + timer.toggle(); + render(); + if (timer.state === 'running' && ticker === undefined) { + // This is only a display refresh; elapsed time comes from the deadline. + ticker = setInterval(() => { + timer.update(); + render(); + }, 50); + } +}); + +reset.addEventListener('click', () => { + timer.reset(); + render(); +}); + +document.addEventListener('visibilitychange', () => { + if (timer.state === 'running') { + timer.update(); + render(); + } +}); + +render(); diff --git a/site/countdown/style.css b/site/countdown/style.css new file mode 100644 index 0000000..cab1ff0 --- /dev/null +++ b/site/countdown/style.css @@ -0,0 +1,101 @@ +body { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +/* Keep the counter vertically centered in the viewport, below the header. */ +header { + position: absolute; + top: 0; +} + +main { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding-block: 10rem; +} + +.timer { + text-align: center; + min-width: 0; + max-width: 100%; + margin: 0; +} + +.counter { + display: flex; + align-items: baseline; + justify-content: center; + max-width: 100%; + overflow-x: auto; + font-family: var(--pico-font-family-monospace); + font-size: clamp(3rem, 12vw, 6rem); + line-height: 1.2; + margin-bottom: 1.5rem; +} + +.counter input { + box-sizing: content-box; + flex: 0 0 auto; + width: 1ch; + height: auto; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: inherit; + font: inherit; + line-height: inherit; + text-align: right; +} + +.counter #seconds { + width: 2ch; + text-align: left; +} + +.counter input:focus { + background: transparent; + box-shadow: none; +} + +.counter input:focus-visible:not([readonly]) { + outline: 2px solid var(--pico-primary); + outline-offset: 4px; +} + +.controls { + display: flex; + justify-content: center; + gap: 1rem; +} + +.controls button { + margin: 0; + min-width: 6rem; +} + +.counter.finished { + animation: flash 1s steps(1, end) infinite; +} + +@keyframes flash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/site/countdown/timer.mjs b/site/countdown/timer.mjs new file mode 100644 index 0000000..10d16eb --- /dev/null +++ b/site/countdown/timer.mjs @@ -0,0 +1,64 @@ +// BigInt keeps entered integers exact, even for very large countdowns. +export function integer(value) { + return /^\d+$/.test(String(value)) ? BigInt(value) : 0n; +} + +export function splitSeconds(seconds) { + return { + minutes: String(seconds / 60n), + seconds: String(seconds % 60n).padStart(2, '0'), + }; +} + +export class Countdown { + constructor(now = Date.now) { + this.now = now; + this.state = 'idle'; + this.finished = false; + this.duration = 0n; + this.remaining = 0n; + this.deadline = 0n; + } + + edit(minutes, seconds) { + if (this.state === 'running') return; + this.duration = (integer(minutes) * 60n + integer(seconds)) * 1000n; + this.remaining = this.duration; + this.state = 'idle'; + this.finished = false; + } + + update() { + if (this.state !== 'running') return; + const delta = this.deadline - BigInt(this.now()); + this.remaining = delta > 0n ? delta : 0n; + if (this.remaining === 0n) { + this.state = 'idle'; + this.finished = true; + } + } + + toggle() { + if (this.state === 'running') { + this.update(); + if (!this.finished) this.state = 'paused'; + return; + } + if (this.finished) this.remaining = this.duration; + this.finished = false; + this.deadline = BigInt(this.now()) + this.remaining; + this.state = 'running'; + this.update(); + } + + reset() { + this.remaining = this.duration; + this.state = 'idle'; + this.finished = false; + } + + display() { + // Round up so the timer only displays zero once its deadline is reached. + return splitSeconds((this.remaining + 999n) / 1000n); + } +} diff --git a/site/countdown/timer.test.mjs b/site/countdown/timer.test.mjs new file mode 100644 index 0000000..fce24f7 --- /dev/null +++ b/site/countdown/timer.test.mjs @@ -0,0 +1,142 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Countdown, integer, splitSeconds } from './timer.mjs'; + +function fixture() { + let now = 1000; + return { + timer: new Countdown(() => now), + advance(ms) { now += ms; }, + }; +} + +test('starts idle at zero and accepts nonnegative integers only', () => { + const { timer } = fixture(); + assert.equal(timer.state, 'idle'); + assert.deepEqual(timer.display(), { minutes: '0', seconds: '00' }); + for (const invalid of ['-1', '1.5', '1e2', 'abc', '']) { + assert.equal(integer(invalid), 0n); + } + assert.equal(integer('00090'), 90n); +}); + +test('normalizes overflowing seconds and preserves arbitrarily large integers', () => { + const { timer } = fixture(); + timer.edit('0', '90'); + timer.toggle(); + assert.deepEqual(timer.display(), { minutes: '1', seconds: '30' }); + const minutes = '123456789012345678901234567890'; + timer.reset(); + timer.edit(minutes, '120'); + assert.deepEqual(timer.display(), { minutes: String(BigInt(minutes) + 2n), seconds: '00' }); + assert.deepEqual(splitSeconds(61n), { minutes: '1', seconds: '01' }); +}); + +test('uses elapsed time, rounding up, even when refreshes are delayed', () => { + const { timer, advance } = fixture(); + timer.edit('0', '90'); + timer.toggle(); + advance(999); + timer.update(); + assert.deepEqual(timer.display(), { minutes: '1', seconds: '30' }); + advance(1); + timer.update(); + assert.deepEqual(timer.display(), { minutes: '1', seconds: '29' }); + advance(75000); + timer.update(); + assert.deepEqual(timer.display(), { minutes: '0', seconds: '14' }); +}); + +test('pause/resume preserves fractional seconds and excludes paused time', () => { + const { timer, advance } = fixture(); + timer.edit('0', '10'); + timer.toggle(); + advance(1250); + timer.toggle(); + assert.equal(timer.state, 'paused'); + assert.equal(timer.remaining, 8750n); + advance(100000); + timer.update(); + assert.equal(timer.remaining, 8750n); + timer.toggle(); + advance(749); + timer.update(); + assert.equal(timer.display().seconds, '09'); + advance(1); + timer.update(); + assert.equal(timer.display().seconds, '08'); +}); + +test('reset restores normalized last input while running or paused', () => { + const { timer, advance } = fixture(); + timer.edit('2', '90'); + for (const pause of [false, true]) { + timer.toggle(); + advance(1500); + if (pause) timer.toggle(); + timer.reset(); + assert.equal(timer.state, 'idle'); + assert.deepEqual(timer.display(), { minutes: '3', seconds: '30' }); + assert.equal(timer.finished, false); + } +}); + +test('editing a paused timer establishes a new countdown and reset value', () => { + const { timer, advance } = fixture(); + timer.edit('0', '10'); + timer.toggle(); + advance(2000); + timer.toggle(); + timer.edit('0', '90'); + assert.equal(timer.state, 'idle'); + timer.toggle(); + advance(3000); + timer.reset(); + assert.deepEqual(timer.display(), { minutes: '1', seconds: '30' }); +}); + +test('running countdown cannot be edited', () => { + const { timer } = fixture(); + timer.edit('0', '10'); + timer.toggle(); + timer.edit('5', '0'); + assert.equal(timer.duration, 10000n); + assert.equal(timer.state, 'running'); +}); + +test('completion returns to idle and Start restarts the original duration', () => { + const { timer, advance } = fixture(); + timer.edit('0', '2'); + timer.toggle(); + advance(5000); + timer.update(); + assert.equal(timer.state, 'idle'); + assert.equal(timer.finished, true); + assert.equal(timer.display().seconds, '00'); + timer.toggle(); + assert.equal(timer.finished, false); + assert.equal(timer.state, 'running'); + assert.equal(timer.display().seconds, '02'); +}); + +test('editing or resetting after completion clears the completion flag', () => { + const { timer } = fixture(); + timer.toggle(); + assert.equal(timer.finished, true); + timer.edit('0', '3'); + assert.equal(timer.finished, false); + timer.toggle(); + timer.reset(); + assert.equal(timer.finished, false); + assert.equal(timer.display().seconds, '03'); +}); + +test('stopping after the deadline completes instead of leaving a paused zero', () => { + const { timer, advance } = fixture(); + timer.edit('0', '1'); + timer.toggle(); + advance(1000); + timer.toggle(); + assert.equal(timer.state, 'idle'); + assert.equal(timer.finished, true); +}); diff --git a/site/index.html b/site/index.html index 0c17283..bfb00c4 100644 --- a/site/index.html +++ b/site/index.html @@ -19,6 +19,7 @@