A multi-user grocery list.
  • JavaScript 85.8%
  • CSS 8.2%
  • HTML 5.5%
  • Dockerfile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Adam Macumber 98ba99ed11 Add JSON export and import to the sync status dialog
Export saves the open list (or all lists, from the home screen) to a JSON
file without photos or deleted items. Import adds the lists in a file as
new lists through ordinary ops, so it syncs normally and never overwrites
anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:23:57 -04:00
public Add JSON export and import to the sync status dialog 2026-10-03 13:23:57 -04:00
server Add a script to seed lists with default categories 2026-10-03 12:58:37 -04:00
shared Add JSON export and import to the sync status dialog 2026-10-03 13:23:57 -04:00
test Add JSON export and import to the sync status dialog 2026-10-03 13:23:57 -04:00
.dockerignore Add Dockerfile and compose.yml for deploying behind Traefik 2026-10-02 11:36:17 -04:00
.gitignore Initial commit: offline-first multi-user grocery list PWA 2026-10-02 09:30:45 -04:00
compose.yml Add Dockerfile and compose.yml for deploying behind Traefik 2026-10-02 11:36:17 -04:00
Dockerfile Add Dockerfile and compose.yml for deploying behind Traefik 2026-10-02 11:36:17 -04:00
package.json Add a script to seed lists with default categories 2026-10-03 12:58:37 -04:00
README.md Add JSON export and import to the sync status dialog 2026-10-03 13:23:57 -04:00

Grocery Run

A shared, offline-first grocery list. Installable as a PWA on phones.

  • Add, edit, check off, and remove items; attach a photo to any item.
  • Mark an item out of stock: it moves to its own section and stays on the list for next time. Checking it off later clears the flag.
  • Group items into categories (Produce, Dairy, …) and put the categories in the order you walk the store. Re-adding something puts it back in the category it was in last time.
  • The add box suggests things you've added before, ranked by frecency (each past add counts, decaying with a 21-day half-life).
  • Archive lists you're done with to tidy the home screen. Archiving only affects your device; restore them any time from the Archived section.
  • Export lists to a JSON file and import them back, from the sync status dialog. Inside a list, Export saves just that list; on the home screen it saves all of them. Import always adds the file's lists as new lists, so it never overwrites anything. Photos aren't included.
  • Share a list by link; everyone with the link can edit it. Changes show up live on other devices.
  • Works fully offline. Unsynced changes are always visible: a status bar shows whether everything is saved to the server, each unsynced item is marked, and tapping the status bar lists every pending or rejected change.

Running

Requires Node 22.13+ (uses the built-in node:sqlite); no dependencies.

npm start                     # http://localhost:8080
PORT=3000 DB_PATH=/var/lib/grocery/db.sqlite npm start
npm test

Installing as a PWA on a phone requires HTTPS. Put it behind any TLS reverse proxy, e.g. Caddy (grocery.example.com { reverse_proxy localhost:8080 }) or tailscale serve 8080. If proxying, disable response buffering for /api/lists/*/events (server-sent events).

Docker + Traefik

compose.yml runs the app behind an existing Traefik instance, attached to an external Docker network (default traefik) with an HTTPS entrypoint (default websecure) and a certificate resolver (default letsencrypt):

GROCERY_HOST=groceries.example.com docker compose up -d --build

Override TRAEFIK_NETWORK, TRAEFIK_ENTRYPOINT or TRAEFIK_CERTRESOLVER in the environment or a .env file if your Traefik setup uses other names. The database lives in the grocery-run_grocery-data volume.

Seeding categories

Add the default categories (Produce, Bakery, Deli, Meat, Seafood, Dairy, Frozen Foods, Natural Foods, …) to lists. Categories a list already has are skipped, so it's safe to re-run, and it works while the server is running; open devices pick the new categories up on their next sync.

npm run seed:categories -- LIST_ID          # the last part of the list's link
npm run seed:categories -- --all --dry-run  # preview for every list
DB_PATH=/var/lib/grocery/db.sqlite npm run seed:categories -- --all
docker compose exec grocery-run node server/seed-categories.js --all

Back up by copying the SQLite file (or sqlite3 db.sqlite ".backup out.sqlite").

How sync works

UI ──▶ outbox (IndexedDB) ──push──▶ server (SQLite) ──pull──▶ confirmed state (IndexedDB)
        pending ops                  applied_ops + records       view = confirmed ⊕ outbox
  • Data model. Each item is a set of per-field last-writer-wins registers stamped with a hybrid logical clock. Merging is commutative and idempotent, so concurrent edits to different fields (one person checks off milk while another changes the quantity) both survive, and retries can't corrupt state. Clients correct their clock against the server's to keep timestamps honest.
  • Every change goes to the outbox first, in a durable IndexedDB transaction, before the UI shows it. Closing the app, losing signal, or crashing loses nothing.
  • Ops leave the outbox only when the server acknowledges them, after the server's SQLite transaction (synchronous=FULL) commits, and in the same local transaction that folds them into the confirmed state.
  • Retries are safe. Ops carry unique ids that the server deduplicates, so a response lost in transit just leads to a harmless resend.
  • Nothing is dropped silently. An op the server rejects permanently is kept and shown as "couldn't be saved", with Retry and Discard options.
  • Images are downscaled on the device, kept in IndexedDB, uploaded with a SHA-256 checksum the server verifies, and shown as "photo not uploaded yet" until they are uploaded.
  • Triggers: right after each change, on server-sent "changed" events, on reconnect or app focus, every 30 seconds, and with exponential backoff (up to 60s) after failures. Web Locks keep multiple tabs from syncing at the same time.
  • Deleted items are kept as tombstones; that's also where the frecency history comes from.

Layout

server/        HTTP API, static files, SQLite storage
shared/        code used by both browser and server: sync engine, merge, HLC, schema, frecency
public/        PWA: UI, IndexedDB store, service worker, manifest, icons
test/          node:test suites, including fault injection (offline, lost responses, 500s)

Security note

There are no accounts. Lists are protected only by their unguessable 128-bit ids, so anyone with a list's link can read and edit it. If you expose this to the internet, consider adding auth at the reverse proxy.