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
+
+
+
+
+
+
+
+
+
+
+
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 @@