1. The Problem
Build a Pomodoro timer that counts down in the terminal. 25-minute work session,
then a 5-minute break. After 4 work sessions, a 15-minute long break.
Update the display in-place (overwrite the same line) with
.
Play a sound or show a system notification when a session ends.
Why this project? setInterval for repeating timers,
in-place terminal output with
, async/Promise-based delays,
and building a clean state machine.
2. How to Think About This
Separate the timer state (how many seconds remain, what type of session) from the display (writing to stdout) and the tick mechanism (setInterval). The state machine cycles: work → short break → work → ... → long break → repeat.
In-place terminal updates use
(carriage return without newline) —
the cursor goes back to the start of the line and overwrites the previous text.
3. The Build
// ── Config ────────────────────────────────────────────────────────────────
const CONFIG = {
work: 25 * 60, // seconds
shortBreak: 5 * 60,
longBreak: 15 * 60,
cyclesBeforeLongBreak: 4,
};
// ── Helpers ───────────────────────────────────────────────────────────────
export function formatTime(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ── Session runner ────────────────────────────────────────────────────────
async function runSession(label, totalSeconds) {
let remaining = totalSeconds;
return new Promise((resolve) => {
const interval = setInterval(() => {
//
overwrites the current line — in-place countdown
process.stdout.write(`
${label}: ${formatTime(remaining)} `);
if (remaining <= 0) {
clearInterval(interval);
process.stdout.write("
"); // move to next line when done
resolve();
}
remaining--;
}, 1000);
// Ctrl+C handler — clean up the interval
process.on("SIGINT", () => {
clearInterval(interval);
console.log("
Timer stopped.");
process.exit(0);
});
});
}
// ── Main loop ─────────────────────────────────────────────────────────────
async function main() {
let workCount = 0;
let sessionNum = 1;
console.log("🍅 Pomodoro Timer — Press Ctrl+C to stop
");
while (true) {
// Work session
console.log(`
Session ${sessionNum} — Work (${CONFIG.work / 60} min)`);
await runSession("Work", CONFIG.work);
workCount++;
sessionNum++;
console.log(" ✓ Work session complete! Time for a break.");
await delay(500);
// Break
if (workCount % CONFIG.cyclesBeforeLongBreak === 0) {
console.log(`
Long break (${CONFIG.longBreak / 60} min) 🌟`);
await runSession("Long break", CONFIG.longBreak);
console.log(" ✓ Long break done. Starting a new cycle.");
} else {
console.log(`
Short break (${CONFIG.shortBreak / 60} min) ☕`);
await runSession("Short break", CONFIG.shortBreak);
console.log(" ✓ Break over. Back to work!");
}
await delay(500);
}
}
main();
4. Test & Prove
import { formatTime } from "./pomodoro.mjs";
let pass=0,fail=0;
function test(desc,fn){try{fn();console.log(" ✓",desc);pass++;}catch(e){console.log(" ✗",desc,"—",e.message);fail++;}}
test("zero seconds", () => { if(formatTime(0)!=="00:00") throw new Error(formatTime(0)); });
test("one minute", () => { if(formatTime(60)!=="01:00") throw new Error(); });
test("25 minutes", () => { if(formatTime(25*60)!=="25:00") throw new Error(); });
test("one hour and a bit", () => { if(formatTime(3661)!=="61:01") throw new Error(formatTime(3661)); });
test("pads single digits", () => { if(formatTime(65)!=="01:05") throw new Error(formatTime(65)); });
test("1 second remaining", () => { if(formatTime(1)!=="00:01") throw new Error(); });
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
Input: none — timer runs automatically. Press Ctrl+C to stop.
Output: in-place countdown that overwrites the same terminal line:
🍅 Pomodoro Timer — Press Ctrl+C to stop Session 1 — Work (25 min) Work: 24:47 ← updates in place every second ✓ Work session complete! Time for a break. Short break (5 min) ☕ Short break: 04:58 ← updates in place
6. Run It
node pomodoro.mjs # starts immediately
node pomodoro.test.mjs # test formatTime without running the timer🎯 Try this next
- Add a bell:
process.stdout.write("")plays the terminal bell when a session ends - Add command-line config:
node pomodoro.mjs --work 50 --break 10 - Add session history: write a log of completed sessions to a JSON file
- Build a browser version with a progress ring (CSS +
setInterval)
What you practised
setInterval for recurring actions, clearInterval to stop it,
Promise-based delays with setTimeout, in-place terminal output via
,
process.on("SIGINT") for graceful Ctrl+C handling, and async/await to sequence
sessions cleanly without callback nesting.
Reference: Async & Promises · Scope & Closures · User Input & Output