An offline-capable tip calculator PWA. No build step, no dependencies — the files that ship are the files in this repo.
npm run serve # http://localhost:8000A plain file server is enough, but the service worker needs http://localhost
or HTTPS; opening index.html over file:// will not register it.
npm testTwo suites, no dependencies:
test/calc.test.jsrunsapp.jsunmodified against a DOM stub (test/dom-stub.js) rather than pulling in jsdom. It covers the money arithmetic — including a sweep of every bill from $0.01 to $500.00 against all four rates, asserting the displayed tip plus the bill always equals the displayed total — and the custom panel's field-derivation rules.test/sw.test.jsrunssw.jsagainst a service worker stub (test/sw-stub.js) with a manually driven clock, covering the precache, cache eviction, and the offline and slow-network fallbacks.
| Path | What it is |
|---|---|
index.html |
Markup; the only page |
app.js |
All behaviour: calculations, custom panel, SW registration |
style.css |
Styles and design tokens |
sw.js |
Service worker — precache and fetch strategy |
manifest.json |
Web app manifest |
fonts/ |
Self-hosted Outfit (variable, 47KB for both subsets) |
icons/ |
Generated icon set — do not edit by hand |
icon.png |
Master artwork the icon set is generated from |
tools/build-icons.py |
Regenerates icons/ from icon.png |
tools/stage-dist.sh |
Assembles dist/ — the files that actually ship |
CLAUDE.md |
Notes for agents working in this repo |
Every amount is an integer number of cents, and each card's total is derived
from its already-rounded tip. Rounding the tip and the total independently at
float precision lets them disagree once displayed — a $1.25 bill at 18% used to
render a $0.22 tip beside a $1.48 total. If you touch the arithmetic, keep the
sweep in test/calc.test.js green.
The bottom of the page shows the running build, e.g. v4 · 826bab6. It is
stamped by tools/stage-dist.sh from VERSION in sw.js plus the short commit
hash, so there is no second place to bump. Served straight from the repo it
reads dev.
Because assets are cache-first, this reports the build actually running, not the one currently deployed — which is the point: if it lags after a deploy, the old bundle is still cached.
The three custom fields are three views of one number. Exactly one of them is authoritative — whichever was last typed into — and the other two are derived from it and the bill. The authoritative percent is kept unrounded in memory even though it displays to one decimal, so a later bill edit does not recalculate from the rounded figure.
A value that cannot produce a tip (a negative bill or percent, or a total below the bill) marks its own field invalid and blanks the derived fields rather than rendering negative money.
Pushing to main deploys to Cloudflare Pages (tip-calc →
https://tip.prenticew.com) via .github/workflows/deploy.yml. Tests must pass
first. Pull requests run the tests but do not deploy.
The workflow stages only the files that belong on the site
(npm run build → dist/), so tests, tooling, docs and the 873KB master
artwork are not published. It resolves the project's production branch from the
Cloudflare API rather than assuming it — guessing wrong publishes a preview
deployment, which succeeds loudly while leaving the live site untouched.
Two repository secrets are required:
| Secret | Where it comes from |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare → My Profile → API Tokens, with the Cloudflare Pages: Edit permission |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare dashboard URL, or Workers & Pages → Account details |
Every path is relative, so the app works from a domain root or a subdirectory (project pages, preview deploys) without changes.
Bump VERSION in sw.js on every deploy. Assets are cache-first and keyed
by that constant; without a bump, returning visitors keep the old files. The
worker calls skipWaiting()/clients.claim() and the page reloads once when a
new worker takes over, so an update lands on the next load rather than waiting
for every tab to close.
Everything the app needs is precached, and nothing loads off-origin, so an installed app works fully offline — it is pure arithmetic with no backend.
Navigations are network-first with a 3 second timeout (NAVIGATION_TIMEOUT_MS
in sw.js). True offline is the easy case: fetch rejects immediately and the
cached shell is served. A degraded connection is the hard one — a captive portal
or one bar of signal leaves fetch hanging, which would strand the user on a
blank screen while a good cached copy sits unused. After the timeout the cache is
served instead; if the abandoned request later lands, its response still refreshes
the cache for next time.
The app must be loaded online once for the worker to install. iOS may evict script-writable storage, the Cache API included, after prolonged non-use, in which case one online load restores it.
pip install Pillow
npm run iconsEdit icon.png and re-run; icons/ is generated output. The script flattens the
master's radial glow, which is what kept the original 894KB icon from
compressing, and fits the maskable variant inside the centre-80% safe zone.
MIT © 2026 Prentice Wongvibulsin.
The Outfit typeface in fonts/ is licensed separately under the
SIL Open Font License 1.1, which the MIT licence
above does not cover.