Studiu de caz

Cheltuieli PWA

O aplicație de buget personal care scrie instant chiar și fără semnal și se sincronizează între telefon și laptop. Mai jos e ce am decis, ce am aruncat și ce încă nu face.

Cum a fost construită

Aplicația a fost scrisă împreună cu un asistent AI (Claude Code), pe parcursul mai multor sesiuni de lucru. Eu am definit ce trebuie să facă, am testat versiunile și am decis ce intră și ce rămâne afară — inclusiv funcțiile aruncate, descrise mai jos.

Ce urmează sunt deciziile și compromisurile proiectului, nu o relatare despre cine a apăsat tastele.

  • 5ecrane într-o pagină
  • 4 zilepentru nucleul cu conturi
  • ~4.600linii de cod
  • 0pași de compilare

Problema

Nevoia era banală: să știu unde se duc banii, într-un context în care sumele sunt mici și dese. O cafea la cantină, un bilet de metrou, o shaormă. Tocmai pentru că fiecare cheltuială e neînsemnată luată separat, la sfârșitul lunii nu mai poți reconstitui nimic din memorie.

Fiecare alternativă eșua în felul ei. Aplicațiile gata făcute cer cont, arată reclame și multe vor acces la contul bancar real — disproporționat pentru tranzacții de zece lei. Foaia de calcul funcționează, dar numai la calculator: nimeni nu deschide un Excel în fața casei ca să treacă o cafea de 7,50, deci datele se adunau retroactiv, ghicite. Notițele pe telefon sunt rapide, dar produc text mort — nu poți întreba o notiță cât ai cheltuit pe mâncare luna trecută.

Cerința reală era combinația: să adaugi ceva în trei atingeri, în stație, fără semnal, și totuși să obții la sfârșitul lunii un răspuns pe categorii. Din asta au ieșit toate deciziile tehnice de mai jos.

Interfața nu așteaptă niciodată rețeaua

Asta e ideea în jurul căreia e construit tot restul. Când adaugi o cheltuială, aplicația o scrie imediat în memoria locală a browserului și redesenează ecranul. Totul se întâmplă în milisecunde, indiferent dacă ai semnal. În paralel, operația intră într-o coadă de modificări netrimise, păstrată tot local, care supraviețuiește închiderii aplicației.

Există o singură sursă de adevăr: baza de date. Memoria locală e strict o copie de lucru plus o listă de intenții. Distincția pare formală, dar ea decide ce se întâmplă în fiecare situație ambiguă.

Structural, proiectul are patru fișiere de cod și niciun pas de compilare. Pagina conține toate cele cinci ecrane simultan, ascunse prin CSS — navigarea mută o clasă, nu încarcă pagini noi. Peste ele stă un service worker propriu, care se ocupă de funcționarea offline.

O particularitate: setările — nume, monedă, buget lunar pe categorii, istoricul importurilor — nu stau într-un tabel, ci în metadatele contului. Sunt puține date, se citesc odată cu sesiunea și n-au avut nevoie de tabel propriu, politici separate și sincronizare distinctă. Prețul e că orice câmp nou trebuie adăugat într-un singur loc din cod, o funcție dedicată apărută după ce o salvare parțială a suprascris date pe care nu le atinsese nimeni.

De ce Supabase

Un backend propriu, scris de la zero, a picat imediat: ar fi însemnat server, găzduire, întreținere și, mai ales, autentificare implementată corect de la zero — exact genul de lucru unde greșelile sunt tăcute și scumpe.

Firebase era alternativa serioasă și ar fi funcționat, dar baza lui nu e relațională. Voiam SQL: studiez cibernetică economică, iar o bază pe care o pot interoga cu SQL era și un câștig didactic, nu doar tehnic. A rămas Supabase — PostgreSQL adevărat, autentificare inclusă, securitate la nivel de rând direct în bază, și o bibliotecă de client care se încarcă dintr-o adresă, fără instalare.

Compromisul pe care l-am acceptat

Serviciul de email inclus e practic inutilizabil în producție: trimite doar câteva mesaje pe oră și doar către adresele membrilor organizației. Asta a dărâmat două funcții deja scrise — autentificarea prin link pe email și cea prin cod — și a forțat dezactivarea confirmării adresei la înregistrare.

Consecința, asumată conștient: nu există recuperare automată de parolă. Pentru o aplicație folosită de mine și de câțiva prieteni e rezonabil. Pentru ceva public n-ar fi.

Cum stau datele separate între utilizatori

Problema e mai adâncă decât pare. Într-o arhitectură clasică, browserul vorbește cu un server pe care îl controlezi tu, iar serverul decide ce are voie să vadă fiecare. Aici nu există un asemenea server: browserul vorbește direct cu baza de date, iar cheia de conectare e publică prin construcție — oricine deschide pagina o poate citi. Asta e ideea ei.

Întrebarea devine: dacă toată lumea are cheia, ce mă împiedică să cer datele altcuiva?

Răspunsul e că regula de acces nu stă în aplicație, ci în tabel. Fiecare rând are un câmp care spune cărui utilizator îi aparține, iar baza are o politică prin care o cerere vede și modifică doar rândurile unde acel câmp coincide cu identitatea celui care întreabă — identitate citită din jetonul de sesiune, nu din ce pretinde aplicația despre sine. Sunt patru politici pe fiecare tabel, câte una pentru citire, inserare, modificare și ștergere.

Diferența practică: filtrarea nu mai e o precauție pe care programatorul trebuie să și-o amintească la fiecare interogare. E o proprietate a bazei. Poți scrie din greșeală o cerere fără niciun filtru și tot primești numai rândurile tale.

Fără protecția asta, aplicația ar fi funcționat identic pentru un utilizator cinstit și ar fi fost complet expusă. E genul de gaură care nu produce niciun simptom până în ziua în care produce toate simptomele deodată.

Sincronizarea, și unde se oprește

Service worker-ul păstrează fișierele aplicației, ca pagina să se deschidă și fără rețea. Regula lui cea mai importantă e însă negativă: nu interceptează niciodată cererile către baza de date. Motivul e scris ca atare în cod, pentru că a fost învățat pe parcurs.

Sincronizarea pornește la fiecare modificare, la revenirea conexiunii și, în plus, o dată pe minut. Coada se golește în ordine, o operație pe rând, iar dacă una eșuează, procesul se oprește acolo și păstrează tot ce n-a apucat să trimită — nu sare peste, ca să nu ajungă serverul într-o stare intermediară.

Partea subtilă e ce urmează. După golire, aplicația recitește tot de pe server și suprascrie copia locală. Aici a existat un bug real: dacă adăugai o cheltuială în timpul citirii, versiunea proaspăt sosită nu o conținea încă și o ștergea de pe ecran. Soluția e o buclă — după fiecare citire se verifică dacă între timp s-au adunat operații noi și, dacă da, se trimit și se citește din nou, până când coada rămâne goală, cu o limită de zece runde ca să nu se blocheze.

Ștergerile nu șterg fizic. Rândul primește o marcă de dată, iar citirile îl ignoră. Asta face ștergerea sincronizabilă ca orice altă modificare, în loc să fie o absență pe care celălalt dispozitiv n-ar avea cum s-o observe.

Ce nu face: conflictele între dispozitive

Nu există comparație de dată a ultimei modificări, niciun număr de versiune pe rând, nicio detectare că altcineva a schimbat același lucru între timp. Modificările se trimit ca suprascrieri simple.

Dacă editezi aceeași tranzacție pe telefon și pe laptop cât ambele sunt offline, câștigă cel care se reconectează al doilea, iar cealaltă modificare dispare fără avertisment.

În practică aproape nu se manifestă, pentru că aplicația are un singur utilizator care nu editează același rând din două locuri simultan. Dar e o proprietate a arhitecturii, nu o scăpare de moment — și e exact motivul pentru care partajarea unui buget între două persoane a fost amânată deliberat.

Bug-ul care m-a costat cel mai mult

Tranzacțiile dispăreau după aproximativ o secundă. Le adăugai, apăreau, apoi se evaporau. Un singur simptom, în realitate patru cauze suprapuse, descoperite una câte una pentru că fiecare o masca pe următoarea.

  1. Cursa dintre scriere și citire descrisă mai sus.
  2. Un mesaj de stare care se suprascria singur, așa că eroarea reală era înlocuită cu un „Sincronizat" liniștitor înainte să apuc s-o citesc.
  3. Funcția de citire înghițea erorile și nu semnala eșecul mai departe.
  4. Cauza rădăcină, și cea mai umilitoare: service worker-ul păstra în cache răspunsurile bazei de date. Datele se salvau corect pe server; aplicația citea din cache o versiune veche care nu le conținea și le ștergea de pe ecran.

Toată căutarea s-a dus într-o problemă de salvare care nu exista. De acolo vine regula categorică de a nu atinge niciodată cererile către bază, scrisă ca avertisment în cod pentru data viitoare.

Ce am învățat pe drum

Dacă repari același lucru de mai multe ori, repari simptomul

În HTML există un atribut standard care ascunde un element, dar orice regulă CSS care stabilește cum se afișează elementul îl anulează. Un ecran ascuns astfel a rămas vizibil. Reparat punctual. S-a repetat la butoane. Reparat punctual. La câmpuri de formular. Iar. Abia a patra oară a devenit clar că nu sunt patru bug-uri, ci unul singur, și s-a rezolvat cu o regulă globală. Lecția n-a fost despre CSS, ci despre semnalul pe care îl dă o problemă care revine.

Aceeași informație scrisă în mai multe locuri se desincronizează garantat

Rândul trimis la server era construit în patru locuri diferite, iar unul singur uitase să includă marcajul de plată recurentă — importul transforma abonamentele în cheltuieli obișnuite. Reparația n-a fost să adaug câmpul lipsă, ci să existe un singur loc care descrie ce se trimite. La fel s-a întâmplat cu detectarea metodei de autentificare: ecranul spunea „parolă neconfigurată" lângă un buton care spunea „schimbă parola".

Sincronizarea nu e un detaliu de implementare

Am crezut la început că e ceva ce adaugi la sfârșit. E arhitectura însăși — decide forma datelor, ordinea operațiilor și ce se întâmplă în fiecare situație ambiguă.

Ce am aruncat

Autentificarea prin link pe email și cea prin cod, ambele scrise complet, ambele inutile după ce am descoperit limitele serviciului de email. Un buton de „arată totalurile" construit ca simplu comutator de vizibilitate, când de fapt trebuia o secțiune proprie. Un total pe categorie care dubla informația din rapoarte.

De la memoria browserului la conturi

Versiunea arhivată salva totul în memoria browserului de pe dispozitivul respectiv. Funcționa, dar avea două limite pe care nu le poți repara prin ajustări: datele existau într-un singur loc, deci telefonul și laptopul erau două aplicații fără legătură, iar golirea datelor de navigare le ștergea definitiv.

Trecerea a fost mai puțin o migrare și mai mult o inversare. În versiunea veche, memoria locală era baza de date. În cea nouă a devenit un cache plus o coadă de intenții.

A trebuit rescris tot ce ținea de salvare și citire, toată partea de autentificare, identificatorii (baza generează chei proprii), ștergerea, și cheile din memoria locală, care acum includ identitatea utilizatorului, ca două conturi de pe același telefon să nu se amestece.

Contorul de versiune e un simplu număr crescut la fiecare modificare publicată. La momentul scrierii paginii, în septembrie 2026, aplicația era la v30.

S-a păstrat aproape neatins: structura vizuală, foaia de stil, felul în care sunt desenate ecranele, logica de calcul a rapoartelor și modelul de date. O cheltuială avea aceleași câmpuri înainte și după — s-a schimbat unde locuiește, nu ce este.

Ce nu face încă

Trei lucruri sunt decise și amânate conștient, ceea ce mi se pare mai onest decât o listă de promisiuni.

Legarea contului Google la conturile cu parolă

Următoarea funcție la rând. Momentan un cont creat cu parolă nu are nicio cale să adauge autentificare prin Google; direcția inversă funcționează deja.

Buget comun între două conturi

Amânat nu din lipsă de timp, ci pentru că e o rescriere a fundației: politicile de securitate presupun un singur proprietar, categoriile sunt personale, bugetul stă în metadatele contului, iar sincronizarea merge pe „ultimul care ajunge câștigă" — acceptabil pentru o persoană cu două dispozitive, inacceptabil pentru doi oameni.

Analiză cu inteligență artificială

Există deja un motor de observații lunare, dar e determinist: reguli explicite care compară cu lunile trecute și semnalează depășirile de buget. Alegerea a fost deliberată — pentru volumul de date al unui buget personal, un model de limbaj ar reformula aceleași concluzii, cu riscul de a greși o cifră, ceea ce la bani e mai rău decât inutil.

Motivul tehnic pentru care integrarea directă e blocată e aceeași lecție ca la cheia publică: o cheie de API nu poate sta în cod care rulează în browser, ar fi vizibilă oricui. Ar fi nevoie de un intermediar pe server. Există în schimb un buton care copiază un rezumat structurat al lunii, gata de lipit într-o discuție cu un asistent.

Mai lipsesc, și e corect să apară ca atare: import din CSV sau Excel (există doar din formatul propriu), recuperare automată de parolă, rezolvarea inteligentă a conflictelor, și o aplicație nativă — se instalează pe ecranul principal ca aplicație web.

Stiva

HTML, CSS și JavaScript simple, fără framework, fără bundler și fără niciun pas de compilare — fișierele care se scriu sunt exact fișierele care ajung pe server.

Două biblioteci externe, ambele fixate pe versiune exactă: Chart.js 4.4.4 pentru graficul circular pe categorii și cel pe bare cu ultimele șase luni, și biblioteca de client Supabase 2.45.4. Fixarea e intenționată: o bibliotecă încărcată la „ultima versiune" se poate schimba sub tine, iar aici nu există teste automate care să prindă asta.

Service worker propriu, cu strategie diferită pe tipuri de resurse — rețea întâi pentru fișierele aplicației, cache întâi pentru bibliotecile fixate, și niciodată cache pentru cererile către bază.

O constrângere care explică multe dintre decizii: pe calculatorul pe care lucrez nu există Node.js, npm sau Python. Publicarea se face manual, prin arhivă trasă în panoul Netlify. Absența unui lanț de unelte moderne n-a fost un handicap, ci constrângerea care a impus simplitatea — fără pas de compilare, tot ce scrii trebuie să fie direct rulabil.

Cod

  • ~3.000 linii JS
  • 1.200 CSS
  • 320 HTML
  • 90 service worker

Bază de date

  • PostgreSQL
  • 2 tabele
  • Row Level Security
  • 3 fișiere SQL

Servicii

  • Supabase
  • Netlify
  • jsDelivr