# Dosatore — Pezzo A: app core locale

**Data:** 2026-08-18
**Stato:** design approvato, da trasformare in piano di implementazione
**Sostituisce:** webapp `casa/app/dosatore` (PHP + SPA JavaScript)

---

## 1. Contesto

Esiste una webapp privata usata da una sola persona per anni. Calcola le dosi
di una ricetta al variare delle porzioni o della quantità realmente disponibile
di un ingrediente. I dati veri sono 12 ricette e 77 ingredienti in un file JSON
su server, protetti dal login del portale `casa`.

L'obiettivo è rifarla come app iOS e Android pubblicata sugli store, dove ogni
utente ha il proprio ricettario sul proprio dispositivo.

### Cosa faceva la webapp

- Elenco ricette con ricerca sul titolo e ordinamento alfabetico italiano.
- Riscalo per numero di porzioni.
- Riscalo inverso a partire dalla quantità disponibile di un ingrediente, con
  ricalcolo delle porzioni risultanti. È la funzione che dà valore all'app.
- Ingredienti "q.b." esclusi dai calcoli.
- Inserimento e modifica con nome, quantità, unità libera, spunta q.b.
- Gruppi di ingredienti ("Impasto", "Crema"), implementati ma mai usati dai dati.
- Estrazione da foto tramite ocr.space più parsing a espressioni regolari.
  Funzionava male e viene rifatta da zero nel pezzo C.

### Difetti da non riportare

| Difetto | Conseguenza |
|---|---|
| Nessun id: ricetta identificata per indice e ritrovata per titolo | Due titoli uguali rompono l'app |
| Dosi riscalate salvate sopra la ricetta | L'originale si perde, gli arrotondamenti si sommano |
| Riscalo per porzioni dipendente da `porzioniOriginali` | Si rompe sulle ricette senza porzioni: 2 su 12 |
| Unità come stringa libera non normalizzata | "g", "gr", "grammi" sono tre unità diverse |
| Arrotondamento fisso a 2 decimali | Produce 805,56 g e 0,75 uova |
| Intero file riscritto ad ogni modifica | Conflitti fra dispositivi |
| Interpolazione in `innerHTML` senza escaping | Irrilevante in privato, non in pubblico |

---

## 2. Decisioni

Prese con l'utente durante il brainstorming.

1. **Dosatore puro**, non ricettario. Fa una cosa sola.
2. **Il riscalo riprende dov'era, l'originale resta intatto.** Riaprendo una
   ricetta si vedono le dosi che si stavano usando, con un tocco per tornare
   alle originali. La ricetta salvata non viene mai riscritta.
3. **Ingredienti inseriti come testo libero** che diventa righe modificabili,
   così incollare un elenco copiato da un sito riempie una ricetta.
4. **Gratis, con un acquisto una tantum** per l'estrazione da foto oltre una
   quota mensile. Riguarda il pezzo C e D, qui è registrato come vincolo.
5. **Due lingue, entrambi i sistemi di misura, nessuna conversione
   automatica.** Italiano e inglese. Il parser riconosce sia il sistema metrico
   sia quello anglosassone. L'app rispetta l'unità che l'utente ha scritto e non
   traduce mai di sua iniziativa. Le conversioni fra unità della stessa famiglia
   sono disponibili su richiesta esplicita. La conversione fra volume e peso,
   tipo tazze verso grammi, resta fuori: dipende dall'ingrediente e va fatta in
   un pezzo a sé (vedi sezione 11).
6. **Categorie create dall'utente, con icona, e schermata principale a
   categorie.** Non categorie fisse: se le fa lui, come vuole. Sono
   raggruppamenti liberi, non etichette: **una ricetta sta in una categoria
   sola**, oppure in nessuna. Chi vuole se ne fa una sola e ci mette tutto, chi
   vuole se ne fa venti.
7. **Una foto per ricetta**, facoltativa, compressa al momento dello scatto.
   Riconoscere una ricetta dall'immagine è più veloce che leggerne il titolo.
   Una sola e compressa tiene il conto sotto controllo: vedi sezione 4.

### Impostazione tecnica

SQLite locale come fonte di verità. La sync futura lavorerà a livello di
documento, non di singola riga: viaggia un documento con tutte le ricette,
ognuna con id e data di modifica. Scartate un file JSON unico, che alla sync
obbliga a "vince l'ultimo che scrive" sull'intero ricettario, e le librerie
local-first con sync integrata, che presuppongono un backend proprio.

---

## 3. Perimetro

**Dentro:** categorie create dall'utente con icona, foto della ricetta, elenco
ricette con ricerca, schermata dosatore, scrittura e modifica ricette con parser
del testo, gruppi di ingredienti, cancellazione, export e import file.

Il file di export è un archivio zip che contiene `ricettario.json` e la cartella
`foto/`. Il JSON ha un'intestazione che dichiara formato, versione e data,
seguita dalle ricette e dalle categorie complete di id. L'intestazione serve a
riconoscere e rifiutare i file che non sono nostri, e a gestire i cambi di
formato futuri. L'import accetta anche un JSON nudo senza foto, perché i file
scritti a mano e quelli di versioni precedenti devono continuare a entrare.

**Fuori:** procedimento, più di una foto per ricetta, preferiti, lista della
spesa, pianificazione pasti, account utente.

Interfaccia e vocabolario del parser in italiano e inglese fanno parte di
questa spec: il parser non si può scrivere due volte.

**Rimandato ad altri pezzi:** sync cloud senza account (B), estrazione da foto
e import da link (C), privacy, acquisti in-app, conversione volume/peso e
materiali per gli store (D).

---

## 4. Modello dati

### Entità

```
Ricetta      id, titolo, descrizione, porzioni?, categoriaId?, foto?, creataIl, modificataIl, cancellataIl?
Gruppo       id, ricettaId, nome?, ordine
Ingrediente  id, gruppoId, nome, quantita?, unita?, ordine
Categoria    id, nome, icona, ordine, creataIl, modificataIl, cancellataIl?
Riscalo      ricettaId, richiesta, aggiornatoIl                (solo locale)
```

- Tutto ha un id stabile: ricette, gruppi e ingredienti.
- `porzioni` assente significa che la ricetta non le dichiara. Il riscalo per
  porzioni non è disponibile, quello per ingrediente sì.
- `quantita` assente significa "q.b.". Un q.b. non entra mai nei calcoli.
- `nome` del gruppo assente significa gruppo unico senza intestazione. Le
  sezioni compaiono nell'interfaccia solo quando ce n'è più di una.
- `categoriaId` assente significa ricetta senza categoria. È uno stato normale,
  non un errore: si arriva lì creando una ricetta prima di avere categorie, o
  cancellando la categoria in cui stava.
- `icona` della categoria è la chiave di una delle icone che forniamo noi, non
  un file caricato dall'utente. Costa niente da sincronizzare e non c'è nulla da
  archiviare.
- `foto` è il nome del file dentro la cartella dell'app, non un percorso
  assoluto: i percorsi assoluti cambiano ad ogni aggiornamento dell'app su iOS e
  il giorno dopo puntano al nulla.

### Riscalo corrente

Vive fuori dalla ricetta e resta sul dispositivo. Non verrà sincronizzato:
quello che si sta cucinando adesso riguarda il telefono che si ha in mano.

**Si salva la richiesta dell'utente, non il risultato del calcolo.** Cioè "sei
porzioni" oppure "250 g di quell'ingrediente", non il moltiplicatore che ne è
uscito. Il fattore si ricalcola all'apertura della ricetta.

La differenza non è accademica. Salvando il moltiplicatore, chi corregge la
ricetta dopo averla riscalata si ritrova con numeri sbagliati sotto un'etichetta
che mente: riscali su 250 g di farina quando la ricetta ne dichiara 200, poi ti
accorgi che erano 180 e la correggi, e riaprendo la fascia dice ancora
"riscalata su 250 g di farina" mentre la riga farina ne mostra 225. Sbagliato in
silenzio, sulla schermata che si guarda mentre si cucina.

Salvando invece la richiesta, il ricalcolo riparte dai numeri aggiornati e torna
giusto da solo. Se il ricalcolo non è più possibile, perché l'ingrediente è stato
cancellato o è diventato q.b. o le porzioni sono state tolte, il riscalo viene
buttato e la ricetta si apre sulle dosi originali.

La richiesta serve anche a scrivere in cima alla schermata "riscalata per 6
porzioni" oppure "riscalata su 250 g di farina" invece di un generico
moltiplicatore.

### Categorie

Le crea l'utente, con nome libero e un'icona scelta fra quelle che diamo noi. Non
c'è nessuna categoria predefinita: un ricettario nuovo non ne ha.

**Una ricetta sta in una categoria sola**, o in nessuna. Sono raggruppamenti,
non etichette: come le cartelle, non come i tag. È l'utente a decidere il criterio
e a conviverci, e chi non vuole pensarci se ne fa una sola e ci mette tutto.

Cancellare una categoria non tocca le ricette che ci stavano dentro: restano, con
`categoriaId` che torna vuoto, e finiscono in "Senza categoria".

### Foto

Una per ricetta, facoltativa, presa dalla fotocamera o dalla libreria.

**Si comprime al momento dello scatto**, non dopo: lato lungo a 1280 pixel e
qualità JPEG intorno a 0,7, che tiene ogni immagine sotto i 300 KB. Duecento
ricette stanno così in una cinquantina di megabyte, che è una quantità che sia
iCloud sia Drive portano senza fatica, e che sta in un archivio spedibile.
Salvare l'originale da otto megabyte della fotocamera vorrebbe dire un ricettario
da un giga e mezzo, e la sincronizzazione diventerebbe un altro problema.

Il file vive nella cartella documenti dell'app e si chiama come l'id della
ricetta. Cancellare una ricetta cancella anche la sua foto: non restano orfani.

### Cancellazioni

Cancellare marca `cancellataIl`, non rimuove la riga. Senza questa tombstone la
sync farebbe riapparire le ricette cancellate. Va introdotta subito anche se la
sync arriva dopo, perché aggiungerla in seguito significherebbe migrare i dati
degli utenti.

Le tombstone non vengono ripulite. Una prima stesura prevedeva di rimuoverle
dopo 90 giorni, ma è esattamente ciò che rompe la cosa che dovevano proteggere:
un tablet rimasto spento quattro mesi si riporterebbe indietro le ricette
cancellate. Su ricettari da qualche decina di ricette il costo di tenerle è
nullo. La questione si riapre quando esisterà la sync, cioè nel pezzo B, dove si
potrà legare la pulizia all'avvenuta sincronizzazione di tutti i dispositivi
invece che al calendario.

---

## 5. Regole di calcolo

**Stato reale, perché la distinzione conta per il piano di lavoro.**

Già scritto e coperto da 17 test: il riscalo per porzioni e quello inverso da un
ingrediente, l'arrotondamento per ordine di grandezza, la famiglia non
frazionabile in italiano, la resa leggibile fra g/kg e ml/l, la formattazione
italiana con la virgola.

Ancora da scrivere: le unità anglosassoni (oz, lb, fl oz, cup, tbsp, tsp) e la
loro assegnazione alle tre famiglie, il lessico non frazionabile inglese (eggs,
sticks, cloves), la resa leggibile fra once e libbre, e il separatore decimale
che dipende dalla lingua. Oggi il codice conosce solo l'italiano e solo il
sistema metrico: `formattaQuantita(1.5, 'oz')` restituisce "1½ oz" invece dei
decimali, e mezzo uovo scritto in inglese diventa "¾ eggs", cioè esattamente i
difetti che la sezione 1 promette di non riportare. E il parser della sezione 6
non esiste affatto.

- Si calcola sempre dalla quantità originale moltiplicata per il fattore, mai
  dal valore già scalato. Gli arrotondamenti non si accumulano.
- Un ingrediente q.b. non scala mai.

Le unità si dividono in tre famiglie, e la divisione vale per entrambi i
sistemi di misura.

- **Di precisione**, cioè quelle che si leggono su una bilancia o un misuratore
  graduato: g, kg, ml, cl, dl, l, oz, lb, fl oz. Si mostrano a decimali con
  arrotondamento per ordine di grandezza: da 100 in su all'intero, da 10 a 100 a
  un decimale, sotto 10 a due. 805,5555 diventa 806.
- **Da misurino**, cioè quelle che esistono solo in tagli fissi: cucchiai,
  cucchiaini, tazze, bicchieri, e i loro corrispettivi cup, tbsp, tsp. Si
  mostrano a frazioni, perché è così che sono fatti i misurini: "1½ cups", non
  "1.5 cups".
- **Non frazionabili**: uova, bustine, cubetti, spicchi, fette, foglie, e in
  inglese eggs, sticks, cloves. Arrotondamento al mezzo più vicino e mai sotto
  mezzo: se la ricetta prevede un ingrediente, riscalando in basso ne resta
  comunque mezzo invece di sparire.
- **Resa leggibile** dentro la stessa famiglia: 1160 g si mostrano come 1,16 kg,
  0,5 l come 500 ml, 18 oz restano 18 oz finché non conviene passare alle libbre.
- **Normalizzazione unità**: "gr" e "grammi" diventano "g", "cucchiaio" diventa
  "cucchiai". Un'unità sconosciuta resta com'è, solo ripulita: non si rifiuta
  niente.
- Separatore decimale secondo la lingua: virgola in italiano, punto in inglese.
- **Nessuna conversione automatica fra famiglie o fra sistemi.** L'unità scritta
  dall'utente è quella che l'utente rilegge.
- Le conversioni sicure, cioè quelle dentro la stessa famiglia fisica, esistono
  come funzione (`converti`) e sono coperte da test, ma in questa versione **non
  hanno un gesto** nell'interfaccia. Il tocco sulla quantità è già occupato dal
  riscalo inverso, che è il cuore dell'app, e caricare lo stesso gesto di due
  significati diversi renderebbe confusa l'unica schermata che conta. La resa
  leggibile automatica, che mostra 1160 g come 1,16 kg, copre già il caso
  pratico più frequente. Il gesto per scegliere l'unità a mano si valuta quando
  l'app esiste e si vede se serve.

Da fare: accordo singolare e plurale, oggi scrive "1 cubetti".

---

## 6. Parser del testo

Deterministico, sul dispositivo, senza rete e senza AI. L'intelligenza
artificiale è il pezzo C e riguarda le foto.

### Struttura: motore generico, vocabolario per lingua

Il parser è uno solo. Ogni lingua fornisce un vocabolario con: unità e loro
abbreviazioni, numeri scritti a parole, i modi di dire "quanto basta", le parole
di riempimento da scartare, e le parole che fanno smettere di leggere.

Quando l'utente incolla, il parser prova **tutti** i vocabolari installati e
tiene il risultato che interpreta più righe. Costa poco e risolve il caso
concreto dell'italiano che incolla una ricetta americana trovata in rete.

### Forme da riconoscere

```
italiano   200 g farina        farina 200 g        200g farina
           200 g di farina     2 uova              sale q.b.
           1/2 bicchiere di latte                  1,5 kg di patate
           un pizzico di sale  mezzo bicchiere di latte   tre cucchiai di olio

inglese    2 cups flour        1 lb butter         1/2 tsp salt
           2 1/2 cups flour    3 oz dark chocolate  salt to taste
           1 stick of butter   a pinch of nutmeg    2 large eggs
```

Più la sporcizia dell'incolla: trattini, pallini e numerazioni a inizio riga.

| Vocabolario | italiano | inglese |
|---|---|---|
| numeri a parole | da "un/uno/una" a "dodici", "mezzo", "mezza" | da "a/an/one" a "twelve", "half" |
| quanto basta | `qb`, `q.b.`, `quanto basta`, `a piacere` | `to taste`, `as needed`, `a pinch of` |
| riempitivi | `di`, `d'` | `of`, `the` |
| stop | `Procedimento`, `Preparazione` | `Method`, `Directions`, `Instructions`, `Preparation` |
| sezioni | `Per l'impasto:` | `For the dough:` |

### Due regole per l'incolla dai siti

- Una riga che finisce con i due punti e non contiene numeri diventa il nome di
  una sezione. `Per l'impasto:` crea il gruppo da solo. Una riga che dice solo
  "Ingredienti" oppure "Ingredients" diventa il gruppo predefinito senza nome.
- Incontrando una parola di stop il parser smette di leggere e scarta il resto.
  Da lì in poi è il metodo, non gli ingredienti.

### Regola sopra tutte

**Non si perde niente.** Una riga non compresa non viene scartata né inventata:
diventa un ingrediente col solo nome, segnato in evidenza. Le righe interpretate
con sicurezza restano quiete, quelle dubbie chiedono attenzione. L'utente vede
subito le due righe da sistemare invece di scoprire dopo che mancava il burro.

### Insidia nota

La virgola separa gli ingredienti quando si incolla tutto su una riga, ma
dentro `1,5 kg` è il separatore decimale. Si divide sulla virgola solo quando
non sta fra due cifre.

---

## 7. Schermate

### Categorie, la schermata principale

In cima la ricerca, che cerca fra **tutte** le ricette ignorando le categorie: chi
sa come si chiama quello che cerca non deve passare da nessuna parte. Appena si
scrive qualcosa la schermata mostra i risultati come elenco di ricette.

Sotto, le categorie dell'utente con la loro icona e il numero di ricette. Sempre
in cima due voci che non si possono cancellare:

- **Tutte le ricette**, che porta all'elenco completo. È la scorciatoia per chi
  ha poche ricette e le categorie non gli servono.
- **Senza categoria**, che compare solo se c'è almeno una ricetta senza.

A ricettario vuoto non mostra "nessuna categoria" ma invita a scrivere la prima
ricetta.

### Elenco ricette

Si arriva qui toccando una categoria, "Tutte le ricette" o "Senza categoria".
Ordine alfabetico italiano, ricerca che filtra dentro quello che si sta
guardando, pulsante tondo in basso per aggiungere.

### Gestione categorie

Creare, rinominare, cambiare icona, riordinare, cancellare. La scelta dell'icona
è una griglia di una trentina di simboli che forniamo noi.

### Dosatore

Il cuore dell'app. In cima titolo e porzioni, sotto gli ingredienti con le
quantità in evidenza. Due modi di riscalare:

- cambiare il numero di porzioni;
- toccare la quantità di un ingrediente e scrivere quanta se ne ha davvero.

Quando la ricetta è riscalata, una fascia in cima dice cosa si sta guardando,
con accanto il ritorno alle dosi originali. Su questa schermata lo schermo non
si spegne da solo: è quella che si guarda con le mani sporche.

Se la ricetta non dichiara le porzioni, il controllo delle porzioni non compare
e resta il riscalo per ingrediente.

### Scrivi e modifica

Titolo, porzioni facoltative, blocco di testo per gli ingredienti che diventa
righe correggibili. Aggiunta di sezioni, cancellazione della ricetta.

---

## 8. Architettura

Quattro strati.

| Strato | Responsabilità | Dipendenze |
|---|---|---|
| `domain` | tipi, riscalo, unità, formattazione, parser, migrazione | nessuna |
| `data` | schema SQLite, migrazioni, repository | domain |
| `io` | export e import file | domain, data |
| `ui` | schermate e componenti | domain, data, io |

Regola: le schermate non parlano mai col database direttamente, e il dominio
non importa niente da nessuno. Dominio e dati restano verificabili senza
dispositivo, in mezzo secondo.

---

## 9. Errori e casi limite

- **File importato rotto o di un'altra app**: rifiutato con un messaggio, senza
  toccare niente di esistente.
- **Import**: additivo, mai sostitutivo. A parità di id resta la versione
  modificata più di recente.
- **Prima di ogni import e di ogni cambio di struttura del database**: una
  copia del database nella cartella dati dell'app, sovrascritta ogni volta.
  Finché non arriva la sync è l'unica rete di sicurezza.
- **Database che non si apre**: l'app lo dice. Non parte vuota fingendo che
  vada tutto bene, perché un ricettario vuoto sembra un ricettario perso.
- **Riscalo con valori assurdi** (zero, negativi, testo, ingrediente q.b. o a
  quantità zero): il motore restituisce "non applicabile" e l'interfaccia non
  fa nulla.
- **Titoli duplicati**: ammessi, perché l'identità è l'id e non il titolo.
- **Categoria cancellata**: le sue ricette restano, con `categoriaId` che torna
  vuoto, e compaiono in "Senza categoria". Non si cancella mai una ricetta
  cancellando una categoria, e la conferma lo dice esplicitamente.
- **Foto che manca sul disco** mentre il database dice che c'è: si mostra la
  ricetta senza immagine e si azzera il campo. Può succedere importando un JSON
  nudo esportato da un dispositivo che le aveva.
- **Categoria vuota**: si mostra lo stesso, con zero accanto. L'utente l'ha
  creata apposta e farla sparire sarebbe sconcertante.
- **Due categorie con lo stesso nome**: ammesse, come per le ricette l'identità è
  l'id. In fase di creazione però si avvisa che esiste già.
- **Ricetta modificata dopo essere stata riscalata**: il fattore si ricalcola
  dalla richiesta salvata. Se non è più calcolabile si torna alle dosi originali,
  senza messaggi di errore: non è un guasto, è una ricetta cambiata.

---

## 10. Verifiche

- **Dominio**: test con `node --test`, senza framework. Oggi 17, tutti su casi
  italiani e metrici: le fixture inglesi non esistono ancora e vanno scritte
  insieme alle unità anglosassoni. Col parser si arriva attorno a 60. Il parser ha un elenco di casi costruito sulle 12 ricette reali
  più i formati dei siti italiani più comuni.
- **Dati**: repository provato su un database in memoria.
- **Interfaccia**: nessun test automatico in questa fase. Verifica a mano su
  TestFlight. È una limitazione dichiarata, non una dimenticanza.
- **Prova di realtà**: lo script che migra il ricettario vero e stampa il
  risultato. Oggi riporta 12 ricette su 12 e 77 ingredienti su 77.

---

## 11. Fuori da questa spec

### Conversione fra volume e peso

Trasformare "2 cups di farina" in grammi non è matematica: dipende
dall'ingrediente. Una tazza di farina pesa circa 120 g, una di zucchero 200, una
di burro 227, una di miele 340. Peggio ancora, la stessa farina passa da 120 a
145 g a tazza a seconda di come si riempie la tazza, quindi anche la tabella di
densità migliore possibile porta con sé un venti per cento di incertezza sul
caso più comune di tutti. Si aggiunga che la tazza americana è 237 ml, quella
australiana 250 e quella imperiale britannica 284.

È la conversione che gli utenti chiedono di più ed è l'unica davvero rischiosa.
Diventa un pezzo a sé: tabella di densità sui cinquanta ingredienti più comuni,
scelta esplicita del paese di provenienza della ricetta, e risultato dichiarato
come approssimativo. Farla male è peggio che non farla.

### Conseguenza delle foto sul pezzo B

Le foto entrano in questa versione (sezione 4), ma vanno segnalate a chi
progetterà la sincronizzazione, perché la cambiano.

Il pezzo B era pensato attorno a un documento JSON che viaggia intero. Con le
foto serve anche portare file binari: su iOS esiste `CKAsset`, fatto apposta,
su Drive sono file nella cartella riservata all'app. Vanno gestiti i
trasferimenti interrotti a metà e va tenuto d'occhio quanto spazio cloud occupa
il ricettario di chi usa l'app.

Il conto resta sostenibile solo grazie al limite deciso qui: una foto per
ricetta, compressa allo scatto, sotto i 300 KB. Se un giorno si volessero più
foto per ricetta, quella decisione va ripresa insieme al costo che comporta.

### Altro

Il nome pubblico dell'app va deciso e verificato sugli store, ma appartiene al
pezzo D insieme al resto della pubblicazione.

Niente ricette di esempio precaricate. Un ricettario personale che arriva con
dentro la roba di qualcun altro va svuotato prima di poter essere usato, e la
schermata vuota che invita a incollare la prima ricetta fa già il suo lavoro
adesso che l'inserimento costa un incolla.
