Tech With Galvan
Back to Articles
Development5/19/2025 • 4 min read

Pomodoro Focus Timer using Python and Streamlit

Build a Pomodoro focus timer with Python and Streamlit — 25/5 work-break cycles, session tracking, and daily focus stats.

Galvan
Galvan

Founder & Creator

Introduction

The Pomodoro technique — 25 minutes of focus, 5 minutes of break, repeat — is simple, and that’s exactly why a digital version should be simple too. This build extends the countdown-timer idea into a cycling app: work sessions flow into breaks automatically, completed pomodoros accumulate, and a daily stats row shows your focus totals. It is the deadline mechanics of the countdown timer plus the session logging of the expense tracker.

Features

  • Work/break cycles — 25-minute focus, 5-minute break, auto-advancing.
  • Live display — current phase, time left, and cycle count.
  • Session log — completed pomodoros saved to CSV with timestamps.
  • Daily stats — total focus minutes today at a glance.
  • Skip button — move to the next phase anytime.

Prerequisites

pip install streamlit streamlit-autorefresh pandas

Step 1: Create the Script

Save as pomodoro.py:

import streamlit as st
import pandas as pd
from datetime import datetime, timedelta
from streamlit_autorefresh import st_autorefresh
import os

LOG = "pomodoros.csv"
WORK, BREAK = 25, 5

st.set_page_config(page_title="Pomodoro", page_icon="🍅")
st.title("🍅 Pomodoro Timer")

running = "deadline" in st.session_state
if running and not st.session_state.get("paused"):
    st_autorefresh(interval=1000, key="tick")


def start_phase(phase):
    mins = WORK if phase == "work" else BREAK
    st.session_state.phase = phase
    st.session_state.deadline = datetime.now() + timedelta(minutes=mins)
    st.session_state.duration = mins * 60
    st.session_state.paused = False


c1, c2 = st.columns(2)
if c1.button("▶️ Start work session", type="primary"):
    start_phase("work")
    st.rerun()
if c2.button("⏭ Skip phase"):
    start_phase("break" if st.session_state.get("phase") == "work" else "work")
    st.rerun()

if running:
    remaining = (st.session_state.deadline - datetime.now()).total_seconds()
    if st.session_state.get("paused"):
        remaining = st.session_state["paused_remaining"]

    mins, secs = divmod(max(int(remaining), 0), 60)
    phase_label = "🍅 Focus" if st.session_state.phase == "work" else "☕ Break"
    st.metric(phase_label, f"{mins:02d}:{secs:02d}")
    st.progress(max(0, 1 - remaining / st.session_state.duration))

    p1, p2 = st.columns(2)
    if p1.button("⏸ Pause" if not st.session_state.get("paused") else "▶️ Resume"):
        if st.session_state.get("paused"):
            st.session_state.deadline = datetime.now() + timedelta(seconds=st.session_state["paused_remaining"])
            st.session_state.paused = False
        else:
            st.session_state.paused = True
            st.session_state["paused_remaining"] = remaining
        st.rerun()

    if remaining <= 0:
        if st.session_state.phase == "work":
            row = pd.DataFrame([{
                "date": datetime.now().strftime("%Y-%m-%d"),
                "time": datetime.now().strftime("%H:%M"), "minutes": WORK,
            }])
            row.to_csv(LOG, mode="a", header=not os.path.exists(LOG), index=False)
            st.balloons()
            start_phase("break")
        else:
            start_phase("work")
        st.rerun()

if os.path.exists(LOG):
    log = pd.read_csv(LOG)
    today = log[log["date"] == datetime.now().strftime("%Y-%m-%d")]
    m1, m2 = st.columns(2)
    m1.metric("Pomodoros today", len(today))
    m2.metric("Focus minutes today", int(today["minutes"].sum()))
    with st.expander("Session log"):
        st.dataframe(log.tail(10), hide_index=True)

Step 2: Run the App

streamlit run pomodoro.py

Start a session, work until the balloons, and the app drops you straight into a break — your tally grows in the stats row.

How It Works

The app is a two-state machine (work ↔ break) wearing a timer’s clothes. start_phase() is the single transition function: it sets the phase name, builds a fresh deadline, and resets pause state. Completion detection — remaining <= 0 — is what triggers the flip, and because the work branch logs to CSV before transitioning, no completed pomodoro is ever lost to a refresh.

The CSV append uses mode="a" with header=not os.path.exists(LOG) — write headers only when creating the file. This one-liner log pattern is deliberately minimal; if you outgrow it, the expense tracker’s read-modify-write handles edits.

Autorefresh runs only while the timer is unpaused, exactly as in the countdown timer — idle screens cost zero requests.

Common Errors & Fixes

  • Break never starts after work ends — the completion branch must end with st.rerun(); without it, the UI shows the old phase until your next click.
  • Double-logged pomodoros — the log line runs on every rerun while remaining <= 0; transition the phase immediately after logging so the condition can’t repeat.
  • Stats show 0 after a session — the date string in the log and the comparison string differ (%Y-%m-%d both sides, or they won’t match).
  • Pause button label lags one click — the label expression reads state that the click handler changes; rerun ordering fixes it, keep the handler and label in the same script pass.

Key Concepts

  • State machine — two phases, one transition function, no special cases.
  • Log-then-transition — persist before flipping state to avoid data loss.
  • CSV append mode — headers-once logging in a single line.
  • Conditional autorefresh — tick only while a phase is active.

What to Try Next

  • Add long breaks — every 4th pomodoro gets 15 minutes (track a cycle counter in session state).
  • Add a task label per session and group stats by task.
  • Chart focus minutes per day with st.bar_chart on the grouped log.
  • Add a completion sound via st.audio, like the countdown’s alert idea.

FAQ

Why 25 and 5 minutes?

Francesco Cirillo’s original technique — long enough to make progress, short enough to stay urgent. The constants are variables; tune them to your rhythm.

Where is my data stored?

In pomodoros.csv next to the script — one row per completed session. Copy it to back up, open it in Excel, or point the app at a shared drive.

Does the timer survive a page refresh?

Yes — the deadline is server-side, so refreshing shows the correct remaining time, exactly like the countdown timer.