DevelopmentMay 19, 20254 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

  • Python 3.8+ — from python.org.
  • Dependencies:
  • code
    pip install streamlit streamlit-autorefresh pandas

    Step 1: Create the Script

    Save as pomodoro.py:

    code
    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

    code
    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 (workbreak) 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.