Dawnlet · under the hood

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.

Append-only log Nothing derived is stored Skip without breaking 10 free

§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.

Why this is worth the computation

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:

DerivedFromNote
Week progress stripWhich of this week's days have an entry Uses the shared weekday indexing, so it lines up with the Agenda grid
Current streakEntries, 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 descriptionThe 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:

1
Flip locally, immediately
The strip updates on the same frame as the tap. No spinner, no wait.
2
Write it
An upsert-or-delete keyed on the habit and the date, so tapping twice leaves no residue.
3
Re-fetch
Picks up the recomputed streak — which the local flip can't know, since it depends on the whole log.

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

KindFires whenTier
Daily reminderA set time, with the count of habits due that dayFree
Streak at riskEvening, when a streak of three or more is due today and not yet doneFree
Streak milestoneOn completion, at 7, 14, 30, 50, 100 and 365 daysPlus

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.