Introduction
Habit trackers sell for the price of a coffee per month, but the core of one is a checkbox and a calendar. This app lets you define habits, check them off daily, and see your consistency as a GitHub-style contribution grid — green squares for done days. Streak counts keep you honest.
It combines the checkbox state handling of the to-do list app with the CSV persistence of the expense tracker, plus one genuinely fun visualization.
Features
Prerequisites
pip install streamlit pandas numpyStep 1: Create the Script
Save as habit_tracker.py:
import streamlit as st
import pandas as pd
import os
from datetime import date, timedelta
LOG = "habits.csv"
st.set_page_config(page_title="Habit Tracker", page_icon="🔥")
st.title("🔥 Habit Tracker")
def load_log():
if os.path.exists(LOG):
return pd.read_csv(LOG, parse_dates=["date"])
return pd.DataFrame(columns=["date", "habit"])
log = load_log()
today = pd.Timestamp(date.today())
# Manage habits
with st.sidebar:
st.header("Your habits")
habits_raw = st.text_area("One per line", value="Read 20 pages\nExercise\nNo sugar", height=140)
habits = [h.strip() for h in habits_raw.splitlines() if h.strip()]
# Today's check-off
st.subheader("Today")
changed = False
for habit in habits:
done_today = ((log["habit"] == habit) & (log["date"] == today)).any()
new_val = st.checkbox(habit, value=done_today, key=f"today_{habit}")
if new_val != done_today:
if new_val:
log = pd.concat([log, pd.DataFrame([{"date": today, "habit": habit}])], ignore_index=True)
else:
log = log[~((log["habit"] == habit) & (log["date"] == today))]
changed = True
if changed:
log.to_csv(LOG, index=False)
st.rerun()
# Grid + streaks
st.subheader("Last 12 weeks")
weeks = 12
start = today - timedelta(days=weeks * 7 - 1)
for habit in habits:
st.markdown(f"**{habit}** — 🔥 {streak(log, habit, today)} day streak")
cells = ""
for d in range(weeks * 7 - 1, -1, -1):
day = today - timedelta(days=d)
done = ((log["habit"] == habit) & (log["date"] == day)).any()
color = "#10b981" if done else "#2a2f35"
cells += (f"<span title='{day.date()}' style='display:inline-block;width:13px;height:13px;"
f"border-radius:3px;background:{color};margin:1px'></span>")
st.markdown(cells, unsafe_allow_html=True)Add the streak helper above the UI code:
def streak(log, habit, today):
days = set(log.loc[log["habit"] == habit, "date"].dt.date)
count, d = 0, today.date()
while d in days:
count += 1
d -= timedelta(days=1)
return countStep 2: Run the App
streamlit run habit_tracker.pyCheck off a habit and watch its square turn green and its streak tick up.
How It Works
The log is deliberately minimal: one CSV, two columns (date, habit). A habit was done on a day if a matching row exists — membership tests instead of pivot tables. Checking a box appends a row; unchecking removes it; the write happens once per interaction batch, guarded by the changed flag so reruns don't rewrite the file needlessly.
The contribution grid is pure HTML: 84 inline-styled <span> squares, green when a matching log row exists for that day. Building it as one markdown string (instead of 84 Streamlit widgets) keeps the app fast — one rerun paints the whole grid. The title attribute gives hover tooltips with the date, a free UX win.
The streak is a backwards walk: start at today, count consecutive days present in the habit's date set, stop at the first gap. A set makes each lookup O(1), so even years of history stay instant.
Common Errors & Fixes
st.rerun() must happen after *all* checkboxes render, not inside the loop per change (the changed flag pattern).unsafe_allow_html=True missing on the grid markdown.Key Concepts
What to Try Next
FAQ
Can multiple habits share one day?
Yes — each habit gets its own rows; the two-column key is (date, habit).
How do I rename a habit without losing history?
Rename it in the sidebar *and* update the CSV rows (log["habit"].replace(...)) — history follows the name.
Why CSV and not a database?
Two columns and a few thousand rows don't need SQL. The day your tracker grows users or queries, the expense tracker's migration notes apply here identically.