Tech With Galvan
Back to Articles
Development6/30/2025 • 3 min read

Habit Tracker using Python and Streamlit

Track daily habits with Python and Streamlit — check off habits, see streaks, and view a GitHub-style contribution grid of your consistency.

Galvan
Galvan

Founder & Creator

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

  • Custom habits — add or remove habits anytime.
  • Daily check-off — today’s habits as a checkbox row.
  • Streak counter — current consecutive days per habit.
  • Contribution grid — last 12 weeks as green squares.
  • CSV persistence — your history survives restarts.

Prerequisites

pip install streamlit pandas numpy

Step 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 count

Step 2: Run the App

streamlit run habit_tracker.py

Check 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

  • Checkbox flips back on rerun — the write-and-st.rerun() must happen after all checkboxes render, not inside the loop per change (the changed flag pattern).
  • KeyError: 'habit' — the CSV exists but is empty (headers only) or was created by an older version; delete it or handle the empty frame explicitly.
  • Streak counts yesterday’s gap as broken — that’s by design (a true daily streak). For “3 out of 7 days” flexibility, track weekly counts instead.
  • Grid renders as raw HTML text — unsafe_allow_html=True missing on the grid markdown.

Key Concepts

  • Membership-model data — a row’s existence is the state; no boolean columns.
  • Batched writes — collect changes, write once, rerun once.
  • HTML micro-visualizations — inline-styled spans as a heatmap.
  • Set-based streaks — O(1) lookups make date math trivial.

What to Try Next

  • Add a weekly summary chart — bar chart of check-ins per week with the dashboard patterns.
  • Add reminders: highlight habits not yet done today in amber.
  • Track quantities (pages read, minutes run) by adding a value column.
  • Sync the log to your home server for multi-device tracking.

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.