Training for the Boston Marathon requires consistency, commitment, and a way to track progress over time. Imagine having a custom-built habit tracker that not only keeps you on pace but also helps visualize your progress—all powered by Python!

Today, we’ll walk through how to build a complete habit-tracking web app using Python, from start to finish. No need for JavaScript—this is a full-stack Python project designed to help you stay on track with your marathon milestones.

We’ll be leveraging the recently released panel-full-calendar, which is an extension of Panel. (You can wrap any Javascript libraries into Python with your own Panel extensions with: copier-template-panel-extension!)

Let’s get started with two straightforward lines:

from panel_full_calendar import Calendar
Calendar().show()

We can see a Calendar launch in our browser:

It’s a little crowded, so let’s set the sizing_mode to stretch to the whole screen:

from panel_full_calendar import Calendar
Calendar(sizing_mode="stretch_both").show()

Much better! In the upcoming app, we won’t need to view the week or day, so let’s also update the header_toolbar. Since we will need more imports later, let’s just do that now too.

import sqlite3
import datetime
import panel as pn
import pandas as pd
from panel_full_calendar import Calendar, CalendarEvent
calendar = Calendar(
    sizing_mode="stretch_both",
    header_toolbar={
        "left": "today",
        "center": "title",
        "right": "prev,next",
    },
)
calendar.show()

A calendar without events isn’t so useful. So let’s add some!

First, “Happy New Year!” You can pass date strings directly into add_event

new_years_str = "2025-01-01"
calendar.add_event(new_years_str, title="🎉 Happy New Year!")

Alternatively, you can also pass in a datetime object. Let’s set the date of the theoretical marathon we’re training for:

marathon_dt = datetime.datetime(2025, 4, 21)
calendar.add_event(marathon_dt, title="🏃 Boston Marathon", color="red")

 

Finally, for every day until the marathon, let’s add a training event.

events = [
    dict(start=date, title="❌ Train")
    for date in pd.date_range(new_years_str, marathon_dt, freq="D")
]
calendar.add_events(events)

Great! We’re getting somewhere, but it’s a little unexciting, so let’s add some interactive toggling.

 

Here, when we click on an event, the ❌ turns into  ✅ and vice versa, so when the entire month is done, we can see all the hard work we’ve put in!

today = pd.to_datetime(datetime.datetime.now())
def toggle_action(event_dict):
    event = CalendarEvent.from_dict(event_dict, calendar)
    if pd.to_datetime(event.start).date() > today.date():
        # do not let users click beyond the current date
        return
    if "❌" in event.title:
        title = event.title.replace("❌", "✅")
    else:
        title = event.title.replace("✅", "❌")
    event.set_props(title=title)
calendar.event_click_callback = toggle_action

Cool! What if we wanted to see our stats, like days until Marathon, or how many days we committed to our streak?

 

Easy! Start by initializing some indicators (don’t be afraid of the length; lots of it is repeat keyword arguments).

progress = pn.indicators.Progress(
    value=0,
    active=False,
    max=len(events) - 2,
    sizing_mode="stretch_width",
    align="center",
)
goals_completed = pn.indicators.Number(
    value=0,
    name="Complete",
    colors=[(0, "red"), (50, "gold"), (100, "green")],
    font_size="36px",
    title_size="18px",
    align="center",
)
goals_missed = pn.indicators.Number(
    value=0,
    name="Miss",
    colors=[(0, "green"), (30, "red")],
    title_size="18px",
    font_size="36px",
    align="center",
)
completion_rate = pn.indicators.Number(
    value=0,
    name="Rate",
    format="{value}%",
    colors=[(0, "red"), (50, "gold"), (100, "green")],
    title_size="18px",
    font_size="36px",
    align="center",
)
days_streak = pn.indicators.Number(
    value=0,
    name="Streak",
    colors=[(0, "red"), (30, "gold"), (60, "green")],
    title_size="18px",
    font_size="36px",
    align="center",
)
days_left = pn.indicators.Number(
    value=0,
    name="Days left",
    colors=[(0, "red"), (60, "gold"), (120, "green")],
    title_size="18px",
    font_size="36px",
    align="center",
)
indicators = pn.Column(
    pn.pane.HTML("<h2>🚀 Progress Tracker</h2>"),
    progress,
    pn.layout.Divider(),
    pn.Row(goals_completed, goals_missed, completion_rate, align="center"),
    pn.layout.Divider(),
    pn.Row(days_streak, days_left, align="center"),
    height=500,
)

 

We can put these in the sidebar, with the calendar in the main. We can also give it a title, use a dark theme, and set custom colors:

pn.template.FastListTemplate(
    sidebar=[indicators],
    main=[calendar],
    title="📅 Marathon Training Calendar",
    accent_base_color="#D3D3D3",
    header_background="#36454F",
    theme="dark",
    sidebar_width=250,
).show()

 

It’s looking good, but the indicators are all 0… Let’s fix that with some calculation function:

async def update_indicators(value):
    total_days = (marathon_dt - today).days
    days_from_start = (today.date() - pd.to_datetime(new_years_str).date()).days
    # Set days_left based on total_days
    days_left.value = total_days
    completed_goals = 0
    missed_goals = 0
    current_streak = 0
    max_streak = 0
    last_date = None
    # Sort events by date to ensure chronological processing
    sorted_events = sorted(value, key=lambda x: pd.to_datetime(x["start"]).date())
    # Single loop to calculate both completed and missed goals
    for event in sorted_events:
        title = event["title"]
        if not ("✅" in title or "❌" in title):
            continue
        start = pd.to_datetime(event["start"]).date()
        # Skip future dates
        if start > today.date():
            break
        # Check for date continuity in streak
        if last_date is not None:
            days_difference = (start - last_date).days
            if days_difference > 1:  # Break in streak
                max_streak = max(max_streak, current_streak)
                current_streak = 
        if "✅" in title:
            completed_goals += 1
            current_streak += 1
            last_date = start
        elif "❌" in title:
            missed_goals += 1
            max_streak = max(max_streak, current_streak)
            current_streak = 0
            last_date = start
    # Handle final streak
    max_streak = max(max_streak, current_streak)
    # Calculate goals met percentage if days_from_start is positive
    completion_rate.value = round(
        (max(completed_goals - 1, 0) / days_from_start * 100)
        if days_from_start > 0
        else 0
    )
    # Set current streak (use max_streak for the longest streak achieved)
    days_streak.value = max_streak
    # Set goals completed
    goals_completed.value = completed_goals
    # Set goals missed (removed the -1 as it was artificially reducing missed goals)
    goals_missed.value = max(missed_goals - 1, 0)
    # Update progress bar
    progress.value = completed_goals

 

Now that we have a function to call, we just need to bind it to the calendar’s value:

 

Here, we watch when the calendar’s value changes to trigger update_indicators; we also trigger the calendar’s value once to initialize the value.

pn.bind(update_indicators, value=calendar.param.value, watch=True)
calendar.param.trigger("value")

And voila: an interactive habit tracker calendar! 🎉