Life Heatmap: An Offline-First Habit Tracker with Serverless Sync
A mobile-first PWA that keeps the free-form notation of a paper habit grid, adds streaks, rule-based colouring and statistics, and keeps a phone and a PC in sync through a 48-line serverless function.
01Why I built it
For a long time I tracked daily routines on paper grids: hatching, solid fills, numbers and symbols in each cell. It worked because the notation was free-form. It failed because paper was not always with me, streaks had to be counted by hand, and months of history could not be summarised. Habit apps solve the counting but reduce every day to done or not done. Life Heatmap keeps the expressiveness of paper and adds only what software is good at: derived streaks, colouring by rule, statistics, a year view, and the same data on phone and PC, with nothing to operate or pay for.
No input screen. Tap a cell, change it in place, and it is already saved.
Writes always succeed locally, and phone and PC converge once they are back online.
Static hosting plus one small function on a free tier, and a single runtime dependency.
02What it does
- Heatmaps created as needed, in three types that can be switched later without losing data: basic, streak and conditional.
- Streaks counted from the last failed day, with a five-level intensity gradient, milestone glows and current and best counters. Auto mode colours the days without any input.
- Conditional heatmaps colour a number by user-defined ranges, offer a weekly-average mode, and open a trend chart drawn in plain SVG.
- A cell is a fill (hatch or solid, six colours), a mark (a number or ○ X ★) and a border, edited in a bottom sheet on mobile or a popover on desktop. Every tap saves.
- A GitHub-style year view sized to the screen, monthly and quarterly statistics, and JSON backup with merge or replace on import.
- Sync between devices with a secret code instead of an account, in an installable PWA that works offline.
03How it works
The app is local-first. The whole state is one JSON document in localStorage, and every change goes through a single store function that persists it, notifies the Preact components and schedules a sync. Nothing derived is stored: streaks, intensity levels, statistics and trend lines are recomputed from the entries by pure functions on each render. Sync exists only once the user sets a code. The client hashes that code with SHA-256, pulls the document from a Cloudflare Pages Function backed by Workers KV, merges it with the local copy, and pushes the result only if it differs. The function validates the key, the size and the JSON, and stores the blob. It never interprets the data.
04Engineering highlights
For one user on a few devices, last-write-wins is enough if the unit is small. The merge works per (heatmap, date), so edits to different cells on two devices both survive. Clearing a cell is stored as a timestamped empty entry, so the clear wins instead of the old value coming back. Deleted heatmaps leave tombstones compared with the heatmap's latest touch, so an edit after a delete brings it back deterministically. Nine merge tests pin this down.
A random 24-character code (about 124 bits) never leaves the device; its SHA-256 hex is the KV key. The Pages Function checks the key format, rejects bodies over 5 MB with 413 and invalid JSON with 400, and stores the blob with no-store caching. Without a code the app makes no network calls, and a visitor to the public URL sees an empty app.
Streak lengths, levels, milestones, statistics and regressions are never persisted. One pure cellVisual() function holds every precedence rule (fail over fill, rule colour over manual fill, value over mark) and feeds both views, so four rule changes shipped without migrating any stored data.
new Date('YYYY-MM-DD') parses as UTC and shifts the day in Korea, so dates stay local YYYY-MM-DD strings, day differences are rounded to absorb 23- and 25-hour days, and week numbers follow the paper convention (a week belongs to the year of its Saturday). Streaks break only on explicit fails, never on empty days. Fourteen date and streak tests cover the edge cases.
The 53-column year grid collapsed on a real iPhone: first 1fr tracks grew to fit their content, then aspect-ratio cells produced squashed rows. I replaced CSS sizing with a ResizeObserver that computes an integer cell size from the container width and writes explicit pixel tracks, and recorded the banned pattern in the spec so it would not come back.
The editor's backdrop swallowed taps on other cells, which broke filling several days in a row; a document-level click filter replaced it. Typing a decimal lost the dot because the input re-rendered the parsed number; the in-progress string now lives in local state. Loading 24 older weeks corrects scrollTop in a layout effect, so the view does not jump.
05Quality & numbers
- Tests target the pure logic where a bug would silently corrupt data: date arithmetic, streak rules, the merge and the regression maths.
- The build runs tsc --noEmit in strict mode first, so a type error blocks a deploy. The live bundle hash matches a clean build of the repository.
- The specs record 32 requirements and 25 dated design decisions with reasons, for example replacing drag-to-reorder with buttons because touch drag fights scrolling.
06Decisions & trade-offs
- Last-write-wins per cell instead of CRDTs or a database. It fits a single user and keeps the server dumb. The known costs are device clock skew and no compare-and-swap in KV, which re-merging on every trigger makes tolerable.
- A secret code instead of accounts. There is no auth to build or leak, but the data is not encrypted at rest yet; client-side encryption keyed by the code is the next step.
- Preact and a hand-written store instead of a larger framework and a state library. The whole state is one document, so one update path and a subscription hook are enough.
- Charts, statistic bars and even the PNG icon generator written by hand. A dependency allow-list in the spec keeps the bundle at 28 KB gzip and the project maintainable for years.