Habit Tracker: Streaks, Freezes and Backfills
EasyAI-assisted coding · AI-Assisted Debugging
Habit Tracker: Streaks, Freezes and Backfills
You've joined the team behind Streak, a habit-tracking app. Users create habits ("run", "read 20 pages"), check in when they do them, and watch their streak grow. Support has three open bugs against the streak counter, and product wants two features on top of it: streak freezes and backfilled check-ins. Plan for about 45 minutes; the three parts build on each other.
Codebase
| File | What it does |
|---|---|
habits/api.py | run(operations) entry point and the Dispatcher that routes each operation |
habits/service.py | HabitService: users, habits, check-ins and streak queries |
habits/streaks.py | Current-streak computation for every schedule type |
habits/clock.py | Day numbers, weekdays, week starts and date labels |
habits/schedule.py | The three schedule types and the strict schedule-string parser |
habits/checkins.py | CheckInLog: the set of local days each habit was checked in on |
habits/registry.py | UserRegistry and HabitRegistry (habits are unique per user) |
habits/models.py | User and Habit records |
habits/errors.py | HabitError and every error code |
habits/config.py | Constants (offset limits, weekday names, ...) |
tests/ | Existing end-to-end tests |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh tracker and returns one result per operation. Every operation is [name, ts, ...args]. ts is an integer epoch second in UTC and never decreases from one operation to the next. Errors are returned as strings ("error: <code>"), never raised.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
add_user | user_id, utc_offset_minutes | true | invalid offset, user exists |
add_habit | user_id, habit_id, schedule | true | user not found, invalid schedule, habit exists |
check_in | user_id, habit_id | the local date, e.g. "2024-03-08" | user not found, habit not found, already checked in |
streak | user_id, habit_id | current streak (int) | user not found, habit not found |
- An unknown operation name returns
"error: unknown operation". A wrong number of arguments, a non-integerts(true/falseare not integers), or an empty or non-stringuser_id/habit_idreturns"error: invalid arguments"(before any other check). - A UTC offset is an integer number of minutes from -720 to 840 inclusive, fixed per user.
- Ids are case-sensitive. A habit is identified by
(user_id, habit_id); two users may each have a habit called"run". - Local day. Everything a user sees is on *their* calendar: the local day of
tsis the UTC calendar day ofts + offset_minutes * 60. A habit's start day is the local day it was added; there is no history before it. - A habit can be checked in at most once per local day.
- Schedules (exact strings):
"daily"; a comma-separated list of distinct weekday names fromMon Tue Wed Thu Fri Sat Sunwith no spaces, in any order ("Mon,Wed,Fri"); or"Nx/week"withNfrom 1 to 7 ("3x/week"). Anything else isinvalid schedule. Weeks start on Monday.
Part 1 — Fix the streak counter (HAB-412, HAB-437, HAB-450)
Goal: make streak follow the rules below. A *period* is one unit of a streak; the current streak is the number of consecutive completed periods ending at the user's current local day.
Reports from support:
- "I check in around 8pm Pacific and the app files it under tomorrow, and my streak is wrong in the evening." (HAB-412)
- "My daily streak shows 0 every morning until I check in again." (HAB-437)
- "I did my 3x/week habit Friday, Saturday and Sunday and that week doesn't count." (HAB-450)
Rules
- *Daily and weekday schedules.* Each scheduled day from the start day up to today is a period; it is completed if the habit was checked in that local day. Days that are not scheduled are not periods: they neither extend nor break a streak. A check-in on an unscheduled day is accepted and recorded (and blocks a second check-in that day) but never counts toward a streak.
- *Today is still in progress.* If today is a scheduled day and not checked in yet, it is left out (it does not break the streak). If it is checked in, it counts. Any earlier scheduled day without a check-in breaks the streak: the streak is the run of completed periods after it.
- *"N times per week".* Each Monday-to-Sunday week from the habit's start week to the current week is a period, completed when it has check-ins on at least
Ndifferent days. A finished week that is not completed breaks the streak. The current week counts if already completed and is otherwise left out. The start week (the week containing the start day) is optional: it counts if completed, and is simply left out if not, so a habit added on a Thursday is not already failing. - A streak of 0 is returned when no period qualifies.
Worked example (Mia is at UTC-08:00, so her days start at 08:00 UTC)
[["add_user", 1709510400, "mia", -480],
["add_habit", 1709510400, "mia", "run", "daily"],
["check_in", 1709532000, "mia", "run"],
["check_in", 1709607600, "mia", "run"],
["streak", 1709618400, "mia", "run"],
["streak", 1709632800, "mia", "run"],
["streak", 1709726400, "mia", "run"]]returns [true, true, "2024-03-03", "2024-03-04", 2, 2, 0]. (1709510400 is 2024-03-04 00:00 UTC = Mia's local 2024-03-03 16:00, so that is her start day. Her local March 5 is unfinished at the first streak query and fully missed at the last.)
tests/test_streaks.py has a failing test for the first report.
Part 2 — Streak freezes (HAB-461)
Goal: a missed period should not instantly destroy a long streak. Users earn freezes that are spent automatically to cover a miss. Add two operations and change what streak returns.
| Operation | Arguments after ts | Returns | Errors |
|---|---|---|---|
freezes | user_id, habit_id | freezes currently held (int) | user not found, habit not found |
frozen | user_id, habit_id | list of the periods a freeze covered, oldest first | user not found, habit not found |
Freezes belong to a habit, not to a user. Replay the habit's periods oldest first (the same periods as Part 1), starting with a streak of 0 and 0 freezes:
Rules
- *Completed period:* the streak goes up by 1. If it is now a multiple of 7 and fewer than 2 freezes are held, one freeze is earned; at the cap of 2 the freeze is lost.
- *Missed period* (it has ended, was not completed, and is not the optional start week): if a freeze is held, one is spent and the streak is unchanged (the period is *frozen*: it neither counts nor breaks). With no freeze the streak resets to 0.
- A period that is still open (today's scheduled day, the current week) or optional never spends a freeze. Today's check-in does count the moment it is made, including earning a freeze.
- Because frozen periods do not count, the next freeze is earned on the 7th, 14th, 21st, ... *completed* period, not on the 7th calendar period.
frozenlabels a period by its date: the scheduled day ("2024-03-11") or, forNx/week, the Monday of the week.- A spent freeze is not given back in this part.
streakis the streak after the replay (everything in Part 1 still applies).
Worked example (Ana is at UTC; 1709510400 = Mon 2024-03-04 00:00, and every check-in is at noon)
[["add_user", 1709510400, "ana", 0],
["add_habit", 1709510400, "ana", "read", "daily"],
["check_in", 1709553600, "ana", "read"], ["check_in", 1709640000, "ana", "read"],
["check_in", 1709726400, "ana", "read"], ["check_in", 1709812800, "ana", "read"],
["check_in", 1709899200, "ana", "read"], ["check_in", 1709985600, "ana", "read"],
["check_in", 1710072000, "ana", "read"],
["streak", 1710100800, "ana", "read"],
["freezes", 1710100800, "ana", "read"],
["check_in", 1710244800, "ana", "read"],
["streak", 1710273600, "ana", "read"],
["freezes", 1710273600, "ana", "read"],
["frozen", 1710273600, "ana", "read"]]returns [true, true, "2024-03-04", "2024-03-05", "2024-03-06", "2024-03-07", "2024-03-08", "2024-03-09", "2024-03-10", 7, 1, "2024-03-12", 8, 0, ["2024-03-11"]]. Ana checks in on Mar 4-10 (streak 7, one freeze earned), skips Mar 11 (the freeze is spent, streak stays 7) and checks in on Mar 12 (streak 8, no freezes left).
Part 3 — Backfilled check-ins (HAB-479)
Goal: people forget to log. Let them record a check-in for a past day, and make the streak and freezes come out exactly as if they had logged it on time. This changes Part 2's rule 6.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
backfill | user_id, habit_id, date | the date | user not found, habit not found, invalid date, outside backfill window, before habit start, already checked in |
Rules
dateis a strictYYYY-MM-DDstring naming a real calendar date, otherwiseinvalid date("2024-3-4","20240304","2024-02-30", and non-strings are all invalid).- Let *today* be the user's local day at
ts. The date must be 1 or 2 days before today (not today, not older), otherwiseoutside backfill window. Today is logged withcheck_in. - The date must not be before the habit's start day (
before habit start); the start day itself is fine, even when the rest of its week is not. It must not already be checked in (already checked in), whether that was on time or backfilled. - A backfill is accepted for unscheduled days too (it is recorded and counts for nothing).
- After a backfill,
streak,freezesandfrozenare what they would be had the check-in been made on that day. In particular a freeze that was spent covering that period is back, a backfill can complete a week forNx/week, and everything after the backfilled day is evaluated again from scratch: a day that the old history had to freeze or break on may now be covered differently, freeze earning dates can shift, and the cap of 2 still applies. - Part 2's rule 6 ("never given back") no longer holds; everything else does.
Worked example (Ana, UTC; Mar 4-9 and Mar 11 are checked in at noon, Mar 10 was forgotten)
[["add_user", 1709510400, "ana", 0],
["add_habit", 1709510400, "ana", "read", "daily"],
["check_in", 1709553600, "ana", "read"], ["check_in", 1709640000, "ana", "read"],
["check_in", 1709726400, "ana", "read"], ["check_in", 1709812800, "ana", "read"],
["check_in", 1709899200, "ana", "read"], ["check_in", 1709985600, "ana", "read"],
["check_in", 1710158400, "ana", "read"],
["streak", 1710237600, "ana", "read"],
["freezes", 1710237600, "ana", "read"],
["backfill", 1710237600, "ana", "read", "2024-03-10"],
["streak", 1710237600, "ana", "read"],
["freezes", 1710237600, "ana", "read"]]returns [true, true, "2024-03-04", "2024-03-05", "2024-03-06", "2024-03-07", "2024-03-08", "2024-03-09", "2024-03-11", 1, 0, "2024-03-10", 8, 1]. On Mar 12 at 10:00 the missed Mar 10 (no freeze held) had reset the streak, so it was 1. The backfill reconnects Mar 4-11 into eight completed days, which earns a freeze at the seventh.
Notes
schedule.py,registry.py,checkins.pyandmodels.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour after each part.
Example 1
- Input:
- [["add_user",1709510400,"mia",-480],["add_habit",1709510400,"mia","run","daily"],["check_in",1709532000,"mia","run"],["check_in",1709607600,"mia","run"],["streak",1709618400,"mia","run"],["streak",1709632800,"mia","run"],["streak",1709726400,"mia","run"]]
- Output:
- [true,true,"2024-03-03","2024-03-04",2,2,0]
- Explanation
- Mia lives at UTC-08:00, so her days start at 08:00 UTC. Both check-ins fall on her local March 3 and March 4 (not the UTC dates), giving a streak of 2; her unfinished local March 5 does not break it, but a fully missed March 5 does.
Example 2
- Input:
- [add_user ana (UTC), add_habit read daily, check_in on 7 consecutive days (Mar 4-10), streak, freezes, check_in on Mar 12, streak, freezes, frozen]
- Output:
- [..., 7, 1, "2024-03-12", 8, 0, ["2024-03-11"]]
- Explanation
- Seven completed days earn one freeze. March 11 is missed, so the freeze is spent automatically: the streak stays at 7 and becomes 8 when March 12 is done.
Example 3
- Input:
- [add_user ana, add_habit read daily, check_in Mar 4-9 and Mar 11, streak, freezes, backfill "2024-03-10", streak, freezes]
- Output:
- [..., 1, 0, "2024-03-10", 8, 1]
- Explanation
- March 10 was missed with no freeze held, so the streak was 1. Backfilling it reconnects Mar 4-11 into an eight-day streak, which also earns a freeze at the seventh completed day.
Guidelines
- Python 3 standard library only
- Timestamps are integer epoch seconds (UTC) and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- A user's UTC offset is fixed (no daylight-saving changes); a check-in belongs to the user's local calendar day