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.

Three taps or fewer

No input screen. Tap a cell, change it in place, and it is already saved.

Same data, even offline

Writes always succeed locally, and phone and PC converge once they are back online.

Nothing to operate

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.
Desktop: streak, basic and conditional heatmaps side by side. All data in these screenshots is generated sample data; the interface is in Korean.
Streak heatmap: intensity grows with the streak, milestones glow, and a failed day (X) resets the count.
Editor bottom sheet. The grid stays tappable while it is open, so several past days can be filled in a row.
Trend popup for a conditional heatmap: a hand-written SVG chart with average, minimum and maximum.
Year view: 53-week grids whose cell size is computed from the measured container width.
Legends stay hidden until requested, so a glance at the screen does not reveal what the colours mean.

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.

1TapA tap calls store.update(), which writes the whole document to localStorage at once, so an edit never waits for the network.
2DerivecellVisual() computes each cell's colour, intensity, marks and milestone state from raw entries, for both the week grid and the year view.
3ScheduleA sync runs 3 s after the last edit, on app open, on tab focus and on reconnect. Requests that arrive during a sync collapse into one more run.
4MergeThe client pulls the remote document and merges it per heatmap and per date by timestamp, applying tombstones for deleted heatmaps.
5PushOnly when the merged result differs from the server copy is it sent back, keyed by the SHA-256 of the sync code.

04Engineering highlights

Multi-device sync without CRDTs

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.

An account-less backend in 48 lines

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.

Store facts, derive everything else

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.

Dates without a date library

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.

A Safari layout bug fixed by measuring

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.

Input that keeps up with the hand

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

34
unit tests for dates, streaks, merging and regression, all passing in Vitest
28 KB
app JavaScript after gzip, with Preact as the only runtime dependency
48
lines in the entire serverless backend
110 KiB
offline precache for the whole app (13 files)
  • 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.
Looking back Version 1 went from scaffold to a deployed PWA with KV sync in one day, and version 2.4.1 followed eight days later, driven by what actual use exposed: iPhone layout bugs, editor friction, a lost decimal point. The lesson was that a small consistency model chosen on purpose, with its failure modes written down, is worth more than a sophisticated one I could not explain.

07Stack & links