DevelopmentJune 30, 20253 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

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

    Step 1: Create the Script

    Save as habit_tracker.py:

    code
    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:

    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

    code
    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 textunsafe_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.