habit-tracker

local-first, no backend, no account

habits, completions, streaks, and heatmaps. everything persists to indexeddb and every derived view is computed at read time, so the data never leaves the browser and the app keeps working with the network off.

react 19, react router 7, typescript, vite, tailwind 4, dexie, date-fns, shoal-client

the overview page with a 20-week heatmap, current streak, best streak and total per habit
overview, with the sample data loaded
today's checklist with a done and a skip control per habit
today

Habit-tracker is a daily habit tracker with one founding constraint: no backend. Habits and per-day completions live in one IndexedDB database, and every derived view is computed at read time rather than stored as a counter. React 19, React Router 7 and Tailwind 4, styled by ash lumen tokens compiled from JSON at build time.

architecture

where do streaks and heatmaps come from if nothing stores them?

two tables and two pure functions. every derived view is computed at read time from completions, so there is no counter to drift after an edit.

stored state
IndexedDB via Dexie habits, completions [habitId+date] index calcStreak pure function buildHeatmapGrid pure function pages Overview, Habit shoal outbox optional sync done-date set completions setCompletion

a missing day breaks the streak, except today: calcStreak skips an unmarked today and counts from yesterday, so streaks do not read zero every morning. skipped completions are a gap, never a break and never an extension.

Two tables. A habit has an id, a name, a creation time, an optional archive time and a sort order. A completion has a habit id, a YYYY-MM-DD date, a status of done or skipped, and a creation time, with a compound [habitId+date] index. Schema version 2 adds three sync tables.

Derivation is two pure functions. calcStreak takes the set of done dates and today. buildHeatmapGrid takes completions and a number of weeks back, 20 on the overview and 52 on a habit’s own page. Reads are live queries, mutations write to Dexie, and the components re-render. There is no state layer. Theme is the one localStorage value, applied by a blocking inline script so the first paint has the right colors.

Dates are the user’s calendar days through date-fns, compared with differenceInCalendarDays, never UTC. Sync is optional: mutations append to an outbox, drained on load, on tab focus, and five seconds after a write burst.

technical decisions

  • No backend. One local database, everything derived at read time, no sync conflicts to resolve in the default configuration.
  • Derived over stored counters. Streaks recompute from completions, so there is no counter to drift or repair after an edit, a delete, or a backfill.
  • What a missed day means. A date absent from the done set breaks the streak, except today. calcStreak skips an unmarked today and counts from yesterday, so streaks do not read zero every morning before check-in. A skipped completion is neither a break nor an extension.
  • The compound index enforces one completion per habit per day at the schema level and makes the per-day lookup an index hit.
  • Tailwind 4, CSS-first. No config file. An @theme inline block maps the generated token variables to utilities, so the token file stays the single source. Opacity modifiers do not work on variable colors, hence color-mix() in the heatmap.
  • Archive, not delete. Archiving keeps history.

hard problems

The streak arithmetic. The current streak walks back up to 365 days with the today-is-optional exception. The longest streak is a separate pass over lexicographically sorted date strings, using calendar-day differences for adjacency rather than string math, which keeps daylight-saving changes and month boundaries correct. Two honest caveats: the 365-day walk silently truncates a longer current streak, and there is no test on calcStreak yet. CI gates on lint and build.

numbers

measurevalue
source25 files, 5 routes
tables2, plus 3 for sync
heatmaps20 weeks on the overview, 52 on a habit
streak walk365-day cap