House-move inventory: The SPA: small pieces, no UI library - Part 5
~8 min read
The web app is how we actually use this: standing in a half-packed bedroom with a phone, typing "duvet" into a box. That use case set the design priorities more than any architecture diagram did - fast entry, big touch targets, and nothing that needs a laptop.
It's a React + Vite + TypeScript single-page app in web/. No server-side rendering, no shared code with the Python side. It talks to the API over JSON and that's all.
Three conventions that carry the weight
1. One file talks HTTP. web/src/api.ts is the only module that calls fetch. It's a typed client: every endpoint is a method, every response type is declared next to it, and money stays a string. When an API field changes, the TypeScript compiler (npm run build runs tsc --noEmit) points at every affected screen.
2. One hook for fetching. useAsync(fn, deps) returns {data, loading, error, reload}. That's the whole data layer - no global store, no caching library. A page that changes something calls reload(). It's crude, and for a two-user app it is exactly enough.
3. Dialogs are a native <dialog>. The <Modal> component wraps showModal() - Esc, backdrop and focus trapping come from the browser, with no dependency. The one wrinkle is jsdom, which doesn't implement the modal methods, so src/test/setup.ts carries a tiny shim. There was also a subtle React bug: StrictMode double-invokes mount effects (showModal → cleanup close → showModal), and the close event from that cleanup would bubble up and unmount the modal on open. A programmatic ref suppresses the event when we are the ones closing it.
Fast entry versus complete entry
The most-used control is the add-item row on a box: a name, a quantity and an Add button. It posts just {name, quantity}. Everything else about an item - values, purchase date, dimensions, fragile, flat-pack - is optional, and a More fields… button opens the full <ItemForm> in a modal, pre-filled with whatever you'd already typed.
That split came from using it. The first version put every field inline in the table row, which made the items table wider than a phone and the inline editor overflow its container. Phase 3.21 pulled the form into a modal, shared between Edit (per row) and New item, and left the read row clean.
Phones, properly
Phase 3.28 was the mobile pass, and it's a list of small things that each mattered:
- Every
table.gridsits in a.table-scrollwrapper (overflow-x: auto), so a wide table scrolls inside its panel instead of breaking the page. - The
.rowpattern (heading plus actions, used on nearly every panel header) wraps below 640px. - Form inputs go to 16px on small screens. At the 15px body default, iOS Safari zooms the whole page when you focus a field. This is the classic one, and the fix is a single CSS rule.
- Small action buttons got bigger touch targets.
- The photo input has
capture="environment", which hints a phone browser to open the camera straight away (post 7).
The note I wrote against the phase says "no component or data changes - CSS plus wrapping <div className="table-scroll">". Most mobile work is like that.
Theme without a flash
Dark mode is three-way (System / Light / Dark) in the top bar, persisted to localStorage and applied via data-theme on <html>. A small inline script sets the attribute before first paint so the page never flashes the wrong theme. The CSS defines tokens on :root for light, and redefines them in two places for dark - the prefers-color-scheme media query guarded by :root:not([data-theme="light"]), and an explicit [data-theme="dark"] - so "System" and a forced choice both work.
Later the toggle moved into Settings to declutter the top bar.
Testing the front end
Phase 3.12 added Vitest, Testing Library and jsdom, with test files next to the code. Coverage thresholds live in vite.config.ts and ratchet up; at the start, product code was only ~43% covered, and every phase since has had to hold the floor. The project now has 170-odd web tests and the CI job posts a per-file coverage table on pull requests.
One lesson from a dependency sweep (Phase 3.25, every package to its latest major in stages, so a failure points at a specific pair): an assertion that "always passed" was racing an effect, and React 19's timing finally exposed it. It's now wrapped in its own waitFor. If a test passes by timing luck, a framework upgrade will find it for you.
Next: the part of this project that's most likely to hurt you at 11pm - changing a database schema that already has data in it.
Previous: ← A REST API whose docs can't go stale · Next: Schema migrations with PRAGMA user_version →
