list.jerzy.net
Lista zakupów
Wspólna lista zakupów dla dwóch osób, zbudowana jako instalowalna PWA na iPhone'a wokół silnika synchronizacji napisanego od zera. Offline nie jest trybem, jest jedyną ścieżką.
Lista, która działa bez zasięgu
Aplikacja dla dwóch osób, które robią zakupy do jednego domu.
Wspólna lista w Przypomnieniach Apple przestała się synchronizować. Zamienniki z App Store były albo tandetne, albo obwieszone reklamami, albo płatne co miesiąc za coś, co ma jedno zadanie. Napisałem więc własną.
Działa w sklepie, w którym nie ma zasięgu. Wszystko, co wpiszesz, zapisuje się od razu na telefonie, a kiedy sieć wraca, aplikacja sama dogaduje się z listą drugiej osoby. Nie trzeba niczego odświeżać ani pamiętać o zapisaniu.
Sama lista wygląda jak wieża drewnianych klocków. Odhaczenie pozycji to wyciągnięcie klocka w bok — reszta wieży przechyla się, zostaje chwilę w tyle i osiada, dokładnie jak w Jendze. Instaluje się z przeglądarki, ikoną na ekranie głównym, i nie zbiera o nikim żadnych danych.
Lista, która działa bez zasięgu
Wspólna lista w Przypomnieniach Apple przestała się synchronizować.
Zamienniki były tandetne, przepchane reklamami albo wyłącznie za subskrypcją, tak więc napisałem silnik synchronizacji sam.
Interfejs nigdy nie rozmawia z siecią. Każda zmiana jest zapisywana do IndexedDB, a silnik synchronizacji uzgadnia ją z Postgresem w tle co zwija ścieżkę online i offline w jedną. Nie ma żadnego „trybu offline", który mógłby zgnić, bo offline jest jedynym trybem, jaki istnieje.
Nie miałem konkretnego planu na design UI, ale jako że miałem pełną kontrolę nad nim, postanowiłem się nim zabawić i nie przejmować się co jest teraz modne. Sama lista jest wieżą drewnianych klocków. Pozycję odhacza się, wyciągając klocek w bok; stos nad nim przechyla się, zostaje chwilę w tyle i osiada. Dokładnie jak w mojej ulubionej grze z dziecistwa Jenga.
Wspólna lista w Przypomnieniach przestała się synchronizować, więc ją zastąpiłem i napisałem silnik synchronizacji sam.
Co jest w tym ciekawe
Cztery rzeczy, które odróżniają ją od kolejnej listy zadań.
Działa bez internetu, zawsze
Nie ma osobnego „trybu offline", który da się włączyć albo zapomnieć włączyć. Aplikacja zawsze zapisuje najpierw na telefonie, a z siecią rozmawia w tle. Tryb samolotowy, zamknięcie aplikacji, otwarcie jej trzy dni później — nic nie ginie.
Mówi wprost, czego jeszcze nie wysłała
Kiedy coś czeka na wysłanie, na ekranie jest napisane „3 czekają na synchronizację". Żadnego udawania, że zapisano, i żadnej ciszy, po której trzeba zgadywać.
Obsługa jedną ręką, bo w drugiej jest koszyk
Wszystko, co się naciska, mieści się w dolnej części ekranu. Lista rośnie do góry, więc nic nie przeskakuje pod palcem, kiedy dopisujesz kolejną rzecz.
Nie da się odhaczyć czegoś przypadkiem
Odhaczenie to przeciągnięcie klocka w bok, nie kliknięcie. Kliknięcie było gotowe i zostało wyrzucone: klocek zajmuje cały wiersz, więc przy przewijaniu co chwilę coś odhaczało się samo.
Liczby
- 2
- osobytyle jej używa i taka jest cała skala
- 3 lata
- historii zakupówprzeniesione ze starej listy
- 10 000+
- pozycjiprzewija się bez zacinania
- 0 zł
- miesięczniemieści się na darmowych planach
Co jest w tym ciekawe
Od najmocniejszego.
Silnik synchronizacji jest napisany ręcznie i to jest właśnie sedno
Około 400 linii: pobieranie różnicowe prowadzone kursorem czasu serwera, outbox będący zbiorem brudnych rekordów, a nie logiem tylko-do-dopisywania, i rozwiązywanie konfliktów przez „ostatni zapis wygrywa" na całym wierszu. PowerSync, ElectricSQL i RxDB były rozważone i odrzucone; dla jednej tabeli, dwóch piszących klientów i dziesięciu tysięcy wierszy usługa synchronizacji dodaje drugiego dostawcę i własny język reguł, żeby uniknąć dokładnie tego kodu, który warto pokazać.
Offline nie jest trybem, jest jedyną ścieżką
Interfejs czyta i zapisuje do IndexedDB i do niczego więcej. Tryb samolotowy, wymuszone zamknięcie, otwarcie trzy dni później: kolejka nadal tam jest i opróżnia się przy następnym uruchomieniu. iOS nie ma API Background Sync, więc aplikacja mówi wprost „3 czekają na synchronizację", zamiast udawać, że zapisała.
Przeszła zderzenie z rzeczywistością, a błędy są najlepszą częścią
upsertw PostgREST kompiluje się doINSERT … ON CONFLICT DO UPDATE, więc sprawdza politykęINSERTnawet dla wierszy, które już istnieją. Edycja pozycji dodanej przez drugą osobę trwale nie przechodziła RLS i zatykała kolejkę. Poprawka rozdziela ścieżkę zapisu według autorstwa.Testy przechodziły, bo każdy z nich działał jako jeden i ten sam użytkownik. To był prawdziwy błąd w konstrukcji zestawu testów, nie w kodzie.
Obsługa jedną ręką jest ograniczeniem, a nie elementem wykończenia
Używa się tego na stojąco w sklepie, z koszykiem w drugiej ręce, więc wszystko interaktywne mieszka w dolnych 40% ekranu. Lista jest wieżą zakotwiczoną u dołu, która rośnie górną krawędzią, dzięki temu nic, co jest już na ekranie, nie przeskakuje przy dodaniu pozycji, a najstarsze i najbardziej zaległe rzeczy leżą prosto pod kciukiem.
Ruch jest łańcuchem sprężyn, a nie silnikiem fizyki
Każdy klocek wyprowadza swoje przechylenie z wartości ruchu przeciąganego klocka, przez własną sprężynę, ze sztywnością i tłumieniem malejącymi wraz z odległością od luki. Ten gradient sam z siebie daje opóźnienie, wybieg i osiadanie, bez integratora i bez detekcji kolizji. Prawdziwy solver odrzuciłem z powodu determinizmu, nie wydajności: nie potrafi zagwarantować, że klocki wrócą dokładnie na swoje pozycje.
Liczby
- ~400
- linii silnika synchronizacji
- 10 000+
- pozycji w historiilista wirtualizowana
- ~30
- asercji na żywym backendzie
- 0
- usług synchronizacjisilnik napisany od zera
Szczegóły inżynierskie
Każdy z tych punktów to decyzja z uzasadnieniem, a nie funkcja.
UUID-y generowane po stronie klienta
Wstawienie offline potrzebuje stabilnej tożsamości natychmiast, inaczej nie da się go do niczego odnieść przed dotarciem do serwera. To również jest to, co pozwala echu realtime z własnego zapisu rozpoznać się jako duplikat lokalnego wiersza.
Czekanie na identyfikator z serwera czyni wstawienia offline nieadresowalnymi i duplikuje każde echo realtime.
Kursor różnicowy to czas serwera minus bezpieczny zakład
updated_atjest stemplowane w momencie wykonania instrukcji, ale wiersz staje się widoczny dopiero, gdy jego transakcja się zatwierdzi, więc wiersz ostemplowany na T może pojawić się po tym, jak kursor minął T, i nigdy nie zostałby pobrany.Każde pobranie cofa zapisany kursor o około 5 sekund. Ponowne pobranie kilku sekund nic nie kosztuje, bo upsert jest idempotentny; zgubienie wiersza jest ciche i trwałe.
Usuwanie miękkie wszędzie, a baza w ogóle odbiera prawo do DELETE
Trzy lata i ponad 2000 zrealizowanych pozycji musiało przejść migrację, a usunięcie miękkie propaguje się przez pobieranie różnicowe jako zwyczajna aktualizacja, bez żadnej tabeli nagrobków.
RLS z funkcją pomocniczą security definer
Polityka na
list_members, która sama czyta zlist_members, rekurencyjnie zapętla się w nieskończoność. Dołączenie do listy po kodzie też nie może być zwykłym wstawieniem, bo RLS nie pozwoli klientowi odnaleźć listy, której nie jest jeszcze członkiem, więc idzie to przez RPC.Postgres domyślnie nadaje też EXECUTE roli PUBLIC, co do momentu wyraźnego odebrania zostawiało każdą funkcję security definer osiągalną dla anon.
iOS zostawia kanały realtime, które kłamią
Kanał dalej raportuje stan
joinedpo tym, jak jego gniazdo zostało przeładowane, i po cichu nie dostarcza niczego. Nie da się tego sprawdzić z zewnątrz, więc po każdej nieobecności dłuższej niż kilka sekund kanał jest wymieniany, a nie badany.Silnik jest weryfikowany przeciwko żywemu backendowi
Około 30 asercji na
fake-indexeddb: opróżnianie kolejki, zachowanie kursora, idempotentność scalania, seria zmian offline przetrwająca wymuszone zamknięcie, ochrona wierszy oczekujących, pełne pobranie na zimnym urządzeniu i druga osoba edytująca pozycję dodaną przez pierwszą.Ten ostatni przypadek jest regresją, która wyłapałaby błąd RLS za pierwszym razem.
Decyzje produktowe
To połowa pracy, której fragmenty kodu nigdy nie pokazują.
Sklepy są markami, nie oddziałami
W zasięgu spaceru są trzy Żabki, a Rossmann jest w dwóch różnych miastach. Przypisanie współrzędnych do sklepu jest decyzją, która czyni filtr błędnym, a nie mądrym, użyteczną osią jest „w którym sklepie to kupuję", a to jest marka.
Filtry wynikają z aktywnych pozycji, a nie z listy wszystkich sklepów
Nie ma nieodhaczonych rzeczy z Lidla, nie ma filtra. Rząd zostaje krótki, a każdy filtr jest gwarantowanie niepustym wynikiem.
Odhaczanie kliknięciem zostało zbudowane, a potem świadomie usunięte
Klocek zajmuje cały wiersz, więc każde muśnięcie przy przewijaniu trafiało w któryś i oznaczało go jako zrobiony. Przypadkowa zmiana stanu kosztuje znacznie więcej niż pół sekundy, które kosztuje celowe przeciągnięcie więc odhaczaniem jest przeciągnięcie, a kliknięcie otwiera panel szczegółów.
Emoji pochodzą z ręcznie zrobionego dwujęzycznego słownika rdzeni, nie z biblioteki
Dwie osoby korzystające z listy celowo przekręcają nazwy w ramach wewnętrznego żartu, więc dopasowanie rozmyte walczyłoby z nimi. Istniejące indeksy słów kluczowych nie mapują polskiego, a cokolwiek semantycznego jest albo zdecydowanie za ciężkie dla PWA działającej offline, albo wywołaniem API.
Brak wyniku jest poprawnym wynikiem, żadne emoji jest lepsze niż złe emoji.
Cały interfejs wyprowadzony jest z jednego koloru akcentu
Powierzchnie, tekst i linie to ten akcent domieszany w kilku procentach do prawie białego albo prawie czarnego, przez
color-mixw oklab. Wybór akcentu przebarwia całą aplikację, a nie jeden przycisk.Słoje drewna są fotografią
Dwie próby proceduralne wypadły wcześniej: powtarzalne gradienty są z definicji równomierne i czyta się je jako sztruks, a
feTurbulencedaje szum, a nie długie, nierówne, miejscami sękate linie, jakie drewno naprawdę ma.
Stack, z powodem przy każdym wierszu
Lista technologii jest nudna. Lista technologii z uzasadnieniem to argument.
| Warstwa | Wybór | Dlaczego ten |
|---|---|---|
| Build | Vite + React + TypeScript | Aplikacja jest w 100% kliencka i offline-first. App Router w Next.js byłby 'use client' wszędzie, a SSR walczy z service workerem. |
| Backend | Supabase | Postgres, auth i realtime po websocketach w jednym darmowym projekcie. Brak warstwy offline jest tu powodem istnienia silnika synchronizacji, zaletą, nie obejściem. |
| Magazyn lokalny | Dexie (IndexedDB) | Jedyne źródło prawdy dla interfejsu. useLiveQuery jest menadżerem stanu, więc dla danych listy nie ma w ogóle Reduxa ani Zustanda. |
| Realtime | postgres_changes | Dwóch użytkowników. Wzorzec Broadcast z triggera jest słuszny w skali i czystym narzutem tutaj. |
| Animacja | Motion (motion/react) | layout i AnimatePresence obsługują przepływ listy; useMotionValue i useSpring prowadzą wieżę, poza ścieżką renderowania. |
| Historia | @tanstack/react-virtual | Ponad 10 000 wierszy. Przypomnienia zaczynały się zacinać około 2000, co było poprzeczką do pobicia. |
| Style | Tailwind v4 + właściwości własne CSS | Motywy to atrybut data-theme plus jeden blok nadpisań; utility kompilują się do var(--color-*). |
| Routing | React Router | Filtry żyją w parametrach URL, więc przycisk wstecz i linki bezpośrednie działają za darmo. |
| Hosting | Vercel + cron w Cloudflare Worker | Statyczny deploy; cron workera pilnuje, żeby darmowy projekt Supabase nie zasnął po siedmiu dniach bezczynności. |
Zrzuty ekranu
Zrobione na widoku wielkości iPhone'a, na zbudowanej aplikacji i żywym backendzie.






Nagranie: synchronizacja po utracie sieci
Kolejność zdarzeń w nagraniu. Sieć jest naprawdę odcinana, nic nie jest symulowane.
- 1Stan „Live", lista zsynchronizowana
- 2Sieć odcięta, aplikacja przechodzi w „Offline"
- 3Trzy zmiany wprowadzone lokalnie: „3 czekają na synchronizację"
- 4Przeładowanie aplikacji, nadal bez sieci, kolejka jest na miejscu
- 5Sieć wraca, stan „Saving"
- 6Powrót do „Live", Postgres uzgodniony