Uso personale

Proofset — un tracker di fitness e benessere self-hosted

Tracker di trasformazione fisica self-hosted, riprogettato in corsa da mono-utente a multi-utente con isolamento completo dei dati.

Progetto privato — in uso personale
  • Node.js
  • Express
  • PostgreSQL
  • Prisma
  • React
  • Vite
  • Docker
  • Ollama
Proofset — un tracker di fitness e benessere self-hosted

Cos'è

Proofset è un'app di tracking per la trasformazione fisica: allenamenti, foto di progresso, misure corporee, visite dal nutrizionista, diario dell'umore con note vocali, e "lettere al futuro" che si sbloccano dopo mesi. Nata come progetto mono-utente per uso personale, è stata riprogettata in corsa per supportare più account con isolamento completo dei dati.

Architettura

Client

PWA React

Backend

Node.js / Express API

Dati

PostgreSQL + Prisma

AI

Ollama self-hosted

Servizio esterno

Hevy API

sync allenamenti

Infrastruttura

Nginx Proxy Manager + Cloudflare DNS

server domestico

PWA ReactNode.js / Express APINode.js / Express APIPostgreSQL + PrismaNode.js / Express APIOllama self-hostedNode.js / Express APIHevy APINode.js / Express APINginx Proxy Manager + Cloudflare DNS

La sfida architetturale: da mono-utente a multi-utente

Il progetto originale non aveva alcun concetto di "utente" — nessuna colonna userId, nessuna registrazione, un solo account seedato via variabili d'ambiente. Quando l'esigenza è cambiata (aprire l'app ad amici, ognuno con i propri dati privati), non è stata una modifica incrementale: ogni tabella del dominio e ogni rotta del backend ha richiesto lo scoping per utente.

La decisione più delicata è stata cosa restasse condiviso e cosa no: il catalogo esercizi (nome → gruppo muscolare) è rimasto una tabella globale condivisa, così nessun nuovo utente deve riclassificare da zero gli stessi esercizi comuni — ma il flag "esercizio tracciato" (personale per definizione) è diventato una tabella di join separata per utente, pur puntando alla stessa riga condivisa di Exercise. Un dettaglio facile da non notare in fase di pianificazione ma che, se sbagliato, avrebbe rotto silenziosamente l'isolamento dei dati.

Cancellazione reversibile senza patchare 30 punti del codice

Quando è arrivata la richiesta di un cestino con ripristino per allenamenti, foto e misure, l'approccio ovvio — aggiungere deletedAt: null a mano in ogni query — avrebbe richiesto di toccare decine di punti sparsi nel codice, con un solo dimenticato che avrebbe fatto riapparire un elemento cancellato in statistiche, record personali o export.

Soluzione: una Prisma Client Extension che intercetta ogni findMany/findFirst/count/aggregate/groupBy sui modelli cancellabili e inietta automaticamente il filtro, con una via di fuga esplicita (includeDeleted: true) per i rari casi che devono vedere tutto — come lo sweep di pulizia file all'eliminazione dell'account, che altrimenti avrebbe lasciato foto orfane su disco. Verificato end-to-end con test live reali (cestino → esclusione da PR e statistiche → ripristino) prima di considerarlo concluso.

Una classe di bug nascosta: fuso orario del server

Il container backend gira con TZ=Europe/Rome (necessario perché i promemoria push si attivino all'ora locale giusta). Questa scelta, corretta per i cron job, ha introdotto silenziosamente lo stesso bug in tre punti indipendenti dell'app: la heatmap di costanza dell'umore, quella degli allenamenti, e il parser del CSV importato da Hevy.

Il pattern è sempre lo stesso: codice che mescola metodi Date locali (setHours, .getDay()) con .toISOString() (sempre UTC) finisce per calcolare le date con uno scarto pari all'offset del fuso orario — un utente vedeva "0 giorni registrati" nella heatmap nonostante avesse un'intera settimana di dati reali. La correzione, applicata in modo sistematico una volta identificato il pattern, ha richiesto di ancorare esplicitamente ogni calcolo a UTC (Date.UTC, setUTCDate) invece di fidarsi del comportamento implicito di JavaScript.

Integrare un'API di terze parti senza documentazione utilizzabile

Per sincronizzare automaticamente gli allenamenti da Hevy (invece del solo import CSV manuale), la documentazione ufficiale dell'API si è rivelata una Swagger UI che carica lo schema via JavaScript — irraggiungibile da un fetch statico. Lo schema OpenAPI reale è stato recuperato da un wrapper open source di terzi che lo rigenera dalla stessa API.

Una volta collegata una vera chiave Hevy Pro fornita dall'utente, il primo test dal vivo ha rivelato che la struttura reale della risposta differiva da quella documentata: l'oggetto allenamento risultava annidato sotto una chiave workout, non appiattito al livello principale come suggeriva lo schema. Bug trovato e corretto nel giro di pochi minuti grazie al test contro l'API reale invece di fidarsi ciecamente della documentazione di terze parti — e una lezione di metodo: verificare sempre con dati reali prima di considerare un'integrazione esterna conclusa.

La sincronizzazione gestisce inoltre la riconciliazione tra dati importati via CSV in passato e dati arrivati ora via API (stesso allenamento, due fonti), evitando duplicati tramite un margine temporale tollerante allo scarto di fuso orario descritto sopra.

Performance: da minuti a 6 secondi

L'import CSV originale eseguiva una o due query al database per ogni singola riga del file — con un export di più anni di allenamenti (migliaia di righe), il processo superava il timeout di 60 secondi di nginx, interrompendo la connessione lato client anche se il server continuava a lavorare in background.

Riscritto per operare in blocco — precaricamento del catalogo esercizi in un'unica query, creazione bulk di quelli mancanti, un solo createMany per tutti i set di tutti gli allenamenti nuovi — il tempo di importazione per un file sintetico di controllo (150 allenamenti, 3750 set) è sceso da tempi potenzialmente superiori al minuto a 5,7 secondi, verificato attraverso lo stesso percorso nginx della produzione.

11

job pianificati

5,7s

import 150 allenamenti

Funzioni AI, con giudizio

Collegata a un'istanza Ollama self-hosted (nessuna API a pagamento), l'app usa un modello linguistico solo dove serve davvero linguaggio naturale — mai per calcoli che devono essere affidabili:

  • Un coach conversazionale che risponde a domande sui progressi reali dell'utente (record, costanza, tendenze), con un prompt di sistema che vieta esplicitamente di inventare dati non forniti nel contesto — verificato chiedendo informazioni volutamente assenti (calorie, sonno) e confermando che l'AI dichiara di non saperlo invece di inventare una risposta plausibile.
  • Classificazione automatica del gruppo muscolare per esercizi non categorizzati, con validazione Zod elemento per elemento (non sull'intero batch, per non scartare classificazioni valide a causa di una sola malformata).
  • Un riepilogo settimanale in linguaggio naturale generato dai dati reali già calcolati, con un fallback deterministico se l'AI non risponde.

Durante i test è emerso un difetto reale del modello (contaminazione occasionale della risposta italiana con testo in un'altra lingua) — risolto con un controllo difensivo che rileva l'anomalia e ripete la richiesta invece di mostrare mai testo corrotto all'utente finale.

PWA e affidabilità delle notifiche push

App installabile su iOS/Android con aggiornamenti forzati in foreground (i service worker standalone su iOS raramente ricevono un controllo di aggiornamento nativo). Le notifiche push via VAPID hanno richiesto di scoprire sul campo che il servizio push di Apple rifiuta silenziosamente le richieste con un dominio del claim JWT non instradabile — e che il codice di invio, tramite Promise.allSettled, ignorava silenziosamente qualunque errore di consegna diverso dalla sottoscrizione scaduta, nascondendo potenziali fallimenti reali. Corretto aggiungendo logging esplicito per ogni tipo di errore di consegna.

In breve

Un'app con undici job pianificati (promemoria personalizzati, sincronizzazione automatica, pulizia dati), sincronizzazione bidirezionale con un servizio esterno, un'estensione ORM su misura per la cancellazione reversibile, e funzioni AI innestate su un'infrastruttura self-hosted — costruita e mantenuta in produzione da un solo sviluppatore, con verifica end-to-end reale (non solo type-check) ad ogni funzionalità aggiunta.

Screenshot

Modalità:
Dashboard con foto e misure di progresso
Confronto tra foto di progresso nel tempo
Diario dell'umore con note vocali
Lettere che si sbloccano dopo mesi
Sincronizzazione automatica degli allenamenti da Hevy
Coach AI che risponde solo sui dati reali dell'utente