thoughts · May 31, 2026 · 12 min read

How I built a cyberpunk Pomodoro timer.
with vanilla everything.

A focus timer built with plain HTML, CSS, and JavaScript. No frameworks, no build tools. One file you open in a browser.

// what we’re building
  1. What we’re building
  2. What you need
  3. The file structure
  4. Stage 1: A timer that counts down
  5. Stage 2: The progress ring
  6. Stage 3: Three modes (focus, short break, long break)
  7. Stage 3.5: Custom time blocks
  8. Stage 4: Sound when time’s up
  9. Stage 5: Saving data with localStorage
  10. Stage 6: Tracking what you study
  11. Stage 7: The analytics dashboard
  12. Bonus: Making it a real app (PWA)
  13. Publishing it for free
  14. Where to go next

What we’re building

By the end of this you’ll have a working Pomodoro timer that:

It’s one HTML file. No npm install, no webpack, no React. If you know a little HTML and CSS, you can follow along.

We build it in seven stages. Each stage is a complete, working version you can run, so you’re never staring at 700 lines wondering what does what.

Want the finished file? The whole thing lives in one file: the complete index.html. Open it to run it, or View Source to read every line in one place. The rest of this post is how you get there from nothing.
The Pomodoro Technique, if you’re new to it: work in focused 25-minute blocks (“pomodoros”), take a 5-minute break after each, and a longer 15-minute break after every four. It’s a simple way to make big tasks feel manageable.

What you need

Create a file called index.html and open it in your browser. Every time you save, refresh the browser to see the change.

A note on what you’re building. This tutorial builds a stripped-down version of the timer. Every concept, formula, and API here is exactly what the finished app uses, just with simpler variable names and none of the extra polish (all 8 themes wired into CSS variables, the settings panel, and so on). Follow all the stages and you’ll have a real, working Pomodoro app you actually understand. The “Where to go next” section at the end covers how to layer on the rest. Same techniques throughout, taught in the order that makes them stick.

The file structure

Before we write any code, the big picture. For most of this tutorial, everything lives in a single file, index.html. That one file holds three things:

text
index.html
├── HTML   →  the structure (buttons, the timer display, the layout)
├── CSS    →  the styling (colors, glow, fonts)  — inside <style> tags
└── JS     →  the behavior (the countdown, clicks) — inside <script> tags

That’s the whole app for Stages 1 through 7. No folders, no setup, just one file you double-click to open. The browser already knows how to read HTML, CSS, and JavaScript, so there’s nothing to install or compile.

Here’s how those three parts sit inside the file:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <style>
    /* ALL your CSS goes here — the look and feel */
  </style>
</head>
<body>

  <!-- ALL your HTML goes here — the things you see -->
  <div id="time">25:00</div>
  <button onclick="toggle()">Start</button>

  <script>
    /* ALL your JavaScript goes here — the logic */
  </script>
</body>
</html>

The HTML is the structure, the CSS is the styling, the JavaScript is the behavior. They live in one file and talk to each other.

Only at the very end, when we turn this into an installable app (the Bonus section), do we add two small companion files. Then the folder looks like this:

text
my-pomodoro/
├── index.html      ← everything we build in Stages 1–7
├── manifest.json   ← tells phones/browsers it's an installable app
├── sw.js           ← the "service worker" that makes it work offline
├── icon-192.png    ← app icon (small)
└── icon-512.png    ← app icon (large)

Ignore those for now. For the entire core tutorial, it’s just index.html. When you see a block of CSS, it goes inside the <style> tags. When you see JavaScript, it goes inside the <script> tags. When you see HTML, it goes inside the <body>. I’ll remind you which is which as we go.

Stage 1: A timer that counts down

Start with the core: a number that ticks down from 25:00 to 00:00.

Paste this into index.html:

html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>POMO — Focus Timer</title>
<style>
  body {
    background: #0a0a0f;
    color: #e0ffe8;
    font-family: monospace;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    margin: 0;
  }
  .time {
    font-size: 72px;
    letter-spacing: -2px;
  }
  button {
    background: transparent;
    border: 2px solid #00ffc8;
    color: #00ffc8;
    padding: 12px 28px;
    font-size: 16px;
    border-radius: 8px;
    cursor: pointer;
    margin-top: 20px;
  }
  button:hover { background: #00ffc8; color: #0a0a0f; }
</style>
</head>
<body>
  <div class="time" id="time">25:00</div>
  <button id="startBtn" onclick="toggle()">Start</button>

<script>
  let remaining = 25 * 60;   // seconds left
  let running = false;       // is the timer ticking?
  let timerId = null;        // reference to setInterval so we can stop it

  // Turn a number of seconds into "MM:SS"
  function format(totalSeconds) {
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
  }

  function render() {
    document.getElementById('time').textContent = format(remaining);
  }

  function toggle() {
    if (running) {
      stop();
    } else {
      start();
    }
  }

  function start() {
    running = true;
    document.getElementById('startBtn').textContent = 'Pause';
    // setInterval runs the function inside it every 1000ms (1 second)
    timerId = setInterval(() => {
      remaining = remaining - 1;
      render();
      if (remaining <= 0) {
        clearInterval(timerId);
        running = false;
        document.getElementById('startBtn').textContent = 'Start';
      }
    }, 1000);
  }

  function stop() {
    clearInterval(timerId);   // stops the repeating function
    running = false;
    document.getElementById('startBtn').textContent = 'Start';
  }

  render();  // draw the initial 25:00
</script>
</body>
</html>

What’s happening here:

Save, refresh, click Start. Working timer. Everything else is built on top of this.

Stage 2: The progress ring

A bare number works but it’s dull. Let’s wrap it in a circular progress ring that drains as time runs out. We’ll use SVG, specifically a trick with stroke-dasharray.

The idea: an SVG circle’s outline can be turned into a dashed line. Make the “dash” exactly as long as the circle’s circumference, then offset it, and the stroke appears to fill or empty.

Replace your <body> content with this:

html
<body>
  <div class="timer-wrap">
    <svg class="timer-svg" viewBox="0 0 240 240">
      <circle class="ring-bg" cx="120" cy="120" r="110"/>
      <circle class="ring-track" id="ring" cx="120" cy="120" r="110"/>
    </svg>
    <div class="timer-inner">
      <div class="time" id="time">25:00</div>
      <div class="label">FOCUS</div>
    </div>
  </div>
  <button id="startBtn" onclick="toggle()">Start</button>

Add these styles inside your <style>:

css
.timer-wrap {
  position: relative;
  width: 240px;
  height: 240px;
}
.timer-svg {
  width: 100%;
  height: 100%;
  /* rotate so the ring starts draining from the top, not 3 o'clock */
  transform: rotate(-90deg);
}
.ring-bg {
  fill: none;
  stroke: #1e1e2a;       /* the dark background track */
  stroke-width: 7;
}
.ring-track {
  fill: none;
  stroke: #00ffc8;        /* the glowing colored part */
  stroke-width: 7;
  stroke-linecap: round;
  filter: drop-shadow(0 0 8px #00ffc8);  /* the glow */
  transition: stroke-dashoffset 1s linear;
}
.timer-inner {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.label {
  font-size: 11px;
  letter-spacing: 0.2em;
  color: #00ffc8;
}

Now the JavaScript. We calculate the circumference and update the ring’s offset each second. Add this near the top of your <script>:

javascript
const RADIUS = 110;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;   // ≈ 691
const total = 25 * 60;   // the full duration, used to calculate the fraction

Then update render() to also move the ring:

javascript
function render() {
  document.getElementById('time').textContent = format(remaining);

  const ring = document.getElementById('ring');
  ring.style.strokeDasharray = CIRCUMFERENCE;
  // As remaining shrinks, the offset grows, "emptying" the ring
  const offset = CIRCUMFERENCE * (1 - remaining / total);
  ring.style.strokeDashoffset = offset;
}

How it works. stroke-dasharray set to the full circumference makes one dash that wraps the whole circle. stroke-dashoffset then pushes that dash around. Offset 0 means the ring is full; offset equal to the circumference means it’s empty. We compute the offset from how much time is left. The transition in the CSS animates it smoothly instead of jumping.

Stage 3: Three modes (focus, short break, long break)

A real Pomodoro timer cycles through focus → short break → focus → … → long break. Let’s add mode tabs and the logic to switch between them.

Add tabs above the timer:

html
<div class="mode-tabs">
  <button class="mode-tab active" id="tab-focus" onclick="setMode('focus')">Focus</button>
  <button class="mode-tab" id="tab-short" onclick="setMode('short')">Short Break</button>
  <button class="mode-tab" id="tab-long" onclick="setMode('long')">Long Break</button>
</div>

Some styling:

css
.mode-tabs { display: flex; gap: 6px; margin-bottom: 24px; }
.mode-tab {
  padding: 9px 16px;
  border: 1px solid rgba(0,255,200,0.3);
  background: transparent;
  color: #3a6a5a;
  border-radius: 6px;
  cursor: pointer;
  font-family: monospace;
  text-transform: uppercase;
  font-size: 11px;
}
.mode-tab.active {
  background: #00ffc8;
  color: #0a0a0f;
}

Now restructure the JavaScript around a config object and a mode variable:

javascript
// How many minutes each mode lasts
const cfg = { focus: 25, short: 5, long: 15 };

let mode = 'focus';
let total = cfg.focus * 60;
let remaining = total;
let running = false;
let timerId = null;
let completedSessions = 0;   // counts focus sessions for the long-break cycle

function setMode(newMode, autoStart = false) {
  if (running) stop();
  mode = newMode;
  total = cfg[newMode] * 60;
  remaining = total;

  // Update which tab looks active
  document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
  document.getElementById('tab-' + newMode).classList.add('active');

  render();
  if (autoStart) setTimeout(start, 700);  // brief pause, then auto-begin
}

The interesting part is what happens when a timer hits zero. Right now the countdown inside start() just stops there. We want it to call a new onComplete() function that decides which mode comes next.

First, the decision-maker:

javascript
function onComplete() {
  if (mode === 'focus') {
    completedSessions++;
    if (completedSessions >= 4) {
      completedSessions = 0;
      setMode('long', true);    // every 4th focus → long break
    } else {
      setMode('short', true);   // otherwise → short break
    }
  } else {
    setMode('focus', true);     // after any break → back to focus
  }
}

Now wire it in. Replace your entire start() function with this version. The only change from Stage 1 is the last line inside the if (remaining <= 0) block, which now calls onComplete():

javascript
function start() {
  running = true;
  document.getElementById('startBtn').textContent = 'Pause';
  timerId = setInterval(() => {
    remaining = remaining - 1;
    render();
    if (remaining <= 0) {
      clearInterval(timerId);
      running = false;
      document.getElementById('startBtn').textContent = 'Start';
      onComplete();   // ← the new line: hand off to the next session
    }
  }, 1000);
}

That single onComplete() call is the hinge the whole cycle turns on. Everything above it is identical to the start() you already wrote, so if you’re copying along, just swap the whole function to be safe.

Now the timer runs a real Pomodoro cycle. It counts four focus sessions, drops short breaks between them, and gives you a long break on the fourth. The autoStart flag carries it from one session to the next without a click.

Stage 3.5: Custom time blocks

The classic Pomodoro is 25/5/15, but plenty of people prefer other rhythms: a 50/10 deep-work cycle, a quick 15/3 sprint, a 90-minute block. Let’s add +/− controls so you can dial in any duration, and make the long-break interval adjustable too.

The durations already live in one place, the cfg object:

javascript
const cfg = { focus: 25, short: 5, long: 15 };

Because everything reads from cfg, we only need a function that changes those numbers and refreshes the display. Add the settings controls to your HTML (one card per mode, plus an interval card):

html
<div class="settings">
  <div class="setting">
    <span class="setting-name">Focus</span>
    <button onclick="adjustDuration('focus', -1)">−</button>
    <span id="val-focus">25</span>
    <button onclick="adjustDuration('focus', 1)">+</button>
  </div>
  <div class="setting">
    <span class="setting-name">Short</span>
    <button onclick="adjustDuration('short', -1)">−</button>
    <span id="val-short">5</span>
    <button onclick="adjustDuration('short', 1)">+</button>
  </div>
  <div class="setting">
    <span class="setting-name">Long</span>
    <button onclick="adjustDuration('long', -1)">−</button>
    <span id="val-long">15</span>
    <button onclick="adjustDuration('long', 1)">+</button>
  </div>
  <div class="setting">
    <span class="setting-name">Interval</span>
    <button onclick="adjustInterval(-1)">−</button>
    <span id="val-interval">4</span>
    <button onclick="adjustInterval(1)">+</button>
  </div>
</div>

A little styling:

css
.settings {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  margin-top: 24px;
}
.setting {
  background: #16161f;
  border: 1px solid rgba(0,255,200,0.13);
  border-radius: 8px;
  padding: 12px 8px;
  text-align: center;
}
.setting-name {
  display: block;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: #5a7a70;
  margin-bottom: 8px;
}
.setting button {
  background: #1e1e2a;
  border: 1px solid rgba(0,255,200,0.3);
  color: #00ffc8;
  width: 22px;
  height: 22px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}
.setting span:not(.setting-name) {
  display: inline-block;
  min-width: 26px;
  font-size: 16px;
}

Now the JavaScript. We need a variable for the interval (replacing the hardcoded 4 from Stage 3) and two small functions:

javascript
let longBreakInterval = 4;   // focus sessions before a long break

function adjustDuration(which, delta) {
  // Clamp between 1 and 90 minutes so nobody sets a 0-minute timer
  cfg[which] = Math.max(1, Math.min(90, cfg[which] + delta));
  document.getElementById('val-' + which).textContent = cfg[which];

  // If we changed the mode we're CURRENTLY in, reset the clock to match.
  // (Changing 'short' while you're mid-focus won't disturb your running timer.)
  if (mode === which) {
    total = cfg[which] * 60;
    remaining = total;
    if (running) stop();
    render();
  }
}

function adjustInterval(delta) {
  longBreakInterval = Math.max(2, Math.min(8, longBreakInterval + delta));
  document.getElementById('val-interval').textContent = longBreakInterval;
}

Finally, update onComplete() from Stage 3 to use the variable instead of the hardcoded 4:

javascript
function onComplete() {
  if (mode === 'focus') {
    completedSessions++;
    if (completedSessions >= longBreakInterval) {   // ← was: >= 4
      completedSessions = 0;
      setMode('long', true);
    } else {
      setMode('short', true);
    }
  } else {
    setMode('focus', true);
  }
}

The design idea here. Every duration lives in cfg, and every screen reads from it. So supporting custom blocks costs almost nothing. Change the numbers in one spot and everything downstream follows: the timer, the ring, the cycle logic. One source of truth. Worth doing in every project.

Why the if (mode === which) check matters. Say you’re 10 minutes into a focus session and you bump the short break up to 10 minutes. You don’t want that resetting the focus timer you’re in the middle of. The check makes sure we only reset the clock for the mode you’re actually running.

Stage 4: Sound when time’s up

We want a chime, but shipping an audio file is overkill. The Web Audio API generates tones in code. Here’s a function that plays a three-note chime:

javascript
let audioCtx = null;

function getAudio() {
  // Browsers require a user click before audio can start,
  // so we create the context lazily on first use.
  if (!audioCtx) {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  }
  if (audioCtx.state === 'suspended') audioCtx.resume();
  return audioCtx;
}

function beep() {
  const ctx = getAudio();
  const notes = [880, 1108, 1318];   // an ascending chord (A5, C#6, E6)

  notes.forEach((frequency, i) => {
    const oscillator = ctx.createOscillator();
    const gain = ctx.createGain();
    oscillator.connect(gain);
    gain.connect(ctx.destination);

    oscillator.type = 'sine';
    oscillator.frequency.value = frequency;

    const startTime = ctx.currentTime + i * 0.18;   // stagger each note
    // Fade in quickly, then fade out — avoids an ugly click
    gain.gain.setValueAtTime(0, startTime);
    gain.gain.linearRampToValueAtTime(0.3, startTime + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.35);

    oscillator.start(startTime);
    oscillator.stop(startTime + 0.35);
  });
}

Then call beep() at the top of onComplete(). Now every session ends with a chime.

How it works. An oscillator generates a raw tone at a given frequency. A gain node controls its volume. Ramp the gain up fast and down slow and each note sounds like a soft bell instead of a harsh blip. Three notes slightly apart make a chord.

Tip. Browsers block audio until the user interacts with the page. That’s why getAudio() builds the context on demand and calls resume(). By the time a session ends the user has already clicked Start, so audio is unlocked.

Stage 5: Saving data with localStorage

Right now, refreshing the page wipes everything. localStorage fixes that. It’s a tiny key-value store in the browser that survives between visits. It only holds strings, so we use JSON.stringify to save and JSON.parse to load.

javascript
function save() {
  const data = {
    completedSessions,
    totalSessions,     // a running lifetime count you'd track in onComplete
    focusMinutes,      // total minutes focused
    log,               // an array of session records (see below)
  };
  try {
    localStorage.setItem('pomo-data', JSON.stringify(data));
  } catch (e) {
    // localStorage can fail in private-browsing mode; fail quietly
  }
}

function load() {
  try {
    const saved = JSON.parse(localStorage.getItem('pomo-data') || 'null');
    if (saved) {
      totalSessions = saved.totalSessions || 0;
      focusMinutes  = saved.focusMinutes || 0;
      log           = saved.log || [];
    }
  } catch (e) {}
}

To build study history, push a record onto log every time a focus session finishes. Inside onComplete(), before the mode switch:

javascript
if (mode === 'focus') {
  const now = new Date();
  const pad = v => String(v).padStart(2, '0');
  const dateKey = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());

  log.unshift({
    timestamp: now.getTime(),
    date: dateKey,                                  // "2026-05-30"
    time: pad(now.getHours()) + ':' + pad(now.getMinutes()),
    duration: cfg.focus,
    subject: activeSubject || '—',                  // from Stage 6
    note: currentNote || '',                        // from Stage 6
  });

  totalSessions++;
  focusMinutes += cfg.focus;
  save();   // persist immediately
}

log.unshift() adds to the front of the array so the newest session is first. Each record stores the date (for grouping), the subject, and a note. This array is the raw material for everything in the analytics dashboard.

Stage 6: Tracking what you study

Three small features turn this into a study tool rather than just a timer: subjects, a session note, and a task list.

The “what are you working on?” note

The simplest one. A text input whose value we read when a session completes:

html
<input id="note" placeholder="What are you working on?" oninput="currentNote = this.value"/>
javascript
let currentNote = '';

When the focus session ends, currentNote gets baked into the log record. We wired that in Stage 5.

Subjects

A subject is a category you attach time to: “Math”, “Spanish”, whatever. We store them in an object mapping name to minutes studied:

javascript
let subjects = {};        // e.g. { "Math": 75, "History": 50 }
let activeSubject = null; // which one is currently selected

function addSubject() {
  const input = document.getElementById('subjectInput');
  const name = input.value.trim();
  if (!name || subjects[name] !== undefined) return;  // skip blanks/dupes
  subjects[name] = 0;
  input.value = '';
  renderSubjects();
  save();
}

function selectSubject(name) {
  // Click to activate; click again to deactivate
  activeSubject = (activeSubject === name) ? null : name;
  renderSubjects();
}

To accrue time live while you focus, tick the active subject up inside the per-second interval:

javascript
// inside setInterval, each second of a focus session:
if (mode === 'focus' && activeSubject && subjects[activeSubject] !== undefined) {
  subjects[activeSubject] += 1 / 60;   // add one second, expressed in minutes
  renderSubjects();
}

Tasks

A simple checklist. Each task is an object with text and a done flag:

javascript
let tasks = [];

function addTask() {
  const input = document.getElementById('taskInput');
  const text = input.value.trim();
  if (!text) return;
  tasks.push({ text, done: false });
  input.value = '';
  renderTasks();
  save();
}

function toggleTask(index) {
  tasks[index].done = !tasks[index].done;
  renderTasks();
  save();
}

The render functions loop over the arrays and build HTML. Here’s the tasks one as an example:

javascript
function renderTasks() {
  const container = document.getElementById('taskList');
  if (tasks.length === 0) {
    container.innerHTML = '<div class="empty">No tasks yet</div>';
    return;
  }
  container.innerHTML = tasks.map((task, i) => `
    <div class="task ${task.done ? 'done' : ''}">
      <div class="checkbox ${task.done ? 'done' : ''}" onclick="toggleTask(${i})"></div>
      <span>${task.text}</span>
      <button onclick="deleteTask(${i})">✕</button>
    </div>
  `).join('');
}
Security note. When you inject user text into HTML with template strings, escape it first (replace < with &lt;) so nobody can break your layout by typing a tag. The full app does this on every user-supplied string.

Stage 7: The analytics dashboard

This is where the saved log pays off. We’ll add three views: summary stats, a bar chart, and a GitHub-style heatmap.

Summary stats

Just reductions over the filtered log:

javascript
function getStats(log) {
  const focusOnly = log.filter(e => !e.skipped);
  const totalMinutes = focusOnly.reduce((sum, e) => sum + e.duration, 0);
  const uniqueDays = new Set(focusOnly.map(e => e.date)).size || 1;
  const avgPerDay = focusOnly.length / uniqueDays;

  return {
    sessions: focusOnly.length,
    hours: Math.floor(totalMinutes / 60),
    avgPerDay: Math.round(avgPerDay * 10) / 10,
  };
}

The bar chart

For charts we pull in Chart.js from a CDN. One line, no install:

html
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>

Then build a “sessions per day” dataset for the last 7 days and hand it to Chart.js:

javascript
function buildChart(log) {
  const now = new Date();
  const labels = [];
  const data = [];

  for (let i = 6; i >= 0; i--) {
    const day = new Date(now);
    day.setDate(day.getDate() - i);
    const pad = v => String(v).padStart(2, '0');
    const key = day.getFullYear() + '-' + pad(day.getMonth() + 1) + '-' + pad(day.getDate());

    labels.push(['Su','Mo','Tu','We','Th','Fr','Sa'][day.getDay()]);
    data.push(log.filter(e => e.date === key && !e.skipped).length);
  }

  new Chart(document.getElementById('dayChart'), {
    type: 'bar',
    data: { labels, datasets: [{ data, backgroundColor: '#00ffc855', borderColor: '#00ffc8', borderWidth: 1.5 }] },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: { legend: { display: false } },
    }
  });
}

We count how many sessions happened on each of the last seven days, then let Chart.js draw the bars. Wrap the <canvas> in a div with a fixed height, or the chart grows uncontrollably:

html
<div style="position: relative; height: 150px;">
  <canvas id="dayChart"></canvas>
</div>

The heatmap

The GitHub-style activity grid is my favorite part. A grid of small squares, one per day for the last six months, shaded by how many sessions you did that day. We build it by hand with a loop:

javascript
function buildHeatmap(log) {
  // Count sessions per day
  const counts = {};
  log.filter(e => !e.skipped).forEach(e => {
    counts[e.date] = (counts[e.date] || 0) + 1;
  });

  const maxCount = Math.max(1, ...Object.values(counts));

  // Pick a shade of the accent color based on intensity
  function shade(n) {
    if (!n) return '#1e1e2a';                       // empty day = dark gray
    // Clamp to 1–4 so we always land on a valid opacity, even if
    // counts and maxCount ever drift (defensive — keeps it bulletproof).
    const level = Math.min(4, Math.max(1, Math.ceil((n / maxCount) * 4)));
    const opacity = [0, 0.3, 0.5, 0.75, 1][level];
    const hex = Math.round(opacity * 255).toString(16).padStart(2, '0');
    return '#00ffc8' + hex;                          // accent + opacity
  }

  // Walk backwards ~6 months, snapping to week boundaries (Sunday start)
  const now = new Date();
  const start = new Date(now);
  start.setDate(start.getDate() - 181);
  start.setDate(start.getDate() - start.getDay());

  let html = '';
  let cursor = new Date(start);

  while (cursor <= now) {
    html += '<div class="week">';
    for (let d = 0; d < 7; d++) {
      const pad = v => String(v).padStart(2, '0');
      const key = cursor.getFullYear() + '-' + pad(cursor.getMonth() + 1) + '-' + pad(cursor.getDate());
      const count = counts[key] || 0;
      html += `<div class="cell" style="background:${shade(count)}" title="${key}: ${count} sessions"></div>`;
      cursor.setDate(cursor.getDate() + 1);
    }
    html += '</div>';
  }

  document.getElementById('heatmap').innerHTML = html;
}
css
#heatmap { display: flex; gap: 3px; }
.week { display: flex; flex-direction: column; gap: 3px; }
.cell { width: 11px; height: 11px; border-radius: 2px; }

The logic. Each column is a week, seven stacked squares, and we lay the weeks side by side. We snap the start date back to a Sunday so the rows line up. shade() maps a day’s session count to an opacity of the accent color: busier day, brighter square. Hovering a square shows the date and count through the title attribute.

Bonus: Making it a real app (PWA)

To make the timer installable on a phone or desktop and work offline, you need two extra files next to index.html.

manifest.json tells the browser what the app is called and what icon to use:

json
{
  "name": "Pomodoro Focus Timer",
  "short_name": "POMO",
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#0a0a0f",
  "theme_color": "#00ffc8",
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

sw.js is a service worker. It caches your files so the app loads with no internet:

javascript
const CACHE = 'pomo-v1';
const ASSETS = ['./index.html', './manifest.json'];

self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
});

self.addEventListener('fetch', e => {
  e.respondWith(
    caches.match(e.request).then(cached => cached || fetch(e.request))
  );
});

Then link them in your <head> and register the service worker:

html
<link rel="manifest" href="manifest.json"/>
<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js');
  }
</script>

Now when someone visits over HTTPS, Chrome shows an “Install” button in the address bar, and the app runs offline.

Publishing it for free

You don’t need a server. Three options, all free, all give you the HTTPS URL the PWA install needs.

Netlify Drop (fastest)

  1. Go to app.netlify.com/drop.
  2. Drag your project folder, or a zip of it, onto the drop zone.
  3. A few seconds later you have a live URL like your-site.netlify.app.

No account needed for that first URL, but the anonymous version is temporary. Make a free account when prompted to claim it and keep it. After that you can rename it under Site settings, and you redeploy by dragging a new folder onto the Deploys page.

Cloudflare Pages

  1. Go to dash.cloudflare.com, open Workers & Pages, and click Create application.
  2. Choose Upload your static files (or the Pages “Get started” link for direct upload).
  3. Name the project first. Leave it blank and Cloudflare hands you something like plain-bar-8396, and there’s no clean rename afterward, you just redeploy.
  4. Drag your unzipped folder in and hit Deploy.

You land on your-project.pages.dev. The free tier has no bandwidth cap, which is the main reason to reach for it.

GitHub Pages

  1. Create a new public repo and put all your files in the root.
  2. In the repo, go to Settings, then Pages.
  3. Under Source, pick Deploy from a branch, choose main and / (root), and Save.
  4. A minute later it’s live at https://username.github.io/reponame.

Free GitHub Pages only serves public repos, so skip it for anything you want kept private.

Where to go next

That’s a working app, built from scratch, that you understand top to bottom. A few ways to take it further:

The point of building with plain HTML, CSS, and JavaScript is that nothing is hidden. Every line is yours to read and change. Open the file, change a color, break something, fix it. That’s how it sticks.

// the code