list.jerzy.net
Shopping List
A two-person shared list, built as an installable iPhone PWA around a sync engine written by hand. Offline is not a mode, it is the only path.
The shopping list that works with no signal
An app for two people shopping for one household.
Apple Reminders' shared list stopped syncing. The replacements on the App Store were either trash, covered in ads, or charged monthly for something with one job. So I wrote my own.
It works in a shop with no signal. Anything you type is saved on the phone straight away, and when the network comes back the app sorts itself out with the other person's list. Nothing to refresh, nothing to remember to save.
The list itself looks like a tower of wooden blocks. Ticking an item off means pulling its block out sideways — the stack above it leans, lags and settles, exactly like Jenga. It installs from the browser as an icon on the home screen, and it collects nothing about anybody.
The shopping list that works with no signal
Apple Reminders' shared list stopped syncing. All the replacements were either trash,
filled with ads or required a monthly subscription, so I wrote the sync engine myself.
The interface never talks to the network. Every change is written to IndexedDB and a sync engine reconciles it with Postgres in the background, which collapses the online and offline paths into one. There is no "offline mode" to rot, because offline is the only mode there is.
Originally I had no plas for the UI design, but since I had full controll over it I decided to have a little fun with it, and don't stress about what is trendy. The list itself is a tower of wooden blocks. You tick an item off by pulling its block out sideways; the stack above it leans, lags and settles. Just like my fav childhood game Jenga
Apple Reminders' shared list stopped syncing, so I replaced it, and wrote the sync engine myself.
What makes it interesting
Four things that set it apart from yet another to-do list.
It works without internet, always
There is no separate "offline mode" to switch on, or forget to switch on. The app always saves to the phone first and talks to the network in the background. Airplane mode, force-quit, open it again three days later — nothing is lost.
It says out loud what it has not sent yet
When something is waiting to go out, the screen says "3 waiting to sync". No pretending it saved, and no silence to guess your way through.
One-handed, because the other hand is holding a basket
Everything you press sits in the lower part of the screen. The list grows upward, so nothing jumps under your thumb while you are adding the next thing.
You cannot tick something off by accident
Ticking off is a sideways drag, not a tap. Tapping was built and then thrown away: a block fills the whole row, so scrolling kept ticking things off on its own.
The numbers
- 2
- peoplethat is the entire scale
- 3 lata
- of shopping historymigrated off the old list
- 10 000+
- itemsscrolls without stuttering
- 0 zł
- cost per monthit fits inside the free tiers
What makes it interesting
Strongest first.
The sync engine is hand-rolled, and that is the point
Roughly 400 lines: a delta pull driven by a server-time cursor, an outbox that is a dirty set rather than an append-only log, and whole-row last-write-wins. PowerSync, ElectricSQL and RxDB were all considered and rejected; for one table, two writers and 10k rows, a sync service adds a second vendor and a rules DSL to avoid the exact code worth showing.
Offline is not a mode, it is the only path
The UI reads and writes IndexedDB and nothing else. Airplane mode, force-quit, reopen three days later: the queue is still there and drains on the next launch. iOS has no Background Sync API, so the app says "3 waiting to sync" out loud instead of pretending it saved.
It survived contact with reality, and the bugs are the good part
PostgREST's upsert compiles to
INSERT … ON CONFLICT DO UPDATE, so it evaluates theINSERTpolicy even for rows that already exist. Editing an item your partner wrote failed RLS permanently and wedged the queue. The fix splits the push path on authorship.The test suite had passed because every test ran as one and the same user. That was a real flaw in how the suite was built, not in the code.
One-handed operation is a constraint, not a polish item
It is used standing in a shop holding a basket, so everything interactive lives in the bottom 40% of the screen. The list is a tower anchored at the bottom that grows at its top edge, which means nothing already on screen moves when an item is added, and the oldest, most overdue items sit right under your thumb.
The motion is a spring chain, not a physics engine
Each block derives its tilt from the dragged block's motion value, through its own spring, with stiffness and damping both falling with distance from the gap. That gradient produces lag, follow-through and settle with no integrator and no collision detection. A real solver was rejected on determinism, not performance: it cannot guarantee blocks return to exact layout positions.
The figures
- ~400
- lines of sync engine
- 10 000+
- history itemsvirtualized list
- ~30
- assertions against the live backend
- 0
- sync servicesthe engine is written from scratch
The engineering detail
Every one of these is a decision with a reason, not a feature.
Client-generated UUIDs
An offline insert needs a stable identity immediately, or it cannot be referenced before it reaches the server. It is also what lets the realtime echo of your own write dedupe against the local row.
Waiting for a server-generated id makes offline inserts unreferenceable and duplicates every realtime echo.
The delta cursor is server time, minus a safety overlap
updated_atis stamped when the statement runs, but a row only becomes visible when its transaction commits, so a row stamped at T can appear after the cursor has moved past T, and would never be pulled again.Every pull rewinds the stored cursor by about 5 seconds. Re-fetching a few seconds costs nothing because the upsert is idempotent; losing a row is silent and permanent.
Soft delete everywhere; the database revokes DELETE outright
Three years and 2,000+ completed items had to survive the migration, and a soft delete propagates through the delta pull as an ordinary update no tombstone table needed.
RLS with a security definer helper
A policy on
list_membersthat itself selects fromlist_membersrecurses infinitely. Joining a list by code cannot be a plain insert either, because RLS stops the client resolving a list it is not yet a member of, so it goes through an RPC.Postgres also grants EXECUTE to PUBLIC by default, which left every security-definer function reachable by anon until explicitly revoked.
iOS leaves realtime channels that lie
A channel keeps reporting
joinedafter its socket has been cycled, and silently delivers nothing. There is no way to interrogate that from outside, so after any absence longer than a few seconds the channel is replaced rather than inspected.The engine is verified against the live backend
About 30 assertions on
fake-indexeddb: queue drain, cursor behaviour, merge idempotency, an offline burst surviving a force-quit, pending-row protection, a cold-device full pull, and a second user editing an item the first user authored.That last case is the regression that would have caught the RLS bug the first time.
Product decisions
This is the half of the work that code samples never show.
Shops are brands, not branches
There are three Żabkas within walking distance and a Rossmann in two different cities. Attaching coordinates to a shop is the decision that makes the filter wrong rather than smart, the useful axis is "which shop do I buy this in", and that is a brand.
Filter chips are derived from the active items, not from every shop
No unchecked Lidl items, no Lidl chip. The row stays short and every chip is a guaranteed-non-empty result.
Tap-to-tick was built, then deliberately removed
A block fills the row, so every scroll nudge landed on one and marked it done. An accidental state change costs far more than the half second a deliberate drag costs, so ticking is the drag, and tapping opens the detail sheet instead.
Emoji come from a curated bilingual stem dictionary, not a library
The two users misspell items on purpose as a running joke, so fuzzy matching would fight them. The English keyword indexes that exist map no Polish, and anything semantic is either far too heavy for an offline PWA or an API call.
Returning nothing is a valid result, no emoji beats a wrong one.
The whole interface is derived from one accent colour
Surfaces, ink and lines are all that accent mixed a few percent into near-white or near-black, through
color-mixin oklab. Picking an accent retints the entire app, not one button.The wood grain is a photograph
Two procedural attempts failed first: repeating gradients are uniform by definition and read as corduroy, and
feTurbulencegives noise rather than the long, uneven, occasionally-knotted lines timber actually has.
The stack, and the sentence for each
A stack list is boring. A stack list with a reason per row is an argument.
| Layer | Choice | Why this one |
|---|---|---|
| Build | Vite + React + TypeScript | The app is 100% client-side and offline-first. Next's App Router would be 'use client' everywhere, and SSR fights the service worker. |
| Backend | Supabase | Postgres, auth and websocket realtime on one free project. Its lack of an offline layer is the reason the sync engine exists, a feature here, not a workaround. |
| Local store | Dexie (IndexedDB) | The single source of truth for the UI. useLiveQuery is the state manager, so there is no Redux or Zustand for list data at all. |
| Realtime | postgres_changes | Two users. The Broadcast-from-trigger pattern is the right call at scale and pure overhead here. |
| Animation | Motion (motion/react) | layout and AnimatePresence handle the list reflow; useMotionValue and useSpring carry the tower, off the render path. |
| History | @tanstack/react-virtual | 10k+ rows. Reminders started lagging at about 2k, which was the bar to beat. |
| Styling | Tailwind v4 + CSS custom properties | Themes are a data-theme attribute plus one block of overrides; utilities compile to var(--color-*). |
| Routing | React Router | Filters live in URL search params, so the back button and deep links work for free. |
| Hosting | Vercel + Cloudflare Worker cron | Static deploy; the worker's cron keeps the free Supabase project from pausing after seven idle days. |
Screenshots
Shot on an iPhone-sized viewport against the built app and the live backend.






Walkthrough: syncing after a network drop
The order of events in the clip. The network is genuinely cut, nothing is simulated.
- 1"Live", the list in sync
- 2Network cut, the app moves to "Offline"
- 3Three changes made locally: "3 waiting to sync"
- 4The app reloaded, still offline, the queue is intact
- 5Network returns, the state reads "Saving"
- 6Back to "Live", Postgres reconciled