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: ce am ales să construiesc, ce am tăiat pe parcurs și ce rămâne de făcut.

Cum a fost construită

Aplicația a fost scrisă împreună cu un asistent AI (Claude Code), în sesiuni repetate, întinse pe mai multe săptămâni — genul de aplicație care se rescrie pe măsură ce o folosești zilnic. Eu am stabilit ce trebuie să calculeze corect, am testat fiecare versiune pe bani reali și am tăiat ce nu ținea.

Mai jos sunt acele decizii, cu tot cu compromisuri — inclusiv ce n-a funcționat din prima. Nu e o poveste despre cine a scris rândurile de cod. Trei lucruri pe care asistentul le-a putut propune și care au rămas în afară sunt vizibile mai jos, la „ce am aruncat" și la „ce nu face încă".

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

Cum arată

Douăsprezece ecrane ale aplicației, pe telefon, din setul de date de demonstrație — nu din contul real. Se pot derula lateral.

Ecranul principal, cu bugetul și bilanțul
Bilanțul lunar — nu mai e doar un tracker de cheltuieli
Tranzacțiile, cu căutare și filtre
Categorii de cheltuieli și de venit
Ultimele 6 luni, împărțite pe categorii
Ritmul lunii, comparat cu luna trecută
Plăți și venituri recurente, separate
Abonamente detectate, cu alertă la schimbări de preț
Observațiile lunii, din reguli explicite
Previzualizarea unui import de extras bancar
„De sortat” — grupat pe comerciant
Setări, cu ambele metode de autentificare active

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.

Conflictele între dispozitive, rezolvate parțial

Adăugările și ștergerile rămân „ultimul câștigă" — la fel ca înainte, fără avertisment. Dar modificarea unei tranzacții existente s-a schimbat: aplicația compară șirul updated_at primit de la server, ca text brut, nu convertit în dată, și dacă rândul s-a schimbat între timp, se oprește înainte să suprascrie.

Apare un dialog care numește rândul — descrierea, suma, data — cu trei opțiuni: păstrează versiunea de-aici, păstrează versiunea de-acolo, sau vezi-le pe amândouă. Dacă închizi dialogul cu X, operația nu se pierde: sincronizarea rămâne oprită până o repornești din Setări.

Un conflict cu o ștergere se tratează separat — rândul propriu, plus un buton „Acceptă ștergerea". Dacă alegi să păstrezi versiunea locală peste una ștearsă, aplicația face restore și apoi upsert, fără nicio altă verificare.

Tot ce lipsește: nimic din asta nu e automat sau „inteligent" — tu alegi de fiecare dată. E 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 ultimei actualizări a acestei pagini, în octombrie 2026, aplicația era la v64 — un număr care crește des, la ritmul la care lucrez la ea. E mai degrabă o fotografie a momentului decât un fapt fix.

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.

De la un fișier propriu, la orice extras bancar

Multă vreme, singurul import posibil era din propriul format JSON — util doar pentru mutarea datelor între dispozitive, inutil pentru cineva care pornește de la un extras real de bancă. A devenit cea mai elaborată funcție din aplicație.

Acum se citesc trei formate: PDF, extrasul oficial de la bancă, deschis cu pdf.js, încărcată doar în momentul în care chiar deschizi un asemenea fișier, ca să nu îngreuneze restul aplicației; CSV, cu potrivirea de coloane ținută minte per bancă, ca s-o refaci o singură dată; și text lipit direct, varianta universală pentru orice format care nu se încadrează în celelalte două.

Varianta de text nu se bazează pe coloane: pe fiecare rând caută o dată, o descriere și o sumă, iar dacă e cheltuială sau venit deduce din felul în care s-a mișcat soldul, nu din cum scrie banca semnul minus — o bancă poate scrie negativ o cheltuială, alta poate scrie totul pozitiv și pune un indicator separat.

La import, aplicația recunoaște 172 de comercianți după cuvinte-cheie și le scurtează denumirea („KAUFLAND ROMANIA SCS SIBIU" devine „Kaufland"), pune tag-uri automate, detectează duplicatele față de ce ai deja în cont și exclude transferurile între conturile proprii, ca să nu se dubleze banii care doar se mută dintr-un cont în altul.

Ecranul „De sortat"

Ce nu recunoaște automat nu se pierde și nu se ghicește greșit în tăcere — intră într-un ecran separat, grupat pe comerciant, unde alegi tu categoria o singură dată. Poți salva alegerea ca regulă proprie comerciant→categorie, vizibilă și ștergibilă mai târziu, ca data viitoare să nu mai fie nevoie.

Importurile intră ca loturi, cu anulare completă și reconstrucție a istoricului din date, dacă ceva a mers greșit. Poți selecta mai multe tranzacții deodată și le editezi în lot. Detectarea abonamentelor s-a extins ca să semnaleze și schimbările de preț și abonamentele care par să se fi oprit. Iar rapoartele analizează acum și pe comerciant, nu doar pe categorie — plus un top 5 al celor mai mari cheltuieli din fiecare lună și, în grafice, o curbă cumulată a lunii curente peste luna trecută, ca să vezi din timp dacă mergi spre o depășire.

Banii decontați, fără să umfle bugetul

Motivația era simplă: dacă plătesc ceva și mi se decontează o parte, cheltuiala reală trebuie să rămână cea reală, nu suma brută plătită la casă. Fiecare venit de tip decont ține o legătură (related_id) către cheltuiala din care provine, într-o categorie de venit numită „Decont", creată automat la prima folosire.

Soldul și casetele de sus arată banii mișcați efectiv, brut — ai plătit atât, ai primit înapoi atât. Dar totalurile pe categorie, bugetul, observațiile lunii și rata de economisire calculează pe net, ca decontul să nu se adune la venit și cheltuiala originală să nu rămână umflată în categoria ei.

Complicația reală: decontul poate ajunge într-o altă lună decât cheltuiala — plătești în martie, primești banii înapoi în aprilie. Detaliul lunii semnalează explicit când se întâmplă asta, iar lista de tranzacții arată ambele rânduri, cu un semn vizual care le leagă. Legarea nu se întâmplă automat la import — o faci manual, după.

Ce nu face încă

Două lucruri sunt decise și amânate conștient, ceea ce mi se pare mai onest decât o listă de promisiuni. (Legarea unui cont cu parolă la autentificarea prin Google era aici — acum se poate face direct din Setări, la „Metode de autentificare".)

Buget comun între două conturi

Amânat nu din lipsă de timp, ci pentru că ar cere schimbarea modelului de acces la date, nu adăugarea unui ecran: 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: citirea directă a fișierelor Excel — un export CSV din Excel se importă fără probleme, dar formatul .xlsx nu se citește direct —, recuperare automată de parolă, îmbinarea automată a conflictelor între dispozitive — acum te întreabă pe tine, nu mai decide tăcut, dar tot manual rămâne —, ș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.

Trei biblioteci externe: Chart.js 4.4.4, fixată pe versiune exactă, folosită acum pentru mai multe grafice decât la început — cel circular pe categorii, cel pe bare cu ultimele șase luni, o curbă cumulată a lunii peste luna trecută și unul venituri față de cheltuieli; biblioteca de client Supabase 2.45.4, la fel fixată; și pdf.js, încărcată doar când deschizi un extras bancar în format PDF, ca să nu îngreuneze restul aplicației. Fixarea versiunilor 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.

Lista celor 172 de comercianți recunoscuți stă separat, în comercianti.js, un script clasic încărcat înaintea codului aplicației — nu un modul ES — și e parte din precache, alături de restul fișierelor. Nu e un pas de build, doar o listă mare ținută departe de fișierul principal.

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 Cloudflare Pages (aplicația a fost mutată acolo de pe Netlify, cu un set complet de antete de securitate, inclusiv o politică de securitate a conținutului activă). 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

  • ~6.654 linii JS (6.382 + 272 în comercianti.js)
  • 1.691 CSS
  • 408 HTML
  • 94 service worker

Bază de date

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

Servicii

  • Supabase
  • Cloudflare Pages
  • jsDelivr