Projects

Our own build · For a friend

A one-tap tracker we built for a friend, on Cloudflare

A friend has been logging a daily habit in a text file since February 2025. One line per entry, rounded to the nearest quarter hour, 6,900 lines by the time he asked us about it. He wanted something on his phone he could tap. We built it in an evening: one Cloudflare Worker, one D1 table, Cloudflare Access for the login, and a manifest so it sits on the home screen like an app. It is small, and it is the same pattern we use for client tools.

  • Cloudflare Workers
  • D1
  • Cloudflare Access
  • PWA
  • Vanilla JS
  • Our own build
Zyn Tracker icon: a ring of quarter-hour ticks around a mint pill shape
The icon is one SVG: the favicon, the header mark and the home-screen tile.

The ask

The text file was already a good spec

The file had a header per day and one time per line, newest day first. That is a data model. The ask on top of it was short: open from the phone, see the current time already rounded to the quarter hour, press once. Make the time adjustable for the times he forgets and comes back an hour later. Put the logger front and center, and some charts and the day's list under it.

So we kept his model exactly. The database stores a day and a time as the same strings he was typing. The import was a 40-line Python script that read 621 days and wrote 3,792 inserts. The export writes the same layout back out, so nothing he built up over twenty months is stuck in our app.

########################################################################
Oct 31, 2026
########################################################################

10:30 AM
12:00 PM
 1:45 PM
 3:30 PM
 5:30 PM
 7:00 PM

What it does

Logger first, everything else underneath

Today tab of the Zyn Tracker app
Today. The current time rounded to the quarter hour, one big button, and nudges for when you forgot. Under it: time since the last one, stat tiles, the day on a timeline, 14 days against the 30-day average, and when in the day it tends to happen.
Trends tab of the Zyn Tracker app
Trends. A 7-day rolling average over 90 days, the last 30 days, averages by weekday, and a past-year heatmap. All of it drawn as plain SVG from one JSON payload.
History tab of the Zyn Tracker app
History. Every day with its own timeline and list, 14 days at a time. Tap the pencil to move an entry or delete it. Add one to a past day if you missed it.

Every log shows a toast with Undo. If the request fails, the entry waits in localStorage and goes up the next time the app is opened with a connection. Picking a time later than now is read as last night, which is the usual reason to pick one.

How it's built

One hostname, one Worker, one table

A tap on the icon goes through four things. Cloudflare Access handles the login before the request reaches our code, and the Worker checks the token again anyway, so the data is safe even if someone reaches the script another way.

  1. Home screen icon Safari "Add to Home Screen". A manifest and a service worker make it open full screen, offline if it has to.
  2. Cloudflare Access Sits in front of the hostname. One Google account allowed, session set to a month, so the phone rarely asks again.
  3. Worker Serves the page, the assets and a small JSON API. Re-checks the Access token on every request, about fifty lines with WebCrypto.
  4. D1 One SQLite table: day, time, source, note. Times stay as the local strings the friend already used. No timezone math.

The same picture with everything in it. Three zones: the phone, the Cloudflare account, and the laptop that deploys. Blue is the authenticated app traffic, brick is the offline retry path, and the sky outline marks where login happens.

PHONE / BROWSER Installed web app Safari "Add to Home Screen" full screen, no browser chrome manifest.webmanifest app.js + app.css vanilla JS, SVG charts renders from one /api/stats call Service worker caches the shell and fonts never caches /api localStorage queue offline logs, sent on return CLOUDFLARE · ONE ACCOUNT DNS · the app's zone proxied record, created by the Worker's custom domain Cloudflare Access · one app Google IdP · policy: one email · session 730h sets the CF_Authorization cookie, injects Cf-Access-Jwt-Assertion aud + team domain are the Worker's two config values Worker · one script src/index.ts router verifyAccess(JWT) → RS256 vs /cdn-cgi/access/certs GET / page shell GET /app.js /app.css /icon-*.png /sw.js /manifest GET|POST|PATCH|DELETE /api/* GET /export.txt observability on · workers.dev off · custom domain route D1 · SQLite at the edge one table: id, day, time, source, note, created_at OPS Laptop wrangler deploy wrangler d1 execute curl → Access API Scoped API token kept outside the repo (our cloudflare-ops hub) GitHub (private) source and docs Google identity provider behind Access https JWT binding env.DB fetch /api retry POST /api/log deploy OIDC token
authenticated app traffic offline retry path auth boundary control and deploy
hosting
Cloudflare Worker on a custom domain
login
Cloudflare Access, Google, one email
storage
D1, one table, 3,792 rows imported
front end
One HTML shell, vanilla JS, hand-rolled SVG charts
offline
Service worker for the shell, localStorage queue for writes
icon
One SVG, rendered to PNG with macOS QuickLook
export
/export.txt rebuilds the original text file
time to ship
One evening, with Claude Code

Setting it up

Four commands and one API call

The hosted Cloudflare MCP is read-only, so anything that creates infrastructure runs through wrangler or curl with a scoped token. The database, the Access app, the Worker and its custom domain all came from the terminal. Nothing was clicked together in a dashboard, which means the whole setup is in the repo's README and can be done again.

# the database and its one table
npx wrangler d1 create zyn-tracker
npx wrangler d1 execute zyn-tracker --remote --file=schema.sql

# the Access app: one POST to /accounts/:id/access/apps
# with the hostname, the Google IdP, a 730h session and a
# policy allowing one email. The response carries the
# audience the Worker verifies against.

# the Worker, bound to the custom domain.
# wrangler creates the DNS record.
npx wrangler deploy

# the icon: QuickLook renders, ImageMagick only resizes
qlmanage -t -s 1024 -o out/ src/assets/icon.svg
magick out/icon.svg.png -resize 512x512 src/assets/icon-512.png

What we'd keep

Decisions that paid for themselves the same day

The text file was the spec

Day headers, one time per line, nearest quarter hour. Keeping that exact model meant the import was a 40-line script and the export is lossless.

Rent the login

Cloudflare Access took one API call to set up and handles Google, sessions and cookies. We did not write a login form, a session store or a password reset.

One payload per screen

The Today and Trends tabs render from a single /api/stats call: the day, the last 30 days of entries, 53 weeks of daily counts. Averages and the heatmap are computed in the browser.

Store what the user wrote

Day and time are local wall-clock strings chosen by the phone. The server validates the shape and nothing else, so midnight and travel do not shift anything.

What cost time

Four things worth knowing before you start

wrangler dev --remote failed

Every request came back "internal error". Local mode with a local D1 copy of the data was instant and fine.

Dev requests carry the production host

Under wrangler dev the request URL says the custom domain, so a "skip auth on localhost" check never fires. The bypass keys off a value in the gitignored .dev.vars instead.

ImageMagick rendered the icon black

Its built-in SVG renderer drops gradients and filters. QuickLook (qlmanage) renders it right; ImageMagick only does the resizing.

The Mac remembered "no such host"

Looking up the hostname before the DNS record existed cached a negative answer locally. curl --resolve got around it; phones never saw it.

The data

Hand-kept logs have typos, so we audited before we imported

A two-minute pass over the file found four day headers typed twice, each sitting next to a day that was missing, and five first-of-the-day times typed as PM that came before noon entries. Three of the duplicated headers were plainly the next day and got moved; the PM times became AM. One duplicated day had no missing neighbour, so we left it merged and told him. The fixes live in a SQL file next to the import, so the history of the data is reproducible.

  • 621 days read
  • 3,792 entries kept
  • 3 exact duplicates dropped
  • 8 corrections, recorded

The stack

  • Cloudflare Workers (TypeScript)
  • D1 (SQLite)
  • Cloudflare Access + Google
  • Web app manifest + service worker
  • Vanilla JS, SVG charts
  • wrangler
  • Claude Code

Have a small thing that should be an app?

A spreadsheet, a text file, a form someone fills in twice a day. This is the size of tool we build in an evening and keep running for years.

Tell us about it