#Overview
A household list app that learns what you buy and how often, and puts it back on the list at roughly the right time.
Two kinds of thing, deliberately not collapsed into one:
- A task is a one-shot commitment. Completing it destroys it.
- A staple is a consumable with a rhythm. Completing it records a purchase and schedules the next occurrence.
Lateness exists here, but is never marked. A date can pass uncompleted and the app keeps stating it, on its own day, in the same quiet voice as any other — no red, no badges, no overdue pile. The date is a fact; the guilt was the design flaw.
The documentation — this file and its siblings, rendered and searchable — is at docs.planet4.me.
#Status
All eighteen build steps of the product spec exist here.
Steps 1–9 are the shared list app and the statistical engine that learns cadence from your own purchase history — to-dos, notes, groups, the calendar, stores and lead time, auto-add and dormancy. Everything in them is client-side arithmetic over your data, and they never need a model.
Steps 10–18 are the Claude intelligence layer: entity resolution,
classification, task capture, seasonal windows, receipt capture, pantry
photos, photo transcription, the assistant, and the review pass. They live in
functions/ as nine callable Cloud Functions — resolveEntity,
classifyStaple, captureTasks, suggestSeasonal, readReceipt,
readPantry, transcribePhoto, assistantChat, reviewPass — and the app
reaches them only through the seams in src/data/intelligence.ts.
The layer is an accelerant, never a dependency. Running it live needs the
Blaze plan, the ANTHROPIC_API_KEY secret, and an App Check site key
(VITE_RECAPTCHA_SITE_KEY in the web build — verify with npm run
check:appcheck). Without the site key — a fresh checkout, CI, the emulator
suites — every seam returns null and the app runs on pure statistics and
manual entry. That is a supported state, not a degraded one.
Every field that was once added ahead of its feature now has something that
writes it: prior from the typical-days guess a staple is born with, region
from the Household section of Settings, household.profile from the seasonal
questions and the pantry photo's hints.
See ARCHITECTURE.md for what exists and DECISIONS.md for why it is that way.
#Running it locally
npm install
cp .env.example .env.local # then fill in the values, see below
npm run dev
#Against the emulators (no Firebase project needed)
# terminal 1
npm run emulators
# terminal 2 — emulators ignore the config values, so dummies are fine
VITE_USE_EMULATORS=true npm run dev
#Against the real project
Fill .env.local from the Firebase console under
Project settings → General → Your apps → Web app → SDK setup and
configuration. These values are not secrets — Firebase web config is designed
to ship in the client bundle, and the apiKey is a project identifier rather than
a credential. They live in env so that pointing at a second project for
development is a config swap rather than a code edit.
#Tests
npm test # layer 1 (plus the docs renderer): pure logic, fixtures in, values out. No network.
npm run test:emulator # layer 2 + security rules, wrapped in firebase emulators:exec
npm run test:e2e # a real browser against the emulators + dev server (both must be up)
npm --prefix functions test # the intelligence layer's own: prompts, the reply stream
npm run build # typecheck, then production build
The pure layer is where every subtle bug in this design lives — median and MAD, season-aware interval filtering, per-unit quantity scaling, prior blending, dormancy thresholds — so it is tested thoroughly and with no mocks. Layers 3–4 get smoke tests only.
The e2e run is fourteen scripts, each driving a real browser because what it proves cannot be proved anywhere else — a held press, a blocked network, a reload that has to come back from disk:
- The journey —
smoke.mjs: sign in, add, check off, reload. - Boot and connection —
blocked-firestore.mjs(an ad blocker aborts every request and the app must say "can't sync", never "offline"),hung-firestore.mjs(requests that neither succeed nor fail),offline-boot.mjs(a returning phone opens from its own disk). - Groups —
group-rename.mjs,group-nest.mjs(a group inside a group, and the fold that puts it away),item-move.mjs(filing a to-do or a note by holding its row). - The calendar —
date-offer.mjs(a day is a mention until you say otherwise),calendar-move.mjs,calendar-remove.mjs,calendar-group.mjs. - Photos —
note-photos.mjs(attach, view, remove, and the cascade a permanent delete owes),photo-exif.mjs(the bytes reach Storage with GPS gone). - People and questions —
people-questions.mjs(a person born by a note's save and renamed without touching one, the @ menu, anaskline captured and answered into the stamp the note itself carries, and the More sheet that pushes no history).
All of it runs on every push via GitHub Actions
(.github/workflows/ci.yml), in three jobs: unit tests, typecheck and both
builds; the emulator suite over layer 2 and the security rules; and the
browser run.
#First-time setup on a real project
These steps need a browser and a human; they cannot be done from a build agent.
- Enable Google sign-in. Firebase console → Authentication → Sign-in method → Google. It is the only provider: no email/password, no anonymous. That removes password reset, email verification, and account recovery from the build entirely, and everyone in a household already has a Google account. Signing in creates an account's own household; a shared one is started from Settings and joined by invite link, with no server involved.
- Authorize your domains. Authentication → Settings → Authorized domains
must include
localhostfor development and your hosting domain for production, or sign-in fails with an opaque error. -
Deploy the security rules. The project is in production-rules lockdown until you do, and every read will be denied:
npx firebase login npx firebase deploy --only firestore:rules - Optional — a Places API key for naming stores automatically on a first visit. Without one the app simply asks which store you are at, which is a normal path rather than a degraded one: indoor geolocation fails often enough that asking has to work well anyway.
#Deploying
npx firebase login
npx firebase deploy # -> https://smart-list-app-22667.web.app
Hosting serves dist/, a build artifact that is not in the repository.
firebase deploy builds it first, through the predeploy hook in
firebase.json, so a fresh clone deploys without a separate step. Deploying the
rules on their own skips the build, because predeploy hooks are per target:
npx firebase deploy --only firestore:rules
#The docs site
docs.planet4.me is a second Hosting site serving
docs-dist/, the output of npm run docs:build: the documents in this
repository, rendered, with search (see ARCHITECTURE.md, "The docs site").
npx firebase deploy builds and ships both sites; --only hosting:docs ships
the docs alone and --only hosting:app the app alone. The output also opens
straight off the disk, no server needed:
npm run docs:build && open docs-dist/index.html
The site is target docs in .firebaserc, mapped to the Hosting site
smart-list-app-docs, which also answers at
smart-list-app-docs.web.app. A
Hosting site has to exist before its first deploy — firebase deploy fails
on the docs target otherwise — so on a fresh project it is one command,
run once, by someone signed in, and the id it names goes in .firebaserc:
npx firebase hosting:sites:create <site id>
The domain is connected once, by hand:
Firebase console → Hosting → smart-list-app-docs → Add custom domain →
docs.planet4.me, proving ownership with the TXT record it dictates and
pointing the name at Hosting with the A records it dictates. That is step 1
of the app's own custom-domain chain below and nothing more: the docs site
has no sign-in, so steps 2–4 do not apply, and no API key needs the domain
added.
#Custom domain
Hosting will serve any domain you connect to it, but sign-in is domain-sensitive, so a custom domain is a chain of four steps — each one missing has its own distinct symptom, which is how you tell where you are:
- Connect the domain. Firebase console → Hosting → Add custom domain. Prove ownership with the TXT record it dictates, then point the domain at Hosting with the A records it dictates (copy them from the console — do not reuse IPs from a blog post). The dashboard walks Needs setup → Pending → Connected; the Pending stretch is DNS propagation plus certificate provisioning and can take from minutes to a day. Until Connected, the symptom is the domain not serving at all, or a certificate warning.
- Authorize it for Auth. Authentication → Settings → Authorized domains
→ add every domain that serves the app. Missing, the app loads and browses
fine but sign-in fails immediately with
auth/unauthorized-domain. authDomainneeds nothing. The bundle uses the domain the page was served from as itsauthDomain, so the OAuth handshake stays first-party on whichever domain the user is on — no rebuild per domain. That matters because a fixedauthDomainruns the handshake on a third-party origin whose storage browsers now partition: the popup path mostly survives, but the redirect fallback silently bounces back signed out, and iOS standalone — the case the fallback exists for — is exactly where it bites. Hosting serves the/__/auth/*helpers on every connected domain, which is what makes the trick work; theVITE_FIREBASE_AUTH_DOMAINvalue is only the fallback for origins with no helpers, i.e. the dev server on localhost.- Tell Google about each handler. Sign-in on a domain redirects through
https://<that domain>/__/auth/handler, and Firebase only auto-registers the…firebaseapp.comone. Google Cloud console → APIs & Services → Credentials → the auto-created web OAuth client: for every domain that serves the app — the stock…web.appdomain included, if anyone signs in there — addhttps://<domain>to Authorized JavaScript origins and the handler URL to Authorized redirect URIs. Missing, Google shows itsredirect_uri_mismatcherror page instead of the account picker.
Any key with a domain allowlist needs the new domain added too: the Places
key's HTTP-referrer restriction (without it store naming falls back to
asking, quietly), the App Check reCAPTCHA key's domain list (without it the
intelligence seams fail), and the Firebase web apiKey only if you chose to
referrer-restrict it in Google Cloud — it ships unrestricted.
Fill in .env.local before you deploy. Vite substitutes VITE_FIREBASE_*
into the bundle at build time; nothing reads them at runtime, so a build done
without them cannot be repaired afterwards by adding them. The bootstrap in
src/firebase/app.ts throws before React mounts, and the result is a blank page
whose only symptom is one line in the browser console. npm run build fails
outright rather than emit that bundle, but only a filled-in .env.local — or
VITE_USE_EMULATORS=true, which is not what you want on hosting — will satisfy
it.
#Worth testing on a real phone
Some things cannot be proven in CI, and two of them are the whole point of the app:
- Offline check-off in an actual store. Grocery stores have terrible signal, and the one moment the app must work is the one moment there is no connectivity. Writes queue locally and flush on reconnect.
- Installed standalone behavior, especially sign-in. iOS standalone can block the sign-in popup; the app falls back to a redirect, but that path is worth confirming on the device you actually use.