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:
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-%dboth 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_charton 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.