Habits & streaks
The habit module is small, and it's built around one decision: nothing about a streak is ever stored. The log of days is the only fact, and everything else is computed from it on demand.
§The model
Two tables. A habit — its name, colour, icon, and which weekdays it's scheduled on. And an entry: one row per habit per day you did it.
That's the whole thing. There is no current_streak column, no
longest_streak, no completion_rate. Loading habits fetches the habits and
every entry in two parallel queries, groups the entries by habit, and computes the rest.
A stored counter is a second source of truth that can disagree with the first. Every way of editing history — checking off a day you forgot, unchecking one you got wrong, deleting a day — becomes a place where the counter has to be updated correctly, and one missed path leaves a streak that's permanently wrong with no way to notice.
Deriving it means correcting a day in the past corrects everything downstream, automatically, because there was never anything else to correct.
1Derived, never stored
Three things are computed from the entry log, as pure functions:
| Derived | From | Note |
|---|---|---|
| Week progress strip | Which of this week's days have an entry | Uses the shared weekday indexing, so it lines up with the Agenda grid |
| Current streak | Entries, walked backwards, and the habit's schedule | A weekdays-only habit doesn't break its streak over the weekend, because the weekend was never due |
| Frequency description | The scheduled weekdays | "Daily", "Weekdays", "Mon · Wed · Fri" |
The streak calculation depending on the schedule is the part that's easy to get wrong. A streak is a run of due days that were met, not a run of consecutive calendar days — otherwise every Monday-only habit would show a streak of one forever.
2Skipping
An entry can be marked as a deliberate skip rather than a completion. That's the difference between "I didn't run today" and "I chose not to run today because I'm ill", and it means a habit can be paused honestly without either lying about having done it or watching a long streak die.
Restoring a skipped or completed day is withheld for dates in the past, via a rule that lives on the shared row component. It used to be duplicated in two places — the habits screen and the day view — which is precisely the kind of duplication that drifts. Moving it onto the row means both hosts get the same answer because they're asking the same code.
3The optimistic toggle
Checking off a habit is the most-repeated interaction in the app, so it's the one place the architecture's "re-fetch, never patch" rule is deliberately bent:
The local flip is limited to exactly one thing: the checkmark and its dot. Anything derived from more than that day waits for the real answer, rather than being optimistically guessed and then visibly corrected.
4Schedules
A habit is either every day or a specific set of weekdays. Deliberately not: every-N-days, monthly, or a target count per week.
The reason is that a habit's whole value is its rhythm being obvious. "Three times a week" makes "am I behind?" a calculation, and a habit you have to do arithmetic about is one you stop doing. Weekday scheduling keeps the answer glanceable — the strip either has a dot or it doesn't.
Habits are day-granular with no time of day, which is why the reminder is one daily time for all of them rather than per-habit. Per-habit times would multiply the scheduling budget by the number of habits — see the notification budget for why that matters more than it sounds.
5What it tells you
| Kind | Fires when | Tier |
|---|---|---|
| Daily reminder | A set time, with the count of habits due that day | Free |
| Streak at risk | Evening, when a streak of three or more is due today and not yet done | Free |
| Streak milestone | On completion, at 7, 14, 30, 50, 100 and 365 days | Plus |
The daily reminder is interesting: future due-counts are knowable, because whether a habit is due on a given day is a pure function of its schedule. So the planner can schedule a reminder for next Tuesday that already says how many habits are due — it just can't say how many you'll have done, which is why the copy counts due, not remaining.
Milestones can't be planned ahead at all — they depend on an action you haven't taken — so they're fired by the check-off itself and de-duplicated by the ledger, so hitting day 30, undoing it and redoing it doesn't congratulate you twice.