A React + TypeScript shopping list app that turns a rough grocery list into an ordered route through the store. It runs as a static frontend by default, can install as a PWA, and can use an optional backend for shared lists.
See What's New for recent changes and major app milestones.
These are real app screenshots with numbered callouts. The README versions are generated from docs/screenshots/source with:
node scripts/generate-readme-screenshots.mjsThe generator uses Playwright Chromium. If the browser binary is missing after installing dependencies, run npx playwright install chromium.
| Route view | Edit and share |
|---|---|
![]() |
![]() |
| Mobile edit and share | Mobile settings |
|---|---|
![]() |
![]() |
- accepts pasted or typed shopping lists
- groups items into supermarket sections using country configs for Belgium, Canada, France, Germany, Italy, Mexico, the Netherlands, Romania, Spain, the UK, and the US
- understands quantities and measurements like
bananas x2,2x apples,500g mince,Β½ tsp, and8 fl. oz - stores liquid and weight quantities in metric form, then displays metric, imperial, or cooking measurements from the route toolbar
- supports size qualifiers like
small milkand renders them as a badge - applies display aliases for common milk shorthand like
blue milk,gold milk,green milk, andred milk - persists data locally so the app can be reopened without losing state
- reloads saved list categories when the product dictionary or approved runtime overrides change, preserving checked state while improving route order
- supports backend-backed shared list links that anyone with the link can edit
- generates themed QR codes for shared lists and can scan shared-list QR codes when the browser supports camera scanning
- can optionally show grouped, low-noise browser notifications when another device adds items to an open shared list while the app is not focused
- records unknown products and reviewer-approved recategorisation suggestions as backend product overrides, so matcher fixes can apply without editing source code immediately
- remembers recently opened shared lists locally on the device for quick reopening
- stores each shared list's country profile with that shared-list record, with local fallback
- supports English, Spanish, French, German, Dutch, Italian, Romanian, and Pirate UI text, defaulting from the browser language
- supports light, dark, and system themes, including PWA chrome theme colours
- includes debug self-checks and diagnostics for backend health, heartbeat latency, database type, runtime host, state consistency, quantity parsing, measurement conversion, variants, storage, section matching, and active shop layout
- deploys to GitHub Pages via GitHub Actions
The app uses normal path-based routing:
/edit- list editor, used when there is no saved list yet or when editing an existing list/route- shopping list route view/sections- read-only section and route-order reference/settings- language, country profile, and theme preferences/about- app version, install status, and release information/debug- debug tools, defaulting to parsed item diagnostics/debug/<tab>- direct debug tabs such as/debug/backend,/debug/products,/debug/host, and/debug/settings/404- not-found page for unknown routes/500- server-error page for backend failure states
Backend-backed shared lists use path routes:
/<uuidv7>/edit- shared list editor/<uuidv7>/route- shared list route view
Legacy /list/<uuidv7>/... URLs are still accepted and normalized to the canonical /<uuidv7>/... structure. Debug tools are generated and normalized as app-level routes because they inspect the current browser/app runtime rather than a specific shared list. Older list-nested debug URLs such as /list/<uuidv7>/debug/backend may still be accepted for compatibility, but the canonical URL is /debug/backend.
If a page needs data that is not available yet, it shows a warning and points you to the page that can populate it.
npm install
npm run devRun Storybook for component and design-system documentation:
npm run storybookStorybook includes component stories with autodocs plus design-system reference pages for actions, accessibility, colour tokens, empty states, forms, internationalisation, measurements, and typography. The theme button in the Storybook toolbar switches the preview and Storybook chrome between light and dark mode.
The app can run in two modes:
- frontend-only mode uses browser
localStorageand works as a static site - backend mode is enabled automatically when
/api/healthresponds
When backend mode is available for a shared list, the app loads the browser record and the backend shared-list record, chooses the newest saved record using updatedAt, writes that winning record to both places, then keeps saving future edits to both local cache and the backend. The country profile is stored as part of each shared-list record. Language, theme, route density, and measurement display mode remain browser preferences and default from the user's browser environment.
Start the backend API:
npm run apiStart the frontend in another terminal:
npm run devThe Vite dev server proxies /api to http://localhost:8787 and forwards the browser-facing dev origin so same-origin CSRF checks still pass for proxied API calls. In non-production mode the API rereads package.json for /api/health, which keeps local version checks current after automatic version bumps without restarting the API process.
For production backend hosting, build the frontend and run the API server:
npm run build
npm run apiPersistent backend storage uses Postgres. Local setup uses Docker Compose, so Docker Desktop must already be installed and running before setup starts.
Once Docker is running, project setup is one command:
npm run setupThat command:
- writes a local
DATABASE_URLto.env.local - starts Postgres with Docker Compose
- creates the required tables
After setup, run the backend as usual with npm run api.
If setup says the Docker daemon is not running, open Docker Desktop, wait until it reports that Docker is running, then run npm run setup again.
If setup prints Docker success messages and then fails with read ECONNRESET, Postgres started but was not ready for schema setup yet. Run npm run setup again; the command is safe to repeat and will reuse the existing container and volume.
To inspect the local database in DBeaver or another Postgres client, use:
Host: 127.0.0.1
Port: 54321
Database: shopping_list
Username: shopping_list
Password: shopping_list
SSL: disabled
The local setup creates this app table:
shared_lists
If a database client cannot connect, check Docker is running and the Postgres container is up:
docker compose psIn production, set the app's environment variables to point at a persistent Postgres database from your hosting provider:
DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=require
Use the real connection string from the database provider. Do not reuse the local Docker username/password in production, and do not commit production credentials to the repository.
If your provider requires TLS and the connection string does not include sslmode=require, add:
DATABASE_SSL=true
Add these variables wherever your host configures app or service environment variables, then redeploy.
The backend creates/updates its schema on startup.
Most Node app hosts need separate build and run commands. Use:
Build command: npm run build
Run command: npm run api
Configure the production database before deploying:
- Create or attach a persistent PostgreSQL database.
- Add the database connection string as
DATABASE_URL. - Add
DATABASE_SSL=trueif the connection string does not already includesslmode=require. - Redeploy the app.
Do not use the local Docker database credentials in production. Use the credentials generated by your database provider.
Unknown product reporting is optional. When the backend is available, items that parse into the Other section are reported from the frontend to the backend with only the item text, normalized/cleaned item text, current matched section when available, suggested section when submitted manually, country profile, and app language. The backend deduplicates those sightings into product_suggestions, which can be reviewed in Debug tools and approved as runtime product overrides.
Approved product overrides are loaded by the frontend and merged into the active country profile before parsing. When overrides or country config change, the current list is reparsed so section fixes apply to saved lists while preserving item identity and checked state.
GitHub issue creation can still be enabled as an optional audit/fallback path. When configured, the backend creates one GitHub issue per product, attaches it as a sub-issue under a parent tracking issue, and adds a +1 comment instead of creating duplicates when the same product is reported again.
For implementation notes, see Unknown Product Automation.
FOOD_GITHUB_REPO=owner/repo
FOOD_GITHUB_TOKEN=github_pat_...
FOOD_GITHUB_REPO falls back to GITHUB_REPOSITORY when available. The GitHub token needs permission to read and write issues for that repository. The GitHub token is only read by the backend and is never exposed to the frontend. The older UNKNOWN_PRODUCTS_GITHUB_* names are still accepted for compatibility.
Unknown product reports are accepted only from the same app origin and require a backend-issued HttpOnly CSRF cookie before the backend stores suggestions or creates/comments on GitHub issues. The CSRF cookie contains a random token and salt, is signed by the backend with CSRF_SECRET, and is not exposed to frontend JavaScript. If your app sits behind a proxy that changes the public origin seen by the backend, configure the allowed origin explicitly:
FOOD_REPORT_ALLOWED_ORIGINS=https://shopping.example.com
Optional overrides:
FOOD_GITHUB_ISSUE=123
FOOD_GITHUB_TITLE=[Automated] Products Filed Under Other
FOOD_REPORT_RATE_LIMIT=30
FOOD_REPORT_RATE_LIMIT_WINDOW_MS=900000
CSRF_SECRET=choose-a-long-random-server-only-secret
FOOD_GITHUB_ISSUE is the parent issue number. If it is omitted, the backend finds or creates an open parent issue using FOOD_GITHUB_TITLE.
If the GitHub variables are not configured, GitHub issue creation is skipped but backend product suggestions still work.
Product issues use this title format:
[PRODUCT] `<product name>` filed under `other`
The product name is taken from the parser's cleaned item name first, then falls back to the normalized or raw item text. For example, canned tuna 4 should be filed as canned tuna when the parser has cleaned the quantity away.
Suggested parent issue description:
## π§Ύ Purpose
This issue collects products that Smart Shopping List could not confidently match to a known store section. When an item is filed under `Other`, the app can report it here so the product matcher can be improved over time.
Each reported product is tracked as a sub-issue. Duplicate sightings are added as comments to the existing product issue instead of creating another duplicate.
## π¦ Data Collected
Reports include only the data needed to improve matching:
- `Product` - the cleaned product identity used for deduping and issue titles, for example `canned tuna`
- `Item` - the product text entered in the shopping list, for example `baby corn`
- `Normalized` - the parser-normalized form, for example `baby corn`
- `Cleaned` - the cleaned display/search form when available
- `Country` - the selected store profile, for example `uk`
- `Locale` - the app language, for example `en`
- `Current section` - the section currently chosen by the matcher, when available
- `Suggested section` - the manually selected target section for recategorisation reports, when available
- `Reported at` - the timestamp when the backend created the report
Reports do not include the full shopping list, checked state, user accounts, payment details, analytics identifiers, or advertising identifiers.
## π Duplicate Handling
The first sighting creates a product sub-issue using the product title format shown above.
Later sightings of the same product add a `+1` comment with the latest country, locale, and parser details.
## β
How This Is Used
Use these reports to decide whether a product should be added to a country-specific food dictionary, mapped to a better section, given an alias, or intentionally left under `Other`.If DATABASE_URL is not configured, the backend falls back to the old JSON file store at data/shopping-list-db.json. That fallback is useful for quick local experiments, but it is not suitable for production deployments on ephemeral filesystems.
Backend utility routes:
GET /api/health- includes backend health and database statusGET /api/database/status- compatibility route for database status onlyGET /api/product-overrides?country=<country>- approved runtime matcher overlays for a country profileGET /api/unknown-products/suggestions?status=pending- pending, approved, or rejected product suggestionsPATCH /api/unknown-products/suggestions/:id- edit a product suggestion before approvalPOST /api/unknown-products/suggestions/:id/approve- approve a suggestion as a runtime overridePOST /api/unknown-products/suggestions/:id/reject- reject a suggestionPOST /api/unknown-products- CSRF-protected unknown product and recategorisation reporting
The health payload also includes the backend app version. The frontend compares that with its built version during backend checks and shows the update reload overlay before refreshing if a newer backend/app revision is detected. Backend records are validated before they are stored. Shopping-list timestamps must use canonical ISO format, country codes must match the supported country profiles, and persisted section keys must be known route sections.
Debug tools are hidden from the normal app flow until debug mode is enabled. Tap the About page version seven times to reveal the Debug tools link, then open /debug or a direct tab route such as /debug/backend or /debug/products.
Debug tabs include:
Parsed- inspect and edit the structured items generated from the current listState- validate parser, matcher, progress, variants, and list identity stateBackend- inspect backend health, current storage type, database metadata, heartbeat history, and latency trendDatabase Entry- inspect the current backend-backed shared-list record as highlighted JSONProducts- review pending unknown-product suggestions, approve/reject runtime matcher overrides, and suggest a better section for any item in the current listEvents- manually trigger notification examples, toast variants, install/update overlays, and hidden interaction previewsHost- show the current hostname, host, origin, protocol, and Vite base path so installed PWAs can be checked against the domain they are running fromConfig,Matcher,Quantities,Measurements,Weights,Variants,Layout,Sections, andStorage- self-checks and reference data for parser and route behaviourSettings- debug-only switches that should stay out of the main user settings area
The Backend tab reports whether the app is currently using LocalStorage, backend JSON DB fallback, or PostgreSQL. It only displays non-sensitive database details such as adapter type, availability, timestamps, and selected heartbeat error details. The heartbeat panel records recent backend status checks, latest latency, and a latency-coloured sparkline using a fixed latency scale, with disconnected/error states shown as failed/offline samples.
Debug Settings currently include:
- force LocalStorage mode
- pause backend heartbeat
- disable automatic backend reconnect
- show PWA install prompts while testing install UI
- disable the PWA splash screen
- disable hidden easter-egg interactions
- enable verbose console diagnostics for route, storage, backend heartbeat, and sharing events
Home Assistant integration code exists in the backend, but it is currently disabled by default. The current implementation does not yet model one Home Assistant list per shared app list, so it is not suitable for general multi-user use.
To experiment with the disabled integration locally, explicitly opt in before starting the backend:
ENABLE_HOME_ASSISTANT_INTEGRATION=true
HOME_ASSISTANT_URL=http://homeassistant.local:8123
HOME_ASSISTANT_TOKEN=your-long-lived-access-tokenDisabled-by-default backend routes:
GET /api/home-assistant/statusPOST /api/home-assistant/syncpushes a supplied shopping-list record, or a stored shared list referenced bylistId, to Home AssistantPOST /api/home-assistant/add-itemwith{ "name": "Milk" }POST /api/home-assistant/remove-itemwith{ "name": "Milk" }POST /api/home-assistant/complete-itemwith{ "name": "Milk" }POST /api/home-assistant/incomplete-itemwith{ "name": "Milk" }POST /api/home-assistant/sort
Every browser session has an internal UUIDv7-style list id. When the backend is connected, that list id is migrated to the backend and shown in path-based URLs:
/<uuidv7>/edit
Anyone with the link can edit the same list. Changes are saved to the shared backend record after each completed app state change and cached locally as an offline backup. If the backend is offline, new offline-only lists keep their UUID hidden from the URL; lists that have already been backend-backed keep the /<uuidv7> URL and render from local storage until the backend comes back.
When supported by the browser and backend, open shared lists subscribe to server-sent events for remote changes. SSE updates trigger an immediate backend fetch and a short working indicator while incoming changes are applied. A slower fallback poll remains in place for missed events or browsers without EventSource.
The sharing panel also supports:
- copying the shared URL
- showing a themed QR code for the current shared list
- scanning another shared-list QR code into the shared-list input
- collapsing pasted shared URLs down to just the UUIDv7 in the shared-list input
- validating that scanned or pasted list ids exist in the backend
- a device-local history of recently opened shared lists with quick reopen/delete actions
- tapping a recent-history card to reopen that list, with drag protection so scroll gestures do not trigger accidental loads
- opt-in grouped notifications for item additions from another device while the app is running, unfocused, and connected to the shared backend list
Empty lists do not create new shared-list entries. The New List action also removes the current shared list instead of leaving behind an empty backend record. If a previously opened shared list is empty, the recent-history panel labels it as Empty list.
Shared list API routes:
POST /api/shared-listsGET /api/shared-lists/:idPUT /api/shared-lists/:idDELETE /api/shared-lists/:idGET /api/shared-lists/:id/events- server-sent events for shared-list updates and deletes
The app includes a web app manifest, install icons, app shortcuts, theme-aware browser favicons, and runtime theme-colour updates. Light, dark, and system theme choices update the browser/PWA chrome where the host OS supports dynamic theme-color changes. Some installed app shells cache manifest metadata, so reinstalling the PWA may be needed after manifest colour, icon, or shortcut changes.
Installed app shortcuts include Edit list and Route. The Debug tools shortcut is added to the runtime manifest only when debug mode is enabled.
An app-level animated splash using public/logo-animated-once.svg is available but disabled by default. Enable it at build time with VITE_ENABLE_PWA_SPLASH=true. Browser/OS native PWA splash screens are generated from the manifest icons and may remain static before the web app shell starts.
On supported browsers you can install the app with the browser's install action:
- desktop Chrome / Edge: use the install icon in the address bar
- Android Chrome: use
Add to Home screen/Install app - iPhone / iPad Safari: use
Share->Add to Home Screen
- the app shell, core icons, and built JS/CSS are cached for offline navigation after the service worker has installed
- online launches and resumes check for updated app assets and backend version changes; when an update reload is needed, a theme-aware translucent overlay with a spinner is shown across the reload
- the app works offline with local storage even without the backend
- backend-backed shared lists still need network access to validate, refresh, or load remote list data
- shared-list notifications are opt-in, only cover item additions while the app/PWA is running, and use service-worker/browser notification delivery with grouped notification tags so later additions update the existing notification silently where the browser supports it
- QR scanning depends on browser camera support and
BarcodeDetector; if it is unavailable, the scanner action is hidden and you can paste the shared UUID or URL manually - if you change icons, shortcuts, or manifest colours, some installed shells keep stale assets until the app is removed and installed again
npm run build
npm run previewRun the automated checks:
npm run lint
npm run typecheck
npm run test:unit
npm run test:storybooknpm run test:storybook runs the Storybook interaction tests, including role/name checks for component stories and design-system documentation examples.
Run a local Lighthouse audit against the production build:
npm run lighthouseThe audit writes lighthouse/report.json and lighthouse/report.html, and fails if the main Lighthouse category scores fall below the configured thresholds. You can audit an already deployed URL with LIGHTHOUSE_URL=https://example.com npm run lighthouse. On Apple Silicon, run this with an arm64 Node install; x64 Node under Rosetta is blocked by Lighthouse because it makes Chrome performance results unreliable.
Before tagging a release, run the same local checks used by CI:
npm run lint
npm run typecheck
npm run test:unit
npm run test:storybook
npm run buildFor accessibility and PWA confidence, also run:
npm run lighthouseThe app version shown on the About page comes from package.json. Use npm run version:major, npm run version:minor, or npm run version:patch when preparing an intentional release bump.
The repo installs .githooks/pre-commit through npm run prepare. The pre-commit hook runs lint, applies the configured automatic version bump, and stages package.json plus package-lock.json. Set VERSION_BUMP=major, VERSION_BUMP=minor, or VERSION_BUMP=patch when you need to control the bump for a specific commit.
Pushes to main automatically create an annotated git tag and GitHub Release. The release workflow uses v<package.json version> as the tag when it is available, and falls back to a SHA-suffixed tag if that version tag already exists so every main push still has a release point.
The repo includes GitHub Actions workflows that build on push to main, publish dist/ to GitHub Pages, and create a GitHub Release for the pushed commit. The production build copies dist/index.html to dist/404.html so direct path-based SPA links work when opened or refreshed on GitHub Pages.
serverPostgres/JSON-backed API and disabled Home Assistant integration stubsrc/config/countriescountry-specific supermarket configssrc/libparsing, matching, routing helpers, and debug checkssrc/lib/repositorypersistence layersrc/componentsreusable UI piecessrc/pagestop-level viewssrc/storiesdesign-system Storybook reference pagessrc/stylesSCSS styling.storybookStorybook configuration, global theme toggle, i18n provider, and accessibility addon setup.github/workflowsGitHub Pages deployment
MIT. See LICENSE.
See PRIVACY.md.



