Project P055

Habit tracker

Up to five habits checked in on real calendar days: the streak still running, the longest streak, check-ins taken back, and any month drawn as a Monday-first calendar with the days done - dates counted from 1900 and weekdays by Zeller's congruence.

3 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-10

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

Up to five habits, checked in on real calendar days. For each habit the tracker shows the streak still running, the longest streak and the days done this month, and draws any month as a calendar with the days done marked. Check-ins can be taken back, or made for an earlier day you forgot. The program has no clock: "today" starts as 2026-10-12 and can be set to any date.

  • main.eml - the menu, choosing a habit and a day, and the messages
  • dates.eml - calendar dates from 1900-01-01 to 2099-12-31: day numbers, leap years, weekdays and reading a date
  • habits.eml - check-ins, streaks and the month calendar

How each part works:

  • A date becomes a day number - days since 1900-01-01 - by adding up the years and months before it, as in the corpus case days-between-dates. Consecutive days then have consecutive numbers, so a streak is a run of numbers with no gap, and a habit's check-ins are a sorted list of them.
  • The weekday comes from Zeller's congruence, as in the corpus case day-of-week-zeller: January and February count as months 13 and 14 of the year before, which puts the leap day at the end of the year. It places the first of a month in its column of the calendar, which starts on Monday.
  • The current streak is the run ending today. If today is not checked in yet, it is the run ending yesterday, because the day is not over. The longest streak is the longest run ever, the earliest if two are equally long.
  • A day after today cannot be checked in. Setting today to an earlier date keeps the check-ins after it, and they still count in the longest streak.

What is checked: menu choices 1 to 8; a habit by its number or its name; a day as YYYY-MM-DD that is a real date between 1900-01-01 and 2099-12-31 and not after today; a month as YYYY-MM; habit names up to 30 characters, each used once, at most five habits. An empty answer cancels, except for a day, where it means today and x cancels.

Sessions: sessions/basic.in loads two sample habits covering the six weeks up to Monday 2026-10-12. - Reading has three days missed: an 11-day streak since 2026-10-02, and a longest of 19 days from 2026-09-12. - Running is on Mondays, Wednesdays and Fridays, so its streaks are single days. - Calendars: October for reading, then September for running (picked by name), with 13 runs. - Running is checked in again for today (it is already in), then for Sunday, making a 2-day streak. - 2026-10-02 is taken back from reading, so the streak is 10 days. - A third habit is added and checked in. - After today moves to Wednesday 2026-10-14, no streak is running, since nothing was checked in on the 13th.

sessions/bad-input.in gives: - menu choices 0 and x; - every action before there is a habit; - an empty name, a name over 30 characters, and a name used twice; - days 2026-13-01, 2026-02-30, the day after today, 1899-12-31, and x; - a check-in for today, then again; - taking back a day that was not checked in; - months 2026-13 and the coming November; - a sixth habit; - habit 9; - today set to 2026-02-29 (not a leap year), then to 2024-02-29.

Built on the verified corpus cases days-between-dates (counting the days between two dates through a day number) and day-of-week-zeller (the weekday of a date by Zeller's congruence).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal
== Habit tracker ==
Check in a habit each day it is done; streaks count the days in a row.
Today is 2026-10-12, a Monday. Change it with 7) today.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 0
Pick a number from 1 to 8.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> x
Pick a number from 1 to 8.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
There is no habit yet - add one first.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 2
There is no habit yet - add one first.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 3
There is no habit yet - add one first.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 4
There is no habit yet - add one first.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> 
Cancelled.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> A name that is far too long to keep
Keep the name to 30 characters.
name> Walk
Added Walk.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> Walk
There is a habit called Walk already.
name> 
Cancelled.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
day (YYYY-MM-DD, nothing for today, x to cancel)> 2026-13-01
Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12.
day (YYYY-MM-DD, nothing for today, x to cancel)> 2026-02-30
Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12.
day (YYYY-MM-DD, nothing for today, x to cancel)> 2026-10-13
That is after today, 2026-10-12.
day (YYYY-MM-DD, nothing for today, x to cancel)> 1899-12-31
Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12.
day (YYYY-MM-DD, nothing for today, x to cancel)> x
Cancelled.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
day (YYYY-MM-DD, nothing for today, x to cancel)> 
Checked in Walk on 2026-10-12, a Monday.
Walk: streak 1 day (since 2026-10-12), longest 1 day (from 2026-10-12), 1 day this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
day (YYYY-MM-DD, nothing for today, x to cancel)> 
Walk is already checked in on 2026-10-12, a Monday.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 2
day (YYYY-MM-DD, nothing for today, x to cancel)> 2026-10-10
Walk was not checked in on 2026-10-10, a Saturday.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 4
month (YYYY-MM, nothing for this month)> 2026-13
Type a month like 2026-09.
month (YYYY-MM, nothing for this month)> 2026-11
Walk, November 2026:
  Mo  Tu  We  Th  Fr  Sa  Su
                          1.
  2.  3.  4.  5.  6.  7.  8.
  9. 10. 11. 12. 13. 14. 15.
 16. 17. 18. 19. 20. 21. 22.
 23. 24. 25. 26. 27. 28. 29.
 30.
That month has not started yet.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> A
Added A.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> B
Added B.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> C
Added C.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> D
Added D.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
Five habits is the most - this tracker keeps them few.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
  1) Walk
  2) A
  3) B
  4) C
  5) D
habit> 9
Type a habit's number or its name.
  1) Walk
  2) A
  3) B
  4) C
  5) D
habit> 
Cancelled.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 7
today (YYYY-MM-DD)> 2026-02-29
Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12.
today (YYYY-MM-DD)> 2024-02-29
Today is now 2024-02-29, a Thursday.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 3
Today is 2024-02-29, a Thursday.
Walk: no streak running, longest 1 day (from 2026-10-12), 0 days this month.
A: no streak running, 0 days this month.
B: no streak running, 0 days this month.
C: no streak running, 0 days this month.
D: no streak running, 0 days this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 8
Bye.
What was typed (46 lines)
0
x
1
2
3
4
5

5
A name that is far too long to keep
Walk
5
Walk

1
2026-13-01
2026-02-30
2026-10-13
1899-12-31
x
1

1

2
2026-10-10
4
2026-13
2026-11
5
A
5
B
5
C
5
D
5
1
9

7
2026-02-29
2024-02-29
3
8

basic

interpreter: byte-equal
== Habit tracker ==
Check in a habit each day it is done; streaks count the days in a row.
Today is 2026-10-12, a Monday. Change it with 7) today.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 6
Two sample habits over the last six weeks: Read 20 pages and Run.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 3
Today is 2026-10-12, a Monday.
Read 20 pages: streak 11 days (since 2026-10-02), longest 19 days (from 2026-09-12), 11 days this month.
Run: streak 1 day (since 2026-10-12), longest 1 day (from 2026-09-02), 5 days this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 4
  1) Read 20 pages
  2) Run
habit> 1
month (YYYY-MM, nothing for this month)> 
Read 20 pages, October 2026:
  Mo  Tu  We  Th  Fr  Sa  Su
              1   2*  3*  4*
  5*  6*  7*  8*  9* 10* 11*
 12* 13. 14. 15. 16. 17. 18.
 19. 20. 21. 22. 23. 24. 25.
 26. 27. 28. 29. 30. 31.
Done on 11 of 12 days so far.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 4
  1) Read 20 pages
  2) Run
habit> Run
month (YYYY-MM, nothing for this month)> 2026-09
Run, September 2026:
  Mo  Tu  We  Th  Fr  Sa  Su
      1   2*  3   4*  5   6
  7*  8   9* 10  11* 12  13
 14* 15  16* 17  18* 19  20
 21* 22  23* 24  25* 26  27
 28* 29  30*
Done on 13 of 30 days so far.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
  1) Read 20 pages
  2) Run
habit> 2
day (YYYY-MM-DD, nothing for today, x to cancel)> 
Run is already checked in on 2026-10-12, a Monday.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
  1) Read 20 pages
  2) Run
habit> 2
day (YYYY-MM-DD, nothing for today, x to cancel)> 2026-10-11
Checked in Run on 2026-10-11, a Sunday.
Run: streak 2 days (since 2026-10-11), longest 2 days (from 2026-10-11), 6 days this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 2
  1) Read 20 pages
  2) Run
habit> 1
day (YYYY-MM-DD, nothing for today, x to cancel)> 2026-10-02
Took back Read 20 pages on 2026-10-02, a Friday.
Read 20 pages: streak 10 days (since 2026-10-03), longest 19 days (from 2026-09-12), 10 days this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 5
name> Stretch
Added Stretch.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 1
  1) Read 20 pages
  2) Run
  3) Stretch
habit> 3
day (YYYY-MM-DD, nothing for today, x to cancel)> 
Checked in Stretch on 2026-10-12, a Monday.
Stretch: streak 1 day (since 2026-10-12), longest 1 day (from 2026-10-12), 1 day this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 7
today (YYYY-MM-DD)> 2026-10-14
Today is now 2026-10-14, a Wednesday.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 3
Today is 2026-10-14, a Wednesday.
Read 20 pages: no streak running, longest 19 days (from 2026-09-12), 10 days this month.
Run: no streak running, longest 2 days (from 2026-10-11), 6 days this month.
Stretch: no streak running, longest 1 day (from 2026-10-12), 1 day this month.

1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit
choice> 8
Bye.
What was typed (26 lines)
6
3
4
1

4
Run
2026-09
1
2

1
2
2026-10-11
2
1
2026-10-02
5
Stretch
1
3

7
2026-10-14
3
8

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

eml
# P055 habit tracker: up to five habits, checked in on real calendar days.
# For each habit: the streak still running, the longest streak, and a month
# drawn as a calendar with the days it was done.
import dates
import habits

def trim(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def counted(n, one, many):
    if n == 1:
        return "1 " + one
    return str(n) + " " + many

def described(h, today):
    habits.current_streak(h[1], today) => now
    habits.longest_streak(h[1]) => best
    dates.date_of(today) => t
    h[0] + ": " => s
    if now > 0:
        today => end
        if not habits.has_day(h[1], today):
            today - 1 => end
        s + "streak " + counted(now, "day", "days") + " (since " + dates.shown(dates.date_of(end - now + 1)) + ")" => s
    else:
        s + "no streak running" => s
    if best[0] > 0:
        s + ", longest " + counted(best[0], "day", "days") + " (from " + dates.shown(dates.date_of(best[1])) + ")" => s
    s + ", " + counted(habits.done_in_month(h[1], t[0], t[1]), "day", "days") + " this month." => s
    return s

def ask_habit(state):
    # The index of a habit chosen by number or name, or -1 when the answer
    # is empty.
    state[1] => hs
    if len(hs) == 1:
        return 0
    while True:
        for k in [0:len(hs) - 1]:
            ("  " + str(k + 1) + ") " + hs[k][0]) ^0
        trim(input("habit> ")) => answer
        if answer == "":
            return -1
        for k in [0:len(hs) - 1]:
            if answer == str(k + 1) or answer == hs[k][0]:
                return k
        "Type a habit's number or its name." ^0

def ask_day(state, prompt):
    # A day number not after today: an empty answer is today itself, or -1
    # when the answer is "x" (cancel).
    while True:
        trim(input(prompt + " (YYYY-MM-DD, nothing for today, x to cancel)> ")) => answer
        if answer == "":
            return state[0]
        if answer == "x":
            return -1
        dates.parsed(answer) => d
        if len(d) == 0:
            "Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12." ^0
        elif dates.day_number(d) > state[0]:
            ("That is after today, " + dates.shown(dates.date_of(state[0])) + ".") ^0
        else:
            return dates.day_number(d)

def check_in(state, undo):
    if len(state[1]) == 0:
        "There is no habit yet - add one first." ^0
        return
    ask_habit(state) => k
    if k == -1:
        "Cancelled." ^0
        return
    state[1][k] => h
    ask_day(state, "day") => n
    if n == -1:
        "Cancelled." ^0
        return
    dates.date_of(n) => d
    dates.shown(d) + ", a " + dates.day_name(d) => when
    if undo:
        if not habits.has_day(h[1], n):
            (h[0] + " was not checked in on " + when + ".") ^0
            return
        habits.without_day(h[1], n) => h[1]
        ("Took back " + h[0] + " on " + when + ".") ^0
    else:
        if habits.has_day(h[1], n):
            (h[0] + " is already checked in on " + when + ".") ^0
            return
        habits.with_day(h[1], n) => h[1]
        ("Checked in " + h[0] + " on " + when + ".") ^0
    described(h, state[0]) ^0

def list_habits(state):
    if len(state[1]) == 0:
        "There is no habit yet - add one first." ^0
        return
    dates.date_of(state[0]) => t
    ("Today is " + dates.shown(t) + ", a " + dates.day_name(t) + ".") ^0
    for h in state[1]:
        described(h, state[0]) ^0

def month(state):
    if len(state[1]) == 0:
        "There is no habit yet - add one first." ^0
        return
    ask_habit(state) => k
    if k == -1:
        "Cancelled." ^0
        return
    state[1][k] => h
    dates.date_of(state[0]) => t
    while True:
        trim(input("month (YYYY-MM, nothing for this month)> ")) => answer
        if answer == "":
            [t[0], t[1]] => ym
            break
        dates.parsed(answer + "-01") => d
        if len(d) > 0:
            [d[0], d[1]] => ym
            break
        "Type a month like 2026-09." ^0
    ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][ym[1]] => name
    (h[0] + ", " + name + " " + str(ym[0]) + ":") ^0
    for line in habits.month_grid(h[1], ym[0], ym[1], state[0]):
        line ^0
    dates.day_number([ym[0], ym[1], 1]) => first
    first + dates.days_in_month(ym[0], ym[1]) - 1 => last
    if last > state[0]:
        state[0] => last
    if last < first:
        "That month has not started yet." ^0
    else:
        ("Done on " + str(habits.done_in_month(h[1], ym[0], ym[1])) + " of " + counted(last - first + 1, "day", "days") + " so far.") ^0

def new_habit(state):
    if len(state[1]) == 5:
        "Five habits is the most - this tracker keeps them few." ^0
        return
    while True:
        trim(input("name> ")) => name
        if name == "":
            "Cancelled." ^0
            return
        False => taken
        for h in state[1]:
            if h[0] == name:
                True => taken
        if taken:
            ("There is a habit called " + name + " already.") ^0
        elif len(name) > 30:
            "Keep the name to 30 characters." ^0
        else:
            state[1] + [[name, []]] => state[1]
            ("Added " + name + ".") ^0
            return

def set_today(state):
    while True:
        trim(input("today (YYYY-MM-DD)> ")) => answer
        if answer == "":
            "Cancelled." ^0
            return
        dates.parsed(answer) => d
        if len(d) > 0:
            dates.day_number(d) => state[0]
            ("Today is now " + dates.shown(d) + ", a " + dates.day_name(d) + ".") ^0
            return
        "Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12." ^0

def sample(state):
    # Two habits over the last six weeks: reading on most days, with two
    # breaks, and running on Mondays, Wednesdays and Fridays.
    state[0] => t
    [] => reading
    [] => running
    for back in [0:41]:
        t - 41 + back => n
        if not (back == 9 or back == 10 or back == 30):
            reading + [n] => reading
        dates.weekday(dates.date_of(n)) => w
        if w == 0 or w == 2 or w == 4:
            running + [n] => running
    [["Read 20 pages", reading], ["Run", running]] => state[1]
    "Two sample habits over the last six weeks: Read 20 pages and Run." ^0

"== Habit tracker ==" ^0
"Check in a habit each day it is done; streaks count the days in a row." ^0
# [today as a day number, the habits]
[dates.day_number([2026, 10, 12]), []] => state
("Today is 2026-10-12, a " + dates.day_name([2026, 10, 12]) + ". Change it with 7) today.") ^0
True => looping
while looping:
    "" ^0
    "1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        check_in(state, False)
    elif choice == "2":
        check_in(state, True)
    elif choice == "3":
        list_habits(state)
    elif choice == "4":
        month(state)
    elif choice == "5":
        new_habit(state)
    elif choice == "6":
        sample(state)
    elif choice == "7":
        set_today(state)
    elif choice == "8":
        False => looping
    else:
        "Pick a number from 1 to 8." ^0
"Bye." ^0
Python projection (main.py)
import dates
import habits

def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def counted(n, one, many):
    if n == 1:
        return "1 " + one
    return str(n) + " " + many

def described(h, today):
    now = habits.current_streak(h[1], today)
    best = habits.longest_streak(h[1])
    t = dates.date_of(today)
    s = h[0] + ": "
    if now > 0:
        end = today
        if not habits.has_day(h[1], today):
            end = today - 1
        s = s + "streak " + counted(now, "day", "days") + " (since " + dates.shown(dates.date_of(end - now + 1)) + ")"
    else:
        s = s + "no streak running"
    if best[0] > 0:
        s = s + ", longest " + counted(best[0], "day", "days") + " (from " + dates.shown(dates.date_of(best[1])) + ")"
    s = s + ", " + counted(habits.done_in_month(h[1], t[0], t[1]), "day", "days") + " this month."
    return s

def ask_habit(state):
    hs = state[1]
    if len(hs) == 1:
        return 0
    while True:
        for k in range(0, len(hs)):
            print("  " + str(k + 1) + ") " + hs[k][0])
        answer = trim(input("habit> "))
        if answer == "":
            return -1
        for k in range(0, len(hs)):
            if answer == str(k + 1) or answer == hs[k][0]:
                return k
        print("Type a habit's number or its name.")

def ask_day(state, prompt):
    while True:
        answer = trim(input(prompt + " (YYYY-MM-DD, nothing for today, x to cancel)> "))
        if answer == "":
            return state[0]
        if answer == "x":
            return -1
        d = dates.parsed(answer)
        if len(d) == 0:
            print("Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12.")
        elif dates.day_number(d) > state[0]:
            print("That is after today, " + dates.shown(dates.date_of(state[0])) + ".")
        else:
            return dates.day_number(d)

def check_in(state, undo):
    if len(state[1]) == 0:
        print("There is no habit yet - add one first.")
        return
    k = ask_habit(state)
    if k == -1:
        print("Cancelled.")
        return
    h = state[1][k]
    n = ask_day(state, "day")
    if n == -1:
        print("Cancelled.")
        return
    d = dates.date_of(n)
    when = dates.shown(d) + ", a " + dates.day_name(d)
    if undo:
        if not habits.has_day(h[1], n):
            print(h[0] + " was not checked in on " + when + ".")
            return
        h[1] = habits.without_day(h[1], n)
        print("Took back " + h[0] + " on " + when + ".")
    else:
        if habits.has_day(h[1], n):
            print(h[0] + " is already checked in on " + when + ".")
            return
        h[1] = habits.with_day(h[1], n)
        print("Checked in " + h[0] + " on " + when + ".")
    print(described(h, state[0]))

def list_habits(state):
    if len(state[1]) == 0:
        print("There is no habit yet - add one first.")
        return
    t = dates.date_of(state[0])
    print("Today is " + dates.shown(t) + ", a " + dates.day_name(t) + ".")
    for h in state[1]:
        print(described(h, state[0]))

def month(state):
    if len(state[1]) == 0:
        print("There is no habit yet - add one first.")
        return
    k = ask_habit(state)
    if k == -1:
        print("Cancelled.")
        return
    h = state[1][k]
    t = dates.date_of(state[0])
    while True:
        answer = trim(input("month (YYYY-MM, nothing for this month)> "))
        if answer == "":
            ym = [t[0], t[1]]
            break
        d = dates.parsed(answer + "-01")
        if len(d) > 0:
            ym = [d[0], d[1]]
            break
        print("Type a month like 2026-09.")
    name = ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][ym[1]]
    print(h[0] + ", " + name + " " + str(ym[0]) + ":")
    for line in habits.month_grid(h[1], ym[0], ym[1], state[0]):
        print(line)
    first = dates.day_number([ym[0], ym[1], 1])
    last = first + dates.days_in_month(ym[0], ym[1]) - 1
    if last > state[0]:
        last = state[0]
    if last < first:
        print("That month has not started yet.")
    else:
        print("Done on " + str(habits.done_in_month(h[1], ym[0], ym[1])) + " of " + counted(last - first + 1, "day", "days") + " so far.")

def new_habit(state):
    if len(state[1]) == 5:
        print("Five habits is the most - this tracker keeps them few.")
        return
    while True:
        name = trim(input("name> "))
        if name == "":
            print("Cancelled.")
            return
        taken = False
        for h in state[1]:
            if h[0] == name:
                taken = True
        if taken:
            print("There is a habit called " + name + " already.")
        elif len(name) > 30:
            print("Keep the name to 30 characters.")
        else:
            state[1] = state[1] + [[name, []]]
            print("Added " + name + ".")
            return

def set_today(state):
    while True:
        answer = trim(input("today (YYYY-MM-DD)> "))
        if answer == "":
            print("Cancelled.")
            return
        d = dates.parsed(answer)
        if len(d) > 0:
            state[0] = dates.day_number(d)
            print("Today is now " + dates.shown(d) + ", a " + dates.day_name(d) + ".")
            return
        print("Type a real date from 1900-01-01 to 2099-12-31, like 2026-10-12.")

def sample(state):
    t = state[0]
    reading = []
    running = []
    for back in range(0, 42):
        n = t - 41 + back
        if not (back == 9 or back == 10 or back == 30):
            reading = reading + [n]
        w = dates.weekday(dates.date_of(n))
        if w == 0 or w == 2 or w == 4:
            running = running + [n]
    state[1] = [["Read 20 pages", reading], ["Run", running]]
    print("Two sample habits over the last six weeks: Read 20 pages and Run.")

print("== Habit tracker ==")
print("Check in a habit each day it is done; streaks count the days in a row.")
state = [dates.day_number([2026, 10, 12]), []]
print("Today is 2026-10-12, a " + dates.day_name([2026, 10, 12]) + ". Change it with 7) today.")
looping = True
while looping:
    print("")
    print("1) check in  2) take back  3) habits  4) month  5) new habit  6) sample  7) today  8) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        check_in(state, False)
    elif choice == "2":
        check_in(state, True)
    elif choice == "3":
        list_habits(state)
    elif choice == "4":
        month(state)
    elif choice == "5":
        new_habit(state)
    elif choice == "6":
        sample(state)
    elif choice == "7":
        set_today(state)
    elif choice == "8":
        looping = False
    else:
        print("Pick a number from 1 to 8.")
print("Bye.")

dates.eml

eml
# P055 habit tracker - real calendar dates, 1900-01-01 to 2099-12-31. A
# date is [year, month, day]; a day number counts days from 1900-01-01
# (day 0), as in the corpus case days-between-dates, so consecutive days
# have consecutive numbers and a streak is a run of numbers.

def is_leap(year):
    if year % 400 == 0:
        return True
    if year % 100 == 0:
        return False
    return year % 4 == 0

def days_in_month(year, month):
    if month == 2:
        if is_leap(year):
            return 29
        return 28
    if month == 4 or month == 6 or month == 9 or month == 11:
        return 30
    return 31

def day_number(d):
    0 => total
    for y in [1900:d[0] - 1]:
        if is_leap(y):
            total + 366 => total
        else:
            total + 365 => total
    for m in [1:d[1] - 1]:
        total + days_in_month(d[0], m) => total
    return total + d[2] - 1

def date_of(n):
    # The date of day number n.
    1900 => y
    365 => length
    while True:
        365 => length
        if is_leap(y):
            366 => length
        if n < length:
            break
        n - length => n
        y + 1 => y
    1 => m
    while n >= days_in_month(y, m):
        n - days_in_month(y, m) => n
        m + 1 => m
    return [y, m, n + 1]

def weekday(d):
    # Zeller's congruence, as in the corpus case day-of-week-zeller: January
    # and February count as months 13 and 14 of the year before. Returns 0
    # for Monday up to 6 for Sunday (Zeller's 0 is Saturday).
    d[0] => y
    d[1] => m
    if m < 3:
        m + 12 => m
        y - 1 => y
    y % 100 => k
    int(y / 100) => j
    (d[2] + int(13 * (m + 1) / 5) + k + int(k / 4) + int(j / 4) + 5 * j) % 7 => h
    return (h + 5) % 7

def parsed(s):
    # "YYYY-MM-DD" as a date, or [] when it is not a real date in range.
    if len(s) != 10 or s[4] != "-" or s[7] != "-":
        return []
    for i in [0, 1, 2, 3, 5, 6, 8, 9]:
        if not (s[i] >= "0" and s[i] <= "9"):
            return []
    int(s[0:4]) => y
    int(s[5:7]) => m
    int(s[8:10]) => d
    if y < 1900 or y > 2099 or m < 1 or m > 12 or d < 1 or d > days_in_month(y, m):
        return []
    return [y, m, d]

def shown(d):
    str(d[1]) => m
    if len(m) == 1:
        "0" + m => m
    str(d[2]) => dd
    if len(dd) == 1:
        "0" + dd => dd
    return str(d[0]) + "-" + m + "-" + dd

def day_name(d):
    return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday(d)]
Python projection (dates.py)
def is_leap(year):
    if year % 400 == 0:
        return True
    if year % 100 == 0:
        return False
    return year % 4 == 0

def days_in_month(year, month):
    if month == 2:
        if is_leap(year):
            return 29
        return 28
    if month == 4 or month == 6 or month == 9 or month == 11:
        return 30
    return 31

def day_number(d):
    total = 0
    for y in range(1900, d[0]):
        if is_leap(y):
            total = total + 366
        else:
            total = total + 365
    for m in range(1, d[1]):
        total = total + days_in_month(d[0], m)
    return total + d[2] - 1

def date_of(n):
    y = 1900
    length = 365
    while True:
        length = 365
        if is_leap(y):
            length = 366
        if n < length:
            break
        n = n - length
        y = y + 1
    m = 1
    while n >= days_in_month(y, m):
        n = n - days_in_month(y, m)
        m = m + 1
    return [y, m, n + 1]

def weekday(d):
    y = d[0]
    m = d[1]
    if m < 3:
        m = m + 12
        y = y - 1
    k = y % 100
    j = int(y / 100)
    h = (d[2] + int(13 * (m + 1) / 5) + k + int(k / 4) + int(j / 4) + 5 * j) % 7
    return (h + 5) % 7

def parsed(s):
    if len(s) != 10 or s[4] != "-" or s[7] != "-":
        return []
    for i in [0, 1, 2, 3, 5, 6, 8, 9]:
        if not (s[i] >= "0" and s[i] <= "9"):
            return []
    y = int(s[0:4])
    m = int(s[5:7])
    d = int(s[8:10])
    if y < 1900 or y > 2099 or m < 1 or m > 12 or d < 1 or d > days_in_month(y, m):
        return []
    return [y, m, d]

def shown(d):
    m = str(d[1])
    if len(m) == 1:
        m = "0" + m
    dd = str(d[2])
    if len(dd) == 1:
        dd = "0" + dd
    return str(d[0]) + "-" + m + "-" + dd

def day_name(d):
    return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday(d)]

habits.eml

eml
# P055 habit tracker - check-ins. A habit is [name, days]: days is the
# sorted list of day numbers it was done on, each at most once.
import dates

def has_day(days, n):
    for x in days:
        if x == n:
            return True
    return False

def with_day(days, n):
    # days with n added in its place, keeping the list sorted.
    [] => out
    False => placed
    for x in days:
        if not placed and n < x:
            out + [n] => out
            True => placed
        out + [x] => out
    if not placed:
        out + [n] => out
    return out

def without_day(days, n):
    [] => out
    for x in days:
        if x != n:
            out + [x] => out
    return out

def run_ending(days, n):
    # How many days in a row, ending with day n, the habit was done.
    0 => run
    while has_day(days, n - run):
        run + 1 => run
    return run

def current_streak(days, today):
    # The run that is still alive: ending today, or ending yesterday when
    # today is not checked in yet - the day is not over.
    if has_day(days, today):
        return run_ending(days, today)
    return run_ending(days, today - 1)

def longest_streak(days):
    # [length, first day] of the longest run; the earliest when two are
    # equally long.
    0 => best
    -1 => best_start
    0 => run
    -1 => start
    -10 => prev
    for x in days:
        if x == prev + 1:
            run + 1 => run
        else:
            1 => run
            x => start
        if run > best:
            run => best
            start => best_start
        x => prev
    return [best, best_start]

def rtrimmed(s):
    len(s) => j
    while j > 0 and s[j - 1] == " ":
        j - 1 => j
    return s[0:j]

def month_grid(days, year, month, today):
    # The month as a calendar, Monday first: each day with * when the habit
    # was done, and . after the days still to come.
    ["  Mo  Tu  We  Th  Fr  Sa  Su"] => lines
    dates.weekday([year, month, 1]) => first
    dates.day_number([year, month, 1]) => n0
    "    " * first => line
    first => col
    for d in [1:dates.days_in_month(year, month)]:
        n0 + d - 1 => n
        str(d) => cell
        while len(cell) < 3:
            " " + cell => cell
        if n > today:
            cell + "." => cell
        elif has_day(days, n):
            cell + "*" => cell
        else:
            cell + " " => cell
        line + cell => line
        col + 1 => col
        if col == 7:
            lines + [rtrimmed(line)] => lines
            "" => line
            0 => col
    if line != "":
        lines + [rtrimmed(line)] => lines
    return lines

def done_in_month(days, year, month):
    dates.day_number([year, month, 1]) => a
    a + dates.days_in_month(year, month) - 1 => b
    0 => count
    for x in days:
        if x >= a and x <= b:
            count + 1 => count
    return count
Python projection (habits.py)
import dates

def has_day(days, n):
    for x in days:
        if x == n:
            return True
    return False

def with_day(days, n):
    out = []
    placed = False
    for x in days:
        if not placed and n < x:
            out = out + [n]
            placed = True
        out = out + [x]
    if not placed:
        out = out + [n]
    return out

def without_day(days, n):
    out = []
    for x in days:
        if x != n:
            out = out + [x]
    return out

def run_ending(days, n):
    run = 0
    while has_day(days, n - run):
        run = run + 1
    return run

def current_streak(days, today):
    if has_day(days, today):
        return run_ending(days, today)
    return run_ending(days, today - 1)

def longest_streak(days):
    best = 0
    best_start = -1
    run = 0
    start = -1
    prev = -10
    for x in days:
        if x == prev + 1:
            run = run + 1
        else:
            run = 1
            start = x
        if run > best:
            best = run
            best_start = start
        prev = x
    return [best, best_start]

def rtrimmed(s):
    j = len(s)
    while j > 0 and s[j - 1] == " ":
        j = j - 1
    return s[0:j]

def month_grid(days, year, month, today):
    lines = ["  Mo  Tu  We  Th  Fr  Sa  Su"]
    first = dates.weekday([year, month, 1])
    n0 = dates.day_number([year, month, 1])
    line = "    " * first
    col = first
    for d in range(1, dates.days_in_month(year, month)+1):
        n = n0 + d - 1
        cell = str(d)
        while len(cell) < 3:
            cell = " " + cell
        if n > today:
            cell = cell + "."
        elif has_day(days, n):
            cell = cell + "*"
        else:
            cell = cell + " "
        line = line + cell
        col = col + 1
        if col == 7:
            lines = lines + [rtrimmed(line)]
            line = ""
            col = 0
    if line != "":
        lines = lines + [rtrimmed(line)]
    return lines

def done_in_month(days, year, month):
    a = dates.day_number([year, month, 1])
    b = a + dates.days_in_month(year, month) - 1
    count = 0
    for x in days:
        if x >= a and x <= b:
            count = count + 1
    return count

Built on these corpus cases