1. The Problem
Build a countdown timer that accepts a duration (e.g. node timer.mjs 5m or node timer.mjs 90s), displays a live countdown that updates in place, and plays a bell when it hits zero.
Why this project? setInterval, clearInterval,
for in-place terminal output, and parsing human-readable time strings — all in one small program.
2. How to Think About This
Parse the input into total seconds. Each second, decrement and redraw the display using
(carriage return without newline — rewrites the same line). When seconds hit zero, clear the interval and ring the terminal bell ().
3. The Build
export function parseTime(input) {
if (!input) throw new Error("Provide a duration: 90s, 5m, 1h30m");
const hours = +(input.match(/(\d+)h/)?.[1] ?? 0);
const minutes = +(input.match(/(\d+)m/)?.[1] ?? 0);
const seconds = +(input.match(/(\d+)s/)?.[1] ?? 0);
const total = hours * 3600 + minutes * 60 + seconds;
if (total <= 0) throw new RangeError("Duration must be greater than zero");
return total;
}
export function formatTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return h
? `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`
: `${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
}
const rawInput = process.argv[2];
let remaining;
try {
remaining = parseTime(rawInput);
} catch (e) {
console.error(e.message);
process.exit(1);
}
console.log(`Starting: ${formatTime(remaining)}
`);
const interval = setInterval(() => {
process.stdout.write(`
${formatTime(remaining)} `);
if (remaining === 0) {
clearInterval(interval);
process.stdout.write("
");
process.stdout.write(""); // terminal bell
console.log(" ✓ Time's up!");
process.exit(0);
}
remaining--;
}, 1000);
4. Test & Prove
import { parseTime, formatTime } from "./timer.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
test("90s = 90", () => { if(parseTime("90s")!==90) throw new Error(); });
test("5m = 300", () => { if(parseTime("5m")!==300) throw new Error(); });
test("1h = 3600", () => { if(parseTime("1h")!==3600) throw new Error(); });
test("1h30m = 5400",() => { if(parseTime("1h30m")!==5400) throw new Error(); });
test("format 90s", () => { if(formatTime(90)!=="01:30") throw new Error(formatTime(90)); });
test("format 3600", () => { if(formatTime(3600)!=="01:00:00") throw new Error(); });
test("format 0", () => { if(formatTime(0)!=="00:00") throw new Error(); });
test("zero throws", () => { try{parseTime("0s");throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
console.log(`
${p} passed, ${f} failed`);5. The Interface
node timer.mjs 90s # 1 minute 30 seconds node timer.mjs 5m # 5 minutes node timer.mjs 1h30m # 1 hour 30 minutes # Updates in place: 01:29 01:28 01:27 ... # When done: ✓ Time's up! + terminal bell
6. Run It
node timer.mjs 30s
node timer.test.mjs🎯 Try this next
- Add a
--labelflag:node timer.mjs 25m --label "Focus session" - Add a pause/resume — listen for a keypress using
process.stdin.setRawMode - Log completed sessions to a JSON file (mini Pomodoro tracker)
- Build a browser version with a circular SVG progress ring
What you practised
Regex-based time string parsing, setInterval/clearInterval, in-place terminal output with
, the terminal bell escape (), and separating pure parse/format logic from the timer loop for testability.
Reference: Regular Expressions · Async & Promises