Add editable countdown timer with deadline-based timing
Co-authored-by: Shelley <shelley@exe.dev>
This commit is contained in:
parent
d3ed55847a
commit
9c993beda8
6 changed files with 440 additions and 0 deletions
38
site/countdown/index.html
Normal file
38
site/countdown/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Countdown Timer - Tools</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script type="module" src="main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="container">
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>Countdown Timer</h1>
|
||||||
|
<p>Set a time and count down</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="timer" aria-label="Countdown timer">
|
||||||
|
<div class="counter" role="group" aria-label="Time remaining">
|
||||||
|
<input id="minutes" type="text" inputmode="numeric" pattern="[0-9]*"
|
||||||
|
value="0" aria-label="Minutes" autocomplete="off" spellcheck="false"
|
||||||
|
title="Up/Down: change by 1. Shift+Up/Down: change by 10.">
|
||||||
|
<span aria-hidden="true">:</span>
|
||||||
|
<input id="seconds" type="text" inputmode="numeric" pattern="[0-9]*"
|
||||||
|
value="00" aria-label="Seconds" autocomplete="off" spellcheck="false"
|
||||||
|
title="Up/Down: change by 1. Shift+Up/Down: change by 10.">
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="start-stop" type="button">Start</button>
|
||||||
|
<button id="reset" class="secondary" type="button">Reset</button>
|
||||||
|
</div>
|
||||||
|
<span id="status" class="visually-hidden" role="status">Idle</span>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
94
site/countdown/main.js
Normal file
94
site/countdown/main.js
Normal file
|
|
@ -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();
|
||||||
101
site/countdown/style.css
Normal file
101
site/countdown/style.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
64
site/countdown/timer.mjs
Normal file
64
site/countdown/timer.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
site/countdown/timer.test.mjs
Normal file
142
site/countdown/timer.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<main>
|
<main>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/clocks/">Clocks</a></li>
|
<li><a href="/clocks/">Clocks</a></li>
|
||||||
|
<li><a href="/countdown/">Countdown Timer</a></li>
|
||||||
<li><a href="/freelens-logo/">Freelens Logo Maker</a></li>
|
<li><a href="/freelens-logo/">Freelens Logo Maker</a></li>
|
||||||
<li><a href="/golines/">Golines Playground</a></li>
|
<li><a href="/golines/">Golines Playground</a></li>
|
||||||
<li><a href="/gotemplate/">Go Template Playground</a></li>
|
<li><a href="/gotemplate/">Go Template Playground</a></li>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue