A private, offline-friendly tracker for CPAP supplies: cleaning schedules, replacement countdowns, and spare inventory. No account, no server of ours — your data stays on this device unless you sign i
HealthHTML · CSSGitHub @eagleadams86OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
PAPTrack — keeps CPAP supplies on schedule three ways at once: cleaning reminders, replacement countdowns on the standard DME schedule, and spares inventory with reorder flags. Single-file web app, works offline, optional Google sync.
Taken from the repository README
Loading the full README.
Read the full README ↗Live: eagleadams86.github.io/paptrack
PAPTrack keeps CPAP supplies on schedule three ways at once: cleaning (daily/weekly reminders), replacement (countdowns per the standard DME schedule), and inventory (spares on hand with reorder flags). Built from a supplier's "clean & replace your equipment" flyer.
This is the web version — index.html plus theme.css (the shared palette, copied from the private theme pack so every app in the family reads the same file). Use the live link above, or serve the folder locally; the two files have to sit together, so opening the HTML straight off disk without theme.css beside it gives an unstyled page. No build step, and no account is ever required (an optional Google sign-in adds cross-device sync — see below).
| Platform | Repo |
|---|---|
| 🌐 Web (this repo) | eagleadams86/paptrack |
| 🍎 iOS | eagleadams86/paptrack-ios — on the App Store |
| 🤖 Android | eagleadams86/paptrack-android |
| 🛟 Support & privacy | eagleadams86/paptrack-support |
| Supply | Clean | Replace |
|---|---|---|
| 😷 Mask | daily | every 3 months |
| 🪢 Headgear | weekly | every 6 months |
| 〰️ Tubing | weekly | every 3 months |
| 🌫️ Disposable Fine Filters | — (replace only) | every 2 weeks |
| 🛟 Full Face Cushions | daily | every month |
| 👃 Nasal Cushions & Pillows | daily | every 2 weeks |
| 💧 Humidifier Chamber | daily | every 6 months |
| 💦 Distilled Water | — | as needed |
Every value is editable per item, and custom items are supported for anything else (chinstrap, SD card, wipes…).
A browser with nothing in it opens on one card:
Welcome to PAPTrack
A tracker for the kit you sleep with: every mask, cushion, filter, hose and chamber on its own schedule — what to wash today and what to wash this week, how long each part has left before it should be replaced, and how many spares you have…
Start Fresh · Load Mask Kit · Load Nasal Kit
Start Fresh opens the Add Supply form with nothing set up. Load Mask Kit and Load Nasal Kit are the one-tap setups below.
Every app in the family opens on this same card, in the same words and the same order — what the app is, where the figures go, the ways in, then a line for each button. The three buttons here are not the family's three words, and that is deliberate: Money Map's Start Fresh lays out a starter set of rows, and here the starter set depends on something only you know — which mask you wear — so the kit choice is two of the three buttons. Restore stays where it was, in the ⇩ Back Up & Restore window.
It replaced a two-column panel that was this app's own invention: prose on the left, the buttons stacked on the right, on a 720px breakpoint nothing else on the page used. Five apps open side by side did not read as one family — the same argument that took the accent-purple pill off the buttons.
Scrolling never changes a number box. A browser steps a number box you have just typed in every time the mouse wheel or trackpad scrolls over it, so scrolling a window down past it used to change the figure without a word. The box now lets go of the cursor the moment you scroll over it, and the page scrolls instead; click it again to carry on typing.
Sync is enabled in this deployment, backed by the paptrack-6c817 Firebase project — the FIREBASE_CONFIG object at the bottom <script type="module"> block of index.html points at it. Setting that constant back to null returns the app to fully-local mode and hides all sync UI. To recreate the setup from scratch (e.g. in a fork):
eagleadams86.github.iorules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /paptrack/{uid} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
}
}
firebaseConfig object and paste it as the value of FIREBASE_CONFIG in index.htmlBecause all three ports (web, iOS, Android) share this one Firebase project and one document per user, a data-deletion request is handled once and covers every device the person used — runbook in DATA_DELETION.md, kept in the iOS repo alongside the checked-in firestore.rules.
The config object is not a secret (access is controlled by the rules above, which restrict each user to their own document). Because the authorized domain is eagleadams86.github.io, sign-in and sync work identically at this repo's /paptrack/ path and at the old /prototypes/ path — no Firebase change was needed for the move.
Sign-in goes through Google Identity Services: a popup straight to accounts.google.com returns an OAuth access token, and Firebase exchanges it for a session via signInWithCredential. GOOGLE_CLIENT_ID, just above FIREBASE_CONFIG, is what makes that possible.
Firebase's own signInWithPopup is deliberately not used. It starts at <project>.firebaseapp.com/__/auth/handler and only redirects on to Google from there, so a proxy that blocks that first hop kills sign-in outright — nothing in the app ever runs. This project's hostname is one of the blocked ones, confirmed on a corporate network.
The block is per hostname, not per domain, which is worth stating because the obvious conclusion is wrong. Measured on one network on a single day: this app's hostname and Team Dashboard's were both refused, while Sprint Velocity's went through untouched — identical sign-in code, and the blocked pair weren't the newest projects. Which way a filter lands on a hostname is outside our control and can change.
Same Google account, same Firestore document, same rules — only the doorway changed. All four web apps in this family now do this, and all four were confirmed working on the network that needed it on 7 August 2026.
Two consequences worth knowing:
accounts.google.com and not firebaseapp.com. authDomain remains in FIREBASE_CONFIG because the SDK requires the field, but nothing loads it. apis.google.com is gone too — it served the old popup.initializeAuth, not getAuth, so the SDK never asks for apis.google.com in the first place. getAuth() always wires in browserPopupRedirectResolver, and the SDK initialises that resolver during startup — which loads apis.google.com/js/api.js to build the gapi iframe that carries signInWithPopup and signInWithRedirect results back to the page. This app calls neither, so nothing consumed it; the visible symptom was a CSP error in the console and nothing else. Token refresh, sign-out and the cross-tab session all run elsewhere in the SDK and never touch the resolver. The three persistences passed in are the ones getAuth would have set, in its order, so existing sessions and cross-tab behaviour are unchanged. Dropping the resolver costs signInWithPopup/signInWithRedirect/phone sign-in, which now raise auth/argument-error; if one is ever wanted, pass browserPopupRedirectResolver to that call rather than reverting to getAuth(). (Web only — the native apps never used the resolver.)GOOGLE_CLIENT_ID is not part of firebaseConfig and can't be derived from it. Cloud Console → APIs & Services → Credentials → the OAuth 2.0 Client ID named Web client (auto created by Google Service). That same screen's Authorized JavaScript origins must list the app's origin — exact match including port, so http://localhost and http://localhost:8011 are different origins — or Google refuses with origin_mismatch.This is web-only, and it brought the web in line with the native apps rather than ahead of them. iOS (GoogleSignIn-iOS) and Android (Credential Manager) already obtain a Google credential from the platform and hand it to the very same signInWithCredential call, never touching a hosted handler. Neither needed any change.
Each person's list keeps its own clock (2026-08-31). The "which side is newer?" question
is asked per profile now: every profile carries its own last-edited timestamp (the first
keeps the original pap-updated key, the others get pap-updated:<id>), an edit stamps only
the person being edited, and switching people stamps nothing — looking at a list is not
changing it. Before this there was one shared timestamp for the whole browser, so a second
person's sync was comparing the cloud against the time the first person was last edited.
A profile that has never been edited on a device has no stamp at all, which deliberately
reads as oldest: it can never out-time an existing cloud copy, only receive it. On the
first load after this change the old shared timestamp becomes the stamp of whichever person
was on screen — everyone else starts unknown, the safe direction. The same change ships in
the iOS and Android apps; all three follow one contract.
How sync behaves: localStorage stays the source of truth. The first time a given Google account signs in on a browser, if both the browser and the cloud already have items saved, a dialog asks which to keep ("Keep this device" vs. "Keep Google's data") instead of guessing — silently picking the most-recently-changed side once wiped out a browser's data when an unrelated/stale cloud doc happened to have a newer timestamp. After that first reconciliation (tracked per-account via a pap-sync-uid flag in localStorage), and for live updates pushed from other devices, whichever side changed most recently (updatedAt) wins — with one hard rule on top: an empty copy never silently beats a copy with items in it, whatever the timestamps say. A fresh sign-in with nothing saved can't overwrite a cloud copy that has your supplies, and if another device genuinely clears everything, this one asks before following suit (declining restores your copy to the other devices). Signing out or losing connectivity just leaves the local copy in charge. Edits made in the few seconds while a sign-in is still settling which copy wins are held back and pushed the moment it settles, so a half-decided cloud copy is never overwritten.
An edit made offline never overwrites a newer copy from another device (web, 2026-10-26). Every save from the web app carries a version number (rev) that goes up by one, and the app remembers — per profile — the version it last saw. A save is sent only if the cloud is still that version; if another device saved in between, the two are combined (2026-10-29) instead of one quietly replacing the other. An offline edit waits on this device (it is not handed to Firebase to replay later) and goes up through the same check when the connection comes back. Device clocks no longer decide which copy is newer on the web. The phones still write the old way until their next release: an iPhone or Android build that predates this can still replace a newer cloud copy when it reconnects. The web app notices that write (the stamp beside the version changes) and treats it like any other device's save, so it never makes things worse — but the protection is only complete once all three apps carry it.
When both devices changed your supplies, both sets of changes are kept (web, 2026-10-29 — Money Map's fix, ported). Each browser remembers the copy of each person's list it last synced, so it can tell what each side actually changed since then: a mask marked cleaned on the phone and a spares count changed on the laptop both survive, and a supply added on one arrives beside a supply edited on the other. You are only asked about something both devices changed to different values — the window names each one (for example "Supply “Mask” · Last cleaned — this device: Oct 29, 2026; Google: Oct 27, 2026") and your choice settles just those; everything else is kept either way. When a browser has no remembered copy to compare against (the first sign-in on it, the first sync after this update, or straight after restoring a backup, which replaces rather than combines), the window falls back to choosing a whole copy, but it now shows when each copy was last saved, marks the newer one, and lists what is different. The remembered copy lives only in this browser and goes with Delete All Data.
When sync stops working, it says so. Failures used to end in the browser console, which
nobody has open — so the button went on showing your name and the note went on promising your
supplies were reaching your other devices, while nothing had left the browser. The button now
reads ⚠️ Not syncing and the note at the foot of the page gives the cause in plain English
and what to do about it. Nothing is ever lost when this happens: this browser stays the source
of truth and the cloud only mirrors it. There's no retry button on purpose — Firestore retries
the transient causes itself, and the next successful save clears the state. A dropped
listener is re-attached by that same save — and, since 2026-09-01, by a once-a-minute retry
while it is down, so a device that only reads is not cut off from the others' updates until it
next saves something. Two details are
load-bearing: the live listener carries an error callback, because a listener that errors is
dropped by Firestore and never fires again (without it, another device's updates simply stop
arriving while the header still says "syncing"); and invalid-argument is not assumed to
mean "too big" — Firestore uses that one code for both an oversized document and a value it
can't store, so the advice to remove a supply appears only when the size really is the problem.
A remedy that destroys data is never the guess.
GitHub Pages (static hosting, this repo, main branch)
├── index.html — the app; loads the Firebase SDK from gstatic.com
├── theme.css — shared palette, copied from the private theme pack
├── sw.js — service worker: keeps the app on your device for offline
└── sw-kill.js — the escape hatch, if sw.js ever needs uninstalling
├── all state ──► browser localStorage (source of truth, works offline)
└── signed in ──► Firestore doc paptrack/{uid} (newer-wins by updatedAt,
with the empty-never-beats-data guard; live onSnapshot
updates on other devices)
Backup/restore via JSON export & import.
There is no server of our own — the only backend is the optional Firebase (auth + one Firestore document per user, free tier). Any static host can serve the app (locally: python3 -m http.server 8011 in the folder), and it degrades to fully-local mode when Firebase is unreachable or the user is signed out. Since the palette moved into theme.css, opening index.html straight off disk isn't supported — serve the folder instead (see the note at the top).
The icon is the airflow mark the iOS and Android apps wear — a source dot with three arcs radiating out. make_favicon.py takes the shipped iOS icon as its master, repaints the rings and writes all three web copies from that one image: favicon.ico and the two base64 PNG data URIs in index.html. It recolours rather than redraws, so the shapes can't drift. The one deliberate difference from the phone icons is the colour: they fade each ring by alpha down to a nearly invisible outer arc, which read as dim beside the other apps in a row of browser tabs, so the web ramp runs between the shared accent tones instead. Re-run with python3 make_favicon.py, then bump the ?v= on the favicon.ico link.
Tests: tests.html (open it via a local server, e.g. python3 -m http.server 8011) loads the real index.html in a hidden iframe and pins the web-only pure functions — the untrusted-input validation that guards backups and synced data, and the .ics calendar generation. No build step, no frameworks; the page either says "All N tests pass" or lists what broke. The shared schedule math is additionally pinned by the iOS and Android unit-test suites.
And a smoke walk, because everything else is a pure function. Pinning normalizeItem, the schedule maths and the .ics generation leaves the largest part of the file — the render layer — never executed at all, so a throw inside a card, a filter or a dialog would ship green. A coverage run on 2026-08-27 measured exactly that: renderDetails, openForm, renderKeepingFocus and 68 others sat at zero. The walk plants a full kit in a second, full-size frame, looks through each of the three filters, opens a supply's details and its edit form from the card's own buttons (a window opened by showModal alone is empty in exactly the way a broken one is — the first draft of this test read 34 characters of static markup off a window that had never opened and called that "drawn"), and fails if the frame throws or a window comes back with nothing in it. Verified by breaking renderDetails on purpose. It writes nothing: the plant hook replaces the list in memory rather than saving.
It only runs on localhost, and enforces that itself. The test code writes nothing, but the iframe boots the real app — and GitHub Pages publishes tests.html next to it, at /paptrack/tests.html, where that iframe would be your signed-in copy: sync would start inside an invisible frame, and the which-copy dialog could fire where nobody can answer it. Two guards. The iframe carries data-pap-tests, which the sync module checks so it never initialises in the harness; and a gate at the foot of tests.html checks location.hostname and, anywhere but localhost / 127.0.0.1 / [::1], never creates the iframe at all — it explains why and says how to run the suite properly. CI reaches the page on localhost:8011, so it is unaffected.
The suite also runs on every push: .github/workflows/tests.yml serves the folder, opens tests.html in headless Chromium and fails the build if the summary goes red or the page throws — same workflow as the rest of the app family.
The app keeps a copy of itself on your device, so it opens with no network at all. Your supplies were always local, so once the page loads everything works: marking things cleaned or replaced, the countdowns, the calendar export, backups. Sync is the one thing that can't — it needs the network by definition, and picks up again on its own when you're back.
What's kept is only the app's own public files — the page, the stylesheet, the privacy policy and the icon, the same files anyone can read on GitHub. Nothing of yours is ever put there, which matters more than it sounds: every one of these apps shares a single browser origin, so that cache is not private to this app.
The network is always tried first, and the stored copy is used only when it genuinely doesn't answer (or takes more than five seconds), so you can't be left on an old version while you're online.
If one device is behind — every saved copy now carries the data format the app that wrote it understood. A copy written by a newer version than the one you're running won't be opened: you get a card saying so, nothing is changed or deleted, and reloading picks up the current version. A backup file from a newer version is refused the same way, without stopping the app you're using. Copies written by the iPhone and Android apps carry no marker at all, which reads as "not newer", so they are never affected by this.
sw-kill.js sits in the repo unused, as an escape hatch: copying it over sw.js and pushing
makes every installed copy uninstall itself and go back to being an ordinary online-only
page.
Not on an ordinary visit any more. init() used to run unconditionally at the foot of the
sync module, so firebase-app, firebase-auth, firebase-firestore and the Google sign-in
client were fetched from www.gstatic.com and accounts.google.com before anyone had touched
anything — four requests to Google carrying the visitor's IP and user-agent, on a page that
might never sync. That is what made the old privacy wording false; this is the change that
lets the strong sentence be true.
It cannot be made fully lazy, and that is the whole difficulty: a returning signed-in reader has to be recognised without clicking anything, and the only thing that knows whether this browser holds a live Firebase session is Firebase. So the app records the answer itself:
pap-sync-live | meaning | on load |
|---|---|---|
'1' | a session was live at last report | load Firebase now |
'0' | there was none, or they signed out | load nothing |
| absent | never asked, or a browser from before this change | fall back to the legacy pap-sync-uid marker |
onAuthStateChanged writes '1' or '0' on every auth report, including the null one
that follows signing out — so signing out stops the requests, not just the syncing. The
absent case is the migration and costs at most one eager load per browser:
pap-sync-uid has been written on the first successful sync for an account since long
before this, and is never removed, so its presence means "this browser has signed in at some
point". A browser that has never signed in has neither key and never takes that path.
The warming is load-bearing, not an optimisation. requestAccessToken() has to be called
from inside the click handler or the browser judges the popup unsolicited and blocks it, and
awaiting a cold SDK import first would spend the gesture. So the load starts on
pointerenter, pointerdown and focus — all of which fire before click. onClick still
awaits ensureInit() as a fallback, for somebody who tabs straight to the button and presses
Enter; if the popup is refused there, the existing popup_failed_to_open message says what to
do and the second press always works. ensureInit() is idempotent, or a hover and a click
would start two Firebase apps.
The click listener is wired at the boot branch, not at the end of init() — init() may
not have run yet, and the button has to be pressable in order to be what causes it to run.
tests.html pins the shape of all of this, and the privacy page's wording with it.
All three sync apps are on the same Firebase version, moved together, exactly like the
vendored Chart.js: package.json pins it for Dependabot and tests.html pins the manifest to
the firebasejs/… URL in index.html, so a manifest-only bump fails. Bumping means changing
the URL and the pin in the same commit, in all three repos, and then proving a real Google
sign-in still works on the live origin.
The one genuinely third-party thing this app runs is Google's Firebase SDK, and it is loaded
by URL from www.gstatic.com — so nothing was watching it. Dependabot reads manifests, and
no manifest named it; the clean bill of health it reported covered nothing at all. (There are
no known advisories against the pinned version — the problem was that nobody would have been
told if there were.)
package.json is that manifest. It installs nothing — it is private, has no scripts, and
CI passes --omit=dev — and the bytes that run still come from Google's CDN at page load.
That creates the same way of ending up lying that a vendored library has: Dependabot cannot
rewrite a URL, so a version-bump PR would raise the manifest while the page went on fetching
the old one. tests.html pins the manifest's version to the firebasejs/… URL in
index.html, which makes a manifest-only bump fail and turns the PR into the right
instruction: a newer SDK exists, now change the URL too.
Never let that pin become a ^ or ~ range — a range cannot be checked against a URL.
privacy.html is the web app's privacy policy, linked from the footer — what
the app stores, what the optional Google sign-in puts in Firestore, and how to have a synced
copy deleted. It exists because other people can sign in with their own Google accounts. The
iOS app has its own policy in the paptrack-support
repo; if what either app stores changes, update both in the same commit.
Since 2026-08-21 it carries the family footer and the family landmarks, which it had never
had — the repo under How it works, the authorship line, and a real <main> / <footer>
pair. It is a public page reached by a link in the app's own footer, so somebody who followed
that link landed on a document with no way back to what it documents and no statement of who
wrote it. </main> closes before the <footer>: a <footer> nested inside main is not
contentinfo at all, it is a plain footer for that section — so .wrap stays an ordinary
<div>. There is deliberately no privacy link in it; you are standing on that page.
tests.html asserts the elements and their order, with HTML comments stripped first, because
the notes beside both elements name them in prose.
Commit subject lines are written in plain English a non-developer can read (what changed and why it matters, not implementation detail). The in-app "Recent changes" panel that listed them was removed in August 2026, along with the GitHub API entry in the page's CSP.
☀, the plain text character, not the emoji-presentation ☀️ — the colour-font variant is a different weight and baseline from the ☾ and ✦ beside it, and the row read as three different sets of glyphs. Both fixed 2026-08-21 and pinned in tests.html; every sibling app follows the same two rulessave() refuses outright. The family's window, the same in all six appshttp(s), and dropped rather than truncated if it is over-long — a cut URL still looks usable and points somewhere nobody chose=, +, - or @ — or with a tab or a carriage return in front of one — is defused with a leading apostrophe, because a spreadsheet reads those as formulas and a supply name is free text; a cell that is simply a number is left as a number, so the day counts and the spares column stay arithmetic.ics export per item with up to three events: next replacement (alarm 2 days before), a reorder reminder ahead of it (half the cycle for short-cycle consumables, at most 30 days — the typical insurance resupply window), and the next cleaning for items cleaned less often than daily. Output is folded to RFC 5545's 75-octet line limit (split between characters, so accents and emoji survive) for calendar apps that reject over-long lineslocalStorage; JSON backup export/import; no account required. Imported backups and synced cloud documents are validated field by field before they're trusted: unreadable records are skipped (the toast says how many), out-of-range numbers are clamped, and impossible dates fall back to today, so a corrupt or hand-edited file can't take the app downLIMITS here, SupplyItem.Limits on both phones): 3650 days for either cycle, 999 spares, a 60-character name and a 140-character note. Until 2026-08-22 this app's were tighter — 99 spares, a reorder-at of 20, 365/1095 days — and because they're applied on every load, import and synced document, a spare count or a reorder threshold typed on a phone was quietly rewritten here and then pushed back, so the phone lost it too. Clamping is what that boundary is for; the bug was that the three apps disagreed about where. tests.html now pins the numbers, and the form's own HTML max attributes against them.btn with a filled .primary variant drawn from the pack's --btn-bg/--btn-text, the same rule Sprint Predictability, Flow Metrics and Money Map carry. Until 2026-08-22 this was the one app whose primary button was an accent-purple pill, so two of them open side by side didn't look like one product. No colour changed: the primary pair was already in the palette and unused here, and --accent keeps its other jobs. + Add Supply wears that button plain, with nothing of its own — until 2026-08-22 it was a pill fixed to the bottom-right corner of the window, a phone pattern that on a desktop hung over the list as the one control in the app that wasn't part of the page. It now sits in the toolbar row between the search box and the filters, the way Golf Handicap's + Add Round sits above the rounds it adds tolocalStorage and applied before first paint. With nothing saved the app follows your own phone or computer — Light on a light setting, Midnight on a dark one — and changes with it while the page is open, no reload needed. Midnight is still the base palette, which is what Auto means by "dark". Forest, Solarized and Synthwave were retired in August 2026, and Slate was renamed Dark — a saved Slate preference carries over automaticallytheme.css (theme pack rule 20, 2026-10-23); before, they looked exactly the same on or off