•
3 min read
the streak that is not zero in the morning
habit-tracker typescript local-first

The habit tracker has no backend and no account. Habits and per-day completions live in one IndexedDB database, streaks and heatmaps are computed at read time, and the app works with the network off. It is the oldest repository in my current set, and it has exactly one decision worth a post.

today is optional

A streak counter that requires today’s check-in to count today reads zero every morning, before the user has done anything. That is technically defensible and it is wrong in the way that matters: the number the user sees first each day is the discouraging one, and it stays that way until they act.

The streak walk runs back from today. At the first step, today, a missing completion is a continue rather than a break. The current-streak half of the function, with the longest-streak pass and the empty-set guard trimmed:

let current = 0;
const todayDate = parseISO(today);

for (let i = 0; i <= 365; i++) {
  const d = toDateStr(subDays(todayDate, i));
  if (doneSet.has(d)) {
    current++;
  } else if (i === 0) {
    // today not done yet: check yesterday for an ongoing streak
    continue;
  } else {
    break;
  }
}

An unmarked today keeps the count going from yesterday. A missed yesterday breaks it. The streak the user sees in the morning is the one they earned, and marking today extends it rather than rescuing it.

adjacency is a calendar question

The longest streak is a separate pass over sorted date strings, and adjacency is decided by differenceInCalendarDays on parsed dates rather than by anything done to the strings. Date strings sort correctly, which makes string comparison tempting, but adjacency across a month boundary or a daylight-saving transition is not a string operation, and a streak that ends on the 31st and continues on the 1st has to count.

A skipped day, explicitly marked skip rather than left blank, is a third state. It neither breaks nor extends a streak. Done, skipped, and absent are three different things, and collapsing skip into either of the others produces a wrong number in one direction or the other.

derived at read time

Streak counters are never stored or incremented. They are derived from the completion set every time they are read. There is no counter to drift after an edit, a delete, or a backfill of old days, and no repair job.

The completions table has a compound index on habit id and date, which enforces one completion per habit per day at the schema level and turns the per-day lookup into a direct index hit.

the frame before first paint

Theme is applied by a blocking inline script in the HTML before the app loads. Reading the stored preference from inside React would render one visible frame in the wrong theme on every load, which on a dark theme is a white flash. The script is six lines and runs before anything else.

caveats, stated

The walk stops after 366 days, so a longer current streak is truncated silently. The streak function has no test. Continuous integration gates on lint and build. Both are written in the case study rather than left for someone to find. Fifteen hundred lines of TypeScript, five routes, and a heatmap that scrolls to the most recent column.