Tier 2 — Intermediate

Pomodoro Timer

In-place terminal countdown — setInterval, clearInterval, Promise delays, SIGINT handling.

🐍 Python 🟨 JavaScript

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

pomodoro.mjs
// ── 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

pomodoro.test.mjs
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

  1. Add a bell: process.stdout.write("") plays the terminal bell when a session ends
  2. Add command-line config: node pomodoro.mjs --work 50 --break 10
  3. Add session history: write a log of completed sessions to a JSON file
  4. 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

Try It Yourself