# QuantoBasta, pezzo A: app core locale — Piano di implementazione

> **Per chi esegue con agenti:** SOTTO-SKILL RICHIESTA. Usare
> `superpowers:subagent-driven-development` (consigliata) oppure
> `superpowers:executing-plans` per eseguire il piano un task alla volta.
> I passi hanno le caselle `- [ ]` per tenere il segno.

**Obiettivo.** Costruire l'app iOS e Android che ricalcola le dosi di una
ricetta, per numero di porzioni o partendo dalla quantità realmente disponibile
di un ingrediente, con il ricettario in locale sul dispositivo, organizzato in
categorie create dall'utente e con una foto per ricetta.

**Architettura.** Quattro strati con dipendenze a senso unico: `domain` è
TypeScript puro senza dipendenze e contiene riscalo, unità, formattazione e
parser; `data` è l'unico strato che conosce SQLite; `io` fa export e import
file; `ui` sono le schermate, che non parlano mai col database direttamente.
Dominio e dati restano verificabili senza dispositivo.

**Tecnologie.** Expo e React Native con TypeScript, SQLite via `expo-sqlite`,
navigazione con `@react-navigation/native-stack`, test con `node --test` senza
framework.

**Spec di riferimento.** `docs/superpowers/specs/2026-08-18-dosatore-core-design.md`

---

## Vincoli globali

Valgono per ogni task, anche dove non sono ripetuti.

- **Lingua del codice**: identificatori, commenti e messaggi di commit in
  italiano. Niente prefissi tipo `feat:`.
- **Import di progetto** con estensione `.ts` esplicita. Serve a Node per
  eseguire i test nativamente; `tsconfig.json` ha già
  `allowImportingTsExtensions: true`.
- **Test** con `node --test` e `node:assert/strict`, senza framework.
- **Il dominio non importa niente da nessuno**, e le schermate non parlano mai
  col database: passano da `data`.
- **Una sola convenzione per l'interfaccia**: i tipi delle rotte stanno in
  `src/ui/navigazione.ts` (`ParametriNav`, `PropsSchermata<N>`), il database si
  prende da `useApp()` di `src/ui/contesto.ts` e mai da `useSQLiteContext()`,
  non si monta nessun `SQLiteProvider`, nessuna schermata riceve `db` o `lingua`
  come props. Il parametro della rotta Modifica si chiama `ricettaId`, e `null`
  significa ricetta nuova.
- **`salvaRicetta` non timbra**: scrive `ricetta.modificataIl` così come arriva.
  Il timestamp lo decide chi chiama, altrimenti l'import perde le date del file
  e la regola "a parità di id vince la più recente" non funziona.
- **`t()` non tace**: un segnaposto senza valore fa lanciare un errore invece di
  finire a schermo scritto in chiaro. I segnaposto hanno nomi parlanti:
  `{porzioni}`, `{quantita}`, `{nome}`, `{titolo}`.
- **Le unità canoniche sono sempre al plurale**, senza eccezioni.
- **Un solo aiutante per i test sul database**: `src/data/dbMemoria.ts` con
  `apriDbInMemoria()`. I parametri delle query si passano sempre come array.
- **Niente uguaglianze esatte su risultati di divisioni** nei test: dove il
  valore non è rappresentabile in binario si usa una tolleranza.
- **La conversione fra volume e peso resta fuori** da questo pezzo: dipende
  dall'ingrediente e va fatta a parte. `converti()` copre solo le conversioni
  dentro la stessa famiglia fisica, e in questa versione non ha un gesto
  nell'interfaccia.
- **Le tombstone non si ripuliscono**: cancellare marca `cancellataIl` e basta.

## I ventitré task

| | Task | Cosa consegna |
|---|---|---|
| Dominio | 1-4 | vocabolari per lingua, unità con le tre famiglie, formattazione bilingue, riscalo dalla richiesta |
| | 5 | categorie e catalogo delle trenta icone |
| | 6-8 | parser: numeri, riga singola, blocco incollato |
| | 9 | migrazione dal formato della vecchia webapp |
| Dati | 10-11 | schema SQLite con le tombstone, repository ricette |
| | 12-13 | repository categorie, repository riscalo |
| | 14-15 | gestione dei file foto, export e import in archivio zip |
| Interfaccia | 16-17 | traduzioni, impalcatura e navigazione |
| | 18-20 | schermata categorie, elenco ricette, dosatore |
| | 21-23 | gestione categorie, scrivi e modifica, export e import |

I test passano da 17 a 220. Ogni task dichiara il totale progressivo atteso, in
modo che un test perso per strada si veda subito.

---

## Cosa stai costruendo

**QuantoBasta** è un'app iOS/Android (Expo + React Native + TypeScript) che
ricalcola le dosi di una ricetta: cambi le porzioni, oppure dici "ho 250 g di
farina invece di 180" e tutti gli altri ingredienti si adeguano. Niente account,
niente rete: SQLite sul dispositivo. Due lingue, italiano e inglese, ed entrambi
i sistemi di misura (metrico e anglosassone) **senza conversioni automatiche fra
i due**: l'unità che l'utente scrive è quella che l'utente rilegge.

Questi due task riguardano solo lo strato `domain`, che non importa niente da
nessuno e si prova senza dispositivo, in mezzo secondo.

### Cose da sapere prima di toccare i file

- Radice del progetto: `/Users/paolo/Server/Siti Web/_private/dosatore-app`.
  Tutti i percorsi qui sotto sono relativi a questa cartella.
- **Codice, commenti e messaggi di commit in italiano.** Il codice esistente è
  già così.
- I test girano con `node --test` e `node:assert/strict`, **senza framework**:
  Node 24 esegue TypeScript nativamente.
- **Gli import fra file del progetto hanno l'estensione `.ts` esplicita**
  (`./units.ts`, non `./units`). Serve a Node; `tsconfig.json` ha già
  `allowImportingTsExtensions: true`.
- Gli import di soli tipi vanno scritti `import type { … }`: Node cancella i
  tipi ma non sa che un import è "solo di tipi" se non glielo dici, e un import
  normale di un'interfaccia fallisce a runtime.
- Comandi: `npm test` (esegue `node --test "src/**/*.test.ts"`),
  `npm run typecheck` (`tsc --noEmit`), `npm run migra` (migra il ricettario
  vero della vecchia webapp e stampa il risultato).
- Vocabolario del dominio: **q.b.** ("quanto basta") è un ingrediente senza
  quantità, tipo il sale; `quantita === null` lo rappresenta e non entra mai nei
  calcoli.

### Da cosa parti

`src/domain/` esiste già, con 17 test verdi in `src/domain/domain.test.ts`:

| File | Contenuto |
|---|---|
| `types.ts` | `Ingrediente`, `Gruppo`, `Ricetta`, `RicettaScalata`, `isQb`, `tuttiGliIngredienti` |
| `units.ts` | `normalizzaUnita`, `resaLeggibile`, `isUnitaDiscreta`, `isUnitaMetrica`, `arrotondaAMezzi` |
| `format.ts` | `formattaQuantita`, `numeroItaliano` |
| `scaling.ts` | `fattoreDaPorzioni`, `fattoreDaIngrediente`, `scala`, `arrotondaPerCucina` |
| `id.ts` | `newId` |
| `migrate.ts` | `migraRicetta`, `migraArchivio` |

Questo codice conosce **solo l'italiano e solo il sistema metrico**: le unità
sono una tabella di alias fissa dentro `units.ts`, `formattaQuantita(1.5, 'oz')`
risponde `"1½ oz"` invece dei decimali e il separatore decimale è sempre la
virgola. I due task qui sotto tolgono la lingua dal motore e la mettono in un
vocabolario per lingua.

### Le tre famiglie di unità (regola che governa tutto)

| Famiglia | Cosa sono | Come si mostrano |
|---|---|---|
| `precisione` | si leggono su una bilancia o un misuratore graduato: g, kg, ml, cl, dl, l, oz, lb, fl oz | decimali, arrotondati per ordine di grandezza |
| `misurino` | esistono solo in tagli fissi: cucchiai, cucchiaini, tazze, bicchieri, cups, tbsp, tsp | frazioni: "1½ cups", non "1.5 cups" |
| `discreta` | non frazionabili: uova, bustine, cubetti, spicchi, eggs, sticks, cloves | arrotondate al mezzo e mai sotto mezzo |

---


### Task 1: Vocabolari per lingua

**Files:**
- Create: `src/domain/lingua/index.ts`
- Create: `src/domain/lingua/it.ts`
- Create: `src/domain/lingua/en.ts`
- Test: `src/domain/lingua/lingua.test.ts`

**Interfaces:**

- Consumes: niente. È il primo task del piano e non importa nulla dal resto del
  progetto. I file esistenti in `src/domain/` non vanno toccati.

- Produces (da `src/domain/lingua/index.ts`):
  ```ts
  export type Lingua = 'it' | 'en';
  export type Famiglia = 'precisione' | 'misurino' | 'discreta';

  export interface VoceUnita {
    canonica: string;          // forma salvata nel db: 'g', 'cucchiai', 'cups'
    alias: string[];           // tutte le scritture, minuscole, canonica inclusa
    famiglia: Famiglia;
    singolare: string;         // resa a schermo con quantità 1: 'cubetto'
    plurale: string;           // resa a schermo con quantità ≠ 1: 'cubetti'
    base?: { unita: string; fattore: number };  // solo per 'precisione'
  }

  export interface Vocabolario {
    lingua: Lingua;
    unita: VoceUnita[];
    numeriAParole: Record<string, number>;   // 'un' -> 1, 'half' -> 0.5
    quantoBasta: string[];
    riempitivi: string[];
    stop: string[];
    intestazioneNeutra: string[];
    separatoreDecimale: ',' | '.';
  }

  export const VOCABOLARI: Record<Lingua, Vocabolario>;
  export const LINGUE: Lingua[];                       // ['it', 'en']
  export function vocabolario(lingua: Lingua): Vocabolario;
  ```
  Più `export const IT: Vocabolario` in `it.ts` ed `export const EN: Vocabolario`
  in `en.ts`.

  Il campo `base` è la conversione verso l'unità base della propria famiglia
  fisica, e serve solo alla resa leggibile del Task 2: `kg -> { unita: 'g',
  fattore: 1000 }`. Le unità base sono base di se stesse con fattore 1 (`g`,
  `ml`, `oz`): senza quello la famiglia fisica non si chiude e il Task 2 non
  trova l'unità grande a cui salire. `fl oz` non ha `base`: è volume
  anglosassone, non si converte né verso `oz` (che è peso) né verso `ml` (altro
  sistema).

---

- [ ] **Step 1: Scrivi il test che fallisce**

Crea la cartella `src/domain/lingua/` e dentro il file `lingua.test.ts` con
questo contenuto:

```ts
/**
 * Test dei vocabolari. Girano con `node --test` senza framework.
 *
 * Sono test sui dati: verificano che i due vocabolari siano coerenti fra loro e
 * con quello che il motore si aspetta. Un alias duplicato o una famiglia
 * sbagliata qui diventa un'unità che sparisce dal parser, e nessun test del
 * motore se ne accorgerebbe.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { LINGUE, VOCABOLARI, vocabolario } from './index.ts';
import type { Famiglia } from './index.ts';

const FAMIGLIE: Famiglia[] = ['precisione', 'misurino', 'discreta'];

test('LINGUE elenca le lingue installate e vocabolario() le restituisce', () => {
  assert.deepEqual(LINGUE, ['it', 'en']);
  assert.equal(vocabolario('it').lingua, 'it');
  assert.equal(vocabolario('en').lingua, 'en');
  assert.deepEqual(Object.keys(VOCABOLARI).sort(), ['en', 'it']);
});

test('nessun alias appare in due unità diverse della stessa lingua', () => {
  for (const lingua of LINGUE) {
    const visti = new Map<string, string>();
    for (const voce of vocabolario(lingua).unita) {
      for (const alias of voce.alias) {
        const gia = visti.get(alias);
        assert.equal(
          gia, undefined,
          `${lingua}: l'alias "${alias}" è conteso fra ${gia} e ${voce.canonica}`,
        );
        visti.set(alias, voce.canonica);
      }
    }
  }
});

test('ogni voce ha famiglia valida, alias minuscoli e forme non vuote', () => {
  for (const lingua of LINGUE) {
    for (const voce of vocabolario(lingua).unita) {
      const dove = `${lingua}/${voce.canonica}`;
      assert.ok(voce.canonica.trim() !== '', `${dove}: canonica vuota`);
      assert.ok(FAMIGLIE.includes(voce.famiglia), `${dove}: famiglia ${voce.famiglia}`);
      assert.ok(voce.singolare.trim() !== '', `${dove}: singolare vuoto`);
      assert.ok(voce.plurale.trim() !== '', `${dove}: plurale vuoto`);
      assert.ok(voce.alias.includes(voce.canonica), `${dove}: la canonica non è fra gli alias`);
      for (const alias of voce.alias) {
        assert.equal(alias, alias.toLowerCase().trim(), `${dove}: alias "${alias}" non normalizzato`);
        assert.ok(alias !== '', `${dove}: alias vuoto`);
      }
    }
  }
});

test("'fl oz' e 'oz' sono due unità distinte", () => {
  const en = vocabolario('en');
  const oz = en.unita.find((v) => v.canonica === 'oz');
  const flOz = en.unita.find((v) => v.canonica === 'fl oz');
  assert.ok(oz && flOz);
  assert.ok(!oz!.alias.includes('fl oz'));
  assert.ok(flOz!.alias.includes('fluid ounces'));
  // oz è peso e fa da base alle libbre; fl oz è volume e non si converte.
  assert.deepEqual(oz!.base, { unita: 'oz', fattore: 1 });
  assert.equal(flOz!.base, undefined);
});

test('le conversioni base sono coerenti', () => {
  for (const lingua of LINGUE) {
    const voc = vocabolario(lingua);
    for (const voce of voc.unita) {
      const dove = `${lingua}/${voce.canonica}`;
      if (voce.base === undefined) continue;
      assert.equal(voce.famiglia, 'precisione', `${dove}: solo le unità di precisione si convertono`);
      assert.ok(voce.base.fattore > 0, `${dove}: fattore non positivo`);
      const base = voc.unita.find((v) => v.canonica === voce.base!.unita);
      assert.ok(base, `${dove}: l'unità base ${voce.base.unita} non esiste in ${lingua}`);
      // La base è base di se stessa con fattore 1: senza questo le famiglie
      // fisiche non si chiudono e la resa leggibile non trova l'unità grande.
      assert.deepEqual(base!.base, { unita: base!.canonica, fattore: 1 }, `${dove}: base non chiusa`);
    }
  }
});

test('i fattori di conversione sono quelli della spec', () => {
  const conversione = (lingua: 'it' | 'en', canonica: string) =>
    vocabolario(lingua).unita.find((v) => v.canonica === canonica)?.base;

  assert.deepEqual(conversione('it', 'g'), { unita: 'g', fattore: 1 });
  assert.deepEqual(conversione('it', 'kg'), { unita: 'g', fattore: 1000 });
  assert.deepEqual(conversione('it', 'ml'), { unita: 'ml', fattore: 1 });
  assert.deepEqual(conversione('it', 'cl'), { unita: 'ml', fattore: 10 });
  assert.deepEqual(conversione('it', 'dl'), { unita: 'ml', fattore: 100 });
  assert.deepEqual(conversione('it', 'l'), { unita: 'ml', fattore: 1000 });
  assert.deepEqual(conversione('en', 'lb'), { unita: 'oz', fattore: 16 });
  // Le unità da misurino e quelle discrete non si convertono mai.
  assert.equal(conversione('it', 'cucchiai'), undefined);
  assert.equal(conversione('en', 'cups'), undefined);
  assert.equal(conversione('it', 'uova'), undefined);
});

test('le unità obbligatorie ci sono, con la famiglia giusta', () => {
  const famiglia = (lingua: 'it' | 'en', alias: string) =>
    vocabolario(lingua).unita.find((v) => v.alias.includes(alias))?.famiglia;

  for (const u of ['g', 'gr', 'grammi', 'kg', 'chili', 'ml', 'cl', 'dl', 'l', 'lt']) {
    assert.equal(famiglia('it', u), 'precisione', `it: ${u}`);
  }
  for (const u of ['cucchiaio', 'cucchiaini', 'tazza', 'bicchiere', 'tazzina', 'bicchierino']) {
    assert.equal(famiglia('it', u), 'misurino', `it: ${u}`);
  }
  for (const u of ['cad', 'uova', 'uovo', 'bustina', 'cubetti', 'spicchio', 'fette', 'foglie']) {
    assert.equal(famiglia('it', u), 'discreta', `it: ${u}`);
  }
  // Il ricettario reale scrive "pizzico": la voce c'è, ed è discreta.
  assert.equal(famiglia('it', 'pizzico'), 'discreta');
  for (const u of ['g', 'kg', 'ml', 'l', 'oz', 'ounces', 'lb', 'pounds', 'fl oz']) {
    assert.equal(famiglia('en', u), 'precisione', `en: ${u}`);
  }
  for (const u of ['cup', 'cups', 'tbsp', 'tablespoons', 'tsp', 'teaspoon']) {
    assert.equal(famiglia('en', u), 'misurino', `en: ${u}`);
  }
  for (const u of ['eggs', 'egg', 'sticks', 'cloves', 'slices', 'packets', 'cans', 'jars']) {
    assert.equal(famiglia('en', u), 'discreta', `en: ${u}`);
  }
});

test('il lessico del parser è completo e tutto minuscolo', () => {
  const it = vocabolario('it');
  const en = vocabolario('en');

  assert.equal(it.numeriAParole['un'], 1);
  assert.equal(it.numeriAParole['dodici'], 12);
  assert.equal(it.numeriAParole['mezzo'], 0.5);
  assert.equal(it.numeriAParole['mezza'], 0.5);
  assert.equal(en.numeriAParole['a'], 1);
  assert.equal(en.numeriAParole['one'], 1);
  assert.equal(en.numeriAParole['twelve'], 12);
  assert.equal(en.numeriAParole['half'], 0.5);

  assert.ok(it.quantoBasta.includes('q.b.') && it.quantoBasta.includes('quanto basta'));
  assert.ok(en.quantoBasta.includes('to taste') && en.quantoBasta.includes('a pinch of'));
  assert.ok(it.riempitivi.includes('di') && en.riempitivi.includes('of'));
  assert.ok(it.stop.includes('procedimento') && en.stop.includes('instructions'));
  assert.deepEqual(it.intestazioneNeutra, ['ingredienti']);
  assert.deepEqual(en.intestazioneNeutra, ['ingredients']);
  assert.equal(it.separatoreDecimale, ',');
  assert.equal(en.separatoreDecimale, '.');

  for (const voc of [it, en]) {
    const parole = [
      ...Object.keys(voc.numeriAParole), ...voc.quantoBasta,
      ...voc.riempitivi, ...voc.stop, ...voc.intestazioneNeutra,
    ];
    for (const p of parole) {
      assert.equal(p, p.toLowerCase().trim(), `${voc.lingua}: "${p}" non è minuscolo e pulito`);
    }
  }
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/src/domain/lingua/index.ts' imported from …/src/domain/lingua/lingua.test.ts`.
I 17 test già esistenti restano verdi (`ℹ pass 17`, `ℹ fail 1`).

- [ ] **Step 3: Scrivi `src/domain/lingua/index.ts`**

```ts
/**
 * Vocabolari per lingua: unità, numeri a parole, lessico del parser.
 *
 * Il motore (unità, formattazione, parser) è uno solo e non sa niente di
 * italiano o inglese: riceve sempre un Vocabolario e lavora su quello. Le
 * lingue si aggiungono qui, non nel motore.
 *
 * it.ts e en.ts importano da questo file SOLO tipi (`import type`), che
 * spariscono a runtime: il ciclo di import esiste solo per TypeScript.
 */

import { IT } from './it.ts';
import { EN } from './en.ts';

export type Lingua = 'it' | 'en';

/**
 * Le tre famiglie della spec:
 * - precisione: si legge su una bilancia o un misuratore graduato -> decimali;
 * - misurino:   esiste solo in tagli fissi (cucchiai, cups) -> frazioni;
 * - discreta:   non frazionabile fine (uova, bustine) -> arrotondata al mezzo.
 */
export type Famiglia = 'precisione' | 'misurino' | 'discreta';

export interface VoceUnita {
  /** Forma canonica salvata nel database, es. 'g', 'cucchiai', 'cups'. */
  canonica: string;
  /** Tutte le scritture che portano a questa unità, minuscole. Include la canonica. */
  alias: string[];
  famiglia: Famiglia;
  /** Resa a schermo con quantità 1, es. 'cubetto'. */
  singolare: string;
  /** Resa a schermo con quantità diversa da 1, es. 'cubetti'. */
  plurale: string;
  /**
   * Solo per la famiglia 'precisione': conversione verso l'unità base della
   * propria famiglia fisica, per la resa leggibile. Es. kg -> { unita: 'g', fattore: 1000 }.
   * Assente = l'unità non si converte.
   */
  base?: { unita: string; fattore: number };
}

export interface Vocabolario {
  lingua: Lingua;
  unita: VoceUnita[];
  /** 'un' -> 1, 'mezzo' -> 0.5, 'three' -> 3. Chiavi minuscole. */
  numeriAParole: Record<string, number>;
  /** Espressioni che significano "quanto basta", minuscole. */
  quantoBasta: string[];
  /** Parole da scartare fra quantità e nome, es. 'di', 'of'. Minuscole. */
  riempitivi: string[];
  /** Parole che fanno smettere di leggere, minuscole. */
  stop: string[];
  /** Intestazioni che NON creano una sezione con nome, es. 'ingredienti'. Minuscole. */
  intestazioneNeutra: string[];
  separatoreDecimale: ',' | '.';
}

export const VOCABOLARI: Record<Lingua, Vocabolario> = { it: IT, en: EN };

/** Ordine in cui il parser prova le lingue: a pari merito vince la prima. */
export const LINGUE: Lingua[] = ['it', 'en'];

export function vocabolario(lingua: Lingua): Vocabolario {
  return VOCABOLARI[lingua];
}
```

Non eseguire ancora i test: questo file importa `it.ts` ed `en.ts`, che non
esistono fino allo Step 5.

- [ ] **Step 4: Scrivi `src/domain/lingua/it.ts`**

```ts
/**
 * Vocabolario italiano.
 *
 * Gli alias sono tutti minuscoli e comprendono la forma canonica. Le unità
 * numerabili hanno il plurale come forma canonica ("cucchiai", "uova"), perché
 * è quella che si legge quasi sempre; il singolare serve solo per l'accordo a
 * schermo con quantità 1.
 */

import type { Vocabolario } from './index.ts';

export const IT: Vocabolario = {
  lingua: 'it',
  unita: [
    // --- precisione: bilancia e misuratore graduato -----------------------
    {
      canonica: 'g', alias: ['g', 'gr', 'grammo', 'grammi'],
      famiglia: 'precisione', singolare: 'g', plurale: 'g',
      base: { unita: 'g', fattore: 1 },
    },
    {
      canonica: 'kg', alias: ['kg', 'chilo', 'chili', 'chilogrammo', 'chilogrammi'],
      famiglia: 'precisione', singolare: 'kg', plurale: 'kg',
      base: { unita: 'g', fattore: 1000 },
    },
    {
      canonica: 'ml', alias: ['ml', 'millilitro', 'millilitri'],
      famiglia: 'precisione', singolare: 'ml', plurale: 'ml',
      base: { unita: 'ml', fattore: 1 },
    },
    {
      canonica: 'cl', alias: ['cl', 'centilitro', 'centilitri'],
      famiglia: 'precisione', singolare: 'cl', plurale: 'cl',
      base: { unita: 'ml', fattore: 10 },
    },
    {
      canonica: 'dl', alias: ['dl', 'decilitro', 'decilitri'],
      famiglia: 'precisione', singolare: 'dl', plurale: 'dl',
      base: { unita: 'ml', fattore: 100 },
    },
    {
      canonica: 'l', alias: ['l', 'lt', 'litro', 'litri'],
      famiglia: 'precisione', singolare: 'l', plurale: 'l',
      base: { unita: 'ml', fattore: 1000 },
    },

    // --- misurino: esistono solo in tagli fissi ---------------------------
    {
      canonica: 'cucchiai', alias: ['cucchiai', 'cucchiaio'],
      famiglia: 'misurino', singolare: 'cucchiaio', plurale: 'cucchiai',
    },
    {
      canonica: 'cucchiaini', alias: ['cucchiaini', 'cucchiaino'],
      famiglia: 'misurino', singolare: 'cucchiaino', plurale: 'cucchiaini',
    },
    {
      canonica: 'tazze', alias: ['tazze', 'tazza'],
      famiglia: 'misurino', singolare: 'tazza', plurale: 'tazze',
    },
    {
      canonica: 'tazzine', alias: ['tazzine', 'tazzina'],
      famiglia: 'misurino', singolare: 'tazzina', plurale: 'tazzine',
    },
    {
      canonica: 'bicchieri', alias: ['bicchieri', 'bicchiere'],
      famiglia: 'misurino', singolare: 'bicchiere', plurale: 'bicchieri',
    },
    {
      canonica: 'bicchierini', alias: ['bicchierini', 'bicchierino'],
      famiglia: 'misurino', singolare: 'bicchierino', plurale: 'bicchierini',
    },

    // --- discrete: o uno, o mezzo, o due ---------------------------------
    {
      canonica: 'cad', alias: ['cad'],
      famiglia: 'discreta', singolare: 'cad', plurale: 'cad',
    },
    {
      canonica: 'pezzi', alias: ['pezzi', 'pezzo'],
      famiglia: 'discreta', singolare: 'pezzo', plurale: 'pezzi',
    },
    {
      canonica: 'uova', alias: ['uova', 'uovo'],
      famiglia: 'discreta', singolare: 'uovo', plurale: 'uova',
    },
    {
      canonica: 'bustine', alias: ['bustine', 'bustina'],
      famiglia: 'discreta', singolare: 'bustina', plurale: 'bustine',
    },
    {
      canonica: 'cubetti', alias: ['cubetti', 'cubetto'],
      famiglia: 'discreta', singolare: 'cubetto', plurale: 'cubetti',
    },
    {
      canonica: 'spicchi', alias: ['spicchi', 'spicchio'],
      famiglia: 'discreta', singolare: 'spicchio', plurale: 'spicchi',
    },
    {
      canonica: 'fette', alias: ['fette', 'fetta'],
      famiglia: 'discreta', singolare: 'fetta', plurale: 'fette',
    },
    {
      canonica: 'foglie', alias: ['foglie', 'foglia'],
      famiglia: 'discreta', singolare: 'foglia', plurale: 'foglie',
    },
    {
      // Il ricettario reale scrive "pizzico", quindi la voce ci deve essere:
      // chi legge "un pizzico di sale" ottiene la canonica 'pizzichi'. Per un
      // esempio di unità che il vocabolario NON conosce serve un'altra parola,
      // tipo "manciata".
      canonica: 'pizzichi', alias: ['pizzichi', 'pizzico'],
      famiglia: 'discreta', singolare: 'pizzico', plurale: 'pizzichi',
    },
    {
      canonica: 'rametti', alias: ['rametti', 'rametto'],
      famiglia: 'discreta', singolare: 'rametto', plurale: 'rametti',
    },
    {
      canonica: 'barattoli', alias: ['barattoli', 'barattolo'],
      famiglia: 'discreta', singolare: 'barattolo', plurale: 'barattoli',
    },
    {
      canonica: 'vasetti', alias: ['vasetti', 'vasetto'],
      famiglia: 'discreta', singolare: 'vasetto', plurale: 'vasetti',
    },
    {
      canonica: 'confezioni', alias: ['confezioni', 'confezione'],
      famiglia: 'discreta', singolare: 'confezione', plurale: 'confezioni',
    },
    {
      canonica: 'scatole', alias: ['scatole', 'scatola'],
      famiglia: 'discreta', singolare: 'scatola', plurale: 'scatole',
    },
    {
      canonica: 'stecche', alias: ['stecche', 'stecca'],
      famiglia: 'discreta', singolare: 'stecca', plurale: 'stecche',
    },
    {
      canonica: 'mazzetti', alias: ['mazzetti', 'mazzetto'],
      famiglia: 'discreta', singolare: 'mazzetto', plurale: 'mazzetti',
    },
    {
      canonica: 'ciuffi', alias: ['ciuffi', 'ciuffo'],
      famiglia: 'discreta', singolare: 'ciuffo', plurale: 'ciuffi',
    },
    {
      canonica: 'gambi', alias: ['gambi', 'gambo'],
      famiglia: 'discreta', singolare: 'gambo', plurale: 'gambi',
    },
    {
      canonica: 'coste', alias: ['coste', 'costa'],
      famiglia: 'discreta', singolare: 'costa', plurale: 'coste',
    },
  ],
  numeriAParole: {
    un: 1, uno: 1, una: 1, due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6,
    sette: 7, otto: 8, nove: 9, dieci: 10, undici: 11, dodici: 12,
    mezzo: 0.5, mezza: 0.5,
  },
  quantoBasta: ['q.b.', 'quanto basta', 'a piacimento', 'a piacere', 'qb'],
  riempitivi: ['di', "d'", 'del', 'della', 'dello', 'dei', 'degli', 'delle'],
  stop: ['procedimento', 'preparazione', 'esecuzione', 'istruzioni'],
  intestazioneNeutra: ['ingredienti'],
  separatoreDecimale: ',',
};
```

- [ ] **Step 5: Scrivi `src/domain/lingua/en.ts`**

```ts
/**
 * Vocabolario inglese.
 *
 * Contiene sia il sistema anglosassone sia quello metrico, perché le ricette
 * inglesi usano spesso i grammi. 'fl oz' (volume) e 'oz' (peso) sono due unità
 * diverse: l'alias più lungo deve vincere, ci pensa il lookup in units.ts.
 */

import type { Vocabolario } from './index.ts';

export const EN: Vocabolario = {
  lingua: 'en',
  unita: [
    // --- precisione: scale and measuring jug ------------------------------
    {
      canonica: 'g', alias: ['g', 'gram', 'grams', 'gramme', 'grammes'],
      famiglia: 'precisione', singolare: 'g', plurale: 'g',
      base: { unita: 'g', fattore: 1 },
    },
    {
      canonica: 'kg', alias: ['kg', 'kilo', 'kilos', 'kilogram', 'kilograms'],
      famiglia: 'precisione', singolare: 'kg', plurale: 'kg',
      base: { unita: 'g', fattore: 1000 },
    },
    {
      canonica: 'ml', alias: ['ml', 'milliliter', 'milliliters', 'millilitre', 'millilitres'],
      famiglia: 'precisione', singolare: 'ml', plurale: 'ml',
      base: { unita: 'ml', fattore: 1 },
    },
    {
      canonica: 'l', alias: ['l', 'liter', 'liters', 'litre', 'litres'],
      famiglia: 'precisione', singolare: 'l', plurale: 'l',
      base: { unita: 'ml', fattore: 1000 },
    },
    {
      canonica: 'oz', alias: ['oz', 'ounce', 'ounces'],
      famiglia: 'precisione', singolare: 'oz', plurale: 'oz',
      base: { unita: 'oz', fattore: 1 },
    },
    {
      canonica: 'lb', alias: ['lb', 'lbs', 'pound', 'pounds'],
      famiglia: 'precisione', singolare: 'lb', plurale: 'lb',
      base: { unita: 'oz', fattore: 16 },
    },
    {
      // Volume, non peso: non si converte verso oz né verso ml (sistemi diversi).
      canonica: 'fl oz', alias: ['fl oz', 'floz', 'fl. oz', 'fl.oz', 'fluid ounce', 'fluid ounces'],
      famiglia: 'precisione', singolare: 'fl oz', plurale: 'fl oz',
    },

    // --- misurino ---------------------------------------------------------
    {
      canonica: 'cups', alias: ['cups', 'cup'],
      famiglia: 'misurino', singolare: 'cup', plurale: 'cups',
    },
    {
      canonica: 'tbsp', alias: ['tbsp', 'tbsps', 'tablespoon', 'tablespoons'],
      famiglia: 'misurino', singolare: 'tbsp', plurale: 'tbsp',
    },
    {
      canonica: 'tsp', alias: ['tsp', 'tsps', 'teaspoon', 'teaspoons'],
      famiglia: 'misurino', singolare: 'tsp', plurale: 'tsp',
    },

    // --- discrete ---------------------------------------------------------
    {
      canonica: 'eggs', alias: ['eggs', 'egg'],
      famiglia: 'discreta', singolare: 'egg', plurale: 'eggs',
    },
    {
      canonica: 'sticks', alias: ['sticks', 'stick'],
      famiglia: 'discreta', singolare: 'stick', plurale: 'sticks',
    },
    {
      canonica: 'cloves', alias: ['cloves', 'clove'],
      famiglia: 'discreta', singolare: 'clove', plurale: 'cloves',
    },
    {
      canonica: 'slices', alias: ['slices', 'slice'],
      famiglia: 'discreta', singolare: 'slice', plurale: 'slices',
    },
    {
      canonica: 'packets', alias: ['packets', 'packet'],
      famiglia: 'discreta', singolare: 'packet', plurale: 'packets',
    },
    {
      canonica: 'cans', alias: ['cans', 'can'],
      famiglia: 'discreta', singolare: 'can', plurale: 'cans',
    },
    {
      canonica: 'jars', alias: ['jars', 'jar'],
      famiglia: 'discreta', singolare: 'jar', plurale: 'jars',
    },
    {
      canonica: 'sachets', alias: ['sachets', 'sachet'],
      famiglia: 'discreta', singolare: 'sachet', plurale: 'sachets',
    },
  ],
  numeriAParole: {
    a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6,
    seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12,
    half: 0.5,
  },
  quantoBasta: ['a pinch of', 'as needed', 'to taste', 'a pinch'],
  riempitivi: ['of', 'the'],
  stop: ['instructions', 'preparation', 'directions', 'method', 'steps'],
  intestazioneNeutra: ['ingredients'],
  separatoreDecimale: '.',
};
```

- [ ] **Step 6: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS, `ℹ pass 25` e `ℹ fail 0` (i 17 test già esistenti più gli 8 nuovi).

- [ ] **Step 7: Verifica i tipi**

Comando: `npm run typecheck`

Atteso: nessun output di errore, uscita 0.

- [ ] **Step 8: Commit**

```bash
git add src/domain/lingua/
git commit -m "Aggiungi i vocabolari italiano e inglese con unità, numeri a parole e lessico del parser"
```

---

---

### Task 2: Rifai `units.ts` sul vocabolario

Oggi `src/domain/units.ts` ha una tabella di alias italiani incorporata,
riconosce solo il sistema metrico e divide le unità in "discrete" e "metriche"
con due `Set`. Questo task lo rifà: nessuna lingua dentro, tutto letto dal
vocabolario del Task 1, e le tre famiglie della spec al posto dei due `Set`.

Cambiando `units.ts` si rompono i suoi tre consumatori (`scaling.ts`,
`format.ts`, `migrate.ts`), lo script `scripts/verifica-migrazione.ts` e i test
esistenti: il task li sistema tutti, perché a fine task `npm test` e
`npm run typecheck` devono tornare verdi.

**Stato in cui il task lascia il codice**, così nessuno lo rifà dopo:
`src/domain/migrate.ts` e `scripts/verifica-migrazione.ts` finiscono il task già
legati al vocabolario italiano, importato come `import { IT } from
'./lingua/it.ts'` (nello script: `'../src/domain/lingua/it.ts'`), e chiamano
`normalizzaUnita(l.unita, IT)`, `scala(r, f, IT)` e
`formattaQuantita(i.quantita, i.unita, IT)`.

**Files:**
- Modify: `src/domain/units.ts` (riscritto per intero)
- Modify: `src/domain/scaling.ts`
- Modify: `src/domain/format.ts`
- Modify: `src/domain/migrate.ts`
- Modify: `src/domain/domain.test.ts`
- Modify: `scripts/verifica-migrazione.ts`
- Test: `src/domain/unita.test.ts` (nuovo)

**Interfaces:**

- Consumes (dal Task 1, `src/domain/lingua/`):
  ```ts
  // da './lingua/index.ts'
  type Famiglia = 'precisione' | 'misurino' | 'discreta';
  interface VoceUnita { canonica: string; alias: string[]; famiglia: Famiglia;
                        singolare: string; plurale: string;
                        base?: { unita: string; fattore: number } }
  interface Vocabolario { lingua: 'it' | 'en'; unita: VoceUnita[]; /* … */ }
  // da './lingua/it.ts'
  const IT: Vocabolario;
  // da './lingua/en.ts'
  const EN: Vocabolario;
  ```
  Dal codice già esistente: `Ricetta`, `RicettaScalata` da `./types.ts`,
  `arrotondaPerCucina` da `./scaling.ts`.

- Produces (da `src/domain/units.ts`):
  ```ts
  export function trovaUnita(raw: string | null | undefined, voc: Vocabolario): VoceUnita | null;
  export function normalizzaUnita(raw: string | null | undefined, voc: Vocabolario): string | null;
  export function famigliaDi(unita: string | null, voc: Vocabolario): Famiglia | 'sconosciuta';
  export function resaLeggibile(quantita: number, unita: string | null, voc: Vocabolario):
    { quantita: number; unita: string | null };
  export function converti(quantita: number, da: string, a: string, voc: Vocabolario): number | null;
  export function arrotondaAMezzi(q: number): number;
  ```
  **Spariscono** `isUnitaDiscreta` e `isUnitaMetrica`: al loro posto si usa
  `famigliaDi(...) === 'discreta'` e `famigliaDi(...) === 'precisione'`.

  Cambiano anche due firme nei file vicini, entrambe già previste dal contratto:
  ```ts
  // src/domain/scaling.ts
  export function scala(ricetta: Ricetta, fattore: number, voc: Vocabolario): RicettaScalata;
  // src/domain/format.ts
  export function formattaQuantita(quantita: number | null, unita: string | null, voc: Vocabolario): string;
  ```
  `format.ts` qui riceve il vocabolario e lo usa **solo** per scegliere fra
  decimali e frazioni; il separatore decimale per lingua, l'accordo
  singolare/plurale e il "q.b." tradotto restano da fare a chi rifà `format.ts`.

  Note per chi userà `units.ts` più avanti:
  - `trovaUnita` fa **match esatto** sulla stringa ripulita (minuscole, spazi
    doppi collassati, punti finali tolti). Chi scrive il parser deve provare
    prima la coppia di parole e poi la singola, altrimenti `2 fl oz` diventa
    `fl` sconosciuto: la voce `fl oz` esiste, ma va cercata per intero.
  - `famigliaDi` risponde `'sconosciuta'` per tutto ciò che non è a vocabolario
    (comprese le unità inglesi cercate nel vocabolario italiano). Chi formatta
    deve trattare `'sconosciuta'` come `'misurino'`, cioè a frazioni: è la
    scelta che non fa mai comparire "0,75 manciate".
  - Il vocabolario italiano è più largo di quanto sembri: parole di cucina come
    `pizzico`, `mazzetto`, `ciuffo`, `costa` ci sono, e `normalizzaUnita`
    restituisce la loro canonica al plurale (`pizzichi`, `mazzetti`, `ciuffi`,
    `coste`). Chi cerca un esempio di unità che il vocabolario NON conosce usi
    **`manciata`**, che è davvero fuori.
  - `converti` è la conversione a richiesta decisa dall'addendum: la funzione
    esiste e ha i suoi test, il gesto nell'interfaccia no. Il tocco sulla
    quantità è già occupato dal riscalo inverso.

Nota sulla spec: la sezione 5 di
`docs/superpowers/specs/2026-08-18-dosatore-core-design.md` è **già allineata** a
questa decisione (la funzione `converti` sì, il gesto no) — è stata corretta a
mano prima di questo piano. Nessuno step di questo task tocca la spec.

---

- [ ] **Step 1: Scrivi il test che fallisce**

Crea `src/domain/unita.test.ts`:

```ts
/**
 * Test del riconoscimento unità, della famiglia e della resa leggibile.
 * `node --test`, niente framework.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { EN } from './lingua/en.ts';
import { IT } from './lingua/it.ts';
import { arrotondaAMezzi, converti, famigliaDi, normalizzaUnita, resaLeggibile, trovaUnita } from './units.ts';

test('trovaUnita riconosce alias, maiuscole, spazi e punti', () => {
  assert.equal(trovaUnita('gr', IT)?.canonica, 'g');
  assert.equal(trovaUnita(' Grammi ', IT)?.canonica, 'g');
  assert.equal(trovaUnita('GR.', IT)?.canonica, 'g');
  assert.equal(trovaUnita('lt', IT)?.canonica, 'l');
  assert.equal(trovaUnita('cucchiaio', IT)?.canonica, 'cucchiai');
  assert.equal(trovaUnita('pizzico', IT)?.canonica, 'pizzichi');
  assert.equal(trovaUnita('manciata', IT), null);
  assert.equal(trovaUnita('', IT), null);
  assert.equal(trovaUnita('   ', IT), null);
  assert.equal(trovaUnita(null, IT), null);
  assert.equal(trovaUnita(undefined, IT), null);
});

test("'fl oz' non viene letto come 'oz'", () => {
  // Due unità diverse: una è volume, l'altra è peso. L'alias più lungo vince.
  assert.equal(trovaUnita('fl oz', EN)?.canonica, 'fl oz');
  assert.equal(trovaUnita('Fl. Oz.', EN)?.canonica, 'fl oz');
  assert.equal(trovaUnita('fluid ounces', EN)?.canonica, 'fl oz');
  assert.equal(trovaUnita('oz', EN)?.canonica, 'oz');
  assert.equal(trovaUnita('ounces', EN)?.canonica, 'oz');
});

test('normalizzaUnita canonizza, oppure lascia intatto lo sconosciuto', () => {
  assert.equal(normalizzaUnita('gr', IT), 'g');
  assert.equal(normalizzaUnita('CHILI', IT), 'kg');
  assert.equal(normalizzaUnita('bicchiere', IT), 'bicchieri');
  assert.equal(normalizzaUnita('pizzico', IT), 'pizzichi');
  assert.equal(normalizzaUnita('Manciata', IT), 'Manciata');  // sconosciuta: intatta
  assert.equal(normalizzaUnita('  ', IT), null);
  assert.equal(normalizzaUnita(null, IT), null);
  assert.equal(normalizzaUnita('pounds', EN), 'lb');
  assert.equal(normalizzaUnita('tablespoon', EN), 'tbsp');
});

test('famigliaDi assegna le tre famiglie e ammette lo sconosciuto', () => {
  assert.equal(famigliaDi('g', IT), 'precisione');
  assert.equal(famigliaDi('l', IT), 'precisione');
  assert.equal(famigliaDi('cucchiaini', IT), 'misurino');
  assert.equal(famigliaDi('uova', IT), 'discreta');
  assert.equal(famigliaDi('cad', IT), 'discreta');
  assert.equal(famigliaDi('manciata', IT), 'sconosciuta');
  assert.equal(famigliaDi(null, IT), 'sconosciuta');
  assert.equal(famigliaDi('cups', EN), 'misurino');
  assert.equal(famigliaDi('fl oz', EN), 'precisione');
  assert.equal(famigliaDi('lb', EN), 'precisione');
  // Il vocabolario italiano non conosce le unità anglosassoni: restano fuori.
  assert.equal(famigliaDi('lb', IT), 'sconosciuta');
});

test('resaLeggibile sale e scende dentro la famiglia metrica', () => {
  assert.deepEqual(resaLeggibile(1160, 'g', IT), { quantita: 1.16, unita: 'kg' });
  assert.deepEqual(resaLeggibile(580, 'g', IT), { quantita: 580, unita: 'g' });
  assert.deepEqual(resaLeggibile(1, 'kg', IT), { quantita: 1, unita: 'kg' });
  assert.deepEqual(resaLeggibile(0.5, 'l', IT), { quantita: 500, unita: 'ml' });
  assert.deepEqual(resaLeggibile(30, 'cl', IT), { quantita: 300, unita: 'ml' });
  assert.deepEqual(resaLeggibile(2.5, 'dl', IT), { quantita: 250, unita: 'ml' });
  assert.deepEqual(resaLeggibile(2500, 'ml', IT), { quantita: 2.5, unita: 'l' });
});

test('resaLeggibile passa alle libbre solo quando il conto torna', () => {
  assert.deepEqual(resaLeggibile(18, 'oz', EN), { quantita: 18, unita: 'oz' });
  assert.deepEqual(resaLeggibile(32, 'oz', EN), { quantita: 2, unita: 'lb' });
  assert.deepEqual(resaLeggibile(24, 'oz', EN), { quantita: 24, unita: 'oz' });
  assert.deepEqual(resaLeggibile(1, 'lb', EN), { quantita: 1, unita: 'lb' });
  assert.deepEqual(resaLeggibile(0.5, 'lb', EN), { quantita: 8, unita: 'oz' });
});

test('resaLeggibile non tocca niente fuori dalla famiglia precisione', () => {
  assert.deepEqual(resaLeggibile(3, 'cucchiai', IT), { quantita: 3, unita: 'cucchiai' });
  assert.deepEqual(resaLeggibile(2000, 'uova', IT), { quantita: 2000, unita: 'uova' });
  assert.deepEqual(resaLeggibile(4, 'manciate', IT), { quantita: 4, unita: 'manciate' });
  assert.deepEqual(resaLeggibile(5, null, IT), { quantita: 5, unita: null });
  // Volume anglosassone: non ha unità base, non si converte né a oz né a ml.
  assert.deepEqual(resaLeggibile(64, 'fl oz', EN), { quantita: 64, unita: 'fl oz' });
  // Dentro la famiglia metrica si sale anche in EN; verso oz/lb non si passa mai.
  assert.deepEqual(resaLeggibile(1160, 'g', EN), { quantita: 1.16, unita: 'kg' });
  assert.deepEqual(resaLeggibile(16, 'oz', IT), { quantita: 16, unita: 'oz' });
});

test('converte solo dentro la stessa famiglia fisica', () => {
  // Peso con peso, volume con volume: il fattore è un numero, non una stima.
  assert.equal(converti(1, 'kg', 'g', IT), 1000);
  assert.equal(converti(500, 'g', 'kg', IT), 0.5);
  assert.equal(converti(1, 'l', 'ml', IT), 1000);
  assert.equal(converti(1500, 'ml', 'l', IT), 1.5);
  assert.equal(converti(16, 'oz', 'lb', EN), 1);
  assert.equal(converti(1, 'lb', 'oz', EN), 16);
  // Volume verso peso no: dipende dall'ingrediente.
  assert.equal(converti(100, 'g', 'ml', IT), null);
  // I misurini non hanno unità base: un cup di farina non è un peso.
  assert.equal(converti(1, 'cups', 'g', EN), null);
  // Fuori vocabolario non si converte niente.
  assert.equal(converti(1, 'sconosciuta', 'g', IT), null);
});

test('tutte le unità del ricettario reale sono riconosciute', () => {
  // Le 13 scritture che compaiono nel vecchio archivio (12 ricette, 77 ingredienti).
  const scritte = ['gr', 'cad', 'ml', 'g', 'bicchiere', 'bustina', 'lt',
    'pizzico', 'cucchiaino', 'kg', 'bicchierino', 'tazzina', 'cubetti'];
  for (const u of scritte) {
    assert.notEqual(trovaUnita(u, IT), null, `"${u}" non è nel vocabolario italiano`);
  }
});

test('arrotondaAMezzi arrotonda al mezzo e non fa mai sparire un ingrediente', () => {
  assert.equal(arrotondaAMezzi(0.6), 0.5);
  assert.equal(arrotondaAMezzi(0.75), 1);
  assert.equal(arrotondaAMezzi(1.4), 1.5);
  assert.equal(arrotondaAMezzi(0.01), 0.5);
  assert.equal(arrotondaAMezzi(2.24), 2);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL. `src/domain/unita.test.ts` non si carica affatto e Node stampa
`SyntaxError: The requested module './units.ts' does not provide an export named …`:
oggi `units.ts` non esporta né `trovaUnita`, né `converti`, né `famigliaDi`, e
Node si ferma sul primo dei tre che non trova.
Gli altri file di test restano verdi: `ℹ pass 25`, `ℹ fail 1` — i 25 lasciati
dal Task 1 (17 preesistenti più 8 di `lingua/lingua.test.ts`), più il file nuovo
che non parte.

- [ ] **Step 3: Riscrivi `src/domain/units.ts`**

Sostituisci l'intero contenuto del file con questo:

```ts
/**
 * Unità di misura: riconoscimento, famiglia, resa leggibile.
 *
 * Il modulo non conosce nessuna lingua: tutto quello che sa lo legge dal
 * Vocabolario che gli viene passato. La vecchia app accettava unità come testo
 * libero ("g", "gr", "grammi" erano tre unità diverse); qui gli alias tornano a
 * una forma canonica, ma senza rifiutare niente: un'unità sconosciuta resta
 * com'è, solo ripulita.
 */

import type { Famiglia, Vocabolario, VoceUnita } from './lingua/index.ts';

/**
 * Indice alias -> voce, costruito una volta per vocabolario.
 *
 * Gli alias entrano dal più lungo al più corto e il primo che arriva vince:
 * così 'fl oz' non può mai essere letto come 'oz'.
 */
const INDICI = new WeakMap<Vocabolario, Map<string, VoceUnita>>();

function indice(voc: Vocabolario): Map<string, VoceUnita> {
  const pronto = INDICI.get(voc);
  if (pronto) return pronto;

  const coppie: [string, VoceUnita][] = [];
  for (const voce of voc.unita) {
    for (const alias of voce.alias) coppie.push([alias, voce]);
  }
  coppie.sort((a, b) => b[0].length - a[0].length);

  const mappa = new Map<string, VoceUnita>();
  for (const [alias, voce] of coppie) if (!mappa.has(alias)) mappa.set(alias, voce);
  INDICI.set(voc, mappa);
  return mappa;
}

/** Toglie spazi doppi e punti finali: "Fl. Oz." -> "Fl. Oz", " gr " -> "gr". */
function ripulisci(raw: string | null | undefined): string {
  if (raw == null) return '';
  return raw.replace(/\s+/g, ' ').trim().replace(/\.+$/, '').trim();
}

/** Cerca l'unità nel vocabolario. Alias più lungo per primo. */
export function trovaUnita(raw: string | null | undefined, voc: Vocabolario): VoceUnita | null {
  const pulita = ripulisci(raw).toLowerCase();
  if (pulita === '') return null;
  return indice(voc).get(pulita) ?? null;
}

/** Forma canonica, oppure la stringa ripulita se sconosciuta, oppure null se vuota. */
export function normalizzaUnita(raw: string | null | undefined, voc: Vocabolario): string | null {
  const pulita = ripulisci(raw);
  if (pulita === '') return null;
  return trovaUnita(pulita, voc)?.canonica ?? pulita;
}

/** 'sconosciuta' per le unità fuori vocabolario: si trattano come 'misurino'. */
export function famigliaDi(unita: string | null, voc: Vocabolario): Famiglia | 'sconosciuta' {
  return trovaUnita(unita, voc)?.famiglia ?? 'sconosciuta';
}

/** L'unità più grande della famiglia fisica che ha `base` come unità di base. */
function unitaGrande(voc: Vocabolario, base: string): VoceUnita | null {
  let grande: VoceUnita | null = null;
  for (const voce of voc.unita) {
    if (voce.base?.unita !== base) continue;
    if (voce.base.fattore <= 1) continue;
    if (grande === null || voce.base.fattore > grande.base!.fattore) grande = voce;
  }
  return grande;
}

/**
 * 1160 g -> 1,16 kg. Solo dentro la stessa famiglia fisica.
 *
 * Si sale all'unità grande quando la quantità la raggiunge, ma solo se il
 * numero che ne esce si legge: le unità decimali (kg, l) vanno bene comunque,
 * le libbre no. 1,125 lb nessuno lo scrive, quindi 18 oz restano 18 oz mentre
 * 32 oz diventano 2 lb.
 */
export function resaLeggibile(
  quantita: number,
  unita: string | null,
  voc: Vocabolario,
): { quantita: number; unita: string | null } {
  const voce = trovaUnita(unita, voc);
  if (voce == null || voce.famiglia !== 'precisione' || voce.base == null) {
    return { quantita, unita };
  }

  const inBase = quantita * voce.base.fattore;
  const grande = unitaGrande(voc, voce.base.unita);
  if (grande != null) {
    const fattore = grande.base!.fattore;
    const decimale = fattore % 10 === 0;
    const inGrande = inBase / fattore;
    if (inBase >= fattore && (decimale || Number.isInteger(inGrande))) {
      return { quantita: inGrande, unita: grande.canonica };
    }
  }
  return { quantita: inBase, unita: voce.base.unita };
}

/**
 * Arrotonda al mezzo più vicino, senza mai scendere a zero: se la ricetta
 * prevede un ingrediente, riscalando in basso ne resta comunque mezzo.
 */
export function arrotondaAMezzi(q: number): number {
  const arrotondato = Math.round(q * 2) / 2;
  return arrotondato < 0.5 ? 0.5 : arrotondato;
}

/**
 * Converte una quantità fra due unità della stessa famiglia fisica:
 * 1 kg -> 1000 g, 0,5 l -> 500 ml, 16 oz -> 1 lb. null quando non si può.
 *
 * Si converte solo se tutte e due le unità hanno un campo `base` e la stessa
 * unità base. È l'unico caso in cui il fattore è un numero e non una stima.
 *
 * Da volume a peso (100 g -> ml, 1 cup -> g) non si passa mai, ed è voluto:
 * quel fattore non esiste in astratto, dipende dall'ingrediente. Un cucchiaio
 * di farina e uno di miele pesano diverso, e sbagliare di un terzo la farina di
 * un pane si vede. Serve una tabella di densità per ingrediente, che è un pezzo
 * a sé: finché non c'è, la funzione dice null invece di inventare un numero.
 * Per lo stesso motivo le unità da misurino (cucchiai, cups) non hanno `base`.
 */
export function converti(
  quantita: number,
  da: string,
  a: string,
  voc: Vocabolario,
): number | null {
  const baseDa = trovaUnita(da, voc)?.base;
  const baseA = trovaUnita(a, voc)?.base;
  if (baseDa == null || baseA == null) return null;
  if (baseDa.unita !== baseA.unita) return null;
  return (quantita * baseDa.fattore) / baseA.fattore;
}
```

- [ ] **Step 4: Esegui i test e guarda dove si rompe il resto**

Comando: `npm test`

Atteso: `src/domain/unita.test.ts` e `src/domain/lingua/lingua.test.ts` PASS,
`src/domain/domain.test.ts` FAIL con
`SyntaxError: The requested module './units.ts' does not provide an export named 'isUnitaDiscreta'`:
quel file non si carica affatto. Conteggio: `ℹ pass 18`, `ℹ fail 1` — i 10 nuovi
di `unita.test.ts` più gli 8 del Task 1, più `domain.test.ts` che non parte.
È il fallimento che ti aspetti: i consumatori di `units.ts` non sono ancora
aggiornati, e li sistemi negli step 5-8.

- [ ] **Step 5: Aggiorna `src/domain/scaling.ts`**

Tre modifiche puntuali, il resto del file non si tocca.

1. Sostituisci il blocco di import in cima al file:
```ts
import type { Ricetta, RicettaScalata } from './types.ts';
import { tuttiGliIngredienti } from './types.ts';
import { arrotondaAMezzi, isUnitaDiscreta, resaLeggibile } from './units.ts';
```
con:
```ts
import type { Vocabolario } from './lingua/index.ts';
import type { Ricetta, RicettaScalata } from './types.ts';
import { tuttiGliIngredienti } from './types.ts';
import { arrotondaAMezzi, famigliaDi, resaLeggibile } from './units.ts';
```

2. Sostituisci l'intestazione della funzione `scala`:
```ts
/** Applica un fattore, senza modificare la ricetta di partenza. */
export function scala(ricetta: Ricetta, fattore: number): RicettaScalata {
```
con:
```ts
/**
 * Applica un fattore, senza modificare la ricetta di partenza.
 * Il vocabolario serve per la famiglia dell'unità e per la resa leggibile.
 */
export function scala(ricetta: Ricetta, fattore: number, voc: Vocabolario): RicettaScalata {
```

3. Dentro `scala`, sostituisci queste tre righe:
```ts
        const reso = resaLeggibile(esatta, ing.unita);
        // Un'unità discreta (uova, bustine, cubetti) non ammette 0,75:
        // si arrotonda al mezzo. Le altre per ordine di grandezza.
        const quantita = isUnitaDiscreta(reso.unita)
```
con:
```ts
        const reso = resaLeggibile(esatta, ing.unita, voc);
        // Un'unità discreta (uova, bustine, cubetti) non ammette 0,75:
        // si arrotonda al mezzo. Le altre per ordine di grandezza.
        const quantita = famigliaDi(reso.unita, voc) === 'discreta'
```

- [ ] **Step 6: Aggiorna `src/domain/format.ts`**

Sostituisci l'intero contenuto del file con questo. Cambia solo la scelta fra
decimali e frazioni, che ora passa dalla famiglia; la resa bilingue vera
(separatore decimale, singolare/plurale, "q.b." tradotto) arriva quando si
riscrive `format.ts` sul vocabolario.

```ts
/**
 * Resa testuale delle quantità.
 *
 * Le unità di precisione (bilancia, misuratore graduato) si leggono a decimali;
 * misurini, unità discrete e unità sconosciute a frazioni: "1½ cucchiai" è
 * giusto, "1½ g" sarebbe assurdo.
 */

import type { Vocabolario } from './lingua/index.ts';
import { famigliaDi } from './units.ts';

const FRAZIONI: [number, string][] = [
  [0.25, '¼'], [0.333, '⅓'], [0.5, '½'], [0.667, '⅔'], [0.75, '¾'],
];
const TOLLERANZA = 0.02;

/** Numero in stile italiano: separatore decimale virgola, zeri finali via. */
export function numeroItaliano(q: number): string {
  return String(Math.round(q * 100) / 100).replace('.', ',');
}

export function formattaQuantita(
  quantita: number | null,
  unita: string | null,
  voc: Vocabolario,
): string {
  if (quantita === null) return 'q.b.';

  const numero = famigliaDi(unita, voc) === 'precisione'
    ? numeroItaliano(quantita)
    : conFrazione(quantita);

  return unita ? `${numero} ${unita}` : numero;
}

function conFrazione(q: number): string {
  const intero = Math.floor(q);
  const resto = q - intero;

  for (const [valore, simbolo] of FRAZIONI) {
    if (Math.abs(resto - valore) < TOLLERANZA) {
      return intero === 0 ? simbolo : `${intero}${simbolo}`;
    }
  }
  return numeroItaliano(q);
}
```

- [ ] **Step 7: Aggiorna `src/domain/migrate.ts`**

Due modifiche puntuali. Dopo questo step `migrate.ts` è definitivamente legato a
`IT` importato da `./lingua/it.ts`: non c'è nessun altro passaggio di
vocabolario da fare in questo file.

1. Sostituisci il blocco di import:
```ts
import type { Gruppo, Ingrediente, Ricetta } from './types.ts';
import { newId } from './id.ts';
import { normalizzaUnita } from './units.ts';
```
con:
```ts
import type { Gruppo, Ingrediente, Ricetta } from './types.ts';
import { newId } from './id.ts';
import { IT } from './lingua/it.ts';
import { normalizzaUnita } from './units.ts';
```

2. In fondo a `convertiIngrediente`, sostituisci:
```ts
  return { id: newId(), nome, quantita: num, unita: normalizzaUnita(l.unita) };
```
con:
```ts
  // Il vecchio archivio è italiano: le sue unità si normalizzano su IT.
  return { id: newId(), nome, quantita: num, unita: normalizzaUnita(l.unita, IT) };
```

- [ ] **Step 8: Adegua `src/domain/domain.test.ts` e `scripts/verifica-migrazione.ts`**

Dei 17 test esistenti ne restano 15, con le stesse asserzioni: cambiano solo le
chiamate, che ora vogliono il vocabolario. I due test di formattazione si
cancellano invece di aggiornarli, per il motivo scritto al punto 6.

In `src/domain/domain.test.ts`:

1. Aggiungi l'import di `IT` sotto quello di `units.ts`, in cima al file:
```ts
import { normalizzaUnita, resaLeggibile } from './units.ts';
import { IT } from './lingua/it.ts';
import type { Ricetta } from './types.ts';
```

2. Aggiungi `, IT` a tutte e otto le chiamate a `scala`:
```ts
  const s = scala(farinata, f!, IT);          // test 'scala per porzioni'
  const s = scala(farinata, 3, IT);           // test 'q.b. non scala mai'
  const s = scala(farinata, f!, IT);          // test 'calcolo inverso'
  const s = scala(senzaPorzioni, f!, IT);     // test 'senza porzioni dichiarate'
  const tripla = scala(farinata, 3, IT);      // test 'riscali a catena'
  const tornata = scala(farinata, 1, IT);     // test 'riscali a catena'
  const s = scala(biscotti, 1.5, IT);         // test 'x1,5 non produce più 0,75 uova'
  const s = scala(biscotti, 0.2, IT);         // test 'riscalo verso il basso'
```

3. Sostituisci per intero il test `'normalizzazione unità e resa leggibile'`.
   Nota il cambio di unità sconosciuta: `mazzetto` adesso è a vocabolario
   (diventa `mazzetti`), quindi come esempio di unità che il vocabolario non
   conosce serve una parola davvero fuori, cioè `manciata`.
```ts
test('normalizzazione unità e resa leggibile', () => {
  assert.equal(normalizzaUnita('gr', IT), 'g');
  assert.equal(normalizzaUnita(' Grammi ', IT), 'g');
  assert.equal(normalizzaUnita('cucchiaio', IT), 'cucchiai');
  assert.equal(normalizzaUnita('manciata', IT), 'manciata'); // sconosciuta: intatta
  assert.equal(normalizzaUnita('', IT), null);
  assert.deepEqual(resaLeggibile(1500, 'g', IT), { quantita: 1.5, unita: 'kg' });
  assert.deepEqual(resaLeggibile(0.5, 'l', IT), { quantita: 500, unita: 'ml' });
  assert.deepEqual(resaLeggibile(3, 'cucchiai', IT), { quantita: 3, unita: 'cucchiai' });
});
```

4. Nella seconda metà del file, sostituisci l'import:
```ts
import { arrotondaAMezzi, isUnitaDiscreta } from './units.ts';
```
con:
```ts
import { arrotondaAMezzi, famigliaDi } from './units.ts';
```

5. Sostituisci per intero il test `'le unità discrete si arrotondano al mezzo, mai a zero'`:
```ts
test('le unità discrete si arrotondano al mezzo, mai a zero', () => {
  assert.equal(arrotondaAMezzi(0.6), 0.5);
  assert.equal(arrotondaAMezzi(0.75), 1);    // equidistante: per eccesso
  assert.equal(arrotondaAMezzi(1.4), 1.5);
  assert.equal(arrotondaAMezzi(0.1), 0.5);   // mai sparire del tutto
  assert.equal(arrotondaAMezzi(2.24), 2);
  assert.equal(famigliaDi('cad', IT), 'discreta');
  assert.equal(famigliaDi('bustine', IT), 'discreta');
  assert.equal(famigliaDi('g', IT), 'precisione');
  assert.equal(famigliaDi('cucchiaini', IT), 'misurino');
});
```

6. Cancella gli ultimi due test del file **e la riga di import che li serviva**
   (è quella subito sopra l'import modificato al punto 4). Sono le sole prove di
   `formattaQuantita` e `numeroItaliano` in questo file, provano la resa
   italiana con la vecchia firma a due argomenti, e vengono riscritte bilingui
   nel file di test dedicato quando si rifà `format.ts` sul vocabolario:
   aggiornarle qui vorrebbe dire buttarle poco dopo. Togli per intero questa
   riga:
```ts
import { formattaQuantita, numeroItaliano } from './format.ts';
```
   e questi due blocchi, che nel file sono ancora nella forma a due argomenti:
```ts
test('le quantità non metriche si leggono a frazione', () => {
  assert.equal(formattaQuantita(1.5, 'cad'), '1½ cad');
  assert.equal(formattaQuantita(0.5, 'cad'), '½ cad');
  assert.equal(formattaQuantita(0.75, 'cucchiaini'), '¾ cucchiaini');
  assert.equal(formattaQuantita(2, 'bustine'), '2 bustine');
  assert.equal(formattaQuantita(null, null), 'q.b.');
});

test('le quantità metriche restano decimali, con la virgola italiana', () => {
  assert.equal(formattaQuantita(1.5, 'g'), '1,5 g');   // non "1½ g"
  assert.equal(formattaQuantita(806, 'g'), '806 g');
  assert.equal(formattaQuantita(1.16, 'kg'), '1,16 kg');
  assert.equal(numeroItaliano(11.1), '11,1');
});
```
   Il cambio fatto allo Step 6 resta comunque sotto controllo: lo prova
   `npm run migra` allo Step 10, che sui dati veri stampa `Acqua 1,08 l`
   (ramo decimali) e `Latte 1½ bicchieri` (ramo frazioni).

In `scripts/verifica-migrazione.ts` (che dopo questi due punti è definitivamente
legato a `IT`, senza altri passaggi di vocabolario da fare):

7. Aggiungi l'import di `IT` sotto quello di `format.ts`:
```ts
import { formattaQuantita } from '../src/domain/format.ts';
import { IT } from '../src/domain/lingua/it.ts';
```

8. Nel ciclo finale, sostituisci queste due righe:
```ts
  const s = scala(r, f);
  const riga = s.gruppi.flatMap((g) => g.ingredienti)
    .map((i) => `${i.nome} ${formattaQuantita(i.quantita, i.unita)}`).join(', ');
```
con:
```ts
  const s = scala(r, f, IT);
  const riga = s.gruppi.flatMap((g) => g.ingredienti)
    .map((i) => `${i.nome} ${formattaQuantita(i.quantita, i.unita, IT)}`).join(', ');
```

- [ ] **Step 9: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS, `ℹ pass 33` e `ℹ fail 0`. I 33 vengono da: 15 in
`domain.test.ts` (i 17 di partenza meno i 2 cancellati al punto 6), più gli 8 di
`lingua/lingua.test.ts` lasciati dal Task 1, più i 10 nuovi di `unita.test.ts`
scritti in questo task.

- [ ] **Step 10: Verifica i tipi e la prova di realtà sui dati veri**

Comando: `npm run typecheck`

Atteso: nessun errore, uscita 0.

Comando: `npm run migra`

Atteso: la migrazione del ricettario vero non perde niente e le unità sono
tutte canoniche. Le prime righe devono dire esattamente:
```
ricette legacy: 12  ->  migrate: 12
ingredienti legacy: 77  ->  migrati: 77
ricette senza porzioni: 2 Tozzetti, Besciamella
id ricetta unici: sì
titoli duplicati nel vecchio archivio: 0
unità distinte dopo normalizzazione: bicchieri | bicchierini | bustine | cad | cubetti | cucchiaini | g | kg | l | ml | pizzichi | tazzine
```
Nessuna unità in forma singolare o abbreviata sporca in quell'elenco: se
compaiono `gr`, `lt`, `bicchiere`, `tazzina` o `pizzico` è la normalizzazione
che non sta girando. Più sotto, nella prova di riscalo, `Polenta` deve stampare
`Acqua 1,08 l` (1080 ml resi in litri) e `Ciambella Bertolini`
`Latte 1½ bicchieri`.

- [ ] **Step 11: Commit**

```bash
git add src/domain/units.ts src/domain/unita.test.ts src/domain/scaling.ts \
        src/domain/format.ts src/domain/migrate.ts src/domain/domain.test.ts \
        scripts/verifica-migrazione.ts
git commit -m "Rifai le unità di misura sul vocabolario, con le tre famiglie e la resa leggibile anglosassone"
```

---

### Task 3: Formattazione bilingue delle quantità

Il Task 2 ha già passato il vocabolario a `src/domain/format.ts`, ma lo usa per
una cosa sola: scegliere fra decimali e frazioni guardando la famiglia
dell'unità. Tutto il resto della resa è ancora italiano scritto a mano — il
separatore decimale è una virgola fissa, `'q.b.'` è una costante in chiaro e
l'unità non si accorda mai al numero, quindi esce "1 cubetti".

Questo task chiude le tre cose. Dopo, la stessa quantità si legge "1,5 g" in
italiano e "1.5 oz" in inglese, i misurini restano a frazione ("1½ cups", non
"1.5 cups"), le unità si accordano ("1 cubetto" ma "1½ cubetti") e "q.b." esce
nella lingua giusta.

**Files:**
- Modify: `src/domain/format.ts`
- Test: `src/domain/format.test.ts`

**Interfaces:**

- Consumes, da `src/domain/lingua/index.ts` (già scritto in un task precedente):

```ts
export type Lingua = 'it' | 'en';
export type Famiglia = 'precisione' | 'misurino' | 'discreta';

export interface VoceUnita {
  /** Forma canonica salvata nel database, es. 'g', 'cucchiai', 'cups'. */
  canonica: string;
  /** Tutte le scritture che portano a questa unità, minuscole. Include la canonica. */
  alias: string[];
  famiglia: Famiglia;
  /** Resa a schermo con quantità 1, es. 'cubetto'. */
  singolare: string;
  /** Resa a schermo con quantità diversa da 1, es. 'cubetti'. */
  plurale: string;
  base?: { unita: string; fattore: number };
}

export interface Vocabolario {
  lingua: Lingua;
  unita: VoceUnita[];
  numeriAParole: Record<string, number>;
  quantoBasta: string[];
  riempitivi: string[];
  stop: string[];
  intestazioneNeutra: string[];
  separatoreDecimale: ',' | '.';
}
```

- Consumes, da `src/domain/lingua/it.ts` e `src/domain/lingua/en.ts`:
  `export const IT: Vocabolario` e `export const EN: Vocabolario`.
  Contengono fra l'altro: `g` e `kg` e `ml` e `l` (famiglia `precisione`, IT ed
  EN), `oz` e `lb` (famiglia `precisione`, solo EN), `cucchiai` e `cucchiaini` e
  `tazze` (famiglia `misurino`, IT), `cups` e `tbsp` e `tsp` (famiglia
  `misurino`, EN), `cad` e `uova` e `bustine` e `cubetti` (famiglia `discreta`,
  IT), `eggs` e `sticks` (famiglia `discreta`, EN).
  `IT.separatoreDecimale === ','`, `EN.separatoreDecimale === '.'`.
  Per le sigle invarianti (`g`, `kg`, `ml`, `l`, `oz`, `lb`, `cad`, `tbsp`,
  `tsp`) `singolare` e `plurale` sono la stessa stringa; per le parole intere
  no: `cubetti` ha `singolare: 'cubetto'`, `uova` ha `singolare: 'uovo'`,
  `cups` ha `singolare: 'cup'`, `cucchiai` ha `singolare: 'cucchiaio'`,
  `eggs` ha `singolare: 'egg'`.
  Nessuno dei due vocabolari contiene `manciata` o `manciate`: sono le parole
  usate qui sotto come esempio di unità fuori vocabolario.
- Consumes, da `src/domain/units.ts` (già rifatto in un task precedente):

```ts
/** Cerca l'unità nel vocabolario, alias più lungo per primo. null se fuori vocabolario. */
export function trovaUnita(raw: string | null | undefined, voc: Vocabolario): VoceUnita | null;
/** 'sconosciuta' per le unità fuori vocabolario, null compreso. */
export function famigliaDi(unita: string | null, voc: Vocabolario): Famiglia | 'sconosciuta';
```

- Produces, per i task successivi (interfaccia utente e resa delle righe):

```ts
/** Separatore decimale secondo la lingua, zeri finali tolti, massimo 2 decimali. */
export function numero(q: number, voc: Vocabolario): string;
/** Quantità + unità accordata. quantita === null è "q.b." nella lingua del vocabolario. */
export function formattaQuantita(quantita: number | null, unita: string | null, voc: Vocabolario): string;
```

  `formattaQuantita` tiene la firma a tre parametri che il Task 2 le ha già
  dato: cambia solo quello che fa. Sparisce invece `numeroItaliano`, sostituita
  da `numero(q, voc)`: nessun altro task deve più nominarla.

Nota: il `Vocabolario` non ha un campo per la resa a schermo di "q.b."
(`quantoBasta` è l'elenco delle forme che il parser deve *riconoscere*:
`qb`, `q.b.`, `quanto basta`, `a piacere`). La forma da *scrivere* è una sola
per lingua e sta in una costante dentro `format.ts`, tipizzata
`Record<Lingua, string>` così che aggiungere una lingua diventi un errore di
compilazione finché non le si dà la sua resa.

---

- [ ] **Step 1: Scrivi il test che fallisce**

Crea `src/domain/format.test.ts`:

```ts
/**
 * Test della resa testuale delle quantità, nelle due lingue.
 * Girano con `npm test`, cioè `node --test`, senza framework.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { formattaQuantita, numero } from './format.ts';
import { IT } from './lingua/it.ts';
import { EN } from './lingua/en.ts';

test('il separatore decimale viene dalla lingua', () => {
  assert.equal(numero(11.1, IT), '11,1');
  assert.equal(numero(11.1, EN), '11.1');
  assert.equal(numero(1.16, IT), '1,16');
  assert.equal(numero(2, IT), '2');        // niente zeri finali
  assert.equal(numero(0.5, EN), '0.5');
});

test('la famiglia di precisione si legge a decimali, in tutte e due le lingue', () => {
  assert.equal(formattaQuantita(1.5, 'g', IT), '1,5 g');
  assert.equal(formattaQuantita(806, 'g', IT), '806 g');
  assert.equal(formattaQuantita(1.16, 'kg', IT), '1,16 kg');
  assert.equal(formattaQuantita(0.5, 'g', IT), '0,5 g');
  // Il difetto che questo task chiude: in inglese 1.5 oz non è "1½ oz".
  assert.equal(formattaQuantita(1.5, 'oz', EN), '1.5 oz');
  assert.equal(formattaQuantita(250, 'ml', EN), '250 ml');
});

test('misurini e unità discrete si leggono a frazione', () => {
  assert.equal(formattaQuantita(1.5, 'cups', EN), '1½ cups');   // non "1.5 cups"
  assert.equal(formattaQuantita(0.25, 'tsp', EN), '¼ tsp');
  assert.equal(formattaQuantita(1 / 3, 'cucchiai', IT), '⅓ cucchiai');
  assert.equal(formattaQuantita(2 / 3, 'tazze', IT), '⅔ tazze');
  assert.equal(formattaQuantita(0.75, 'cucchiaini', IT), '¾ cucchiaini');
  assert.equal(formattaQuantita(2, 'bustine', IT), '2 bustine');
});

test('una quantità senza frazione riconoscibile torna ai decimali', () => {
  assert.equal(formattaQuantita(1.1, 'cucchiai', IT), '1,1 cucchiai');
  assert.equal(formattaQuantita(2.2, 'cups', EN), '2.2 cups');
});

test("un'unità fuori vocabolario si scrive com'è, e va a frazione", () => {
  // 'manciate' non è in nessuna delle due liste: è il caso 'sconosciuta'.
  assert.equal(formattaQuantita(1.5, 'manciate', IT), '1½ manciate');
  assert.equal(formattaQuantita(0.5, 'manciate', IT), '½ manciate');
});

test('q.b. si scrive nella lingua del vocabolario', () => {
  assert.equal(formattaQuantita(null, null, IT), 'q.b.');
  assert.equal(formattaQuantita(null, null, EN), 'to taste');
  // Un q.b. non ha unità: se ne arriva una, si ignora.
  assert.equal(formattaQuantita(null, 'g', EN), 'to taste');
});

test('senza unità resta il solo numero', () => {
  assert.equal(formattaQuantita(2, null, IT), '2');
  assert.equal(formattaQuantita(0.5, null, EN), '½');
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `SyntaxError: The requested module './format.ts' does not
provide an export named 'numero'` — `format.ts` oggi esporta `numeroItaliano`,
non `numero`, e il file di test non si carica nemmeno. Gli altri file di test
restano verdi: `ℹ pass 33`, `ℹ fail 1` — i 33 lasciati dal Task 2 (15 in
`domain.test.ts`, 8 in `lingua/lingua.test.ts`, 10 in `unita.test.ts`), più il
file nuovo che non parte.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

Sostituisci per intero `src/domain/format.ts` con:

```ts
/**
 * Resa testuale delle quantità, nella lingua del vocabolario.
 *
 * Due rese diverse perché sono due mestieri diversi:
 * - la famiglia 'precisione' (g, kg, ml, l, oz, lb) si legge su una bilancia o
 *   su un misuratore graduato, quindi decimali: "806 g", "1.5 oz";
 * - tutto il resto (misurini, unità discrete, unità fuori vocabolario) si legge
 *   a frazione, perché è così che sono fatti i misurini e i mezzi uova:
 *   "1½ cups", non "1.5 cups".
 *
 * Il separatore decimale viene dal vocabolario: virgola in italiano, punto in
 * inglese. Non si converte mai un'unità in un'altra: qui si scrive soltanto.
 */

import type { Lingua, Vocabolario } from './lingua/index.ts';
import { famigliaDi } from './units.ts';

/**
 * Resa a schermo di "quanto basta". Il vocabolario elenca le forme che il
 * parser deve RICONOSCERE ('qb', 'q.b.', 'quanto basta', 'a piacere'); quella
 * da SCRIVERE è una sola per lingua e sta qui. Il tipo Record<Lingua, string>
 * fa fallire la compilazione se un giorno si aggiunge una lingua e ci si
 * dimentica della sua resa.
 */
const QUANTO_BASTA: Record<Lingua, string> = { it: 'q.b.', en: 'to taste' };

const FRAZIONI: [number, string][] = [
  [0.25, '¼'], [1 / 3, '⅓'], [0.5, '½'], [2 / 3, '⅔'], [0.75, '¾'],
];
/** Quanto ci si può allontanare dalla frazione e continuare a scriverla. */
const TOLLERANZA = 0.02;

/** Separatore decimale secondo la lingua, zeri finali tolti, massimo 2 decimali. */
export function numero(q: number, voc: Vocabolario): string {
  const arrotondato = String(Math.round(q * 100) / 100);
  return voc.separatoreDecimale === ','
    ? arrotondato.replace('.', ',')
    : arrotondato;
}

export function formattaQuantita(
  quantita: number | null,
  unita: string | null,
  voc: Vocabolario,
): string {
  if (quantita === null) return QUANTO_BASTA[voc.lingua];

  const testo = famigliaDi(unita, voc) === 'precisione'
    ? numero(quantita, voc)
    : conFrazione(quantita, voc);

  return unita ? `${testo} ${unita}` : testo;
}

/** 1.5 -> "1½". Se il resto non somiglia a nessuna frazione da cucina, decimali. */
function conFrazione(q: number, voc: Vocabolario): string {
  const intero = Math.floor(q);
  const resto = q - intero;

  for (const [valore, simbolo] of FRAZIONI) {
    if (Math.abs(resto - valore) < TOLLERANZA) {
      return intero === 0 ? simbolo : `${intero}${simbolo}`;
    }
  }
  return numero(q, voc);
}
```

- [ ] **Step 4: Verifica che della formattazione non resti traccia in `domain.test.ts`**

Qui non c'è niente da cambiare: l'import di `./format.ts` e i due test di
formattazione che stavano in `src/domain/domain.test.ts` li ha già tolti il
Task 2. Resta solo da controllarlo:

```bash
grep -n 'formattaQuantita\|numeroItaliano' src/domain/domain.test.ts
```

Atteso: nessuna riga stampata, `grep` esce con codice 1.

- [ ] **Step 5: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ pass 40` e `ℹ fail 0`: i 33 lasciati dal Task 2 più i 7 nuovi
di `format.test.ts`. `domain.test.ts` non cambia numero e resta a 15, perché i
due test di formattazione li aveva già tolti il Task 2.

- [ ] **Step 6: Commit**

```bash
git add src/domain/format.ts src/domain/format.test.ts
git commit -m "Rendi bilingue la resa delle quantità: separatore decimale e famiglie dal vocabolario"
```

- [ ] **Step 7: Scrivi il test dell'accordo singolare/plurale**

Aggiungi in fondo a `src/domain/format.test.ts`:

```ts
test("l'unità si accorda al numero: 1 singolare, tutto il resto plurale", () => {
  // Il difetto storico: la vecchia app scriveva "1 cubetti".
  assert.equal(formattaQuantita(1, 'cubetti', IT), '1 cubetto');
  assert.equal(formattaQuantita(1.5, 'cubetti', IT), '1½ cubetti');
  assert.equal(formattaQuantita(0.6, 'cubetti', IT), '0,6 cubetti');
  assert.equal(formattaQuantita(0.5, 'cubetti', IT), '½ cubetti');
  assert.equal(formattaQuantita(1, 'uova', IT), '1 uovo');
  assert.equal(formattaQuantita(4, 'uova', IT), '4 uova');
  assert.equal(formattaQuantita(1, 'cucchiai', IT), '1 cucchiaio');
  assert.equal(formattaQuantita(1, 'cups', EN), '1 cup');
  assert.equal(formattaQuantita(1, 'eggs', EN), '1 egg');
  assert.equal(formattaQuantita(2, 'eggs', EN), '2 eggs');
});

test('le sigle e le unità fuori vocabolario non si accordano', () => {
  assert.equal(formattaQuantita(1, 'g', IT), '1 g');
  assert.equal(formattaQuantita(1, 'kg', IT), '1 kg');
  assert.equal(formattaQuantita(1, 'cad', IT), '1 cad');
  assert.equal(formattaQuantita(1, 'oz', EN), '1 oz');
  // Fuori vocabolario non si indovina un singolare: si riscrive quel che c'era.
  assert.equal(formattaQuantita(1, 'manciate', IT), '1 manciate');
});
```

- [ ] **Step 8: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `AssertionError [ERR_ASSERTION]: Expected values to be
strictly equal: '1 cubetti' !== '1 cubetto'` nel test
`l'unità si accorda al numero`. Conteggio: `ℹ pass 41`, `ℹ fail 1` — i 40 dello
Step 5 più i 2 nuovi, di cui uno rosso. Il secondo test nuovo, quello sulle
sigle, passa già, perché sigle e unità sconosciute si riscrivono tali e quali
anche senza accordo.

- [ ] **Step 9: Implementa l'accordo**

Sostituisci per intero `src/domain/format.ts` con:

```ts
/**
 * Resa testuale delle quantità, nella lingua del vocabolario.
 *
 * Due rese diverse perché sono due mestieri diversi:
 * - la famiglia 'precisione' (g, kg, ml, l, oz, lb) si legge su una bilancia o
 *   su un misuratore graduato, quindi decimali: "806 g", "1.5 oz";
 * - tutto il resto (misurini, unità discrete, unità fuori vocabolario) si legge
 *   a frazione, perché è così che sono fatti i misurini e i mezzi uova:
 *   "1½ cups", non "1.5 cups".
 *
 * Il separatore decimale e l'accordo singolare/plurale vengono dal vocabolario:
 * "1 cubetto" ma "1½ cubetti", "1,5 g" ma "1.5 oz".
 * Non si converte mai un'unità in un'altra: qui si scrive soltanto.
 */

import type { Lingua, Vocabolario } from './lingua/index.ts';
import { famigliaDi, trovaUnita } from './units.ts';

/**
 * Resa a schermo di "quanto basta". Il vocabolario elenca le forme che il
 * parser deve RICONOSCERE ('qb', 'q.b.', 'quanto basta', 'a piacere'); quella
 * da SCRIVERE è una sola per lingua e sta qui. Il tipo Record<Lingua, string>
 * fa fallire la compilazione se un giorno si aggiunge una lingua e ci si
 * dimentica della sua resa.
 */
const QUANTO_BASTA: Record<Lingua, string> = { it: 'q.b.', en: 'to taste' };

const FRAZIONI: [number, string][] = [
  [0.25, '¼'], [1 / 3, '⅓'], [0.5, '½'], [2 / 3, '⅔'], [0.75, '¾'],
];
/** Quanto ci si può allontanare dalla frazione e continuare a scriverla. */
const TOLLERANZA = 0.02;

/** Separatore decimale secondo la lingua, zeri finali tolti, massimo 2 decimali. */
export function numero(q: number, voc: Vocabolario): string {
  const arrotondato = String(Math.round(q * 100) / 100);
  return voc.separatoreDecimale === ','
    ? arrotondato.replace('.', ',')
    : arrotondato;
}

export function formattaQuantita(
  quantita: number | null,
  unita: string | null,
  voc: Vocabolario,
): string {
  if (quantita === null) return QUANTO_BASTA[voc.lingua];

  const testo = famigliaDi(unita, voc) === 'precisione'
    ? numero(quantita, voc)
    : conFrazione(quantita, voc);

  const reso = unitaAccordata(quantita, unita, voc);
  return reso ? `${testo} ${reso}` : testo;
}

/**
 * Accordo: esattamente 1 vuole il singolare, tutto il resto il plurale, anche
 * mezzo e uno e mezzo. Un'unità fuori vocabolario si riscrive com'è: inventarle
 * un singolare significherebbe sbagliarlo.
 */
function unitaAccordata(
  quantita: number,
  unita: string | null,
  voc: Vocabolario,
): string | null {
  if (unita == null || unita === '') return null;
  const voce = trovaUnita(unita, voc);
  if (!voce) return unita;
  return quantita === 1 ? voce.singolare : voce.plurale;
}

/** 1.5 -> "1½". Se il resto non somiglia a nessuna frazione da cucina, decimali. */
function conFrazione(q: number, voc: Vocabolario): string {
  const intero = Math.floor(q);
  const resto = q - intero;

  for (const [valore, simbolo] of FRAZIONI) {
    if (Math.abs(resto - valore) < TOLLERANZA) {
      return intero === 0 ? simbolo : `${intero}${simbolo}`;
    }
  }
  return numero(q, voc);
}
```

- [ ] **Step 10: Esegui il test e verifica che passi**

Comandi: `npm test` poi `npm run typecheck`

Atteso: `npm test` PASS, `ℹ pass 42` e `ℹ fail 0` — 15 test in `domain.test.ts`,
9 in `format.test.ts`, 8 in `lingua/lingua.test.ts`, 10 in `unita.test.ts`.
`npm run typecheck` non stampa errori ed esce 0.

- [ ] **Step 11: Commit**

```bash
git add src/domain/format.ts src/domain/format.test.ts
git commit -m "Accorda le unità al numero: 1 cubetto, 1½ cubetti"
```

---

---

### Task 4: Riscalo che riparte dalla richiesta salvata

Tre cose insieme, perché sono lo stesso pezzo di dominio.

**La prima:** `src/domain/types.ts` si chiude qui, in un colpo solo. Questo è
l'unico task che lo tocca e deve lasciarlo completo com'è nel contratto:
`cancellataIl` sulla `Ricetta`, più `Richiesta`, `Riscalo` e
`IngredienteScalato`. Sono tipi che serviranno ai task dei dati e
dell'interfaccia, e vanno aggiunti tutti adesso: aggiungerne uno alla volta
lascerebbe il typecheck rosso per task interi. Nello stesso passo si adegua
`src/domain/migrate.ts`, che costruisce una `Ricetta` e senza `cancellataIl`
non compilerebbe più.

**La seconda:** si salva **quello che l'utente ha chiesto** ("6 porzioni",
"250 g di farina"), non il moltiplicatore che ne è uscito, e il fattore si
ricalcola sulla ricetta di adesso. Chi corregge una ricetta dopo averla
riscalata deve ritrovare numeri veri, non numeri vecchi sotto un'etichetta che
mente. Se il ricalcolo non è più possibile — ingrediente cancellato, diventato
q.b., porzioni tolte — la funzione torna `null` e la ricetta si riapre sulle
dosi originali, senza messaggi di errore: non è un guasto, è una ricetta
cambiata.

**La terza:** la copertura del riscalo si sposta da `domain.test.ts` a
`scaling.test.ts` e si estende all'inglese. Il Task 2 ha già dato il vocabolario
a `scala` e le ha fatto scegliere l'arrotondamento dalla famiglia dell'unità, ma
i test che la provano stanno ancora nel file vecchio e usano solo l'italiano:
nessuno ha mai verificato che `cups`, `eggs` e `ml` finiscano nei tre
arrotondamenti giusti. Prima si scrive la prova nuova, poi si buttano i
doppioni.

**Files:**
- Modify: `src/domain/types.ts`
- Modify: `src/domain/migrate.ts`
- Modify: `src/domain/scaling.ts`
- Modify: `src/domain/domain.test.ts`
- Test: `src/domain/scaling.test.ts`

**Interfaces:**

- Consumes, da `src/domain/types.ts` (parte già scritta, che questo task non
  cambia):

```ts
export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }
export const isQb: (ing: Ingrediente) => boolean;
/** Tutti gli ingredienti in un array piatto, nell'ordine di visualizzazione. */
export const tuttiGliIngredienti: (r: Ricetta) => Ingrediente[];
```

- Consumes, da `src/domain/migrate.ts`: `migraRicetta` costruisce e restituisce
  un letterale `Ricetta` con `id`, `titolo`, `descrizione`, `porzioni`,
  `gruppi`, `creataIl`, `modificataIl`. Il campo `cancellataIl` non c'è ancora,
  e lo aggiunge lo Step 3 di questo task.

- Consumes, da `src/domain/units.ts`:

```ts
/** 1160 g -> 1,16 kg. Solo dentro la stessa famiglia fisica; le altre unità restano com'erano. */
export function resaLeggibile(quantita: number, unita: string | null, voc: Vocabolario):
  { quantita: number; unita: string | null };
/** 'sconosciuta' per le unità fuori vocabolario, null compreso. */
export function famigliaDi(unita: string | null, voc: Vocabolario): Famiglia | 'sconosciuta';
/** Al mezzo più vicino, mai sotto 0.5. */
export function arrotondaAMezzi(q: number): number;
```

- Consumes, da `src/domain/scaling.ts` (già portata al vocabolario dal Task 2):

```ts
export const FATTORE_ORIGINALE = 1;
/** Da 100 in su all'intero, da 10 a 100 a un decimale, sotto 10 a due. */
export function arrotondaPerCucina(q: number): number;
export function fattoreDaPorzioni(ricetta: Ricetta, porzioniDesiderate: number): number | null;
export function fattoreDaIngrediente(ricetta: Ricetta, ingredienteId: string, quantitaDisponibile: number): number | null;
/** Applica il fattore. Il vocabolario serve per le famiglie e la resa leggibile. */
export function scala(ricetta: Ricetta, fattore: number, voc: Vocabolario): RicettaScalata;
```

  `scala` passa già ogni quantità per `resaLeggibile` e poi arrotonda con
  `arrotondaAMezzi` se `famigliaDi(...) === 'discreta'`, altrimenti con
  `arrotondaPerCucina`. Un fattore non finito o non positivo viene sostituito da
  `FATTORE_ORIGINALE`, così una richiesta assurda lascia la ricetta com'è invece
  di rovinarla.

- Consumes, da `src/domain/lingua/`: il tipo `Vocabolario`, con
  `Famiglia = 'precisione' | 'misurino' | 'discreta'`, e le due costanti
  `IT` (da `./lingua/it.ts`) ed `EN` (da `./lingua/en.ts`). Nel vocabolario
  italiano `g` e `kg` sono `precisione`, `cucchiai` è `misurino`, `cad` e
  `cubetti` e `uova` sono `discreta`; in quello inglese `ml` e `oz` sono
  `precisione`, `cups` è `misurino`, `eggs` è `discreta`.

- Produces, da `src/domain/types.ts` (il file resta così per tutto il piano):

```ts
export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  gruppi: Gruppo[];
  creataIl: string;              // ISO 8601
  modificataIl: string;          // ISO 8601
  cancellataIl: string | null;   // tombstone, null = viva
}

/** Cosa ha chiesto l'utente. Si salva questa, non il fattore che ne esce. */
export type Richiesta =
  | { tipo: 'porzioni'; porzioni: number }
  | { tipo: 'ingrediente'; ingredienteId: string; quantita: number };

export interface Riscalo { ricettaId: string; richiesta: Richiesta; aggiornatoIl: string }

export interface IngredienteScalato {
  id: string;
  nome: string;
  quantita: number | null;        // null = q.b.
  quantitaEsatta: number | null;  // non arrotondata, per i calcoli a catena
  unita: string | null;
}

export interface RicettaScalata {
  fattore: number;
  porzioni: number | null;
  gruppi: { id: string; nome: string | null; ingredienti: IngredienteScalato[] }[];
}
```

- Produces, da `src/domain/scaling.ts`:

```ts
/** Risolve una Richiesta salvata contro la ricetta ATTUALE. null = non più calcolabile. */
export function risolviRichiesta(ricetta: Ricetta, richiesta: Richiesta): number | null;
```

---

- [ ] **Step 1: Scrivi il test che fallisce**

Crea `src/domain/scaling.test.ts`:

```ts
/**
 * Test del motore di riscalo. Girano con `npm test`, cioè `node --test`,
 * senza framework.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { risolviRichiesta } from './scaling.ts';
import type { Ricetta, Richiesta } from './types.ts';

/**
 * La Farinata dell'archivio reale, con la farina e le porzioni parametriche
 * così da poter "correggere la ricetta" come farebbe l'utente.
 * grammiDiFarina === null significa che la farina è diventata q.b.
 */
const farinata = (
  grammiDiFarina: number | null,
  porzioni: number | null = 14,
): Ricetta => ({
  id: 'r1',
  titolo: 'Farinata',
  descrizione: '',
  porzioni,
  gruppi: [{
    id: 'g1',
    nome: null,
    ingredienti: [
      {
        id: 'i1',
        nome: 'Farina',
        quantita: grammiDiFarina,
        unita: grammiDiFarina === null ? null : 'g',
      },
      { id: 'i2', nome: 'Acqua', quantita: 580, unita: 'g' },
      { id: 'i3', nome: 'Rosmarino', quantita: null, unita: null },
    ],
  }],
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
});

test('il fattore si ricalcola sulla ricetta di adesso, non su quella di ieri', () => {
  // Il caso della spec: riscalo salvato su 250 g di farina quando la ricetta
  // ne dichiarava 200; poi ci si accorge che erano 180 e la si corregge.
  const richiesta: Richiesta = { tipo: 'ingrediente', ingredienteId: 'i1', quantita: 250 };

  const primaDellaCorrezione = risolviRichiesta(farinata(200), richiesta);
  assert.equal(primaDellaCorrezione, 1.25);          // 250 / 200

  const dopoLaCorrezione = risolviRichiesta(farinata(180), richiesta);
  assert.ok(dopoLaCorrezione !== null);
  assert.ok(Math.abs(dopoLaCorrezione - 250 / 180) < 1e-12);
  // Se avessimo salvato il moltiplicatore invece della richiesta, qui ci
  // saremmo tenuti l'1,25 di ieri.
  assert.notEqual(dopoLaCorrezione, 1.25);
});

test('anche la richiesta per porzioni riparte dai numeri aggiornati', () => {
  const richiesta: Richiesta = { tipo: 'porzioni', porzioni: 28 };
  assert.equal(risolviRichiesta(farinata(180, 14), richiesta), 2);
  assert.equal(risolviRichiesta(farinata(180, 7), richiesta), 4);
});

test('una richiesta non più calcolabile vale null: si torna alle dosi originali', () => {
  const su250diFarina: Richiesta = { tipo: 'ingrediente', ingredienteId: 'i1', quantita: 250 };

  // 1. l'ingrediente su cui si era riscalato è stato cancellato
  const base = farinata(180);
  const senzaFarina: Ricetta = {
    ...base,
    gruppi: [{
      id: 'g1',
      nome: null,
      ingredienti: base.gruppi[0].ingredienti.filter((i) => i.id !== 'i1'),
    }],
  };
  assert.equal(risolviRichiesta(senzaFarina, su250diFarina), null);

  // 2. l'ingrediente è diventato q.b.
  assert.equal(risolviRichiesta(farinata(null), su250diFarina), null);

  // 3. le porzioni sono state tolte dalla ricetta
  assert.equal(risolviRichiesta(farinata(180, null), { tipo: 'porzioni', porzioni: 6 }), null);

  // 4. richieste assurde, che il database potrebbe comunque contenere
  assert.equal(risolviRichiesta(farinata(180), { tipo: 'ingrediente', ingredienteId: 'i1', quantita: 0 }), null);
  assert.equal(risolviRichiesta(farinata(180), { tipo: 'ingrediente', ingredienteId: 'mai-esistito', quantita: 5 }), null);
  assert.equal(risolviRichiesta(farinata(180), { tipo: 'porzioni', porzioni: -2 }), null);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `SyntaxError: The requested module './scaling.ts' does not
provide an export named 'risolviRichiesta'`. Conteggio: `ℹ pass 42`,
`ℹ fail 1` — i 42 lasciati dal Task 3, più il file nuovo che non parte.

Qui non lanciare `npm run typecheck`: la ricetta di prova ha già
`cancellataIl: null`, che `types.ts` non conosce fino allo Step 3, e vedresti un
errore che non ti dice niente di nuovo.

- [ ] **Step 3: Chiudi `types.ts` e adegua `migrate.ts`**

Sostituisci per intero `src/domain/types.ts` con:

```ts
/**
 * Modello dati Dosatore.
 *
 * Differenze chiave rispetto alla vecchia webapp:
 * - ogni entità ha un id stabile (prima la ricetta era identificata per indice
 *   e ritrovata per titolo: due titoli uguali rompevano l'app);
 * - la ricetta è IMMUTABILE rispetto allo scaling. Le dosi riscalate non
 *   vengono più salvate: sono stato di sessione, calcolato sempre a partire
 *   dall'originale. Così non si accumula deriva di arrotondamento;
 * - cancellare non rimuove la riga, marca una tombstone: senza, la sync del
 *   pezzo B farebbe riapparire le ricette cancellate.
 */

/** quantita === null significa "q.b." (quanto basta): non entra nei calcoli. */
export interface Ingrediente {
  id: string;
  nome: string;
  quantita: number | null;
  unita: string | null;
}

/** nome === null: gruppo unico, l'intestazione non va mostrata. */
export interface Gruppo {
  id: string;
  nome: string | null;
  ingredienti: Ingrediente[];
}

export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  /** Porzioni della ricetta così com'è scritta. null = non dichiarate. */
  porzioni: number | null;
  gruppi: Gruppo[];
  creataIl: string;
  modificataIl: string;
  /** Tombstone: la data in cui è stata cancellata, null se è viva. */
  cancellataIl: string | null;
}

/**
 * Cosa ha chiesto l'utente: "sei porzioni" oppure "250 g di quell'ingrediente".
 * Si salva QUESTA, non il fattore che ne esce.
 *
 * Salvando il moltiplicatore, chi corregge la ricetta dopo averla riscalata si
 * ritrova numeri sbagliati sotto un'etichetta che mente. Salvando la richiesta,
 * il ricalcolo riparte dai numeri aggiornati e torna giusto da solo.
 */
export type Richiesta =
  | { tipo: 'porzioni'; porzioni: number }
  | { tipo: 'ingrediente'; ingredienteId: string; quantita: number };

/**
 * Il riscalo aperto su una ricetta. Vive fuori dalla ricetta e resta sul
 * dispositivo: quello che si sta cucinando adesso riguarda il telefono che si
 * ha in mano, non gli altri.
 */
export interface Riscalo {
  ricettaId: string;
  richiesta: Richiesta;
  /** ISO 8601. */
  aggiornatoIl: string;
}

/** Un ingrediente dopo il riscalo. Non viene mai salvato. */
export interface IngredienteScalato {
  id: string;
  nome: string;
  /** null = q.b. */
  quantita: number | null;
  /** Valore non arrotondato, per i calcoli a catena. */
  quantitaEsatta: number | null;
  unita: string | null;
}

/** Risultato del riscalo: non viene mai persistito. */
export interface RicettaScalata {
  fattore: number;
  /** Porzioni risultanti, null se la ricetta non dichiara le porzioni. */
  porzioni: number | null;
  gruppi: {
    id: string;
    nome: string | null;
    ingredienti: IngredienteScalato[];
  }[];
}

export const isQb = (ing: Ingrediente): boolean => ing.quantita === null;

/** Tutti gli ingredienti in un array piatto, nell'ordine di visualizzazione. */
export const tuttiGliIngredienti = (r: Ricetta): Ingrediente[] =>
  r.gruppi.flatMap((g) => g.ingredienti);
```

Poi, in `src/domain/migrate.ts`, sostituisci il `return` finale di
`migraRicetta`:

```ts
  return {
    id: newId(),
    titolo,
    descrizione: (l.descrizione ?? '').trim(),
    porzioni,
    gruppi,
    creataIl: adesso,
    modificataIl: adesso,
  };
```

con:

```ts
  return {
    id: newId(),
    titolo,
    descrizione: (l.descrizione ?? '').trim(),
    porzioni,
    gruppi,
    creataIl: adesso,
    modificataIl: adesso,
    cancellataIl: null,   // una ricetta migrata nasce viva
  };
```

- [ ] **Step 4: Aggiungi `risolviRichiesta` a `scaling.ts`**

In `src/domain/scaling.ts` porta la riga di import dei tipi da:

```ts
import type { Ricetta, RicettaScalata } from './types.ts';
```

a:

```ts
import type { Ricetta, RicettaScalata, Richiesta } from './types.ts';
```

e aggiungi in fondo al file:

```ts
/**
 * Risolve una richiesta salvata contro la ricetta ATTUALE.
 *
 * null significa "non più calcolabile": l'ingrediente è stato cancellato o è
 * diventato q.b., oppure le porzioni sono sparite dalla ricetta. Chi chiama
 * butta il riscalo e riapre la ricetta sulle dosi originali, senza dire niente
 * all'utente: non è un guasto, è una ricetta cambiata.
 */
export function risolviRichiesta(ricetta: Ricetta, richiesta: Richiesta): number | null {
  return richiesta.tipo === 'porzioni'
    ? fattoreDaPorzioni(ricetta, richiesta.porzioni)
    : fattoreDaIngrediente(ricetta, richiesta.ingredienteId, richiesta.quantita);
}
```

- [ ] **Step 5: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ pass 45` e `ℹ fail 0`: i 42 lasciati dal Task 3 più i 3 nuovi
di `scaling.test.ts`.

`npm run typecheck` è ancora rosso e lo resta fino allo Step 9: le due ricette
di prova in `domain.test.ts` sono dichiarate `: Ricetta` e non hanno
`cancellataIl`. Le si guarda in faccia allo Step 8 e spariscono allo Step 9
insieme ai test che le usano.

- [ ] **Step 6: Commit**

```bash
git add src/domain/types.ts src/domain/migrate.ts src/domain/scaling.ts src/domain/scaling.test.ts
git commit -m "Ricalcola il riscalo dalla richiesta salvata e chiudi il modello dati con la tombstone"
```

- [ ] **Step 7: Porta in `scaling.test.ts` la prova di `scala`, anche in inglese**

In cima a `src/domain/scaling.test.ts` sostituisci la riga

```ts
import { risolviRichiesta } from './scaling.ts';
```

con

```ts
import {
  arrotondaPerCucina,
  fattoreDaIngrediente,
  fattoreDaPorzioni,
  risolviRichiesta,
  scala,
} from './scaling.ts';
import { EN } from './lingua/en.ts';
import { IT } from './lingua/it.ts';
```

e aggiungi in fondo al file:

```ts
/** Ricetta inglese: una unità per famiglia, per vedere i tre arrotondamenti. */
const pancakes: Ricetta = {
  id: 'r2',
  titolo: 'Pancakes',
  descrizione: '',
  porzioni: 4,
  gruppi: [{
    id: 'g2',
    nome: null,
    ingredienti: [
      { id: 'p1', nome: 'Flour', quantita: 2, unita: 'cups' },   // misurino
      { id: 'p2', nome: 'Eggs', quantita: 1, unita: 'eggs' },    // discreta
      { id: 'p3', nome: 'Milk', quantita: 200, unita: 'ml' },    // precisione
    ],
  }],
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
};

test('scala per porzioni: 14 -> 28 raddoppia tutto, con resa leggibile', () => {
  const f = fattoreDaPorzioni(farinata(180), 28);
  assert.equal(f, 2);
  const s = scala(farinata(180), f!, IT);
  const ing = s.gruppi[0].ingredienti;
  assert.equal(ing[0].quantita, 360);
  assert.equal(ing[0].unita, 'g');
  assert.equal(ing[1].quantita, 1.16);     // 1160 g resi come 1,16 kg
  assert.equal(ing[1].unita, 'kg');
  assert.equal(s.porzioni, 28);
});

test('un q.b. non scala mai e non si prende un\'unità per strada', () => {
  const rosmarino = scala(farinata(180), 3, IT).gruppi[0].ingredienti[2];
  assert.equal(rosmarino.quantita, null);
  assert.equal(rosmarino.quantitaEsatta, null);
  assert.equal(rosmarino.unita, null);
});

test('accanto alla quantità arrotondata resta quella esatta', () => {
  const f = fattoreDaIngrediente(farinata(180), 'i1', 250)!;
  const acqua = scala(farinata(180), f, IT).gruppi[0].ingredienti[1];
  assert.equal(acqua.quantita, 806);       // 805,55… arrotondato da cucina
  assert.ok(Math.abs(acqua.quantitaEsatta! - 580 * 250 / 180) < 1e-9);
});

test('il caso della spec, dal principio: la farina corretta guida tutto', () => {
  const richiesta: Richiesta = { tipo: 'ingrediente', ingredienteId: 'i1', quantita: 250 };
  const ricetta = farinata(180);
  const f = risolviRichiesta(ricetta, richiesta)!;
  const s = scala(ricetta, f, IT);
  // Col moltiplicatore vecchio (250/200) la farina direbbe 225 g.
  assert.equal(s.gruppi[0].ingredienti[0].quantita, 250);
  assert.equal(s.porzioni, 19);            // round(14 x 250/180)
});

test("l'arrotondamento dipende dalla famiglia dell'unità", () => {
  const s = scala(pancakes, 0.375, EN);
  const ing = s.gruppi[0].ingredienti;
  assert.equal(ing[0].quantita, 0.75);     // misurino: 2 x 0,375, per ordine di grandezza
  assert.equal(ing[1].quantita, 0.5);      // discreta: 0,375 al mezzo più vicino
  assert.equal(ing[2].quantita, 75);       // precisione
  assert.equal(s.porzioni, 2);             // round(1,5)
});

test("un'unità discreta non sparisce mai riscalando in basso", () => {
  const ing = scala(pancakes, 0.1, EN).gruppi[0].ingredienti;
  assert.equal(ing[1].quantita, 0.5);      // mezzo uovo, non zero
  assert.equal(ing[0].quantita, 0.2);      // il misurino invece può scendere
  assert.equal(ing[2].quantita, 20);
});

test('input assurdi non riscalano niente', () => {
  assert.equal(fattoreDaIngrediente(farinata(180), 'i3', 10), null);   // q.b.
  assert.equal(fattoreDaIngrediente(farinata(180), 'i1', -5), null);
  assert.equal(fattoreDaPorzioni(farinata(180, null), 6), null);
  // Un fattore impossibile lascia la ricetta com'è invece di rovinarla.
  const s = scala(farinata(180), 0, IT);
  assert.equal(s.fattore, 1);
  assert.equal(s.gruppi[0].ingredienti[0].quantita, 180);
});

test('arrotondamento da cucina per ordine di grandezza', () => {
  assert.equal(arrotondaPerCucina(805.5555), 806);
  assert.equal(arrotondaPerCucina(11.111), 11.1);
  assert.equal(arrotondaPerCucina(1.3333), 1.33);
});

test('riscalare non accumula deriva: x3 e ritorno danno gli originali', () => {
  const ricetta = farinata(180);
  assert.equal(scala(ricetta, 3, IT).gruppi[0].ingredienti[0].quantita, 540);
  const tornata = scala(ricetta, 1, IT);
  assert.equal(tornata.gruppi[0].ingredienti[0].quantita, 180);
  assert.equal(tornata.gruppi[0].ingredienti[1].quantita, 580);
});
```

- [ ] **Step 8: Esegui i test e guarda cosa resta rosso**

Comandi: `npm test` poi `npm run typecheck`

`npm test`: PASS, `ℹ pass 54` e `ℹ fail 0` (i 45 dello Step 5 più i 9 nuovi di
questo passo). Questi
nove passano subito ed è giusto così: `scala` ha già ricevuto il vocabolario nel
Task 2, e questi test servono a inchiodarne il comportamento — in inglese, dove
non era mai stato provato — **prima** di cancellare i doppioni italiani da
`domain.test.ts`. Se uno di loro fosse rosso, la cancellazione dello Step 9
perderebbe copertura invece di spostarla.

`npm run typecheck`: FAIL con due errori, tutti e due in
`src/domain/domain.test.ts` e tutti e due
`error TS2741: Property 'cancellataIl' is missing in type '{ id: string; titolo: string; … }' but required in type 'Ricetta'`:
uno sulla ricetta di prova `farinata`, uno su `biscotti`. Sono le due fixture
del vecchio file, dichiarate `: Ricetta` prima che la tombstone esistesse. Le
cancella lo Step 9 insieme ai test che le usano.

- [ ] **Step 9: Riduci `domain.test.ts` a migrazione e unità**

I nove test di riscalo di `src/domain/domain.test.ts` sono adesso doppioni di
`scaling.test.ts`, e le due fixture che li servono sono le uniche cose in quel
file che non compilano più. Se ne va tutto insieme.

Sostituisci per intero `src/domain/domain.test.ts` con:

```ts
/**
 * Test della migrazione dal vecchio archivio e delle unità di misura.
 *
 * Il riscalo sta in `scaling.test.ts`, la resa testuale in `format.test.ts`:
 * qui restano soltanto la migrazione e le unità. Girano con `npm test`, cioè
 * `node --test`, senza framework.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { migraArchivio, migraRicetta } from './migrate.ts';
import { arrotondaAMezzi, famigliaDi, normalizzaUnita, resaLeggibile } from './units.ts';
import { IT } from './lingua/it.ts';

test('normalizzazione unità e resa leggibile', () => {
  assert.equal(normalizzaUnita('gr', IT), 'g');
  assert.equal(normalizzaUnita(' Grammi ', IT), 'g');
  assert.equal(normalizzaUnita('cucchiaio', IT), 'cucchiai');
  assert.equal(normalizzaUnita('manciata', IT), 'manciata'); // sconosciuta: intatta
  assert.equal(normalizzaUnita('', IT), null);
  assert.deepEqual(resaLeggibile(1500, 'g', IT), { quantita: 1.5, unita: 'kg' });
  assert.deepEqual(resaLeggibile(0.5, 'l', IT), { quantita: 500, unita: 'ml' });
  assert.deepEqual(resaLeggibile(3, 'cucchiai', IT), { quantita: 3, unita: 'cucchiai' });
});

test('migrazione dal vecchio formato piatto', () => {
  const r = migraRicetta({
    titolo: 'Farinata', descrizione: '',
    porzioni: 20, porzioniOriginali: 14,   // "porzioni" sporcato da un riscalo
    ingredienti: [
      { nome: 'Farina', quantita: 180, unita: 'gr' },
      { nome: 'Sale', quantita: 'q.b.', unita: '' },
    ],
  });
  assert.ok(r);
  assert.equal(r!.porzioni, 14);            // vince porzioniOriginali
  assert.equal(r!.gruppi.length, 1);
  assert.equal(r!.gruppi[0].nome, null);
  assert.equal(r!.gruppi[0].ingredienti[0].unita, 'g');
  assert.equal(r!.gruppi[0].ingredienti[1].quantita, null);
  assert.ok(r!.id && r!.gruppi[0].ingredienti[0].id);
});

test('migrazione dal formato a gruppi, gruppi vuoti scartati', () => {
  const r = migraRicetta({
    titolo: 'Torta', porzioniOriginali: 8,
    gruppi: [
      { nome: 'Impasto', ingredienti: [{ nome: 'Farina', quantita: 200, unita: 'g' }] },
      { nome: 'Crema', ingredienti: [] },
    ],
  });
  assert.equal(r!.gruppi.length, 1);
  assert.equal(r!.gruppi[0].nome, 'Impasto');
});

test('ricette senza titolo o senza ingredienti vengono scartate', () => {
  assert.equal(migraRicetta({ titolo: '', ingredienti: [{ nome: 'x', quantita: 1 }] }), null);
  assert.equal(migraRicetta({ titolo: 'Vuota', ingredienti: [] }), null);
  assert.deepEqual(migraArchivio('non un array'), []);
});

test('gli id generati sono unici', () => {
  const r = migraRicetta({
    titolo: 'T', porzioniOriginali: 2,
    ingredienti: [
      { nome: 'a', quantita: 1, unita: 'g' },
      { nome: 'b', quantita: 2, unita: 'g' },
    ],
  })!;
  const ids = r.gruppi[0].ingredienti.map((i) => i.id);
  assert.notEqual(ids[0], ids[1]);
});

test('le unità discrete si arrotondano al mezzo, mai a zero', () => {
  assert.equal(arrotondaAMezzi(0.6), 0.5);
  assert.equal(arrotondaAMezzi(0.75), 1);    // equidistante: per eccesso
  assert.equal(arrotondaAMezzi(1.4), 1.5);
  assert.equal(arrotondaAMezzi(0.1), 0.5);   // mai sparire del tutto
  assert.equal(arrotondaAMezzi(2.24), 2);
  assert.equal(famigliaDi('cad', IT), 'discreta');
  assert.equal(famigliaDi('bustine', IT), 'discreta');
  assert.equal(famigliaDi('g', IT), 'precisione');
  assert.equal(famigliaDi('cucchiaini', IT), 'misurino');
});
```

Verifica che del riscalo non resti traccia in quel file:

```bash
grep -n "scaling.ts\|scala(\|fattoreDa\|arrotondaPerCucina" src/domain/domain.test.ts
```

Atteso: nessuna riga stampata, `grep` esce con codice 1.

- [ ] **Step 10: Esegui i test, i tipi e la prova sui dati veri**

Comando: `npm test`

Atteso: PASS, `ℹ pass 45` e `ℹ fail 0` — i 54 dello Step 8 meno i 9 test di
riscalo appena cancellati da `domain.test.ts`. In dettaglio: 6 test in
`domain.test.ts`, 12 in `scaling.test.ts`, 9 in `format.test.ts`, 8 in
`lingua/lingua.test.ts`, 10 in `unita.test.ts`.

Comando: `npm run typecheck`

Atteso: nessun errore, uscita 0. Le due fixture che mancavano di `cancellataIl`
non ci sono più, e `migraRicetta` lo restituisce dallo Step 3.

Comando: `npm run migra`

Atteso: la migrazione del ricettario vero gira come prima — `cancellataIl` non
viene stampato, quindi le prime sei righe sono identiche a quelle verificate nel
task delle unità:

```
ricette legacy: 12  ->  migrate: 12
ingredienti legacy: 77  ->  migrati: 77
ricette senza porzioni: 2 Tozzetti, Besciamella
id ricetta unici: sì
titoli duplicati nel vecchio archivio: 0
unità distinte dopo normalizzazione: bicchieri | bicchierini | bustine | cad | cubetti | cucchiaini | g | kg | l | ml | pizzichi | tazzine
```

- [ ] **Step 11: Commit**

```bash
git add src/domain/scaling.test.ts src/domain/domain.test.ts
git commit -m "Sposta la copertura del riscalo in scaling.test.ts e provala anche in inglese"
```

---

### Task 5: Categorie e icone nel dominio

Il committente ha aggiunto le categorie create dall'utente e una foto per
ricetta. Il dominio si adegua qui, prima che qualunque cosa venga scritta su
disco: lo schema SQLite del Task 10 nasce già con la tabella `categorie` e con
le colonne `categoria_id` e `foto` su `ricette`, quindi i tipi devono esistere
prima.

Tre cose, in quest'ordine.

**La prima: il catalogo delle icone.** Una categoria ha un nome libero e
un'icona scelta fra quelle che diamo noi — trenta, catalogo chiuso. Non icone
caricate dall'utente: costerebbero un archivio da gestire e, nel pezzo B, da
sincronizzare, mentre una chiave di trenta valori costa niente. Si usano le
`MaterialCommunityIcons`, che è il set che `@expo/vector-icons` porta con sé.

Il catalogo tiene separate due cose che è comodo confondere e costoso aver
confuso: la **chiave**, che è nostra, va nel database e nell'export, e non
cambia mai; e il **nome** dell'icona nel font, che è di un pacchetto di terzi.
Tenendole separate, cambiare set di icone un domani è una riga in `icone.ts`
invece che una migrazione dei dati di tutti gli utenti.

E c'è il difetto da cui il task si difende: **un nome di icona sbagliato non dà
errore di compilazione**. `<MaterialCommunityIcons name="cavolfiore" />`
compila, gira, e disegna un quadratino vuoto. Non lo prende il typecheck, non lo
prende la revisione del codice, lo prende un utente. Perciò i trenta nomi non si
scrivono a memoria: si rileggono dal glyphmap del pacchetto installato, e c'è un
test che lo rilegge ad ogni `npm test`.

**La seconda: `Ricetta` guadagna `categoriaId` e `foto`.** Entrambi
`string | null`, entrambi obbligatori nel tipo. `categoriaId` a null è lo stato
normale, non un errore: ci si arriva scrivendo una ricetta prima di avere
categorie, o cancellando la categoria in cui stava. `foto` contiene **il nome
del file**, tipo `a1b2c3.jpg`, mai un percorso assoluto: su iOS la cartella
dell'app cambia ad ogni aggiornamento e il giorno dopo il percorso salvato
punta al nulla. Il tipo non può impedirlo da solo, ma il commento sul campo e il
fatto che `src/data/foto.ts` (Task 14) sia l'unico posto che compone i percorsi
sì.

**La terza: rimettere in piedi quello che i due campi obbligatori rompono.**
`migraRicetta` costruisce un letterale `Ricetta` e senza i due campi non compila
più; la fixture di `scaling.test.ts` idem. Si sistemano nello stesso task, non si
lascia il typecheck rosso a fine task.

**Nota verificata prima di scrivere questo task, e verificata di nuovo sul
repository vero.** `@expo/vector-icons` **non** arriva insieme a `expo`: non è
fra le dipendenze di `expo@57.0.14`, si controlla con
`node -e "console.log('@expo/vector-icons' in require('./node_modules/expo/package.json').dependencies)"`
che stampa `false`. Compare però in `node_modules/expo/bundledNativeModules.json`
come `"^15.0.2"`, ed è **già dichiarato e già installato in questo progetto**:
`package.json` ha `"@expo/vector-icons": "^15.0.2"` fra le `dependencies` e
`node_modules/@expo/vector-icons` è alla 15.1.1. I Task 1-4 non toccano le
dipendenze, quindi la situazione al Task 5 è questa. Perciò lo Step 1 **verifica**
invece di installare: `npx expo install` resta scritto come rimedio, non come
passo normale.

Quello che invece manca è il **commit**: la riga in `package.json` (e
`package-lock.json` che la accompagna) arriva a questo task modificata e non
committata. Ce la mette in storia lo Step 6, che è il primo task ad avere davvero
bisogno del pacchetto.

**Files:**
- Create: `src/domain/icone.ts`
- Modify: `src/domain/types.ts`
- Modify: `src/domain/migrate.ts`
- Modify: `src/domain/domain.test.ts`
- Modify: `src/domain/scaling.test.ts`
- Modify: `package.json`, `package-lock.json` (la dipendenza c'è già: qui entra
  soltanto nella storia di git, con lo Step 6)
- Test: `src/domain/icone.test.ts`

**Interfaces:**

- Consumes, da `src/domain/types.ts` così come lo lascia il Task 4. Questo task
  aggiunge `Categoria` e due campi a `Ricetta`, e non tocca nient'altro del file:

```ts
export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }

export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  gruppi: Gruppo[];
  creataIl: string;
  modificataIl: string;
  cancellataIl: string | null;   // tombstone, null = viva
}

export type Richiesta =
  | { tipo: 'porzioni'; porzioni: number }
  | { tipo: 'ingrediente'; ingredienteId: string; quantita: number };
export interface Riscalo { ricettaId: string; richiesta: Richiesta; aggiornatoIl: string }
export interface IngredienteScalato { id: string; nome: string; quantita: number | null; quantitaEsatta: number | null; unita: string | null }
export interface RicettaScalata { fattore: number; porzioni: number | null; gruppi: { id: string; nome: string | null; ingredienti: IngredienteScalato[] }[] }
export const isQb: (ing: Ingrediente) => boolean;
export const tuttiGliIngredienti: (r: Ricetta) => Ingrediente[];
```

- Consumes, da `src/domain/migrate.ts` così come lo lascia il Task 4:

```ts
/** Migra una ricetta dal formato della vecchia webapp. null = da scartare. */
export function migraRicetta(l: RicettaLegacy, adesso?: string): Ricetta | null;
export function migraArchivio(json: unknown): Ricetta[];
```

  Il `return` finale di `migraRicetta` costruisce un letterale `Ricetta` con
  `id`, `titolo`, `descrizione`, `porzioni`, `gruppi`, `creataIl`,
  `modificataIl`, `cancellataIl`. I due campi nuovi glieli aggiunge lo Step 10.

- Consumes, dal pacchetto `@expo/vector-icons` (già installato, lo Step 1 lo
  verifica): il file
  `node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/MaterialCommunityIcons.json`.
  È un JSON semplice, `{ "nome-icona": codepoint }`, 7448 chiavi nella 15.1.1.
  Solo il test lo legge, e lo legge da disco: `icone.ts` non importa nulla da
  quel pacchetto, così resta un modulo di dominio puro che gira sotto
  `node --test` senza React Native attorno.

- Produces, da `src/domain/icone.ts`:

```ts
/**
 * Le trenta voci del catalogo, chiave nostra + nome MaterialCommunityIcons.
 * Il letterale per intero sta nello Step 4: qui conta la FORMA.
 */
export const ICONE = [
  { chiave: 'piatto', nome: 'silverware-fork-knife' },
  // ...le altre ventinove, vedi Step 4
] as const;
/** L'unione delle trenta chiavi: un valore fuori catalogo non compila. */
export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
export interface VoceIcona { chiave: ChiaveIcona; nome: string }
/** Icona di una categoria che l'utente non ha scelto diversamente. */
export const ICONA_PREDEFINITA: ChiaveIcona;
/** Guardia per il confine coi dati: da SQLite e dall'import arriva una stringa qualunque. */
export function iconaValida(c: string): c is ChiaveIcona;
/** Il nome da dare a <MaterialCommunityIcons name=...>. Chiave sconosciuta -> predefinita. */
export function nomeIcona(c: string): string;
```

  **`as const` obbligatorio, annotazione di tipo su `ICONE` vietata.** Chi
  consuma questo modulo scriva nelle proprie Consumes esattamente queste due
  righe, non una parafrasi:

```ts
export const ICONE = [ /* trenta voci { chiave, nome } */ ] as const;
export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
```

  Non `export const ICONE: readonly VoceIcona[]`, non
  `export const ICONE: readonly { readonly chiave: string; readonly nome: string }[]`,
  e soprattutto non `export type ChiaveIcona = string`. Sono tutte e tre la
  stessa rinuncia: annotare `chiave` come `string` fa collassare a `string` anche
  la derivazione `(typeof ICONE)[number]['chiave']`, e la prova negativa dello
  Step 5 — `const sbagliata: ChiaveIcona = 'cavolfiore'` deve far uscire `tsc`
  con 2 — passerebbe invece di fallire. `ChiaveIcona` è l'unione dei trenta
  letterali `'piatto' | 'pasta' | ... | 'segnalibro'`, ed è tutta la protezione
  che questo task costruisce.

  `VoceIcona` resta come tipo di comodo per chi scrive una funzione che prende
  una voce (`{ chiave: ChiaveIcona; nome: string }`), ma non è il tipo di
  `ICONE`: le voci del catalogo sono `readonly` e con `chiave` letterale.

  E `nomeIcona` è **l'unico** posto dove chiave e glifo si incontrano, ripiego
  sulla predefinita compreso. Chi disegna un'icona chiama
  `nomeIcona(chiave)`; nessuno si ricostruisce la mappa da `ICONE` per conto
  proprio, perché quella copia non sarebbe coperta dal test che rilegge il
  glyphmap.

  Le trenta chiavi sono, in ordine: `piatto`, `pasta`, `riso`, `pizza`, `pane`,
  `zuppa`, `panino`, `griglia`, `pentola`, `colazione`, `torta`, `biscotti`,
  `dolcetti`, `gelato`, `pesce`, `carne`, `salumi`, `uova`, `formaggi`,
  `verdure`, `funghi`, `frutta`, `erbe`, `caffe`, `te`, `vino`, `cocktail`,
  `stella`, `cuore`, `segnalibro`.

- Produces, da `src/domain/types.ts`:

```ts
export interface Categoria {
  id: string;
  nome: string;
  icona: ChiaveIcona;
  /** Posizione nell'elenco, scelta dall'utente riordinando. */
  ordine: number;
  creataIl: string;
  modificataIl: string;
  cancellataIl: string | null;   // tombstone, come per le ricette
}
```

  e su `Ricetta` i due campi nuovi:

```ts
  /** null = senza categoria. È uno stato normale, non un errore. */
  categoriaId: string | null;
  /** NOME del file dentro documentDirectory/foto/, mai un percorso assoluto. */
  foto: string | null;
```

---

- [ ] **Step 1: Verifica che `@expo/vector-icons` ci sia e che il glyphmap ci sia davvero**

Comando: `node -p "require('./package.json').dependencies['@expo/vector-icons']"`

Atteso: `^15.0.2`. È la versione che `node_modules/expo/bundledNativeModules.json`
associa a Expo 57; dentro `^15.0.2` npm risolve alla 15.1.1, e si controlla con
`node -p "require('./node_modules/@expo/vector-icons/package.json').version"`.

Non c'è niente da installare: la dipendenza è già dichiarata e già scaricata, e i
Task 1-4 non toccano le dipendenze. Se invece il comando stampa `undefined` — un
`node_modules` cancellato, un clone fresco senza `npm install` — il rimedio è
`npx expo install @expo/vector-icons`, che sceglie proprio `^15.0.2` leggendo
`bundledNativeModules.json`, e poi si ricomincia da questo Step.

Comando:

```bash
ls node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/MaterialCommunityIcons.json
```

Atteso: il percorso viene stampato, `ls` esce con codice 0. Se esce 1 il
pacchetto non c'è e non ha senso proseguire: tutto il resto del task poggia su
questo file.

Comando:

```bash
node -e "const g=require('./node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/MaterialCommunityIcons.json'); console.log(Object.keys(g).length, 'icone;', ['pasta','pizza','fish','cheese','glass-wine'].every(n=>n in g) ? 'campione ok' : 'CAMPIONE MANCANTE')"
```

Atteso: `7448 icone; campione ok`. Il numero può salire con le versioni future
del pacchetto; quello che deve restare vero è `campione ok`.

- [ ] **Step 2: Scrivi il test che fallisce**

Crea `src/domain/icone.test.ts`:

```ts
/**
 * Verifica del catalogo delle icone. Gira con `npm test`, cioè `node --test`,
 * senza framework.
 *
 * Il primo test è quello che conta. Un nome di icona sbagliato non dà errore di
 * compilazione: `<MaterialCommunityIcons name="cavolfiore" />` compila, gira, e
 * disegna un quadratino vuoto. Lo si prende solo rileggendo il glyphmap del
 * pacchetto installato, che è quello che fa il test qui sotto.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

import { ICONE, ICONA_PREDEFINITA, iconaValida, nomeIcona } from './icone.ts';

/**
 * Percorso relativo alla radice del progetto: `npm test` gira sempre da lì.
 * Se il file non c'è, `@expo/vector-icons` non è installato e il test deve
 * fallire con ENOENT, non passare in silenzio.
 */
const GLYPHMAP =
  'node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/MaterialCommunityIcons.json';

test('le trenta icone esistono davvero in MaterialCommunityIcons', () => {
  const glifi = JSON.parse(readFileSync(GLYPHMAP, 'utf8')) as Record<string, number>;
  assert.ok(Object.keys(glifi).length > 1000, 'glyphmap letto male');

  const inventate = ICONE.filter((v) => !(v.nome in glifi)).map((v) => v.nome);
  assert.deepEqual(inventate, [], `nomi che non esistono nel font: ${inventate.join(', ')}`);
});

test('il catalogo è chiuso: trenta voci, nessun doppione', () => {
  assert.equal(ICONE.length, 30);
  assert.equal(new Set(ICONE.map((v) => v.chiave)).size, 30);
  assert.equal(new Set(ICONE.map((v) => v.nome)).size, 30);
});

test('iconaValida accetta solo le chiavi del catalogo, e nomeIcona non lascia mai a mani vuote', () => {
  assert.equal(iconaValida(ICONA_PREDEFINITA), true);
  assert.equal(iconaValida('pasta'), true);
  assert.equal(iconaValida('silverware-fork-knife'), false); // è un nome, non una chiave
  assert.equal(iconaValida('cavolfiore'), false);
  assert.equal(iconaValida(''), false);

  assert.equal(nomeIcona('pasta'), 'pasta');
  assert.equal(nomeIcona('vino'), 'glass-wine');
  // Chiave sparita dal catalogo: si ripiega sulla predefinita invece di
  // lasciare vuota la riga della categoria.
  assert.equal(nomeIcona('cavolfiore'), 'silverware-fork-knife');
});
```

- [ ] **Step 3: Esegui il test e verifica che fallisca**

Comando: `node --test src/domain/icone.test.ts`

Atteso: FAIL. `src/domain/icone.ts` non esiste ancora, quindi Node non risolve
l'import e il file intero conta come un fallimento solo:

```
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/domain/icone.ts'
✖ src/domain/icone.test.ts
ℹ tests 1
ℹ pass 0
ℹ fail 1
```

- [ ] **Step 4: Crea il catalogo**

Crea `src/domain/icone.ts`:

```ts
/**
 * Catalogo chiuso delle icone per le categorie.
 *
 * `chiave` è nostra: finisce nel database e nell'archivio di export, e non
 * cambia mai. `nome` è il nome dell'icona in MaterialCommunityIcons, cioè il
 * set che @expo/vector-icons porta con sé. Tenerle separate vuol dire che
 * cambiare set di icone un domani è una riga qui, invece di una migrazione dei
 * dati di tutti gli utenti.
 *
 * Trenta è un numero scelto: stanno in una griglia che si guarda in un colpo
 * d'occhio, e coprono come la gente raggruppa davvero le ricette — portate,
 * tipi di piatto, ingrediente guida, bevande — più tre simboli neutri per chi
 * si fa categorie che non parlano di cibo. Il catalogo è chiuso di proposito:
 * niente icone caricate dall'utente, così non c'è nulla da archiviare e, nel
 * pezzo B, nulla da sincronizzare.
 *
 * I nomi NON si aggiungono a memoria. Uno inventato non dà errore di
 * compilazione, dà un quadratino vuoto a schermo. Li verifica `icone.test.ts`
 * rileggendo il glyphmap del pacchetto installato.
 */
export const ICONE = [
  // Portate e tipi di piatto
  { chiave: 'piatto', nome: 'silverware-fork-knife' },
  { chiave: 'pasta', nome: 'pasta' },
  { chiave: 'riso', nome: 'rice' },
  { chiave: 'pizza', nome: 'pizza' },
  { chiave: 'pane', nome: 'bread-slice' },
  { chiave: 'zuppa', nome: 'bowl-mix' },
  { chiave: 'panino', nome: 'hamburger' },
  { chiave: 'griglia', nome: 'grill' },
  { chiave: 'pentola', nome: 'pot-steam' },
  { chiave: 'colazione', nome: 'food-croissant' },
  // Dolci
  { chiave: 'torta', nome: 'cake-variant' },
  { chiave: 'biscotti', nome: 'cookie' },
  { chiave: 'dolcetti', nome: 'cupcake' },
  { chiave: 'gelato', nome: 'ice-cream' },
  // Ingrediente guida
  { chiave: 'pesce', nome: 'fish' },
  { chiave: 'carne', nome: 'food-steak' },
  { chiave: 'salumi', nome: 'sausage' },
  { chiave: 'uova', nome: 'egg' },
  { chiave: 'formaggi', nome: 'cheese' },
  { chiave: 'verdure', nome: 'carrot' },
  { chiave: 'funghi', nome: 'mushroom' },
  { chiave: 'frutta', nome: 'food-apple' },
  { chiave: 'erbe', nome: 'leaf' },
  // Bevande
  { chiave: 'caffe', nome: 'coffee' },
  { chiave: 'te', nome: 'tea' },
  { chiave: 'vino', nome: 'glass-wine' },
  { chiave: 'cocktail', nome: 'glass-cocktail' },
  // Neutre, per le categorie che non parlano di cibo
  { chiave: 'stella', nome: 'star' },
  { chiave: 'cuore', nome: 'heart' },
  { chiave: 'segnalibro', nome: 'bookmark' },
] as const;

/**
 * L'unione delle trenta chiavi, derivata dal catalogo invece che riscritta a
 * mano: aggiungere una voce a ICONE allarga il tipo da solo, e assegnare una
 * chiave che non c'è non compila.
 */
export type ChiaveIcona = (typeof ICONE)[number]['chiave'];

export interface VoceIcona {
  chiave: ChiaveIcona;
  /** Nome dell'icona in MaterialCommunityIcons. */
  nome: string;
}

/** Icona di una categoria per cui l'utente non ne ha scelta una. */
export const ICONA_PREDEFINITA: ChiaveIcona = 'piatto';

const CHIAVI: ReadonlySet<string> = new Set(ICONE.map((v) => v.chiave));

/**
 * Serve al confine coi dati: quello che arriva da SQLite o da un file importato
 * è una stringa qualunque, e il tipo non la controlla per noi.
 */
export function iconaValida(c: string): c is ChiaveIcona {
  return CHIAVI.has(c);
}

/**
 * Il nome da passare a `<MaterialCommunityIcons name=...>`.
 * Una chiave che il catalogo non conosce più ripiega sulla predefinita: la
 * categoria si vede lo stesso, invece di lasciare un buco nell'elenco.
 */
export function nomeIcona(c: string): string {
  return ICONE.find((v) => v.chiave === c)?.nome
    ?? ICONE.find((v) => v.chiave === ICONA_PREDEFINITA)!.nome;
}
```

- [ ] **Step 5: Esegui il test e verifica che passi**

Comando: `node --test src/domain/icone.test.ts`

Atteso: PASS, tre test verdi.

```
✔ le trenta icone esistono davvero in MaterialCommunityIcons
✔ il catalogo è chiuso: trenta voci, nessun doppione
✔ iconaValida accetta solo le chiavi del catalogo, e nomeIcona non lascia mai a mani vuote
ℹ tests 3
ℹ pass 3
ℹ fail 0
```

Prova poi che il tipo stringa davvero. Crea un file usa e getta:

```bash
cat > src/domain/prova-negativa.ts <<'EOF'
import type { ChiaveIcona } from './icone.ts';
export const sbagliata: ChiaveIcona = 'cavolfiore';
EOF
npx tsc --noEmit; echo "uscita: $?"
rm src/domain/prova-negativa.ts
```

Atteso: `tsc` segnala l'errore ed esce con 2.

```
src/domain/prova-negativa.ts(2,14): error TS2322: Type '"cavolfiore"' is not assignable to type '"piatto" | "pasta" | "riso" | ...
uscita: 2
```

Il file va cancellato subito: serviva solo a vedere il tipo mordere.

- [ ] **Step 6: Commit**

`package.json` e `package-lock.json` sono nell'elenco non perché questo task li
abbia cambiati — non li ha cambiati, lo Step 1 si è limitato a leggerli — ma
perché la riga `"@expo/vector-icons": "^15.0.2"` arriva qui **modificata e non
ancora committata**, ed è questo il primo task che del pacchetto ha bisogno.
Lasciarla fuori vorrebbe dire trascinarla sporca fino in fondo al piano. Se
invece nel tuo albero risultassero già puliti, `git add` su un file immutato non
fa niente e il commit riesce lo stesso grazie ai due file di dominio.

```bash
git add src/domain/icone.ts src/domain/icone.test.ts package.json package-lock.json
git commit -m "Catalogo chiuso di trenta icone per le categorie

I nomi MaterialCommunityIcons sono verificati contro il glyphmap del
pacchetto installato: un nome sbagliato non dà errore di compilazione,
dà un quadratino vuoto a schermo, e va preso dai test.

La chiave del catalogo è nostra e va nel database; il nome dell'icona è
del pacchetto. Separate, cambiare set di icone non è una migrazione.

Entra in storia anche @expo/vector-icons ^15.0.2, che era dichiarato in
package.json ma non ancora committato: è il primo task che lo usa."
```

Atteso: il commit va a buon fine.

- [ ] **Step 7: Scrivi il test che fallisce sui due campi nuovi**

`categoriaId` e `foto` sono due campi che il vecchio archivio non ha e che la
migrazione non deve inventarsi: una ricetta importata da un file di versione 1
entra senza categoria e senza foto, e nessuno dei due si riempie da solo.

In `src/domain/domain.test.ts` aggiungi in fondo al file:

```ts
test('la migrazione non inventa categorie né foto', () => {
  const r = migraRicetta({
    titolo: 'Farinata',
    porzioniOriginali: 14,
    ingredienti: [{ nome: 'Farina', quantita: 180, unita: 'gr' }],
  })!;
  // Il vecchio archivio non ha categorie e non ha foto. Chi importa un file di
  // versione 1 deve trovarsi le ricette in "Senza categoria", non dentro una
  // categoria che non ha mai creato.
  assert.equal(r.categoriaId, null);
  assert.equal(r.foto, null);
});
```

- [ ] **Step 8: Esegui il test e verifica che fallisca**

Comando: `node --test src/domain/domain.test.ts`

Atteso: FAIL sul test appena scritto, gli altri sei verdi. `migraRicetta` non
mette i due campi, quindi valgono `undefined`, e `node:assert/strict` non
confonde `undefined` con `null`:

```
✖ la migrazione non inventa categorie né foto
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  + actual - expected

  + undefined
  - null

ℹ tests 7
ℹ pass 6
ℹ fail 1
```

- [ ] **Step 9: Aggiungi `Categoria` e i due campi a `types.ts`**

In `src/domain/types.ts`, sotto il commento di intestazione del file, aggiungi
l'import del tipo della chiave. È l'unica dipendenza che `types.ts` ha su un
altro modulo di dominio, e va in un verso solo: `icone.ts` non importa niente da
qui, quindi non si crea un ciclo.

```ts
import type { ChiaveIcona } from './icone.ts';
```

Poi, fra la fine di `interface Gruppo` e l'inizio di `interface Ricetta`,
inserisci:

```ts
/**
 * Un raggruppamento creato dall'utente: nome libero, icona scelta fra le
 * trenta del catalogo. Sono cartelle, non etichette — una ricetta sta in una
 * categoria sola, o in nessuna — quindi non c'è nessuna tabella di legame.
 *
 * Non ce n'è nessuna predefinita: un ricettario nuovo non ha categorie, e va
 * benissimo così.
 */
export interface Categoria {
  id: string;
  nome: string;
  icona: ChiaveIcona;
  /** Posizione nell'elenco, scelta dall'utente riordinando. */
  ordine: number;
  creataIl: string;
  modificataIl: string;
  /** Tombstone, come per le ricette. Cancellarla non cancella le sue ricette. */
  cancellataIl: string | null;
}
```

Sempre in `src/domain/types.ts`, dentro `interface Ricetta`, sostituisci:

```ts
  /** Porzioni della ricetta così com'è scritta. null = non dichiarate. */
  porzioni: number | null;
  gruppi: Gruppo[];
```

con:

```ts
  /** Porzioni della ricetta così com'è scritta. null = non dichiarate. */
  porzioni: number | null;
  /**
   * La categoria in cui sta, o null. null è uno stato normale, non un errore:
   * ci si arriva scrivendo una ricetta prima di avere categorie, o cancellando
   * la categoria in cui stava.
   */
  categoriaId: string | null;
  /**
   * NOME del file dentro `documentDirectory/foto/`, tipo `a1b2c3.jpg`.
   * MAI un percorso assoluto: su iOS la cartella dell'app cambia ad ogni
   * aggiornamento e il giorno dopo il percorso salvato punta al nulla.
   * I percorsi li compone `src/data/foto.ts`, e nessun altro.
   */
  foto: string | null;
  gruppi: Gruppo[];
```

Comando: `npm run typecheck`

Atteso: rosso, ed è previsto. I due campi sono obbligatori e ci sono **tre**
letterali `Ricetta` che non li hanno ancora, uno in `migrate.ts` e due in
`scaling.test.ts`: li chiude lo Step 10. Gli errori sono questi tre:

```
src/domain/migrate.ts(...): error TS2739: Type '{ id: string; titolo: string; ... }' is missing the following properties from type 'Ricetta': categoriaId, foto
src/domain/scaling.test.ts(...): error TS2739: Type '{ id: string; titolo: string; ... }' is missing the following properties from type 'Ricetta': categoriaId, foto
src/domain/scaling.test.ts(...): error TS2739: Type '{ id: string; titolo: string; ... }' is missing the following properties from type 'Ricetta': categoriaId, foto
```

Le due righe di `scaling.test.ts` sono la fabbrica `farinata` e la costante
`pancakes`. Se ne vedi una sola, il Task 4 non ha lasciato il file come si
aspetta questo task.

- [ ] **Step 10: Chiudi il rosso in `migrate.ts` e nelle due fixture di `scaling.test.ts`**

In `src/domain/migrate.ts`, sostituisci il `return` finale di `migraRicetta`:

```ts
  return {
    id: newId(),
    titolo,
    descrizione: (l.descrizione ?? '').trim(),
    porzioni,
    gruppi,
    creataIl: adesso,
    modificataIl: adesso,
    cancellataIl: null,   // una ricetta migrata nasce viva
  };
```

con:

```ts
  return {
    id: newId(),
    titolo,
    descrizione: (l.descrizione ?? '').trim(),
    porzioni,
    categoriaId: null,    // il vecchio archivio non ha categorie
    foto: null,           // né foto
    gruppi,
    creataIl: adesso,
    modificataIl: adesso,
    cancellataIl: null,   // una ricetta migrata nasce viva
  };
```

Poi in `src/domain/scaling.test.ts`, nella fixture `farinata`, sostituisci:

```ts
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
});
```

con:

```ts
  categoriaId: null,
  foto: null,
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
});
```

Nello **stesso file** c'è una seconda ricetta tipata, la costante `pancakes`,
che il Task 4 usa per i casi inglesi. Anche lei va sistemata, altrimenti il
typecheck resta rosso e lo Step 11 dichiara il falso. Sostituisci:

```ts
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
};
```

con:

```ts
  categoriaId: null,
  foto: null,
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
};
```

Le due sostituzioni si distinguono dalla chiusura: `});` è la fabbrica
`farinata`, `};` è la costante `pancakes`. Sono gli unici due letterali `Ricetta`
del file, e dopo questo passo `tsc --noEmit` non ha più niente da dire.

- [ ] **Step 11: Esegui test, tipi e la prova sui dati veri**

Comando: `npm test`

Atteso: PASS, ℹ pass 49, ℹ fail 0 (i 45 lasciati dal Task 4 più i 4 nuovi, 3 di
`icone.test.ts` e 1 di `domain.test.ts`). In dettaglio: 7 test in
`domain.test.ts` (i 6 di prima più quello dello Step 7), 3 in `icone.test.ts`,
12 in `scaling.test.ts`, 9 in `format.test.ts`, 8 in `lingua/lingua.test.ts`,
10 in `unita.test.ts`.

Comando: `npm run typecheck`

Atteso: nessun errore, uscita 0. I due letterali `Ricetta` incompleti dello
Step 9 adesso hanno tutti i campi.

Comando: `npm run migra`

Atteso: la migrazione del ricettario vero gira come prima. Lo script non stampa
`categoriaId` né `foto`, quindi l'uscita è identica a quella verificata nel
task del riscalo:

```
ricette legacy: 12  ->  migrate: 12
ingredienti legacy: 77  ->  migrati: 77
ricette senza porzioni: 2 Tozzetti, Besciamella
id ricetta unici: sì
titoli duplicati nel vecchio archivio: 0
unità distinte dopo normalizzazione: bicchieri | bicchierini | bustine | cad | cubetti | cucchiaini | g | kg | l | ml | pizzichi | tazzine
```

Comando finale di controllo, che non resti in giro il file usa e getta dello
Step 5:

```bash
ls src/domain/prova-negativa.ts 2>/dev/null; git status --porcelain
```

Atteso: `ls` non stampa niente, e `git status --porcelain` elenca soltanto i
quattro file toccati da questo secondo giro. `package.json` e `package-lock.json`
non compaiono più: li ha committati lo Step 6.

```
 M src/domain/domain.test.ts
 M src/domain/migrate.ts
 M src/domain/scaling.test.ts
 M src/domain/types.ts
```

- [ ] **Step 12: Commit**

```bash
git add src/domain/types.ts src/domain/migrate.ts src/domain/domain.test.ts src/domain/scaling.test.ts
git commit -m "Categoria nel modello dati, categoriaId e foto sulla ricetta

Una ricetta sta in una categoria sola o in nessuna: categoriaId a null è
lo stato normale, non un errore. Niente tabella di legame.

foto è il NOME del file, mai un percorso assoluto: su iOS la cartella
dell'app cambia ad ogni aggiornamento e i percorsi salvati puntano al
nulla il giorno dopo.

La migrazione dal vecchio archivio lascia entrambi a null: chi importa un
file di versione 1 trova le ricette in Senza categoria."
```

Atteso: il commit va a buon fine.

---

### Task 6: leggere un numero a inizio riga

**Files:**
- Create: `src/domain/parser/numeri.ts`
- Test: `src/domain/parser/numeri.test.ts`

**Interfaces:**

- Consumes — da `src/domain/lingua/index.ts`, già esistente:

```ts
export type Lingua = 'it' | 'en';

export interface Vocabolario {
  lingua: Lingua;
  unita: VoceUnita[];
  /** 'un' -> 1, 'mezzo' -> 0.5, 'three' -> 3. Chiavi minuscole. */
  numeriAParole: Record<string, number>;
  quantoBasta: string[];
  riempitivi: string[];
  stop: string[];
  intestazioneNeutra: string[];
  separatoreDecimale: ',' | '.';
}

export function vocabolario(lingua: Lingua): Vocabolario;
```

  Di quel vocabolario a questo task servono solo due campi, e questo è il loro
  contenuto reale:

  - `numeriAParole` italiano: `un`, `uno`, `una` = 1, `due` = 2, `tre` = 3,
    `quattro` = 4, `cinque` = 5, `sei` = 6, `sette` = 7, `otto` = 8, `nove` = 9,
    `dieci` = 10, `undici` = 11, `dodici` = 12, `mezzo` e `mezza` = 0.5.
  - `numeriAParole` inglese: `a`, `an`, `one` = 1, `two` = 2, `three` = 3,
    `four` = 4, `five` = 5, `six` = 6, `seven` = 7, `eight` = 8, `nine` = 9,
    `ten` = 10, `eleven` = 11, `twelve` = 12, `half` = 0.5.
  - `separatoreDecimale`: `','` per l'italiano, `'.'` per l'inglese.

- Produces — usato dal Task 7 (`src/domain/parser/riga.ts`):

```ts
export interface NumeroLetto {
  valore: number;
  /** Caratteri consumati dall'inizio della stringa passata. */
  lunghezza: number;
}
export function leggiNumero(testo: string, voc: Vocabolario): NumeroLetto | null;
```

Il campo `lunghezza` è la ragione per cui non basta `parseFloat`: chi chiama
deve sapere dove finisce il numero, perché quello che resta è unità più nome.
Il caso che dimostra la differenza è `"2 1/2 cups flour"`: valore 2.5 e
lunghezza 5, non valore 2 e lunghezza 1.

---

- [ ] **Step 1: Scrivi i test che falliscono (interi, decimali, intervalli, parole)**

Crea `src/domain/parser/numeri.test.ts`:

```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { leggiNumero } from './numeri.ts';
import { vocabolario } from '../lingua/index.ts';

const IT = vocabolario('it');
const EN = vocabolario('en');

test('interi e decimali, virgola e punto accettati in entrambe le lingue', () => {
  assert.deepEqual(leggiNumero('200 g farina', IT), { valore: 200, lunghezza: 3 });
  assert.deepEqual(leggiNumero('1,5 kg di patate', IT), { valore: 1.5, lunghezza: 3 });
  assert.deepEqual(leggiNumero('1.5 kg di patate', IT), { valore: 1.5, lunghezza: 3 });
  assert.deepEqual(leggiNumero('1.5 oz chocolate', EN), { valore: 1.5, lunghezza: 3 });
  assert.deepEqual(leggiNumero('1,5 oz chocolate', EN), { valore: 1.5, lunghezza: 3 });
  assert.equal(leggiNumero('farina 200 g', IT), null);
  assert.equal(leggiNumero('', IT), null);
});

test('il separatore che non è quello della lingua, con tre cifre, sono le migliaia', () => {
  assert.deepEqual(leggiNumero('1.500 g farina', IT), { valore: 1500, lunghezza: 5 });
  assert.deepEqual(leggiNumero('1,500 g flour', EN), { valore: 1500, lunghezza: 5 });
  assert.deepEqual(leggiNumero('1,500 g farina', IT), { valore: 1.5, lunghezza: 5 });
});

test('gli intervalli valgono il primo numero e consumano tutto', () => {
  assert.deepEqual(leggiNumero('2-3 cucchiai di olio', IT), { valore: 2, lunghezza: 3 });
  assert.deepEqual(leggiNumero('200-250 g farina', IT), { valore: 200, lunghezza: 7 });
});

test('numeri scritti a parole, per lingua e a parola intera', () => {
  assert.deepEqual(leggiNumero('un pizzico di sale', IT), { valore: 1, lunghezza: 2 });
  assert.deepEqual(leggiNumero('una noce di burro', IT), { valore: 1, lunghezza: 3 });
  assert.deepEqual(leggiNumero('mezzo bicchiere di latte', IT), { valore: 0.5, lunghezza: 5 });
  assert.deepEqual(leggiNumero('tre cucchiai di olio', IT), { valore: 3, lunghezza: 3 });
  assert.deepEqual(leggiNumero('three tablespoons of oil', EN), { valore: 3, lunghezza: 5 });
  assert.deepEqual(leggiNumero('half a lemon', EN), { valore: 0.5, lunghezza: 4 });
  assert.deepEqual(leggiNumero('a pinch of nutmeg', EN), { valore: 1, lunghezza: 1 });
  assert.equal(leggiNumero('unanimità', IT), null);
  assert.equal(leggiNumero('three tablespoons of oil', IT), null);
  assert.equal(leggiNumero('tre cucchiai di olio', EN), null);
});
```

- [ ] **Step 2: Esegui i test e verifica che falliscano**

Comando: `npm test`

Atteso: FAIL con `Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'.../src/domain/parser/numeri.ts' imported from
'.../src/domain/parser/numeri.test.ts'`.

- [ ] **Step 3: Implementa interi, decimali, intervalli e parole**

Crea `src/domain/parser/numeri.ts`:

```ts
/**
 * Lettura dei numeri all'inizio di una riga di ingrediente.
 *
 * Non basta un parseFloat: nelle ricette un numero è "200", "1,5", "1/2",
 * "2 1/2", "2-3", "½" oppure "mezzo". E serve sapere quanti caratteri sono
 * stati consumati, perché quello che resta è unità più nome.
 */

import type { Vocabolario } from '../lingua/index.ts';

export interface NumeroLetto {
  valore: number;
  /** Caratteri consumati dall'inizio della stringa passata. */
  lunghezza: number;
}

const DECIMALE = /^(\d+)(?:([.,])(\d+))?/;
const INTERVALLO = /^[-–]\d+(?:[.,]\d+)?/;

/** Legge un numero a inizio stringa: 200 | 1,5 | 1.5 | 2-3 (primo) | un | half */
export function leggiNumero(testo: string, voc: Vocabolario): NumeroLetto | null {
  if (!testo) return null;
  return decimale(testo, voc) ?? aParole(testo, voc);
}

/**
 * Interi e decimali. Virgola e punto valgono entrambi come separatore
 * decimale, ma quello che NON è il separatore della lingua seguito da esattamente
 * tre cifre è un separatore delle migliaia: in italiano "1.500 g" è un chilo e
 * mezzo, non un grammo e mezzo.
 * Un intervallo attaccato ("2-3") consuma tutto e vale il primo numero.
 */
function decimale(testo: string, voc: Vocabolario): NumeroLetto | null {
  const m = DECIMALE.exec(testo);
  if (!m) return null;
  const [tutto, intero, separatore, decimali] = m;

  let valore = Number(intero);
  if (separatore && decimali) {
    valore = separatore !== voc.separatoreDecimale && decimali.length === 3
      ? Number(intero + decimali)
      : Number(intero + '.' + decimali);
  }

  const intervallo = INTERVALLO.exec(testo.slice(tutto.length));
  return { valore, lunghezza: tutto.length + (intervallo ? intervallo[0].length : 0) };
}

/** "un", "mezzo", "three", "half". Solo a parola intera: "unanime" non vale 1. */
function aParole(testo: string, voc: Vocabolario): NumeroLetto | null {
  const minuscolo = testo.toLowerCase();
  const parole = Object.keys(voc.numeriAParole).sort((a, b) => b.length - a.length);
  for (const parola of parole) {
    if (!minuscolo.startsWith(parola)) continue;
    const dopo = testo[parola.length] ?? ' ';
    if (/[\p{L}\d]/u.test(dopo)) continue;
    return { valore: voc.numeriAParole[parola], lunghezza: parola.length };
  }
  return null;
}
```

Le parole si provano dalla più lunga alla più corta, altrimenti in `"una noce"`
vincerebbe `un` e la lunghezza consumata sarebbe sbagliata di un carattere.

- [ ] **Step 4: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS. I quattro test di `src/domain/parser/numeri.test.ts` passano e il
resto della suite resta verde.

- [ ] **Step 5: Aggiungi il test delle frazioni, che fallisce**

Aggiungi in fondo a `src/domain/parser/numeri.test.ts`:

```ts
test('frazioni, numeri misti e frazioni unicode', () => {
  assert.deepEqual(leggiNumero('1/2 bicchiere di latte', IT), { valore: 0.5, lunghezza: 3 });
  assert.deepEqual(leggiNumero('1 / 2 tsp salt', EN), { valore: 0.5, lunghezza: 5 });
  assert.deepEqual(leggiNumero('2 1/2 cups flour', EN), { valore: 2.5, lunghezza: 5 });
  assert.deepEqual(leggiNumero('½ tsp salt', EN), { valore: 0.5, lunghezza: 1 });
  assert.deepEqual(leggiNumero('1½ cups flour', EN), { valore: 1.5, lunghezza: 2 });
  // divisione per zero: niente Infinity, si legge solo l'intero
  assert.deepEqual(leggiNumero('1/0 farina', IT), { valore: 1, lunghezza: 1 });
});
```

- [ ] **Step 6: Esegui i test e verifica che il nuovo fallisca**

Comando: `npm test`

Atteso: FAIL su `frazioni, numeri misti e frazioni unicode` con
`AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
actual: { valore: 1, lunghezza: 1 }, expected: { valore: 0.5, lunghezza: 3 }`.
Oggi `"1/2"` legge solo l'intero e si ferma al primo carattere.

- [ ] **Step 7: Implementa frazioni, numeri misti e frazioni unicode**

In `src/domain/parser/numeri.ts` aggiungi le costanti subito sotto
l'interfaccia `NumeroLetto`:

```ts
/** Frazioni a carattere singolo: i siti di ricette le usano parecchio. */
const FRAZIONI_UNICODE: Record<string, number> = {
  '½': 0.5, '⅓': 1 / 3, '⅔': 2 / 3, '¼': 0.25, '¾': 0.75, '⅛': 0.125,
};

const MISTO = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/;
const MISTO_UNICODE = /^(\d*)\s*([½⅓⅔¼¾⅛])/;
const FRAZIONE = /^(\d+)\s*\/\s*(\d+)/;
```

sostituisci `leggiNumero` per intero:

```ts
/** Legge un numero a inizio stringa: 200 | 1,5 | 1.5 | 1/2 | 2 1/2 | 2-3 (primo) | un | half */
export function leggiNumero(testo: string, voc: Vocabolario): NumeroLetto | null {
  if (!testo) return null;
  return (
    misto(testo) ??
    mistoUnicode(testo) ??
    frazione(testo) ??
    decimale(testo, voc) ??
    aParole(testo, voc)
  );
}
```

e aggiungi le tre funzioni fra `leggiNumero` e `decimale`:

```ts
/** "2 1/2 cups" vale 2,5 e consuma 5 caratteri, non 2 e un carattere. */
function misto(testo: string): NumeroLetto | null {
  const m = MISTO.exec(testo);
  if (!m || Number(m[3]) === 0) return null;
  return { valore: Number(m[1]) + Number(m[2]) / Number(m[3]), lunghezza: m[0].length };
}

/** "1½ cups" e "½ tsp". */
function mistoUnicode(testo: string): NumeroLetto | null {
  const m = MISTO_UNICODE.exec(testo);
  if (!m) return null;
  const intero = m[1] === '' ? 0 : Number(m[1]);
  return { valore: intero + FRAZIONI_UNICODE[m[2]], lunghezza: m[0].length };
}

function frazione(testo: string): NumeroLetto | null {
  const m = FRAZIONE.exec(testo);
  if (!m || Number(m[2]) === 0) return null;
  return { valore: Number(m[1]) / Number(m[2]), lunghezza: m[0].length };
}
```

L'ordine dei tentativi è la sostanza di questo passo: il numero misto va provato
prima della frazione e la frazione prima dell'intero, altrimenti `"2 1/2"`
diventa 2 e `"1/2"` diventa 1.

- [ ] **Step 8: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS, ℹ pass 54, ℹ fail 0 (i 49 lasciati dal Task 5 più i 5 di
`numeri.test.ts`). Cinque test in `src/domain/parser/numeri.test.ts`, nessun
fallimento nel resto della suite.

- [ ] **Step 9: Commit**

```bash
git add src/domain/parser/numeri.ts src/domain/parser/numeri.test.ts
git commit -m "Leggi i numeri delle ricette: interi, decimali, frazioni e parole"
```

---

---

### Task 7: leggere una riga di ingrediente

**Files:**
- Create: `src/domain/parser/riga.ts`
- Test: `src/domain/parser/riga.test.ts`

**Interfaces:**

- Consumes — da `src/domain/parser/numeri.ts` (Task 6):

```ts
export interface NumeroLetto { valore: number; lunghezza: number }
export function leggiNumero(testo: string, voc: Vocabolario): NumeroLetto | null;
```

- Consumes — da `src/domain/units.ts`, già esistente:

```ts
/** Cerca l'unità nel vocabolario. Alias più lungo per primo. null se non la conosce. */
export function trovaUnita(raw: string | null | undefined, voc: Vocabolario): VoceUnita | null;

/** Forma canonica, oppure la stringa ripulita se sconosciuta, oppure null se vuota. */
export function normalizzaUnita(raw: string | null | undefined, voc: Vocabolario): string | null;
```

- Consumes — da `src/domain/lingua/index.ts`, già esistente:

```ts
export type Famiglia = 'precisione' | 'misurino' | 'discreta';

export interface VoceUnita {
  /** Forma canonica salvata nel database, es. 'g', 'cucchiai', 'cups'. */
  canonica: string;
  /** Tutte le scritture che portano a questa unità, minuscole. Include la canonica. */
  alias: string[];
  famiglia: Famiglia;
  singolare: string;
  plurale: string;
  base?: { unita: string; fattore: number };
}

export interface Vocabolario {
  lingua: 'it' | 'en';
  unita: VoceUnita[];
  numeriAParole: Record<string, number>;
  /** Espressioni che significano "quanto basta", minuscole. */
  quantoBasta: string[];
  /** Parole da scartare fra quantità e nome, es. 'di', 'of'. Minuscole. */
  riempitivi: string[];
  stop: string[];
  intestazioneNeutra: string[];
  separatoreDecimale: ',' | '.';
}

export function vocabolario(lingua: 'it' | 'en'): Vocabolario;
```

  Il contenuto del vocabolario su cui poggiano i test di questo task:

  - unità italiane citate qui sotto, con la canonica a sinistra e gli alias fra
    parentesi: `g` (gr, grammo, grammi), `kg`, `l` (lt, litro, litri),
    `cucchiai` (cucchiaio), `cucchiaini` (cucchiaino), `bicchieri` (bicchiere),
    `spicchi` (spicchio), `bustine` (bustina), `uova` (uovo),
    `pizzichi` (pizzico);
  - unità inglesi: `cups` (cup), `tsp` (teaspoon), `lb` (lbs, pound, pounds),
    `oz` (ounce, ounces), `fl oz` (floz, fluid ounce, fluid ounces),
    `eggs` (egg), `sticks` (stick), `cloves` (clove);
  - `quantoBasta` italiano: `qb`, `q.b.`, `quanto basta`, `a piacere`,
    `a piacimento`; inglese: `to taste`, `as needed`, `a pinch of`, `a pinch`;
  - `riempitivi` italiano: `di`, `d'`, `del`, `della`, `dello`, `dei`, `degli`,
    `delle`; inglese: `of`, `the`.

  Attenzione: un'unità scritta al singolare torna sempre alla propria forma
  canonica, che spesso è il plurale. `pizzico` è un alias di `pizzichi`,
  `bicchiere` di `bicchieri`, `spicchio` di `spicchi`: i test qui sotto si
  aspettano la canonica, non la parola come l'ha scritta l'utente.

  Per provare che un'unità **sconosciuta** non si perde serve una parola davvero
  fuori vocabolario: si usa `manciata`, che nel vocabolario italiano non c'è (la
  stessa che `src/domain/unita.test.ts` usa già come unità sconosciuta).

- Produces — usato dal Task 8 (`src/domain/parser/blocco.ts`):

```ts
export interface RigaLetta {
  nome: string;
  quantita: number | null;   // null = q.b.
  unita: string | null;
  /** false quando il parser non ha capito: la riga va segnalata all'utente. */
  sicura: boolean;
}
export function leggiRiga(riga: string, voc: Vocabolario): RigaLetta;
```

Le forme da riconoscere, tutte dalla spec:

```
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.

Tre regole meno ovvie, che i test fissano:

- **il nome non resta mai vuoto.** In `"2 uova"` la parola `uova` è un'unità di
  vocabolario, ma è anche l'unica cosa rimasta: allora è il nome e l'unità è
  `null`. In `"2 large eggs"` invece `eggs` è unità e `large` resta come nome.
- **le note fra parentesi restano nel nome** e non fanno da quantità:
  `"burro (circa 1 panetto) 200 g"` pesa 200 g, non 1.
- **la virgola dentro una riga non separa niente.**
  `"2 large eggs, room temperature"` è un ingrediente solo, con la nota dentro
  al nome. La spec fa dividere sulla virgola soltanto quando l'utente ha
  incollato tutto su una riga sola, e di quel caso si occupa il Task 8 in
  `blocco.ts`: lì `dividiSuVirgole` si applica solo se il testo incollato non
  contiene a capo. `leggiRiga` riceve già il pezzo da leggere e le virgole non
  le guarda mai.

---

- [ ] **Step 1: Scrivi i test che falliscono (quantità, unità, nome)**

Crea `src/domain/parser/riga.test.ts`:

```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { leggiRiga } from './riga.ts';
import { vocabolario } from '../lingua/index.ts';

const IT = vocabolario('it');
const EN = vocabolario('en');

test('le forme italiane della spec', () => {
  assert.deepEqual(leggiRiga('200 g farina', IT), { nome: 'farina', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('farina 200 g', IT), { nome: 'farina', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('200g farina', IT), { nome: 'farina', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('200 g di farina', IT), { nome: 'farina', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('1,5 kg di patate', IT), { nome: 'patate', quantita: 1.5, unita: 'kg', sicura: true });
  assert.deepEqual(leggiRiga('1/2 bicchiere di latte', IT), { nome: 'latte', quantita: 0.5, unita: 'bicchieri', sicura: true });
  assert.deepEqual(leggiRiga('mezzo bicchiere di latte', IT), { nome: 'latte', quantita: 0.5, unita: 'bicchieri', sicura: true });
  assert.deepEqual(leggiRiga('tre cucchiai di olio', IT), { nome: 'olio', quantita: 3, unita: 'cucchiai', sicura: true });
  assert.deepEqual(leggiRiga('500 gr di farina', IT), { nome: 'farina', quantita: 500, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga("1 spicchio d'aglio", IT), { nome: 'aglio', quantita: 1, unita: 'spicchi', sicura: true });
});

test('le forme inglesi della spec', () => {
  assert.deepEqual(leggiRiga('2 cups flour', EN), { nome: 'flour', quantita: 2, unita: 'cups', sicura: true });
  assert.deepEqual(leggiRiga('1 lb butter', EN), { nome: 'butter', quantita: 1, unita: 'lb', sicura: true });
  assert.deepEqual(leggiRiga('1/2 tsp salt', EN), { nome: 'salt', quantita: 0.5, unita: 'tsp', sicura: true });
  assert.deepEqual(leggiRiga('2 1/2 cups flour', EN), { nome: 'flour', quantita: 2.5, unita: 'cups', sicura: true });
  assert.deepEqual(leggiRiga('3 oz dark chocolate', EN), { nome: 'dark chocolate', quantita: 3, unita: 'oz', sicura: true });
  assert.deepEqual(leggiRiga('1 stick of butter', EN), { nome: 'butter', quantita: 1, unita: 'sticks', sicura: true });
  assert.deepEqual(leggiRiga('4 fl oz of milk', EN), { nome: 'milk', quantita: 4, unita: 'fl oz', sicura: true });
});

test('quantità senza unità, e unità che in realtà è il nome', () => {
  assert.deepEqual(leggiRiga('2 uova', IT), { nome: 'uova', quantita: 2, unita: null, sicura: true });
  assert.deepEqual(leggiRiga('3 patate grandi', IT), { nome: 'patate grandi', quantita: 3, unita: null, sicura: true });
  // il nome non resta mai vuoto: senza altro testo l'unità diventa il nome
  assert.deepEqual(leggiRiga('200 g', IT), { nome: 'g', quantita: 200, unita: null, sicura: true });
});

test('fra numero e unità può esserci una parola che non è un riempitivo', () => {
  assert.deepEqual(leggiRiga('2 large eggs', EN), { nome: 'large', quantita: 2, unita: 'eggs', sicura: true });
  // 'pizzico' è a vocabolario come alias di 'pizzichi': esce la canonica
  assert.deepEqual(leggiRiga('un pizzico di sale', IT), { nome: 'sale', quantita: 1, unita: 'pizzichi', sicura: true });
  // 'manciata' invece non c'è: unità sconosciuta, non si perde e resta com'era
  assert.deepEqual(leggiRiga('una manciata di prezzemolo', IT), { nome: 'prezzemolo', quantita: 1, unita: 'manciata', sicura: true });
  // ma solo in fondo: un'unità presa dal mezzo incollerebbe pezzi di frase lontani
  assert.deepEqual(leggiRiga('2 large eggs, room temperature', EN), { nome: 'large eggs, room temperature', quantita: 2, unita: null, sicura: true });
});

test('trattini, pallini e numerazioni a inizio riga', () => {
  const atteso = { nome: 'farina', quantita: 200, unita: 'g', sicura: true };
  assert.deepEqual(leggiRiga('- 200 g farina', IT), atteso);
  assert.deepEqual(leggiRiga('• 200 g farina', IT), atteso);
  assert.deepEqual(leggiRiga('· 200 g farina', IT), atteso);
  assert.deepEqual(leggiRiga('* 200 g farina', IT), atteso);
  assert.deepEqual(leggiRiga('1. 200 g farina', IT), atteso);
  assert.deepEqual(leggiRiga('2) 200 g farina', IT), atteso);
  assert.deepEqual(leggiRiga('   200 g farina  ', IT), atteso);
});

test('quello che non si capisce non si perde: sicura false e riga intera nel nome', () => {
  assert.deepEqual(leggiRiga('scorza di limone', IT), { nome: 'scorza di limone', quantita: null, unita: null, sicura: false });
  assert.deepEqual(leggiRiga('- burro per la teglia', IT), { nome: 'burro per la teglia', quantita: null, unita: null, sicura: false });
  assert.deepEqual(leggiRiga('   ', IT), { nome: '', quantita: null, unita: null, sicura: false });
});
```

- [ ] **Step 2: Esegui i test e verifica che falliscano**

Comando: `npm test`

Atteso: FAIL con `Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'.../src/domain/parser/riga.ts' imported from
'.../src/domain/parser/riga.test.ts'`.

- [ ] **Step 3: Implementa quantità, unità e nome**

Crea `src/domain/parser/riga.ts`:

```ts
/**
 * Lettura di una riga di ingrediente.
 *
 * Regola sopra tutte: non si perde niente. Se la riga non si capisce, il nome
 * si prende la riga intera e `sicura` va a false, così l'utente la vede in
 * evidenza invece di scoprire dopo che mancava il burro.
 */

import type { Vocabolario } from '../lingua/index.ts';
import { normalizzaUnita, trovaUnita } from '../units.ts';
import { leggiNumero } from './numeri.ts';

export interface RigaLetta {
  nome: string;
  quantita: number | null;   // null = q.b.
  unita: string | null;
  /** false quando il parser non ha capito: la riga va segnalata all'utente. */
  sicura: boolean;
}

/**
 * Trattini, pallini e numerazioni che l'incolla si porta dietro.
 * ponytail: la numerazione vince sulla quantità, quindi "2. uova" perde il 2 e
 * finisce fra le righe da controllare. Meglio che inventare "1 farina" su una
 * lista numerata.
 */
const SPORCIZIA = /^\s*(?:[-–—•·*]+\s*)?(?:\d+[.)]\s+)?/;

/** Punteggiatura ai bordi del nome. Le parentesi restano: sono note dell'utente. */
const BORDI = /^[\s.,;:•·*\-–—]+|[\s.,;:•·*\-–—]+$/g;

export function leggiRiga(riga: string, voc: Vocabolario): RigaLetta {
  const pulita = riga.replace(SPORCIZIA, '').replace(/\s+/g, ' ').trim();
  if (pulita === '') return { nome: '', quantita: null, unita: null, sicura: false };

  const inTesta = leggiNumero(pulita, voc);
  if (inTesta && inTesta.valore > 0) {
    return conQuantita(inTesta.valore, '', pulita.slice(inTesta.lunghezza), voc);
  }

  const inCoda = cercaNumeroInCoda(pulita, voc);
  if (inCoda) {
    return conQuantita(inCoda.valore, pulita.slice(0, inCoda.da), pulita.slice(inCoda.a), voc);
  }

  return { nome: ripulisci(pulita), quantita: null, unita: null, sicura: false };
}

/** Mette insieme il pezzo prima del numero, l'unità e il nome. */
function conQuantita(
  quantita: number,
  testa: string,
  coda: string,
  voc: Vocabolario,
): RigaLetta {
  const separata = separaUnitaENome(coda, voc);
  let nome = ripulisci(`${ripulisci(testa)} ${separata.nome}`);
  let unita = separata.unita;

  // Il nome non può restare vuoto: in "2 uova" la parola è il nome, non l'unità.
  if (nome === '' && separata.unitaTesto !== null) {
    nome = ripulisci(separata.unitaTesto);
    unita = null;
  }
  return { nome, quantita, unita, sicura: true };
}

interface UnitaENome {
  nome: string;
  unita: string | null;
  /** Testo grezzo dell'unità, serve a recuperarla come nome quando il nome è vuoto. */
  unitaTesto: string | null;
}

function separaUnitaENome(coda: string, voc: Vocabolario): UnitaENome {
  const parole = coda.trim().split(/\s+/).filter((p) => p !== '');
  if (parole.length === 0) return { nome: '', unita: null, unitaTesto: null };

  // 1. l'unità è attaccata al numero: "200 g farina", "1 fl oz of vanilla",
  //    "un pizzico di sale" (dove 'pizzico' è alias di 'pizzichi').
  const subito = unitaInTesta(parole, voc);
  if (subito) {
    return {
      nome: nomeDaParole(parole.slice(subito.parole), voc),
      unita: subito.voce.canonica,
      unitaTesto: parole.slice(0, subito.parole).join(' '),
    };
  }

  // 2. "una manciata di prezzemolo": davanti a un riempitivo anche una parola
  //    sconosciuta è un'unità, e resta scritta com'era invece di sparire.
  if (parole.length >= 2 && isRiempitivo(parole[1], voc)) {
    return {
      nome: nomeDaParole(parole.slice(2), voc),
      unita: normalizzaUnita(nudo(parole[0]), voc),
      unitaTesto: parole[0],
    };
  }

  // 3. l'unità chiude la riga: "2 large eggs", dove "large" resta nel nome.
  //    Solo in fondo: togliere una parola dal mezzo incollerebbe pezzi di frase
  //    lontani fra loro ("2 large eggs, room temperature").
  const ultima = parole.length - 1;
  const inFondo = parole.length >= 3
    ? trovaUnita(`${nudo(parole[ultima - 1])} ${nudo(parole[ultima])}`, voc)
    : null;
  if (inFondo) {
    return {
      nome: nomeDaParole(parole.slice(0, ultima - 1), voc),
      unita: inFondo.canonica,
      unitaTesto: parole.slice(ultima - 1).join(' '),
    };
  }
  const sola = parole.length >= 2 ? trovaUnita(nudo(parole[ultima]), voc) : null;
  if (sola) {
    return {
      nome: nomeDaParole(parole.slice(0, ultima), voc),
      unita: sola.canonica,
      unitaTesto: parole[ultima],
    };
  }

  // 4. nessuna unità: "2 uova", "3 patate grandi".
  return { nome: nomeDaParole(parole, voc), unita: null, unitaTesto: null };
}

/** Prova prima due parole ("fl oz") e poi una sola: l'alias più lungo vince. */
function unitaInTesta(parole: string[], voc: Vocabolario) {
  if (parole.length >= 2) {
    const due = trovaUnita(`${nudo(parole[0])} ${nudo(parole[1])}`, voc);
    if (due) return { voce: due, parole: 2 };
  }
  const una = trovaUnita(nudo(parole[0]), voc);
  return una ? { voce: una, parole: 1 } : null;
}

function nomeDaParole(parole: string[], voc: Vocabolario): string {
  const resto = [...parole];
  while (resto.length > 0 && isRiempitivo(resto[0], voc)) resto.shift();
  let nome = ripulisci(resto.join(' '));
  // "d'aglio": il riempitivo con l'apostrofo sta attaccato alla parola dopo.
  for (const riempitivo of voc.riempitivi) {
    if (riempitivo.endsWith("'") && nome.toLowerCase().startsWith(riempitivo)) {
      nome = ripulisci(nome.slice(riempitivo.length));
      break;
    }
  }
  return nome;
}

const isRiempitivo = (parola: string, voc: Vocabolario): boolean =>
  voc.riempitivi.includes(nudo(parola).toLowerCase());

/** Toglie la punteggiatura attorno a una parola prima di cercarla nel vocabolario. */
const nudo = (parola: string): string => parola.replace(/^[^\p{L}\d]+|[^\p{L}\d']+$/gu, '');

const ripulisci = (testo: string): string => testo.replace(BORDI, '').replace(/\s+/g, ' ').trim();

/** Cerca la quantità scritta dopo il nome: "farina 200 g". */
function cercaNumeroInCoda(
  testo: string,
  voc: Vocabolario,
): { valore: number; da: number; a: number } | null {
  for (let i = 1; i < testo.length; i++) {
    if (!/\s/.test(testo[i - 1])) continue;
    const numero = leggiNumero(testo.slice(i), voc);
    if (!numero || numero.valore <= 0) continue;
    return { valore: numero.valore, da: i, a: i + numero.lunghezza };
  }
  return null;
}
```

Il numero si cerca solo a inizio parola, e uno zero non conta come quantità:
è quello che salva `"Farina 00"` dal diventare zero grammi di farina.

- [ ] **Step 4: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS. I sei test di `src/domain/parser/riga.test.ts` passano e il resto
della suite resta verde.

- [ ] **Step 5: Aggiungi i test di q.b. e delle parentesi, che falliscono**

Aggiungi in fondo a `src/domain/parser/riga.test.ts`:

```ts
test('q.b. in tutte le sue forme, e non è una riga incerta', () => {
  assert.deepEqual(leggiRiga('sale q.b.', IT), { nome: 'sale', quantita: null, unita: null, sicura: true });
  assert.deepEqual(leggiRiga('Sale qb', IT), { nome: 'Sale', quantita: null, unita: null, sicura: true });
  assert.deepEqual(leggiRiga('pepe a piacere', IT), { nome: 'pepe', quantita: null, unita: null, sicura: true });
  assert.deepEqual(leggiRiga('salt to taste', EN), { nome: 'salt', quantita: null, unita: null, sicura: true });
  assert.deepEqual(leggiRiga('a pinch of nutmeg', EN), { nome: 'nutmeg', quantita: null, unita: null, sicura: true });
  assert.deepEqual(leggiRiga('water as needed', EN), { nome: 'water', quantita: null, unita: null, sicura: true });
});

test('le note fra parentesi restano nel nome e non fanno da quantità', () => {
  assert.deepEqual(leggiRiga('200 g di farina (tipo 00)', IT), { nome: 'farina (tipo 00)', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('farina (tipo 00) 200 g', IT), { nome: 'farina (tipo 00)', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('burro (circa 1 panetto) 200 g', IT), { nome: 'burro (circa 1 panetto)', quantita: 200, unita: 'g', sicura: true });
  assert.deepEqual(leggiRiga('cioccolato fondente 70%', IT), { nome: 'cioccolato fondente 70%', quantita: null, unita: null, sicura: false });
});
```

- [ ] **Step 6: Esegui i test e verifica che i due nuovi falliscano**

Comando: `npm test`

Atteso: FAIL su due test.

`q.b. in tutte le sue forme, e non è una riga incerta`:
`actual: { nome: 'sale q.b', quantita: null, unita: null, sicura: false },
expected: { nome: 'sale', quantita: null, unita: null, sicura: true }`.

`le note fra parentesi restano nel nome e non fanno da quantità`:
`actual: { nome: 'burro (circa panetto) 200', quantita: 1, unita: 'g', sicura: true },
expected: { nome: 'burro (circa 1 panetto)', quantita: 200, unita: 'g', sicura: true }`,
e la percentuale che diventa una quantità:
`actual: { nome: 'cioccolato fondente %', quantita: 70, unita: null, sicura: true }`.

- [ ] **Step 7: Implementa q.b., parentesi e percentuali**

In `src/domain/parser/riga.ts` sostituisci `leggiRiga` per intero:

```ts
export function leggiRiga(riga: string, voc: Vocabolario): RigaLetta {
  // Niente ripulitura dei bordi qui: il punto finale di "q.b." serve ancora.
  const pulita = riga.replace(SPORCIZIA, '').replace(/\s+/g, ' ').trim();
  if (pulita === '') return { nome: '', quantita: null, unita: null, sicura: false };

  // Il q.b. si controlla per primo: "a pinch of" comincia con un numero a parole.
  const senzaQb = togliQuantoBasta(pulita, voc);
  if (senzaQb !== null) {
    const nome = ripulisci(senzaQb);
    return { nome: nome || ripulisci(pulita), quantita: null, unita: null, sicura: true };
  }

  const inTesta = leggiNumero(pulita, voc);
  if (inTesta && inTesta.valore > 0) {
    return conQuantita(inTesta.valore, '', pulita.slice(inTesta.lunghezza), voc);
  }

  const inCoda = cercaNumeroInCoda(pulita, voc);
  if (inCoda) {
    return conQuantita(inCoda.valore, pulita.slice(0, inCoda.da), pulita.slice(inCoda.a), voc);
  }

  return { nome: ripulisci(pulita), quantita: null, unita: null, sicura: false };
}
```

aggiungi la funzione nuova sopra `cercaNumeroInCoda`:

```ts
/** Toglie l'espressione di q.b. dalla riga. null = non c'era. */
function togliQuantoBasta(testo: string, voc: Vocabolario): string | null {
  const minuscolo = testo.toLowerCase();
  const espressioni = [...voc.quantoBasta].sort((a, b) => b.length - a.length);
  for (const espressione of espressioni) {
    const i = minuscolo.indexOf(espressione);
    if (i === -1) continue;
    const prima = minuscolo[i - 1] ?? ' ';
    const dopo = minuscolo[i + espressione.length] ?? ' ';
    if (/[\p{L}\d]/u.test(prima) || /[\p{L}\d]/u.test(dopo)) continue;
    return `${testo.slice(0, i)} ${testo.slice(i + espressione.length)}`;
  }
  return null;
}
```

e sostituisci `cercaNumeroInCoda` per intero:

```ts
/**
 * Cerca la quantità scritta dopo il nome: "farina 200 g".
 * Salta quello che sta fra parentesi ("burro (circa 1 panetto) 200 g") e le
 * percentuali ("cioccolato fondente 70%"), che fanno parte del nome.
 */
function cercaNumeroInCoda(
  testo: string,
  voc: Vocabolario,
): { valore: number; da: number; a: number } | null {
  let profondita = 0;
  for (let i = 1; i < testo.length; i++) {
    const carattere = testo[i];
    if (carattere === '(') { profondita += 1; continue; }
    if (carattere === ')') { profondita = Math.max(0, profondita - 1); continue; }
    if (profondita > 0 || !/\s/.test(testo[i - 1])) continue;

    const numero = leggiNumero(testo.slice(i), voc);
    if (!numero || numero.valore <= 0) continue;
    if (testo[i + numero.lunghezza] === '%') continue;
    return { valore: numero.valore, da: i, a: i + numero.lunghezza };
  }
  return null;
}
```

Le espressioni di q.b. si provano dalla più lunga alla più corta, altrimenti in
inglese `"a pinch"` vincerebbe su `"a pinch of"` e la riga si porterebbe dietro
un `of` orfano.

- [ ] **Step 8: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS, ℹ pass 62, ℹ fail 0 (i 54 lasciati dal Task 6 più gli 8 di
`riga.test.ts`). Otto test in `src/domain/parser/riga.test.ts`, nessun
fallimento nel resto della suite.

- [ ] **Step 9: Verifica i tipi**

Comando: `npm run typecheck`

Atteso: nessun errore, uscita vuota.

- [ ] **Step 10: Commit**

```bash
git add src/domain/parser/riga.ts src/domain/parser/riga.test.ts
git commit -m "Leggi una riga di ingrediente in italiano e in inglese"
```

---

### Task 8: Parser del blocco incollato

Quando l'utente incolla un elenco di ingredienti copiato da un sito, questo
modulo lo trasforma in gruppi di righe. È l'ultimo pezzo del parser: la lettura
del singolo numero e della singola riga esistono già (Task 6 e 7), qui si
gestiscono le righe multiple, le sezioni, il procedimento da buttare e la scelta
della lingua.

Le tre regole che vengono dai casi reali:

1. si divide sugli a capo. La virgola divide **solo quando l'incolla è tutto su
   una riga sola**: dentro un elenco a capo `2 large eggs, room temperature` è
   una riga sola e spezzarla produrrebbe un ingrediente di nome
   "room temperature". Anche quando la virgola divide, quella dentro `1,5 kg` è
   il separatore decimale, quindi non divide se sta **fra due cifre**;
2. una riga che finisce con i due punti e non contiene cifre è il nome di una
   sezione (`Per l'impasto:`); `Ingredienti` e `Ingredients` invece aprono il
   gruppo predefinito, quello senza nome;
3. da una parola di stop in poi c'è il procedimento, non gli ingredienti: si
   smette di leggere e si scarta il resto.

Il task si chiude con un elenco di casi costruito sulle 12 ricette vere e sui
formati dei siti di ricette più diffusi: è la verifica promessa dalla sezione 10
della spec, e serve a evitare che il parser sia verde solo sulle righe che ci
siamo inventati noi.

**Files:**
- Create: `src/domain/parser/blocco.ts`
- Test: `src/domain/parser/blocco.test.ts`
- Test: `src/domain/parser/casi-reali.test.ts`

**Interfaces:**

- Consumes, da `src/domain/lingua/index.ts` (già scritto, Task 1):

```ts
export type Lingua = 'it' | 'en';
export type Famiglia = 'precisione' | 'misurino' | 'discreta';

export interface VoceUnita {
  canonica: string;
  alias: string[];
  famiglia: Famiglia;
  singolare: string;
  plurale: string;
  base?: { unita: string; fattore: number };
}

export interface Vocabolario {
  lingua: Lingua;
  unita: VoceUnita[];
  /** 'un' -> 1, 'mezzo' -> 0.5, 'three' -> 3. Chiavi minuscole. */
  numeriAParole: Record<string, number>;
  /** Espressioni che significano "quanto basta", minuscole: 'qb', 'q.b.', 'to taste'... */
  quantoBasta: string[];
  /** Parole da scartare fra quantità e nome, minuscole: 'di', 'of'. */
  riempitivi: string[];
  /** Parole che fanno smettere di leggere, minuscole: 'preparazione', 'directions'... */
  stop: string[];
  /** Intestazioni che NON creano una sezione con nome, minuscole: 'ingredienti', 'ingredients'. */
  intestazioneNeutra: string[];
  separatoreDecimale: ',' | '.';
}

export const VOCABOLARI: Record<Lingua, Vocabolario>;
export function vocabolario(lingua: Lingua): Vocabolario;
export const LINGUE: Lingua[];   // ['it', 'en']
```

- Consumes, da `src/domain/parser/riga.ts` (già scritto, Task 7):

```ts
export interface RigaLetta {
  nome: string;
  quantita: number | null;   // null = q.b.
  unita: string | null;
  /** false quando il parser non ha capito: la riga va segnalata all'utente. */
  sicura: boolean;
}
export function leggiRiga(riga: string, voc: Vocabolario): RigaLetta;
```

- Consumes, da `src/domain/types.ts`:

```ts
export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }
```

- Consumes, da `src/domain/id.ts`: `export function newId(): string`

- Produces:

```ts
export interface GruppoLetto { nome: string | null; ingredienti: RigaLetta[] }
export interface EsitoParser {
  lingua: Lingua;
  gruppi: GruppoLetto[];
  righeSicure: number;
  righeTotali: number;
}
export function leggiBlocco(testo: string, voc: Vocabolario): EsitoParser;
export function leggiBloccoMultilingua(testo: string): EsitoParser;
export function esitoInGruppi(esito: EsitoParser): Gruppo[];
```

La segnalazione `sicura` vive solo dentro `EsitoParser`: la schermata di
modifica evidenzia le righe dubbie guardando l'esito, e chiama `esitoInGruppi`
solo al salvataggio, quando l'utente le ha già viste.

---

- [ ] **Step 1: Scrivi il test che fallisce (lettura del blocco)**

Crea `src/domain/parser/blocco.test.ts`:

```ts
/**
 * Test del parser di blocco: da un testo incollato a gruppi di righe.
 *
 * I nomi si confrontano in minuscolo e senza spazi ai bordi: qui interessa che
 * la riga sia finita nel gruppo giusto con i numeri giusti, non come leggiRiga
 * tratti le maiuscole.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { leggiBlocco } from './blocco.ts';
import type { EsitoParser, GruppoLetto } from './blocco.ts';
import { vocabolario } from '../lingua/index.ts';

const IT = vocabolario('it');
const EN = vocabolario('en');

const nomiDi = (g: GruppoLetto): string[] =>
  g.ingredienti.map((i) => i.nome.trim().toLowerCase());
const tuttiINomi = (e: EsitoParser): string[] => e.gruppi.flatMap(nomiDi);

/** Incolla tipico da un sito italiano: intestazione, due sezioni, procedimento in fondo. */
const BLOCCO_ITALIANO = `Ingredienti

Per l'impasto:
250 g di farina 00
1 bustina di lievito
125 g burro

Per la crema:
500 ml di latte
150 g zucchero
1,5 kg di fragole
sale q.b.
prezzemolo q.b.

Preparazione
Mescolare la farina con il burro e le uova fino a ottenere un impasto liscio.
Cuocere in forno per quaranta minuti.`;

/** Incolla tipico da un sito americano. */
const BLOCCO_AMERICANO = `Ingredients
2 cups flour
1 lb butter
1/2 tsp salt
3 oz dark chocolate

For the topping:
1 stick of butter
2 tbsp sugar
a pinch of nutmeg
salt to taste

Directions
Preheat the oven and melt the chocolate over a pot of simmering water.
Bake until golden brown.`;

test('su una riga sola la virgola separa gli ingredienti, tranne quando è il separatore decimale', () => {
  const esito = leggiBlocco('200 g di farina, 2 cucchiai di zucchero, 1,5 kg di patate', IT);
  assert.equal(esito.gruppi.length, 1);
  assert.equal(esito.gruppi[0].nome, null);
  assert.deepEqual(nomiDi(esito.gruppi[0]), ['farina', 'zucchero', 'patate']);
  const patate = esito.gruppi[0].ingredienti[2];
  assert.equal(patate.quantita, 1.5);
  assert.equal(patate.unita, 'kg');
  assert.equal(esito.righeTotali, 3);
});

test('con più righe la virgola non divide più: è punteggiatura dentro il nome', () => {
  // La spec dice che la virgola separa "quando si incolla tutto su una riga".
  // Dividendo anche qui, "room temperature" diventerebbe un ingrediente.
  const esito = leggiBlocco('200 g farina\n2 large eggs, room temperature', EN);
  assert.equal(esito.righeTotali, 2);
  assert.deepEqual(nomiDi(esito.gruppi[0]), ['farina', 'large eggs, room temperature']);
  assert.equal(esito.gruppi[0].ingredienti[1].quantita, 2);
});

test('blocco italiano: intestazione neutra, due sezioni, righe vuote saltate', () => {
  const esito = leggiBlocco(BLOCCO_ITALIANO, IT);
  assert.equal(esito.lingua, 'it');
  assert.equal(esito.gruppi.length, 3);
  assert.equal(esito.gruppi[0].nome, null);             // "Ingredienti" apre il gruppo senza nome
  assert.equal(esito.gruppi[0].ingredienti.length, 0);  // e resta vuoto: subito dopo c'è una sezione
  assert.equal(esito.gruppi[1].nome, "Per l'impasto");
  assert.equal(esito.gruppi[2].nome, 'Per la crema');
  assert.deepEqual(nomiDi(esito.gruppi[1]), ['farina 00', 'lievito', 'burro']);
  assert.deepEqual(nomiDi(esito.gruppi[2]), ['latte', 'zucchero', 'fragole', 'sale', 'prezzemolo']);
  const fragole = esito.gruppi[2].ingredienti[2];
  assert.equal(fragole.quantita, 1.5);
  assert.equal(fragole.unita, 'kg');
  assert.equal(esito.gruppi[2].ingredienti[3].quantita, null);   // sale q.b.
  assert.equal(esito.righeTotali, 8);
  assert.equal(esito.righeSicure, 8);
});

test('da "Preparazione" in poi non si legge più niente', () => {
  const esito = leggiBlocco(BLOCCO_ITALIANO, IT);
  assert.ok(!tuttiINomi(esito).some((n) => n.includes('mescolare') || n.includes('cuocere')));
  assert.equal(tuttiINomi(esito).length, 8);
});

test('blocco americano: "For the topping:" apre una sezione, "Directions" ferma tutto', () => {
  const esito = leggiBlocco(BLOCCO_AMERICANO, EN);
  assert.equal(esito.lingua, 'en');
  assert.equal(esito.gruppi.length, 2);
  assert.equal(esito.gruppi[0].nome, null);
  assert.deepEqual(nomiDi(esito.gruppi[0]), ['flour', 'butter', 'salt', 'dark chocolate']);
  assert.equal(esito.gruppi[1].nome, 'For the topping');
  assert.deepEqual(nomiDi(esito.gruppi[1]), ['butter', 'sugar', 'nutmeg', 'salt']);
  const sale = esito.gruppi[0].ingredienti[2];
  assert.equal(sale.quantita, 0.5);
  assert.equal(sale.unita, 'tsp');
  assert.equal(esito.gruppi[1].ingredienti[2].quantita, null);   // a pinch of nutmeg
  assert.equal(esito.gruppi[1].ingredienti[3].quantita, null);   // salt to taste
  assert.equal(esito.righeTotali, 8);
  assert.equal(esito.righeSicure, 8);
  assert.ok(!tuttiINomi(esito).some((n) => n.includes('preheat') || n.includes('bake')));
});

test('una riga di stop con dentro dei numeri non è una parola di stop', () => {
  // "Preparazione: 20 minuti" in cima alla pagina non deve buttare via la ricetta.
  const esito = leggiBlocco('Preparazione: 20 minuti\n200 g di farina', IT);
  assert.equal(esito.gruppi.length, 1);
  assert.equal(esito.gruppi[0].ingredienti.length, 2);
  const farina = esito.gruppi[0].ingredienti[1];
  assert.equal(farina.nome.trim().toLowerCase(), 'farina');
  assert.equal(farina.quantita, 200);
  // "Preparazione della torta" invece è procedimento a tutti gli effetti.
  const conStop = leggiBlocco('200 g di farina\nPreparazione della torta\n2 uova sbattute', IT);
  assert.equal(conStop.righeTotali, 1);
});

test("la sporcizia dell'incolla a inizio riga non finisce nel nome", () => {
  const esito = leggiBlocco('- 200 g di farina\n• 2 cucchiai di zucchero\n1. 1 bustina di lievito', IT);
  assert.deepEqual(nomiDi(esito.gruppi[0]), ['farina', 'zucchero', 'lievito']);
  assert.equal(esito.gruppi[0].ingredienti[0].quantita, 200);
  assert.equal(esito.gruppi[0].ingredienti[2].quantita, 1);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'.../src/domain/parser/blocco.ts' imported from
.../src/domain/parser/blocco.test.ts` — il file non esiste ancora.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

Crea `src/domain/parser/blocco.ts`:

```ts
/**
 * Parser di un blocco di testo incollato: da un elenco copiato da un sito a
 * gruppi di righe pronte da correggere.
 *
 * Tre regole che vengono dai casi reali:
 * - si divide sugli a capo; la virgola divide solo quando l'incolla è tutto su
 *   una riga sola, e nemmeno allora se sta fra due cifre ("1,5 kg");
 * - una riga che finisce con i due punti e non contiene cifre è il nome di una
 *   sezione ("Per l'impasto:"); "Ingredienti" e "Ingredients" aprono invece il
 *   gruppo predefinito, quello senza nome;
 * - da una parola di stop in poi c'è il procedimento: si smette di leggere e si
 *   scarta il resto.
 *
 * Regola sopra tutte: non si perde niente. Una riga non capita diventa un
 * ingrediente col solo nome e con sicura = false, così l'utente la vede.
 */

import type { Lingua, Vocabolario } from '../lingua/index.ts';
import type { RigaLetta } from './riga.ts';
import { leggiRiga } from './riga.ts';

export interface GruppoLetto {
  nome: string | null;
  ingredienti: RigaLetta[];
}

export interface EsitoParser {
  lingua: Lingua;
  gruppi: GruppoLetto[];
  righeSicure: number;
  righeTotali: number;
}

const eCifra = (c: string | undefined): boolean => c !== undefined && c >= '0' && c <= '9';

/** Divide sulle virgole, tranne quando la virgola sta fra due cifre (1,5 kg). */
function dividiSuVirgole(riga: string): string[] {
  const pezzi: string[] = [];
  let corrente = '';
  for (let i = 0; i < riga.length; i++) {
    const c = riga[i];
    if (c === ',' && !(eCifra(riga[i - 1]) && eCifra(riga[i + 1]))) {
      pezzi.push(corrente);
      corrente = '';
    } else {
      corrente += c;
    }
  }
  pezzi.push(corrente);
  return pezzi;
}

/** Via la sporcizia dell'incolla a inizio riga: trattini, pallini, numerazioni. */
function ripulisciInizio(pezzo: string): string {
  return pezzo
    .replace(/^[\s\-–—•*·▪◦]+/, '')
    .replace(/^\d+[.)]\s+/, '')
    .trim();
}

/** Forma di confronto per intestazioni e parole di stop: niente due punti, minuscolo. */
const perConfronto = (pulito: string): string =>
  pulito.replace(/:+$/, '').trim().toLowerCase();

/**
 * Una parola di stop ferma il parser solo se la riga è davvero un titolo:
 * "Preparazione" e "Preparazione della torta" sì, "Preparazione: 20 minuti" no,
 * perché quella riga sui siti sta in cima e fermarsi lì butterebbe la ricetta.
 */
const eParolaDiStop = (confronto: string, voc: Vocabolario): boolean =>
  voc.stop.some(
    (s) => confronto === s || (confronto.startsWith(s + ' ') && !/\d/.test(confronto)),
  );

export function leggiBlocco(testo: string, voc: Vocabolario): EsitoParser {
  const gruppi: GruppoLetto[] = [];
  let corrente: GruppoLetto | null = null;
  let righeSicure = 0;
  let righeTotali = 0;

  const apriGruppo = (nome: string | null): GruppoLetto => {
    const g: GruppoLetto = { nome, ingredienti: [] };
    gruppi.push(g);
    return g;
  };

  // La virgola separa gli ingredienti solo quando si incolla tutto su una riga
  // (spec, sezione 6). Dentro un elenco a capo è punteggiatura di frase, e
  // spezzarla trasformerebbe "2 large eggs, room temperature" in due voci, la
  // seconda incomprensibile.
  const righe = testo.split(/\r\n|\r|\n/);
  const pezzi = righe.length === 1 ? dividiSuVirgole(righe[0]) : righe;

  for (const pezzo of pezzi) {
    const pulito = ripulisciInizio(pezzo);
    const confronto = perConfronto(pulito);
    if (confronto === '') continue;

    if (eParolaDiStop(confronto, voc)) {
      return { lingua: voc.lingua, gruppi, righeSicure, righeTotali };
    }

    if (voc.intestazioneNeutra.includes(confronto)) {
      corrente = apriGruppo(null);
      continue;
    }

    if (pulito.endsWith(':') && !/\d/.test(pulito)) {
      corrente = apriGruppo(pulito.replace(/:+$/, '').trim());
      continue;
    }

    const letta = leggiRiga(pulito, voc);
    if (corrente === null) corrente = apriGruppo(null);
    corrente.ingredienti.push(letta);
    righeTotali++;
    if (letta.sicura) righeSicure++;
  }

  return { lingua: voc.lingua, gruppi, righeSicure, righeTotali };
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS — i sette test di `blocco.test.ts` verdi, insieme a quelli già
esistenti.

- [ ] **Step 5: Scrivi il test che fallisce (scelta della lingua)**

In `src/domain/parser/blocco.test.ts` cambia due righe di import (non sono
adiacenti, in mezzo c'è `import type { EsitoParser, GruppoLetto }`):

- `import { leggiBlocco } from './blocco.ts';`
  diventa `import { leggiBlocco, leggiBloccoMultilingua } from './blocco.ts';`
- `import { vocabolario } from '../lingua/index.ts';`
  diventa `import { LINGUE, vocabolario } from '../lingua/index.ts';`

Poi aggiungi in fondo al file:

```ts
test('leggiBloccoMultilingua sceglie la lingua che capisce più righe', () => {
  const americano = leggiBloccoMultilingua(BLOCCO_AMERICANO);
  assert.equal(americano.lingua, 'en');
  assert.equal(americano.righeSicure, 8);
  assert.equal(americano.gruppi.length, 2);
  assert.equal(americano.gruppi[1].nome, 'For the topping');

  const italiano = leggiBloccoMultilingua(BLOCCO_ITALIANO);
  assert.equal(italiano.lingua, 'it');
  assert.equal(italiano.righeSicure, 8);
  assert.equal(italiano.gruppi.length, 3);
});

test('a pari merito vince la prima lingua di LINGUE', () => {
  // Due nomi soli: nessuna delle due lingue ne capisce una in più dell'altra.
  const esito = leggiBloccoMultilingua('rosmarino\nsalvia');
  assert.equal(esito.lingua, LINGUE[0]);
  assert.equal(esito.lingua, 'it');
  assert.equal(esito.righeTotali, 2);
});
```

- [ ] **Step 6: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `SyntaxError: The requested module './blocco.ts' does not
provide an export named 'leggiBloccoMultilingua'`: il file non la esporta
ancora, quindi nemmeno i test già verdi vengono caricati.

- [ ] **Step 7: Implementa il minimo che fa passare il test**

In `src/domain/parser/blocco.ts` aggiungi una riga di import subito sotto
`import type { Lingua, Vocabolario } from '../lingua/index.ts';`, così le due
righe diventano:

```ts
import type { Lingua, Vocabolario } from '../lingua/index.ts';
import { LINGUE, vocabolario } from '../lingua/index.ts';
```

e aggiungi in fondo al file:

```ts
/**
 * Prova tutti i vocabolari installati e tiene quello che ha capito più righe.
 * Costa poco e risolve il caso concreto dell'italiano che incolla una ricetta
 * americana trovata in rete. A pari merito vince la prima lingua di LINGUE.
 */
export function leggiBloccoMultilingua(testo: string): EsitoParser {
  let migliore = leggiBlocco(testo, vocabolario(LINGUE[0]));
  for (const lingua of LINGUE.slice(1)) {
    const esito = leggiBlocco(testo, vocabolario(lingua));
    if (esito.righeSicure > migliore.righeSicure) migliore = esito;
  }
  return migliore;
}
```

- [ ] **Step 8: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS — nove test verdi in `blocco.test.ts`, compresi i due nuovi
`leggiBloccoMultilingua sceglie la lingua che capisce più righe` e `a pari
merito vince la prima lingua di LINGUE`.

- [ ] **Step 9: Scrivi il test che fallisce (conversione in gruppi salvabili)**

In `src/domain/parser/blocco.test.ts` cambia la prima riga di import:

- `import { leggiBlocco, leggiBloccoMultilingua } from './blocco.ts';`
  diventa `import { esitoInGruppi, leggiBlocco, leggiBloccoMultilingua } from './blocco.ts';`

Poi aggiungi in fondo al file:

```ts
test('esitoInGruppi genera gli id e scarta i gruppi rimasti vuoti', () => {
  const gruppi = esitoInGruppi(leggiBlocco(BLOCCO_ITALIANO, IT));
  assert.equal(gruppi.length, 2);   // il gruppo aperto da "Ingredienti" è rimasto senza righe
  assert.equal(gruppi[0].nome, "Per l'impasto");
  assert.equal(gruppi[1].nome, 'Per la crema');
  assert.equal(gruppi[0].ingredienti.length, 3);
  assert.equal(gruppi[1].ingredienti.length, 5);

  const fragole = gruppi[1].ingredienti[2];
  assert.equal(fragole.nome.trim().toLowerCase(), 'fragole');
  assert.equal(fragole.quantita, 1.5);
  assert.equal(fragole.unita, 'kg');
  assert.equal(gruppi[1].ingredienti[3].quantita, null);   // sale q.b.

  const ids = gruppi.flatMap((g) => [g.id, ...g.ingredienti.map((i) => i.id)]);
  assert.equal(new Set(ids).size, ids.length);
  assert.ok(ids.every((id) => typeof id === 'string' && id.length > 0));
});

test('esitoInGruppi su un incolla senza sezioni dà un gruppo solo, senza nome', () => {
  const gruppi = esitoInGruppi(leggiBlocco('200 g di farina\n500 ml di latte', IT));
  assert.equal(gruppi.length, 1);
  assert.equal(gruppi[0].nome, null);
  assert.equal(gruppi[0].ingredienti.length, 2);
});
```

- [ ] **Step 10: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `SyntaxError: The requested module './blocco.ts' does not
provide an export named 'esitoInGruppi'`.

- [ ] **Step 11: Implementa il minimo che fa passare il test**

In `src/domain/parser/blocco.ts` aggiungi in cima, sotto gli altri import:

```ts
import type { Gruppo, Ingrediente } from '../types.ts';
import { newId } from '../id.ts';
```

e in fondo al file:

```ts
/**
 * Converte l'esito in gruppi pronti da salvare, generando gli id. I gruppi
 * rimasti senza ingredienti spariscono: un'intestazione senza righe sotto non
 * è una sezione, è solo un titolo di pagina.
 */
export function esitoInGruppi(esito: EsitoParser): Gruppo[] {
  return esito.gruppi
    .filter((g) => g.ingredienti.length > 0)
    .map((g) => ({
      id: newId(),
      nome: g.nome,
      ingredienti: g.ingredienti.map(
        (r): Ingrediente => ({
          id: newId(),
          nome: r.nome,
          quantita: r.quantita,
          unita: r.unita,
        }),
      ),
    }));
}
```

- [ ] **Step 12: Esegui test e typecheck e verifica che passino**

Comandi:

```bash
npm test
npm run typecheck
```

Atteso: PASS, gli undici test di `blocco.test.ts` verdi e `tsc --noEmit` senza
errori.

- [ ] **Step 13: Scrivi l'elenco dei casi reali**

Questo file non introduce codice nuovo: è la verifica promessa dalla sezione 10
della spec. Le righe delle 12 ricette vere sono ricostruite da
`/Users/paolo/Server/Siti Web/_private/casa/app/dosatore/data/ricette.json`
nella forma in cui si scrivono a mano (`gr` invece di `g`, `mezza tazzina`
invece di `0.5 tazzina`), perché è quella che l'utente incolla.

Crea `src/domain/parser/casi-reali.test.ts`:

```ts
/**
 * Il parser provato sui dati veri, non su righe inventate.
 *
 * Tre gruppi di casi:
 * - le righe delle 12 ricette del vecchio ricettario, riscritte come si
 *   scrivono a mano;
 * - due incolla nei formati dei siti di ricette italiani più diffusi;
 * - un incolla da un sito americano.
 *
 * La misura è il rapporto fra righe capite con sicurezza e righe totali. Non è
 * un test di regressione fine: è la rete che si accorge se una modifica al
 * parser fa smettere di capire la ricetta della nonna.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { leggiBlocco } from './blocco.ts';
import { vocabolario } from '../lingua/index.ts';
import type { Vocabolario } from '../lingua/index.ts';

const IT = vocabolario('it');
const EN = vocabolario('en');

/**
 * Soglia dichiarata: su questo elenco il parser deve capire almeno il 95% delle
 * righe. Oggi le capisce tutte; il margine serve a poter aggiungere un formato
 * ostico senza far diventare rosso il test, e resta abbastanza alto da
 * accorgersi di una regressione vera.
 */
const SOGLIA = 0.95;

/** Le 12 ricette vere, 77 righe in tutto, nella forma in cui si incollano. */
const RICETTE_VERE: { titolo: string; testo: string }[] = [
  {
    titolo: 'Farinata',
    testo: `180 g di farina
580 g di acqua
8 g di sale`,
  },
  {
    titolo: 'Ciambella Bertolini',
    testo: `500 gr di farina
300 gr di zucchero
175 gr di burro
4 uova
1 bicchiere di latte
1 bustina di lievito`,
  },
  {
    titolo: 'Riso zucca castelmagno',
    testo: `300 gr di riso carnaroli
1 lt di brodo vegetale
600 gr di zucca frullata
150 ml di panna
125 gr di castelmagno
40 gr di burro nocciola
salvia q.b.`,
  },
  {
    titolo: 'Polenta',
    testo: `180 gr di polenta
720 ml di acqua`,
  },
  {
    titolo: 'Minestra di zucca',
    testo: `600 gr di zucca
125 ml di acqua
600 ml di latte
100 gr di grana
sale q.b.
pepe q.b.`,
  },
  {
    titolo: 'Tozzetti',
    testo: `4 uova
350 gr di nocciole
1 kg di farina
1 bustina di lievito
1 bicchierino di sambuca
250 gr di cioccolato fondente
1 limone
mezza tazzina di latte
350 gr di zucchero`,
  },
  {
    titolo: 'Baci di Dama',
    testo: `100 gr di farina
50 gr di fecola
100 gr di zucchero
100 gr di burro
150 gr di nocciole tostate
1 pizzico di sale
1 pizzico di cacao
cioccolato fondente q.b.`,
  },
  {
    titolo: 'Biscotti al limone',
    testo: `1 uovo
100 gr di zucchero
100 gr di olio
1 cucchiaino di lievito
succo di mezzo limone
zucchero a velo q.b.
300 gr di farina`,
  },
  {
    titolo: 'Besciamella',
    testo: `0,9 l di latte
50 gr di farina
50 gr di burro
noce moscata q.b.
sale q.b.`,
  },
  {
    titolo: 'Flan di zucca',
    testo: `450 gr di zucca delica pulita
4 uova
100 gr di latte
200 gr di panna fresca liquida
50 gr di grana
olio q.b.
sale q.b.`,
  },
  {
    titolo: 'Empanadas',
    testo: `50 gr di burro
1 uovo
225 gr di farina
mezzo bicchiere di acqua
4 gr di sale
mezzo cubetto di lievito di birra
1 uovo per dorare`,
  },
  {
    titolo: 'Muffin',
    testo: `125 gr di burro
265 gr di farina
135 gr di zucchero
135 gr di latte
2 uova
100 gr di gocce di cioccolato
vaniglia q.b.
1 cucchiaino di bicarbonato
sale q.b.
10 gr di lievito per dolci`,
  },
];

/** Formato più comune dei siti italiani: elenco puntato fra due intestazioni. */
const SITO_ITALIANO_ELENCO = `Ingredienti

- 250 g di farina 00
- 3 uova
- 100 g di zucchero
- 1 bustina di lievito per dolci
- 1 pizzico di sale
- burro q.b.

Preparazione

Mescolare le uova con lo zucchero, unire la farina setacciata e il lievito.
Infornare a 180 gradi per 35 minuti.`;

/** Stesso formato, ma diviso in sezioni: l'altro grande classico italiano. */
const SITO_ITALIANO_SEZIONI = `Per la base:
300 g di biscotti secchi
150 g di burro fuso

Per la crema:
500 g di mascarpone
4 uova
100 g di zucchero a velo
1 cucchiaio di cacao amaro
mezzo bicchiere di caffè

Procedimento
Tritare i biscotti e mescolarli col burro fuso, poi stendere la crema.`;

/** Incolla da un sito americano, con le virgole dentro le righe. */
const SITO_AMERICANO = `Ingredients
1 1/2 cups all-purpose flour
2 large eggs, room temperature
1/2 cup granulated sugar
1 stick unsalted butter, softened
1 tsp vanilla extract
a pinch of salt

Directions
Preheat the oven to 350 degrees and butter a round pan.`;

const CASI: { nome: string; testo: string; voc: Vocabolario }[] = [
  ...RICETTE_VERE.map((r) => ({ nome: r.titolo, testo: r.testo, voc: IT })),
  { nome: 'sito italiano, elenco puntato', testo: SITO_ITALIANO_ELENCO, voc: IT },
  { nome: 'sito italiano, a sezioni', testo: SITO_ITALIANO_SEZIONI, voc: IT },
  { nome: 'sito americano', testo: SITO_AMERICANO, voc: EN },
];

const testoDi = (titolo: string): string => {
  const trovata = RICETTE_VERE.find((r) => r.titolo === titolo);
  if (!trovata) throw new Error(`ricetta di prova assente: ${titolo}`);
  return trovata.testo;
};

test('le 77 righe delle 12 ricette vere si leggono tutte', () => {
  let sicure = 0;
  let totali = 0;
  for (const ricetta of RICETTE_VERE) {
    const esito = leggiBlocco(ricetta.testo, IT);
    assert.equal(esito.gruppi.length, 1, `${ricetta.titolo}: un gruppo solo, senza sezioni`);
    sicure += esito.righeSicure;
    totali += esito.righeTotali;
  }
  assert.equal(totali, 77);   // gli stessi 77 ingredienti che conta npm run migra
  assert.equal(sicure, 77);
});

test('le righe vere più insidiose finiscono giuste', () => {
  const biscotti = leggiBlocco(testoDi('Biscotti al limone'), IT).gruppi[0].ingredienti;
  // "uovo" è unità di vocabolario, ma è anche l'unica parola: allora è il nome.
  assert.deepEqual(biscotti[0], { nome: 'uovo', quantita: 1, unita: null, sicura: true });
  // numero a parole in mezzo alla riga, col nome spezzato in due.
  assert.deepEqual(biscotti[4], { nome: 'succo di limone', quantita: 0.5, unita: null, sicura: true });
  assert.deepEqual(biscotti[5], { nome: 'zucchero a velo', quantita: null, unita: null, sicura: true });

  const tozzetti = leggiBlocco(testoDi('Tozzetti'), IT).gruppi[0].ingredienti;
  assert.deepEqual(tozzetti[7], { nome: 'latte', quantita: 0.5, unita: 'tazzine', sicura: true });

  const baci = leggiBlocco(testoDi('Baci di Dama'), IT).gruppi[0].ingredienti;
  assert.deepEqual(baci[5], { nome: 'sale', quantita: 1, unita: 'pizzichi', sicura: true });

  // la virgola decimale non deve spezzare la riga né sparire dal numero.
  const besciamella = leggiBlocco(testoDi('Besciamella'), IT).gruppi[0].ingredienti;
  assert.deepEqual(besciamella[0], { nome: 'latte', quantita: 0.9, unita: 'l', sicura: true });
});

test('sito italiano: elenco puntato fra "Ingredienti" e "Preparazione"', () => {
  const esito = leggiBlocco(SITO_ITALIANO_ELENCO, IT);
  assert.equal(esito.gruppi.length, 1);
  assert.equal(esito.gruppi[0].nome, null);
  assert.equal(esito.righeTotali, 6);
  assert.equal(esito.righeSicure, 6);
  assert.deepEqual(
    esito.gruppi[0].ingredienti.map((i) => i.nome),
    ['farina 00', 'uova', 'zucchero', 'lievito per dolci', 'sale', 'burro'],
  );
  assert.equal(esito.gruppi[0].ingredienti[5].quantita, null);   // burro q.b.
});

test('sito italiano: le sezioni "Per la base:" e "Per la crema:"', () => {
  const esito = leggiBlocco(SITO_ITALIANO_SEZIONI, IT);
  assert.equal(esito.gruppi.length, 2);
  assert.equal(esito.gruppi[0].nome, 'Per la base');
  assert.equal(esito.gruppi[1].nome, 'Per la crema');
  assert.equal(esito.gruppi[0].ingredienti.length, 2);
  assert.equal(esito.gruppi[1].ingredienti.length, 5);
  assert.equal(esito.righeTotali, 7);
  assert.equal(esito.righeSicure, 7);
  assert.deepEqual(
    esito.gruppi[1].ingredienti[2],
    { nome: 'zucchero a velo', quantita: 100, unita: 'g', sicura: true },
  );
  assert.deepEqual(
    esito.gruppi[1].ingredienti[4],
    { nome: 'caffè', quantita: 0.5, unita: 'bicchieri', sicura: true },
  );
});

test('sito americano: le virgole restano dentro i nomi', () => {
  const esito = leggiBlocco(SITO_AMERICANO, EN);
  assert.equal(esito.gruppi.length, 1);
  assert.equal(esito.righeTotali, 6);
  assert.equal(esito.righeSicure, 6);
  assert.deepEqual(
    esito.gruppi[0].ingredienti.map((i) => i.nome),
    [
      'all-purpose flour',
      'large eggs, room temperature',
      'granulated sugar',
      'unsalted butter, softened',
      'vanilla extract',
      'salt',
    ],
  );
  assert.equal(esito.gruppi[0].ingredienti[0].quantita, 1.5);
  assert.equal(esito.gruppi[0].ingredienti[0].unita, 'cups');
  assert.equal(esito.gruppi[0].ingredienti[5].quantita, null);   // a pinch of salt
});

test('sul totale dei casi reali il parser resta sopra la soglia', () => {
  let sicure = 0;
  let totali = 0;
  for (const caso of CASI) {
    const esito = leggiBlocco(caso.testo, caso.voc);
    sicure += esito.righeSicure;
    totali += esito.righeTotali;
  }
  assert.equal(totali, 96);   // 77 dalle ricette vere + 19 dai tre incolla
  assert.ok(
    sicure / totali >= SOGLIA,
    `righe capite ${sicure} su ${totali} (${(sicure / totali).toFixed(2)}), sotto la soglia ${SOGLIA}`,
  );
});
```

- [ ] **Step 14: Esegui i test e verifica che passino**

Comandi:

```bash
npm test
npm run typecheck
```

Atteso: PASS, ℹ pass 79, ℹ fail 0 (i 62 lasciati dal Task 7 più gli 11 di
`blocco.test.ts` e i 6 di `casi-reali.test.ts`).
I sei test di `casi-reali.test.ts` sono verdi appena scritti: il
parser è già finito, e questo file serve a dimostrarlo sui dati veri. Se uno di
loro è rosso, il difetto è nel parser scritto in questo task o nel vocabolario
del Task 1, e va sistemato prima del commit — non si abbassa la soglia.
`tsc --noEmit` senza errori.

- [ ] **Step 15: Commit**

```bash
git add src/domain/parser/blocco.ts src/domain/parser/blocco.test.ts \
        src/domain/parser/casi-reali.test.ts
git commit -m "Parser del blocco incollato: sezioni, parole di stop, scelta della lingua e prova sui casi reali"
```

---

---

### Task 9: Prova di realtà della migrazione

`src/domain/migrate.ts` importa il ricettario della vecchia webapp PHP
(`data/ricette.json`: 12 ricette, 77 ingredienti). È stato scritto prima del
modello dati definitivo, ed è già stato adeguato strada facendo: il Task 2 gli
ha passato il vocabolario italiano per `normalizzaUnita`, il Task 4 gli ha
aggiunto `cancellataIl: null`. Quindi qui **non si cambia il comportamento**, si
chiudono le tre cose rimaste aperte:

- `migrate.ts` non ha un test suo. L'unica prova che funzioni è quello che
  stampa `npm run migra`, cioè un umano che legge dei numeri;
- `migrate.ts` tiene ancora una sua copia privata delle espressioni di "quanto
  basta" (`const QB = new Set([...])`), doppione di `IT.quantoBasta`. Due
  elenchi della stessa cosa si sfasano al primo che si aggiorna;
- lo script `npm run migra` racconta e basta: non verifica niente ed esce sempre
  con codice 0. Una migrazione che perdesse tre ricette stamperebbe
  `12 -> 9` e passerebbe inosservata.

Alla fine lo script deve continuare a riportare 12 ricette su 12 e 77
ingredienti su 77, ma dicendolo con dei `ok` e uscendo con 1 se qualcosa non
torna.

**Files:**
- Modify: `src/domain/migrate.ts`
- Modify: `scripts/verifica-migrazione.ts`
- Test: `src/domain/migrate.test.ts` (nuovo)

**Interfaces:**

- Consumes, da `src/domain/types.ts` (completato dal Task 4):

```ts
export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }
export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  gruppi: Gruppo[];
  creataIl: string;              // ISO 8601
  modificataIl: string;          // ISO 8601
  cancellataIl: string | null;   // tombstone, null = viva
}
export const tuttiGliIngredienti: (r: Ricetta) => Ingrediente[];
```

- Consumes, da `src/domain/lingua/it.ts` (già importato in `migrate.ts` e nello
  script dal Task 2, e si continua a usare quello: il vecchio archivio è
  italiano per costruzione, non dipende dalla lingua del dispositivo):

```ts
export const IT: Vocabolario;
```

  Di `IT` qui servono due campi: `quantoBasta`, che per l'italiano vale
  `['q.b.', 'quanto basta', 'a piacimento', 'a piacere', 'qb']`, e le `unita`
  che alimentano `normalizzaUnita`.

- Consumes, da `src/domain/units.ts`:

```ts
/** Forma canonica, oppure la stringa ripulita se sconosciuta, oppure null se vuota. */
export function normalizzaUnita(raw: string | null | undefined, voc: Vocabolario): string | null;
```

- Consumes, da `src/domain/scaling.ts` e `src/domain/format.ts` (servono allo script):

```ts
export function fattoreDaIngrediente(ricetta: Ricetta, ingredienteId: string, quantitaDisponibile: number): number | null;
export function scala(ricetta: Ricetta, fattore: number, voc: Vocabolario): RicettaScalata;
export function formattaQuantita(quantita: number | null, unita: string | null, voc: Vocabolario): string;
```

- Consumes, da `src/domain/id.ts`: `export function newId(): string`

- Produces, invariati come firma e usati dall'import di file e dal seed dei dati:

```ts
export function migraRicetta(l: RicettaLegacy, adesso?: string): Ricetta | null;
export function migraArchivio(json: unknown): Ricetta[];
```

Le ricette prodotte hanno `cancellataIl: null`, unità normalizzate col
vocabolario italiano, e un id nuovo su ricetta, gruppi e ingredienti.

---

- [ ] **Step 1: Scrivi il test che manca a `migrate.ts`**

Crea `src/domain/migrate.test.ts`:

```ts
/**
 * Test della migrazione dal vecchio archivio della webapp PHP.
 *
 * Il vecchio formato aveva due varianti compresenti (piatta e a gruppi),
 * quantità scritte come "q.b." dentro un campo numerico, unità come testo
 * libero ("gr", "lt", "bicchiere") e nessun id.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { migraArchivio, migraRicetta } from './migrate.ts';
import { tuttiGliIngredienti } from './types.ts';

test('la ricetta migrata nasce viva: cancellataIl è null', () => {
  const r = migraRicetta({
    titolo: 'Farinata',
    porzioniOriginali: 14,
    ingredienti: [{ nome: 'Farina', quantita: 180, unita: 'gr' }],
  });
  assert.ok(r);
  assert.equal(r!.cancellataIl, null);
  assert.equal(r!.creataIl, r!.modificataIl);
  assert.ok(r!.creataIl.length > 0);
});

test('le unità passano dal vocabolario italiano', () => {
  const r = migraRicetta({
    titolo: 'Prova',
    porzioniOriginali: 4,
    ingredienti: [
      { nome: 'Farina', quantita: 500, unita: 'gr' },
      { nome: 'Brodo', quantita: 1, unita: 'lt' },
      { nome: 'Latte', quantita: 1, unita: 'bicchiere' },
      { nome: 'Lievito', quantita: 1, unita: 'bustina' },
      // Fuori vocabolario: resta com'è, solo ripulita. Non si rifiuta niente.
      // (Se un giorno 'manciata' entrasse in src/domain/lingua/it.ts, cambia questa riga.)
      { nome: 'Prezzemolo', quantita: 2, unita: ' Manciata ' },
    ],
  })!;
  assert.deepEqual(
    tuttiGliIngredienti(r).map((i) => i.unita),
    ['g', 'l', 'bicchieri', 'bustine', 'Manciata'],
  );
});

test('le quantità "q.b." del vecchio archivio diventano ingredienti senza quantità', () => {
  const r = migraRicetta({
    titolo: 'Farinata',
    porzioniOriginali: 14,
    ingredienti: [
      { nome: 'Sale', quantita: 'q.b.', unita: '' },
      { nome: 'Rosmarino', quantita: 'a piacere', unita: null },
      { nome: 'Origano', quantita: 'A Piacimento', unita: null },
      { nome: 'Olio', quantita: 0, unita: 'ml' },
      { nome: 'Farina', quantita: 180, unita: 'gr' },
    ],
  })!;
  const ing = tuttiGliIngredienti(r);
  assert.equal(ing.length, 5);                                   // non si perde niente
  assert.deepEqual(ing.map((i) => i.quantita), [null, null, null, null, 180]);
  assert.deepEqual(ing.map((i) => i.unita), [null, null, null, null, 'g']);
});

test('porzioniOriginali vince su porzioni, che nel vecchio file era sporcata dai riscali', () => {
  const r = migraRicetta({
    titolo: 'Ciambella',
    porzioni: 45,
    porzioniOriginali: 30,
    ingredienti: [{ nome: 'Farina', quantita: 500, unita: 'gr' }],
  })!;
  assert.equal(r.porzioni, 30);
  // Besciamella nel file vero ha porzioni 0: vale come "non dichiarate".
  const senza = migraRicetta({
    titolo: 'Besciamella',
    porzioni: 0,
    porzioniOriginali: 0,
    ingredienti: [{ nome: 'Burro', quantita: 50, unita: 'gr' }],
  })!;
  assert.equal(senza.porzioni, null);
});

test('migraArchivio: id unici ovunque, niente ricette cancellate, scarti coerenti', () => {
  const ricette = migraArchivio([
    { titolo: 'A', porzioniOriginali: 2, ingredienti: [{ nome: 'x', quantita: 1, unita: 'g' }] },
    {
      titolo: 'B',
      gruppi: [
        { nome: 'Impasto', ingredienti: [{ nome: 'y', quantita: 2, unita: 'g' }] },
        { nome: 'Crema', ingredienti: [] },
      ],
    },
    { titolo: '', ingredienti: [{ nome: 'z', quantita: 1, unita: 'g' }] },
    { titolo: 'Vuota', ingredienti: [] },
  ]);
  assert.equal(ricette.length, 2);                 // senza titolo e senza ingredienti: scartate
  assert.equal(ricette[1].gruppi.length, 1);       // il gruppo vuoto sparisce
  assert.equal(ricette[1].gruppi[0].nome, 'Impasto');
  assert.equal(ricette[0].gruppi[0].nome, null);   // formato piatto: gruppo unico senza nome
  assert.ok(ricette.every((r) => r.cancellataIl === null));

  const ids = ricette.flatMap((r) => [
    r.id,
    ...r.gruppi.flatMap((g) => [g.id, ...g.ingredienti.map((i) => i.id)]),
  ]);
  assert.equal(new Set(ids).size, ids.length);
  assert.ok(ids.every((id) => typeof id === 'string' && id.length > 0));
  assert.deepEqual(migraArchivio('non un array'), []);
});
```

- [ ] **Step 2: Esegui i test e verifica che passino**

Comando: `npm test`

Atteso: PASS sui cinque test di `migrate.test.ts`. Non è un test-che-fallisce:
il Task 2 ha già passato `IT` a `normalizzaUnita` e il Task 4 ha già messo
`cancellataIl: null` nel `return` di `migraRicetta`, quindi il comportamento
atteso c'è già ed è questo il primo file che lo scrive nero su bianco. Se uno
dei cinque è rosso, vuol dire che uno di quei due task è rimasto a metà: si
torna lì prima di andare avanti.

Il test `le quantità "q.b." del vecchio archivio...` è quello che tiene ferma la
riga dello step successivo: passa sia con l'elenco privato sia col vocabolario,
e deve continuare a passare dopo.

- [ ] **Step 3: Togli da `migrate.ts` il doppione di "quanto basta"**

Due modifiche puntuali in `src/domain/migrate.ts`.

1. Cancella la riga:
```ts
const QB = new Set(['q.b.', 'qb', 'quanto basta']);
```

2. Dentro `convertiIngrediente`, sostituisci:
```ts
    (typeof q === 'string' && QB.has(q.trim().toLowerCase())) ||
```
con:
```ts
    // Le espressioni di q.b. le sa il vocabolario: un secondo elenco qui si
    // sfaserebbe dal primo ("a piacimento" mancava già).
    (typeof q === 'string' && IT.quantoBasta.includes(q.trim().toLowerCase())) ||
```

L'import di `IT` c'è già dal Task 2, in cima al file:
`import { IT } from './lingua/it.ts';`.

- [ ] **Step 4: Esegui test e typecheck e verifica che passino**

Comandi:

```bash
npm test
npm run typecheck
```

Atteso: PASS. I cinque test di `migrate.test.ts` restano verdi — è il punto:
lo step 3 toglie un doppione, non cambia cosa fa il codice. `tsc --noEmit`
senza errori.

- [ ] **Step 5: Guarda cosa NON verifica la prova di realtà**

Comandi:

```bash
npm run migra
echo $?
```

Atteso: lo script stampa il riepilogo (`ricette legacy: 12  ->  migrate: 12`,
`ingredienti legacy: 77  ->  migrati: 77`, l'elenco delle unità, le 12 righe di
riscalo) e `echo $?` stampa `0`. Nessuna riga comincia con `ok` o con `GUASTO`,
e non c'è nessuna riga finale che dica se è andata bene: oggi lo script racconta
e basta. Uscirebbe con 0 anche stampando `12 -> 9`.

- [ ] **Step 6: Aggiungi le verifiche e il codice di uscita allo script**

Sostituisci il contenuto di `scripts/verifica-migrazione.ts` con:

```ts
/**
 * Prova di realtà: migra il ricettario vero della vecchia webapp e verifica che
 * non si perda niente. Deve riportare 12 ricette su 12 e 77 ingredienti su 77.
 * Esce con codice 1 se una verifica fallisce, così il guasto non passa
 * inosservato in mezzo alle righe stampate.
 */
import { readFileSync } from 'node:fs';
import { migraArchivio } from '../src/domain/migrate.ts';
import { fattoreDaIngrediente, scala } from '../src/domain/scaling.ts';
import { tuttiGliIngredienti } from '../src/domain/types.ts';
import { formattaQuantita } from '../src/domain/format.ts';
import { IT } from '../src/domain/lingua/it.ts';

const SRC = '/Users/paolo/Server/Siti Web/_private/casa/app/dosatore/data/ricette.json';

interface IngredienteLegacy { nome?: string; quantita?: number | string | null; unita?: string | null }
interface RicettaLegacy {
  titolo?: string;
  ingredienti?: IngredienteLegacy[];
  gruppi?: { nome?: string | null; ingredienti?: IngredienteLegacy[] }[];
}

const legacy = JSON.parse(readFileSync(SRC, 'utf8')) as RicettaLegacy[];
const ricette = migraArchivio(legacy);

let guasti = 0;
function verifica(etichetta: string, ok: boolean): void {
  console.log(`${ok ? 'ok     ' : 'GUASTO '}${etichetta}`);
  if (!ok) guasti++;
}

console.log(`ricette legacy: ${legacy.length}  ->  migrate: ${ricette.length}`);
verifica('nessuna ricetta persa', ricette.length === legacy.length);

const ingLegacy = legacy.reduce(
  (n, r) =>
    n +
    (r.gruppi
      ? r.gruppi.flatMap((g) => g.ingredienti ?? []).length
      : (r.ingredienti ?? []).length),
  0,
);
const ingNuovi = ricette.reduce((n, r) => n + tuttiGliIngredienti(r).length, 0);
console.log(`ingredienti legacy: ${ingLegacy}  ->  migrati: ${ingNuovi}`);
verifica('nessun ingrediente perso', ingNuovi === ingLegacy);

verifica('nessuna ricetta nasce cancellata', ricette.every((r) => r.cancellataIl === null));

const senzaPorzioni = ricette.filter((r) => r.porzioni === null);
console.log(`ricette senza porzioni: ${senzaPorzioni.length} ${senzaPorzioni.map((r) => r.titolo).join(', ')}`);

const idRicette = ricette.map((r) => r.id);
verifica('id ricetta unici', new Set(idRicette).size === idRicette.length);

const idGruppi = ricette.flatMap((r) => r.gruppi.map((g) => g.id));
verifica(
  'id gruppo unici e non vuoti',
  new Set(idGruppi).size === idGruppi.length && idGruppi.every((i) => i.length > 0),
);

const idIngredienti = ricette.flatMap((r) => tuttiGliIngredienti(r).map((i) => i.id));
verifica('id ingrediente unici', new Set(idIngredienti).size === idIngredienti.length);

const titoli = new Set(ricette.map((r) => r.titolo));
console.log(`titoli duplicati nel vecchio archivio: ${ricette.length - titoli.size}`);

const unita = new Set<string>();
ricette.forEach((r) => tuttiGliIngredienti(r).forEach((i) => i.unita && unita.add(i.unita)));
console.log(`unità distinte dopo normalizzazione: ${[...unita].sort().join(' | ')}`);

console.log('\n--- prova di riscalo su ogni ricetta (calcolo inverso, primo ingrediente x1.5) ---');
for (const r of ricette) {
  const primo = tuttiGliIngredienti(r).find((i) => i.quantita !== null);
  if (!primo) {
    console.log(`${r.titolo}: solo q.b., niente da riscalare`);
    continue;
  }
  const f = fattoreDaIngrediente(r, primo.id, primo.quantita! * 1.5);
  if (f === null) {
    verifica(`riscalo calcolabile per ${r.titolo}`, false);
    continue;
  }
  const s = scala(r, f, IT);
  const riga = s.gruppi
    .flatMap((g) => g.ingredienti)
    .map((i) => `${i.nome} ${formattaQuantita(i.quantita, i.unita, IT)}`)
    .join(', ');
  console.log(`${r.titolo} [${r.porzioni ?? '?'} -> ${s.porzioni ?? '?'} porz.]: ${riga}`);
}

if (guasti > 0) {
  console.error(`\n${guasti} verifiche fallite`);
  process.exitCode = 1;
} else {
  console.log('\ntutte le verifiche passate');
}
```

Rispetto a prima cambiano tre cose e basta: il `verifica()` con il contatore e
il codice di uscita, i controlli sugli id di gruppi e ingredienti (prima si
guardavano solo quelli delle ricette), e il `!` tolto da `fattoreDaIngrediente`,
che nascondeva un `null` invece di segnalarlo. Le righe stampate restano quelle,
nello stesso ordine.

- [ ] **Step 7: Esegui la prova di realtà e verifica che passi**

Comandi:

```bash
npm run typecheck
npm run migra
echo $?
npm test
```

Atteso: PASS. `typecheck` senza errori; `npm run migra` comincia con

```
ricette legacy: 12  ->  migrate: 12
ok     nessuna ricetta persa
ingredienti legacy: 77  ->  migrati: 77
ok     nessun ingrediente perso
ok     nessuna ricetta nasce cancellata
ricette senza porzioni: 2 Tozzetti, Besciamella
ok     id ricetta unici
ok     id gruppo unici e non vuoti
ok     id ingrediente unici
titoli duplicati nel vecchio archivio: 0
unità distinte dopo normalizzazione: bicchieri | bicchierini | bustine | cad | cubetti | cucchiaini | g | kg | l | ml | pizzichi | tazzine
```

poi le 12 righe di riscalo — fra cui `Polenta` con `Acqua 1,08 l` e
`Ciambella Bertolini` con `Latte 1½ bicchieri` — e in fondo
`tutte le verifiche passate`. Nessun `GUASTO`, `echo $?` stampa `0`.
`npm test` resta verde: PASS, ℹ pass 84, ℹ fail 0 (i 79 lasciati dal Task 8 più
i 5 di `migrate.test.ts`).

- [ ] **Step 8: Commit**

```bash
git add src/domain/migrate.ts src/domain/migrate.test.ts scripts/verifica-migrazione.ts
git commit -m "Metti la migrazione sotto test e fai fallire davvero la prova di realtà"
```

---

### Task 10: Schema SQLite, migrazioni versionate e apertura del database

**Files:**
- Create: `src/data/dbMemoria.ts`
- Create: `src/data/schema.ts`
- Create: `src/data/db.ts`
- Test: `src/data/schema.test.ts`
- Modify: `package.json` (nuove dipendenze, aggiunte dal comando `npx expo install`)

**Interfaces:**

- **Consumes**
  - `expo-sqlite`, dipendenza installata nel passo 1. Di tutta la sua API
    servono solo il tipo `SQLiteDatabase` e questi metodi, tutti asincroni:
    ```ts
    execAsync(source: string): Promise<void>
    runAsync(source: string, params: (string | number | null)[]): Promise<{ lastInsertRowId: number; changes: number }>
    getFirstAsync<T>(source: string, params?: (string | number | null)[]): Promise<T | null>
    getAllAsync<T>(source: string, params?: (string | number | null)[]): Promise<T[]>
    withTransactionAsync(task: () => Promise<void>): Promise<void>
    closeAsync(): Promise<void>
    ```
    expo-sqlite accetta i parametri sia come array sia sciolti: qui e in tutto
    il progetto si usa **sempre l'array**.
    Servono inoltre le funzioni di modulo
    `openDatabaseAsync(nome: string): Promise<SQLiteDatabase>` e la costante
    `defaultDatabaseDirectory` (percorso della cartella dove expo-sqlite tiene
    i file dei database).
  - `expo-file-system`, dipendenza installata nel passo 1. Serve solo la classe
    `File`: `new File(cartella, nome)`, la proprietà `exists: boolean`, i metodi
    `delete(): void` e `copy(destinazione: File): Promise<void>`.

- **Produces**
  - `src/data/schema.ts`
    ```ts
    export const VERSIONE_SCHEMA = 1;
    export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void>;
    ```
  - `src/data/db.ts` — esattamente le tre dichiarazioni del contratto, niente di
    più: il nome del file del database resta una costante interna al modulo e la
    causa dell'errore viaggia nel campo standard `cause` di `Error`.
    ```ts
    export class ErroreDatabase extends Error {}
    export async function apriDb(nome?: string): Promise<SQLiteDatabase>;
    export async function copiaDiSicurezza(): Promise<void>;
    ```
  - `src/data/dbMemoria.ts`, l'adattatore usato dai test di questo task, del
    Task 11, del Task 12, del Task 13 e di tutti i task successivi che toccano
    il database:
    ```ts
    export function apriDbInMemoria(): SQLiteDatabase;   // database in memoria, solo per i test
    ```
  - Lo schema che il Task 11 e tutti i task successivi interrogano:
    ```
    categorie    id TEXT PK, nome TEXT, icona TEXT, ordine INTEGER,
                 creata_il TEXT, modificata_il TEXT, cancellata_il TEXT NULL
    ricette      id TEXT PK, titolo TEXT, descrizione TEXT, porzioni REAL NULL,
                 categoria_id TEXT NULL -> categorie(id) ON DELETE SET NULL,
                 foto TEXT NULL,
                 creata_il TEXT, modificata_il TEXT, cancellata_il TEXT NULL
    gruppi       id TEXT PK, ricetta_id TEXT -> ricette(id) ON DELETE CASCADE,
                 nome TEXT NULL, ordine INTEGER
    ingredienti  id TEXT PK, gruppo_id TEXT -> gruppi(id) ON DELETE CASCADE,
                 nome TEXT, quantita REAL NULL, unita TEXT NULL, ordine INTEGER
    riscalo      ricetta_id TEXT PK NOT NULL -> ricette(id) ON DELETE CASCADE,
                 richiesta TEXT NOT NULL (JSON), aggiornato_il TEXT NOT NULL
    ```
    La DDL vera, quella che verrà eseguita, è nel passo 5: chi la cita altrove
    deve copiarla da lì.

**Cinque tabelle nella versione 1, non quattro più una migrazione**

Categorie e foto fanno parte dell'app fin dal primo avvio. Al primo avvio non
esiste ancora nessun database, quindi non c'è niente da migrare: creare uno
schema senza `categorie` per aggiungerla tre task dopo significherebbe scrivere,
provare e mantenere una migrazione che nessun dispositivo eseguirà mai. La
struttura per le migrazioni successive resta (è il `PRAGMA user_version` del
passo 5), ma la versione 1 è già quella completa.

**Perché `categoria_id` è `ON DELETE SET NULL`**

Cancellare una categoria non deve portarsi via le ricette che ci stavano dentro:
restano, con `categoria_id` vuoto, e finiscono in "Senza categoria". Con
`ON DELETE CASCADE` — l'altro comportamento che viene in mente — l'utente che
cancella la categoria "Dolci" perderebbe tutte le torte, cioè esattamente il
disastro che il nome del gesto non lascia prevedere.

Il repository delle categorie (Task 12) cancella con la tombstone, come le
ricette, e azzera lui `categoria_id` con un `UPDATE` esplicito. Il vincolo
`ON DELETE SET NULL` copre il caso restante: una `DELETE` vera sulla riga,
che oggi non fa nessuno e domani potrebbe farla un import o una pulizia. È la
rete sotto, non la strada.

**Perché i test girano su `node:sqlite` e non su expo-sqlite in `:memory:`**

expo-sqlite è un modulo nativo: il pacchetto JavaScript è solo un guscio che
chiama codice Swift e Kotlin attraverso il bridge di Expo. Fuori dal simulatore
o dal dispositivo quel bridge non esiste, quindi in `node --test` non si riesce
nemmeno a importare il modulo, e la modalità `:memory:` non cambia niente: il
problema non è dove sta il file, è che il modulo nativo non c'è.

La soluzione adottata qui: i test usano `node:sqlite`, che è SQLite vero
compilato dentro Node, avvolto in un adattatore che espone gli stessi metodi
asincroni di `SQLiteDatabase` elencati sopra. Il codice sotto test resta scritto
contro `SQLiteDatabase` e non sa niente di tutto questo, perché
`import type { SQLiteDatabase } from 'expo-sqlite'` è un import di solo tipo:
sparisce a runtime, quindi Node non prova mai a caricare il modulo nativo.

L'adattatore è **uno solo per tutto il progetto**, `src/data/dbMemoria.ts`. Un
secondo adattatore con convenzioni sui parametri leggermente diverse è il modo
più veloce per farsi male: un repository scritto contro l'uno si romperebbe in
modo incomprensibile sotto l'altro.

Il cast dell'adattatore è l'unico del progetto ed è confinato a quel file. Il
rischio che il cast nasconde (un metodo di `SQLiteDatabase` usato dal codice ma
non implementato nell'adattatore) si manifesta come test rosso, non come bug
silenzioso in produzione.

`node:sqlite` stampa un `ExperimentalWarning` a ogni esecuzione. È innocuo e non
va zittito: se un giorno l'API cambia, quel warning è l'avviso.

- [ ] **Step 1: Installa le dipendenze native**

Comando:
```bash
npx expo install expo-sqlite expo-file-system
```

Atteso: `package.json` contiene `"expo-sqlite"` e `"expo-file-system"` fra le
`dependencies`, con versioni compatibili con l'SDK di Expo del progetto.
Verifica:
```bash
node -e "const p=require('./package.json');console.log(p.dependencies['expo-sqlite'],p.dependencies['expo-file-system'])"
```

- [ ] **Step 2: Crea il database in memoria per i test**

Non è ancora un test, è lo strumento con cui i test girano. Va scritto prima
perché il test del passo 3 lo importa, e lo importeranno anche i test del Task
11 e dei task successivi che toccano il database: è l'unico adattatore del
progetto.

`src/data/dbMemoria.ts`:
```ts
/**
 * Database SQLite in memoria, per i test dei repository.
 *
 * `expo-sqlite` non si carica fuori dall'app: dietro c'è un modulo nativo.
 * Qui ricostruiamo su `node:sqlite` (dentro Node, niente da installare) il
 * pezzo di API che i repository usano davvero, così i test dei dati girano in
 * mezzo secondo insieme a quelli del dominio, senza dispositivo.
 *
 * Lo importano solo i file di test: il bundle dell'app non lo vede mai.
 */
import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
import type { SQLiteDatabase } from 'expo-sqlite';

/**
 * expo accetta i parametri sia come array (`[a, b]`) sia sciolti (`a, b`) sia
 * come oggetto con nome; node:sqlite li vuole sciolti, e non lega i booleani.
 */
function argomenti(params: unknown[]): SQLInputValue[] {
  const soli = params.length === 1 && Array.isArray(params[0]) ? (params[0] as unknown[]) : params;
  if (soli.length === 1 && soli[0] !== null && typeof soli[0] === 'object') {
    return soli as SQLInputValue[]; // parametri con nome: oggetto unico
  }
  return soli.map((v) => {
    if (typeof v === 'boolean') return v ? 1 : 0;
    if (v === undefined) return null;
    return v as SQLInputValue;
  });
}

/** Le righe di node:sqlite hanno prototipo null: assert.deepEqual le rifiuterebbe. */
const semplice = <T>(riga: Record<string, unknown>): T => ({ ...riga }) as T;

export function apriDbInMemoria(): SQLiteDatabase {
  const db = new DatabaseSync(':memory:');
  const finto = {
    async execAsync(sql: string): Promise<void> {
      db.exec(sql);
    },
    async runAsync(sql: string, ...params: unknown[]) {
      const esito = db.prepare(sql).run(...argomenti(params));
      return { changes: Number(esito.changes), lastInsertRowId: Number(esito.lastInsertRowid) };
    },
    async getAllAsync<T>(sql: string, ...params: unknown[]): Promise<T[]> {
      return db.prepare(sql).all(...argomenti(params)).map((r) => semplice<T>(r));
    },
    async getFirstAsync<T>(sql: string, ...params: unknown[]): Promise<T | null> {
      const riga = db.prepare(sql).get(...argomenti(params));
      return riga === undefined ? null : semplice<T>(riga);
    },
    async withTransactionAsync(compito: () => Promise<void>): Promise<void> {
      db.exec('BEGIN');
      try {
        await compito();
        db.exec('COMMIT');
      } catch (e) {
        db.exec('ROLLBACK');
        throw e;
      }
    },
    async closeAsync(): Promise<void> {
      db.close();
    },
  };
  return finto as unknown as SQLiteDatabase;
}
```

- [ ] **Step 3: Scrivi il test che fallisce**

Sei test: le cinque tabelle e la versione, la rieseguibilità delle migrazioni,
le due cascate che devono scattare e le due regole nuove sulle categorie.

`src/data/schema.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { apriDbInMemoria } from './dbMemoria.ts';
import { applicaMigrazioni, VERSIONE_SCHEMA } from './schema.ts';

const ADESSO = '2026-01-01T00:00:00.000Z';

const INSERISCI_RICETTA =
  'INSERT INTO ricette (id, titolo, descrizione, porzioni, categoria_id, foto, creata_il, modificata_il, cancellata_il) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
const INSERISCI_CATEGORIA =
  'INSERT INTO categorie (id, nome, icona, ordine, creata_il, modificata_il, cancellata_il) VALUES (?, ?, ?, ?, ?, ?, ?)';

test('le migrazioni creano le cinque tabelle e segnano la versione', async () => {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);

  const tabelle = await db.getAllAsync<{ name: string }>(
    "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
  );
  assert.deepEqual(
    tabelle.map((t) => t.name),
    ['categorie', 'gruppi', 'ingredienti', 'ricette', 'riscalo'],
  );

  const versione = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
  assert.equal(versione?.user_version, VERSIONE_SCHEMA);
});

test("rieseguire le migrazioni non tocca i dati gia' presenti", async () => {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await db.runAsync(INSERISCI_RICETTA, ['r1', 'Polenta', '', 5, null, null, ADESSO, ADESSO, null]);

  await applicaMigrazioni(db);

  const righe = await db.getAllAsync<{ titolo: string }>('SELECT titolo FROM ricette');
  assert.deepEqual(righe.map((r) => r.titolo), ['Polenta']);
});

test('cancellare una ricetta a mano porta via gruppi e ingredienti', async () => {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await db.runAsync(INSERISCI_RICETTA, ['r1', 'Polenta', '', 5, null, null, ADESSO, ADESSO, null]);
  await db.runAsync('INSERT INTO gruppi (id, ricetta_id, nome, ordine) VALUES (?, ?, ?, ?)', ['g1', 'r1', null, 0]);
  await db.runAsync(
    'INSERT INTO ingredienti (id, gruppo_id, nome, quantita, unita, ordine) VALUES (?, ?, ?, ?, ?, ?)',
    ['i1', 'g1', 'Polenta', 180, 'g', 0],
  );

  await db.runAsync('DELETE FROM ricette WHERE id = ?', ['r1']);

  const gruppi = await db.getAllAsync<{ id: string }>('SELECT id FROM gruppi');
  const ingredienti = await db.getAllAsync<{ id: string }>('SELECT id FROM ingredienti');
  assert.deepEqual(gruppi, []);
  assert.deepEqual(ingredienti, []);
});

test('un ingrediente senza gruppo viene rifiutato', async () => {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await assert.rejects(() =>
    db.runAsync(
      'INSERT INTO ingredienti (id, gruppo_id, nome, quantita, unita, ordine) VALUES (?, ?, ?, ?, ?, ?)',
      ['i1', 'gruppo-che-non-esiste', 'Farina', 180, 'g', 0],
    ),
  );
});

test('cancellare una categoria azzera il campo e lascia stare la ricetta', async () => {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await db.runAsync(INSERISCI_CATEGORIA, ['c1', 'Dolci', 'cupcake', 0, ADESSO, ADESSO, null]);
  await db.runAsync(INSERISCI_RICETTA, ['r1', 'Torta di mele', '', 8, 'c1', 'r1.jpg', ADESSO, ADESSO, null]);

  await db.runAsync('DELETE FROM categorie WHERE id = ?', ['c1']);

  const riga = await db.getFirstAsync<{ titolo: string; categoria_id: string | null; foto: string | null }>(
    'SELECT titolo, categoria_id, foto FROM ricette WHERE id = ?',
    ['r1'],
  );
  // La ricetta resta intera: se ne va solo il legame con la categoria.
  assert.equal(riga?.titolo, 'Torta di mele');
  assert.equal(riga?.categoria_id, null);
  assert.equal(riga?.foto, 'r1.jpg');
});

test('una ricetta in una categoria che non esiste viene rifiutata', async () => {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await assert.rejects(() =>
    db.runAsync(INSERISCI_RICETTA, ['r1', 'Torta', '', 8, 'categoria-mai-creata', null, ADESSO, ADESSO, null]),
  );
});
```

- [ ] **Step 4: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/data/schema.ts' imported from .../src/data/schema.test.ts`,
e il riepilogo con `✖ src/data/schema.test.ts`.

- [ ] **Step 5: Implementa lo schema**

`categorie` va creata per prima perché `ricette` la nomina.

`src/data/schema.ts`:
```ts
/**
 * Schema SQLite e migrazioni.
 *
 * La versione dello schema sta in `PRAGMA user_version`: ogni migrazione futura
 * si aggiunge in coda senza toccare le precedenti, cosi' chi aggiorna l'app non
 * perde le ricette.
 */
import type { SQLiteDatabase } from 'expo-sqlite';

export const VERSIONE_SCHEMA = 1;

const MIGRAZIONE_1 = `
CREATE TABLE categorie (
  id            TEXT PRIMARY KEY NOT NULL,
  nome          TEXT NOT NULL,
  icona         TEXT NOT NULL,
  ordine        INTEGER NOT NULL,
  creata_il     TEXT NOT NULL,
  modificata_il TEXT NOT NULL,
  cancellata_il TEXT
);
CREATE TABLE ricette (
  id            TEXT PRIMARY KEY NOT NULL,
  titolo        TEXT NOT NULL,
  descrizione   TEXT NOT NULL DEFAULT '',
  porzioni      REAL,
  categoria_id  TEXT REFERENCES categorie(id) ON DELETE SET NULL,
  foto          TEXT,
  creata_il     TEXT NOT NULL,
  modificata_il TEXT NOT NULL,
  cancellata_il TEXT
);
CREATE TABLE gruppi (
  id         TEXT PRIMARY KEY NOT NULL,
  ricetta_id TEXT NOT NULL REFERENCES ricette(id) ON DELETE CASCADE,
  nome       TEXT,
  ordine     INTEGER NOT NULL
);
CREATE TABLE ingredienti (
  id        TEXT PRIMARY KEY NOT NULL,
  gruppo_id TEXT NOT NULL REFERENCES gruppi(id) ON DELETE CASCADE,
  nome      TEXT NOT NULL,
  quantita  REAL,
  unita     TEXT,
  ordine    INTEGER NOT NULL
);
CREATE TABLE riscalo (
  ricetta_id    TEXT PRIMARY KEY NOT NULL REFERENCES ricette(id) ON DELETE CASCADE,
  richiesta     TEXT NOT NULL,
  aggiornato_il TEXT NOT NULL
);
CREATE INDEX gruppi_per_ricetta ON gruppi (ricetta_id);
CREATE INDEX ingredienti_per_gruppo ON ingredienti (gruppo_id);
CREATE INDEX ricette_vive ON ricette (cancellata_il);
CREATE INDEX ricette_per_categoria ON ricette (categoria_id);
`;

export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void> {
  // Fuori dalla transazione: dentro, questo PRAGMA e' un'istruzione che non fa
  // niente. E senza, ON DELETE CASCADE e ON DELETE SET NULL non scattano,
  // perche' SQLite nasce con le chiavi esterne spente.
  await db.execAsync('PRAGMA foreign_keys = ON');

  await db.withTransactionAsync(async () => {
    const riga = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
    const versione = riga?.user_version ?? 0;
    if (versione >= VERSIONE_SCHEMA) return;
    if (versione === 0) await db.execAsync(MIGRAZIONE_1);
    await db.execAsync(`PRAGMA user_version = ${VERSIONE_SCHEMA}`);
  });
}
```

`ricette_per_categoria` è l'indice che regge la schermata principale: sia il
filtro per categoria sia i conteggi del Task 11 passano da `categoria_id`. Su
`categorie` non c'è nessun indice e non è una dimenticanza: le categorie sono
qualche decina, un indice costerebbe più della scansione che eviterebbe.

La migrazione numero 2, quando servirà, si aggiungerà come
`if (versione <= 1) await db.execAsync(MIGRAZIONE_2);` sotto la riga di
`MIGRAZIONE_1`, alzando `VERSIONE_SCHEMA` a 2. Le tabelle già create non si
toccano mai più.

- [ ] **Step 6: Esegui il test e verifica che passi**

Comandi:
```bash
node --test src/data/schema.test.ts
npm test
```

Atteso: il primo comando stampa `ℹ pass 6` e `ℹ fail 0`, con i sei test verdi di
`src/data/schema.test.ts`; il secondo stampa `ℹ fail 0` e comprende quei sei fra
i verdi. **Questo task aggiunge 6 test al totale del progetto.**

In entrambi gli output compare anche
`ExperimentalWarning: SQLite is an experimental feature and might change at any time`:
è `node:sqlite` del database in memoria, non è un errore.

- [ ] **Step 7: Commit**

```bash
git add package.json package-lock.json src/data/dbMemoria.ts src/data/schema.ts src/data/schema.test.ts
git commit -m "Aggiungi lo schema SQLite con categorie, foto e migrazioni versionate"
```

- [ ] **Step 8: Scrivi l'apertura del database e la copia di sicurezza**

Questo file non ha test automatici e non è una dimenticanza: fa solo da ponte
verso il runtime nativo (apre il database vero, copia un file vero) e in Node
non si può nemmeno importare. La logica che vale la pena provare sta in
`schema.ts` e in `ricette.ts`, che i test coprono. La verifica di questo file è
il passo 10, a mano.

Il modulo esporta esattamente le tre dichiarazioni del contratto. Il nome del
file del database è una costante interna, e `copiaDiSicurezza()` non prende
parametri: chi la chiama da fuori (l'import del pezzo `io`) vuole sempre il
database dell'app. La copia di un nome diverso, che serve solo ad `apriDb`,
passa dalla funzione interna `copia`.

`src/data/db.ts`:
```ts
/**
 * Apertura del database e copia di sicurezza.
 *
 * Se il database non si apre l'app deve dirlo invece di partire vuota: un
 * ricettario vuoto sembra un ricettario perso.
 *
 * Questo file tocca solo il runtime nativo (expo-sqlite ed expo-file-system) e
 * per questo non ha test automatici: in Node non si puo' nemmeno importare.
 */
import * as SQLite from 'expo-sqlite';
import type { SQLiteDatabase } from 'expo-sqlite';
import { File } from 'expo-file-system';

import { applicaMigrazioni, VERSIONE_SCHEMA } from './schema.ts';

const NOME_DB = 'quantobasta.db';

export class ErroreDatabase extends Error {
  constructor(messaggio: string, causa?: unknown) {
    // `cause` e' il campo standard di Error: niente campo nostro da mantenere.
    super(messaggio, { cause: causa });
    this.name = 'ErroreDatabase';
  }
}

/**
 * Apre il database e lo porta all'ultima versione dello schema.
 * Se lo schema sta per cambiare forma fa prima la copia di sicurezza.
 */
export async function apriDb(nome: string = NOME_DB): Promise<SQLiteDatabase> {
  let db: SQLiteDatabase;
  try {
    db = await SQLite.openDatabaseAsync(nome);
  } catch (causa) {
    throw new ErroreDatabase(`Non riesco ad aprire il database ${nome}.`, causa);
  }

  try {
    const riga = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
    const versione = riga?.user_version ?? 0;
    // Versione 0 vuol dire database appena nato: non c'e' niente da salvare.
    if (versione > 0 && versione < VERSIONE_SCHEMA) await copia(nome);
    await applicaMigrazioni(db);
  } catch (causa) {
    await db.closeAsync().catch(() => {});
    throw new ErroreDatabase(`Non riesco ad aggiornare il database ${nome}.`, causa);
  }

  return db;
}

/**
 * Copia il file del database accanto all'originale, sovrascrivendo la copia
 * precedente. Va chiamata prima di ogni import e di ogni cambio di schema:
 * finche' non c'e' la sync e' l'unica rete di sicurezza.
 *
 * Le foto non entrano nella copia: stanno in un'altra cartella e chi le tocca
 * (l'import) le sovrascrive solo dopo aver scritto le ricette.
 */
export async function copiaDiSicurezza(): Promise<void> {
  await copia(NOME_DB);
}

/**
 * Copia un file solo perche' il database non usa il journal WAL, quindi non ci
 * sono file -wal e -shm da portarsi dietro.
 */
async function copia(nome: string): Promise<void> {
  const origine = new File(SQLite.defaultDatabaseDirectory, nome);
  if (!origine.exists) return;
  const destinazione = new File(SQLite.defaultDatabaseDirectory, `${nome}.backup`);
  try {
    if (destinazione.exists) destinazione.delete();
    await origine.copy(destinazione);
  } catch (causa) {
    throw new ErroreDatabase('Non riesco a fare la copia di sicurezza del database.', causa);
  }
}
```

- [ ] **Step 9: Verifica i tipi e che i test siano ancora verdi**

Comandi:
```bash
npm run typecheck
npm test
```

Atteso: `npm run typecheck` non stampa niente ed esce con codice 0;
`npm test` PASS, ℹ pass 90, ℹ fail 0 (gli 84 lasciati dal Task 9 più i 6 nuovi
di `schema.test.ts`), sempre sei test verdi in `src/data/schema.test.ts`.
`db.ts` non viene caricato dai test: è normale e voluto.

- [ ] **Step 10: Commit**

```bash
git add src/data/db.ts
git commit -m "Apri il database segnalando gli errori invece di partire vuoto"
```

---

---

### Task 11: Repository delle ricette

**Files:**
- Create: `src/data/ricette.ts`
- Test: `src/data/ricette.test.ts`

**Interfaces:**

- **Consumes**
  - `src/data/dbMemoria.ts` (Task 10): `export function apriDbInMemoria(): SQLiteDatabase`,
    database SQLite in memoria per i test. È l'unico adattatore del progetto:
    accetta i parametri delle query come array, che è la forma usata qui.
  - `src/data/schema.ts` (Task 10): `export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void>`,
    che crea le tabelle e accende `PRAGMA foreign_keys`.
  - Lo schema creato dal Task 10, che qui serve colonna per colonna:
    ```
    categorie    id, nome, icona, ordine, creata_il, modificata_il, cancellata_il
    ricette      id, titolo, descrizione, porzioni,
                 categoria_id (FK categorie ON DELETE SET NULL), foto,
                 creata_il, modificata_il, cancellata_il
    gruppi       id, ricetta_id (FK ricette ON DELETE CASCADE), nome, ordine
    ingredienti  id, gruppo_id (FK gruppi ON DELETE CASCADE), nome, quantita, unita, ordine
    riscalo      ricetta_id (PK, FK ricette ON DELETE CASCADE), richiesta, aggiornato_il
    ```
  - `src/domain/types.ts`, che il Task 5 ha lasciato completo. A questo task
    servono queste tre dichiarazioni:
    ```ts
    export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
    export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }
    export interface Ricetta {
      id: string;
      titolo: string;
      descrizione: string;
      porzioni: number | null;
      categoriaId: string | null;    // null = senza categoria, stato normale
      foto: string | null;           // NOME del file, es. 'r-emp.jpg', mai un percorso
      gruppi: Gruppo[];
      creataIl: string;              // ISO 8601
      modificataIl: string;          // ISO 8601
      cancellataIl: string | null;   // tombstone, null = viva
    }
    ```
    `quantita === null` significa "q.b.", `nome === null` su un gruppo significa
    gruppo unico senza intestazione, `categoriaId === null` significa ricetta
    senza categoria: è uno stato normale, non un errore.
    `foto` contiene **il nome del file, non un percorso assoluto**: su iOS la
    cartella dell'app cambia ad ogni aggiornamento e un percorso salvato ieri
    oggi punta al nulla. Qui il campo viaggia come stringa opaca: chi lo
    trasforma in un percorso è il Task 14.

  - **Dipendenza in avanti**, `src/data/foto.ts` del Task 14:
    ```ts
    export async function cancellaFoto(nomeFile: string | null): Promise<void>;
    ```
    Cancellare una ricetta deve cancellare anche il file della sua foto, e qui
    quella chiamata non si può ancora scrivere: `src/data/foto.ts` parla con
    `expo-file-system`, cioè con il runtime nativo, e questo task deve girare
    sotto `node --test`, dove quel modulo non si importa nemmeno.

    Il contratto della funzione, deciso una volta sola nell'addendum 3:
    **`cancellaFoto` non lancia**. Cancellare qualcosa che è già assente è il
    risultato voluto, non un guasto, e vale anche fuori dall'app, dove a mancare
    non è il file ma il modulo nativo: la funzione tace e torna. Il Task 14 la
    scrive così e la copre con un test apposta.

- **Cosa fa `cancellaRicetta` qui e cosa diventa al Task 14**

  Adesso azzera la colonna `foto` insieme alla tombstone, così il database non
  promette un file che sta per sparire. Al Task 14 la stessa funzione viene
  riscritta per leggere il nome prima dell'`UPDATE` e chiamare
  `await cancellaFoto(nome)` fuori dalla transazione, perché il filesystem non è
  transazionale.

  Il test scritto qui, `cancellare dimentica il riscalo e azzera la foto`, non
  cambia una riga, ma **cambia il codice che esegue**, e va detto invece di
  promettere che non succede niente: dopo il Task 14 la cancellazione di `r-emp`
  passa da `cancellaFoto('r-emp.jpg')` — la fixture `empanadas` la foto ce l'ha —
  e sotto `node --test` quella chiamata non trova né il file né
  `expo-file-system`, quindi per contratto non fa niente e non lancia. Le due
  asserzioni restano vere per le stesse ragioni di prima: il riscalo sparisce, la
  colonna `foto` va a NULL. Provano l'`UPDATE`, non il disco. E se un giorno
  `cancellaFoto` tornasse a lanciare quando non trova niente da cancellare,
  questo test diventerebbe rosso: è il punto in cui quella promessa si rompe
  rumorosamente invece che in silenzio, dentro l'app, quando è tardi.

  Fino al Task 14 una ricetta cancellata lascia sul disco un file orfano da
  meno di 300 KB. È un difetto noto, contenuto e a scadenza, non uno da
  inseguire prima del tempo.

- **Produces**
  ```ts
  export type FiltroElenco =
    | { tipo: 'tutte' }
    | { tipo: 'categoria'; id: string }
    | { tipo: 'senza' };

  export interface Conteggi {
    /** Ricette vive, in tutto. */
    totale: number;
    /** Ricette vive senza categoria. */
    senza: number;
    /** id della categoria -> quante ricette vive ha. Le categorie a zero non compaiono. */
    perCategoria: Record<string, number>;
  }

  export async function elencoRicette(db: SQLiteDatabase, filtro: FiltroElenco): Promise<Ricetta[]>;
  export async function conteggiPerCategoria(db: SQLiteDatabase): Promise<Conteggi>;
  export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null>;
  export async function salvaRicetta(db: SQLiteDatabase, ricetta: Ricetta): Promise<void>;
  export async function cancellaRicetta(db: SQLiteDatabase, id: string): Promise<void>;
  export async function ricetteDaEsportare(db: SQLiteDatabase): Promise<Ricetta[]>;
  ```
  - `elencoRicette`: solo le vive (`cancellata_il IS NULL`), ordinate per titolo
    con `localeCompare('it')`. Il filtro **non ha valore predefinito**: la
    schermata Elenco arriva sempre da una voce precisa della schermata
    Categorie, e scrivere quale è meno faticoso che scoprire dopo perché la
    lista mostra tutto. `{ tipo: 'categoria', id }` con un id che non esiste
    restituisce l'elenco vuoto, non un errore.
  - `conteggiPerCategoria`: i numeri della schermata principale, in **una query
    sola** con `GROUP BY categoria_id`. Il conto lo fa SQLite, non JavaScript:
    contare in memoria vorrebbe dire caricare tutte le ricette con i loro
    ingredienti per poi buttarli, ed è la differenza fra un'app che regge
    trecento ricette e una che si pianta. Le categorie senza ricette vive non
    compaiono in `perCategoria`: chi mostra lo zero accanto a una categoria
    vuota legge `conteggi.perCategoria[id] ?? 0`.
  - `leggiRicetta`: restituisce anche una cancellata, se quell'id esiste; `null`
    se non esiste.
  - `salvaRicetta`: upsert completo dentro una transazione, gruppi e ingredienti
    riscritti nell'ordine dell'array; scrive anche `categoria_id` e `foto`;
    `creataIl` resta quello del primo salvataggio, e **`modificataIl` viene
    scritto così come arriva nell'oggetto**: il timestamp lo decide chi chiama,
    non questa funzione.
    La riga di `ricette` si aggiorna con `INSERT ... ON CONFLICT(id) DO UPDATE`,
    mai cancellandola e ricreandola: la cancellazione porterebbe via in cascata
    la riga di `riscalo`.
  - `cancellaRicetta`: marca `cancellata_il` e `modificata_il` con l'ora
    corrente, azzera `foto`, non rimuove la riga, e cancella il riscalo di
    quella ricetta.
  - `ricetteDaEsportare`: tutte, tombstone comprese.

**Le fixture dei test sono ricette vere** del ricettario che l'app sostituisce
(`Polenta`: 180 g di polenta e 720 ml d'acqua per 5 porzioni, senza categoria e
senza foto; `Empanadas`: 4 porzioni, con i due gruppi che la ricetta ha davvero,
nella categoria "Al forno" e con una foto).

Le categorie di prova si inseriscono con SQL grezzo: il loro repository è il
Task 12, che viene dopo, e questo task non deve dipenderci. Le righe servono
comunque, perché `ricette.categoria_id` è una chiave esterna e senza la
categoria l'inserimento verrebbe rifiutato.

- [ ] **Step 1: Scrivi il test che fallisce**

`src/data/ricette.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { SQLiteDatabase } from 'expo-sqlite';

import { apriDbInMemoria } from './dbMemoria.ts';
import { applicaMigrazioni } from './schema.ts';
import {
  cancellaRicetta, conteggiPerCategoria, elencoRicette, leggiRicetta,
  ricetteDaEsportare, salvaRicetta,
} from './ricette.ts';
import type { Ricetta } from '../domain/types.ts';

const ADESSO = '2026-01-01T00:00:00.000Z';
const TUTTE = { tipo: 'tutte' } as const;

/**
 * Le categorie si inseriscono con SQL grezzo: il loro repository arriva dopo
 * (Task 12) e questo test non deve dipenderci.
 */
async function creaCategoria(
  db: SQLiteDatabase, id: string, nome: string, icona: string, ordine: number,
): Promise<void> {
  await db.runAsync(
    'INSERT INTO categorie (id, nome, icona, ordine, creata_il, modificata_il, cancellata_il) VALUES (?, ?, ?, ?, ?, ?, ?)',
    [id, nome, icona, ordine, ADESSO, ADESSO, null],
  );
}

async function dbPronto(): Promise<SQLiteDatabase> {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await creaCategoria(db, 'c-forno', 'Al forno', 'bread-slice', 0);
  await creaCategoria(db, 'c-dolci', 'Dolci', 'cupcake', 1);
  return db;
}

/** La Polenta reale del vecchio ricettario: senza categoria e senza foto. */
function polenta(id: string, titolo = 'Polenta'): Ricetta {
  return {
    id, titolo, descrizione: '', porzioni: 5, categoriaId: null, foto: null,
    gruppi: [{
      id: `${id}-g1`, nome: null, ingredienti: [
        { id: `${id}-i1`, nome: 'Polenta', quantita: 180, unita: 'g' },
        { id: `${id}-i2`, nome: 'Acqua', quantita: 720, unita: 'ml' },
      ],
    }],
    creataIl: ADESSO, modificataIl: ADESSO, cancellataIl: null,
  };
}

/** Le Empanadas reali, nei due gruppi che la ricetta ha davvero. */
const empanadas: Ricetta = {
  id: 'r-emp', titolo: 'Empanadas', descrizione: 'Forno 200 gradi', porzioni: 4,
  categoriaId: 'c-forno', foto: 'r-emp.jpg',
  gruppi: [
    {
      id: 'g-impasto', nome: 'Impasto', ingredienti: [
        { id: 'i-farina', nome: 'Farina', quantita: 225, unita: 'g' },
        { id: 'i-burro', nome: 'Burro', quantita: 50, unita: 'g' },
        { id: 'i-acqua', nome: 'Acqua', quantita: 0.5, unita: 'bicchieri' },
        { id: 'i-sale', nome: 'Sale', quantita: 4, unita: 'g' },
        { id: 'i-lievito', nome: 'Lievito di birra', quantita: 0.5, unita: 'cubetti' },
      ],
    },
    {
      id: 'g-ripieno', nome: 'Ripieno', ingredienti: [
        { id: 'i-uovo', nome: 'Uovo', quantita: 1, unita: 'cad' },
        { id: 'i-dorare', nome: 'Uovo per dorare', quantita: 1, unita: 'cad' },
        { id: 'i-pepe', nome: 'Pepe', quantita: null, unita: null },
      ],
    },
  ],
  creataIl: ADESSO, modificataIl: ADESSO, cancellataIl: null,
};

test('una ricetta salvata si rilegge identica, categoria e foto comprese', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, empanadas);

  const letta = await leggiRicetta(db, 'r-emp');
  assert.ok(letta !== null);
  assert.equal(letta.titolo, 'Empanadas');
  assert.equal(letta.descrizione, 'Forno 200 gradi');
  assert.equal(letta.porzioni, 4);
  assert.equal(letta.categoriaId, 'c-forno');
  assert.equal(letta.foto, 'r-emp.jpg');
  assert.equal(letta.cancellataIl, null);
  assert.deepEqual(letta.gruppi.map((g) => g.nome), ['Impasto', 'Ripieno']);
  assert.deepEqual(
    letta.gruppi[0].ingredienti.map((i) => i.nome),
    ['Farina', 'Burro', 'Acqua', 'Sale', 'Lievito di birra'],
  );
  assert.deepEqual(letta.gruppi[0].ingredienti[2], {
    id: 'i-acqua', nome: 'Acqua', quantita: 0.5, unita: 'bicchieri',
  });
  // Il q.b. resta q.b.: quantita e unita nulle, non zero e non stringa vuota.
  assert.deepEqual(letta.gruppi[1].ingredienti[2], {
    id: 'i-pepe', nome: 'Pepe', quantita: null, unita: null,
  });
});

test('senza categoria e senza foto restano null, non stringhe vuote', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, polenta('r1'));

  const letta = await leggiRicetta(db, 'r1');
  assert.equal(letta?.categoriaId, null);
  assert.equal(letta?.foto, null);
});

test('leggiRicetta su un id che non esiste torna null', async () => {
  const db = await dbPronto();
  assert.equal(await leggiRicetta(db, 'mai-vista'), null);
});

test("l'elenco ordina all'italiana, non per codice carattere", async () => {
  const db = await dbPronto();
  await salvaRicetta(db, { ...polenta('r1', 'Zuppa di zucca') });
  await salvaRicetta(db, { ...polenta('r2', 'besciamella') });
  await salvaRicetta(db, { ...polenta('r3', 'Muffin') });

  const titoli = (await elencoRicette(db, TUTTE)).map((r) => r.titolo);
  // Un sort() ingenuo darebbe Muffin, Zuppa di zucca, besciamella.
  assert.deepEqual(titoli, ['besciamella', 'Muffin', 'Zuppa di zucca']);
});

test('due ricette con lo stesso titolo convivono senza pestarsi i piedi', async () => {
  const db = await dbPronto();
  const primaPolenta = polenta('r1');
  const secondaPolenta: Ricetta = {
    ...polenta('r2'), porzioni: 12,
    gruppi: [{
      id: 'r2-g1', nome: null, ingredienti: [
        { id: 'r2-i1', nome: 'Polenta', quantita: 500, unita: 'g' },
        { id: 'r2-i2', nome: 'Acqua', quantita: 2, unita: 'l' },
      ],
    }],
  };
  await salvaRicetta(db, primaPolenta);
  await salvaRicetta(db, secondaPolenta);

  const elenco = await elencoRicette(db, TUTTE);
  assert.equal(elenco.length, 2);
  assert.deepEqual(elenco.map((r) => r.titolo), ['Polenta', 'Polenta']);

  // Modificare l'una non tocca l'altra: l'identita' e' l'id, non il titolo.
  await salvaRicetta(db, { ...primaPolenta, porzioni: 99 });
  assert.equal((await leggiRicetta(db, 'r1'))?.porzioni, 99);
  assert.equal((await leggiRicetta(db, 'r2'))?.porzioni, 12);
  assert.equal((await leggiRicetta(db, 'r2'))?.gruppi[0].ingredienti[0].quantita, 500);
});

test('risalvare riscrive gruppi e ingredienti invece di accumularli', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, empanadas);
  await salvaRicetta(db, {
    ...empanadas,
    gruppi: [{
      id: 'g-unico', nome: null, ingredienti: [
        { id: 'i-farina2', nome: 'Farina', quantita: 300, unita: 'g' },
      ],
    }],
  });

  const letta = await leggiRicetta(db, 'r-emp');
  assert.equal(letta?.gruppi.length, 1);
  assert.equal(letta?.gruppi[0].ingredienti.length, 1);

  const orfani = await db.getAllAsync<{ n: number }>('SELECT COUNT(*) AS n FROM ingredienti');
  assert.equal(orfani[0].n, 1);
});

test('salvare tiene creataIl e scrive modificataIl come arriva', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, polenta('r1'));
  const letta = await leggiRicetta(db, 'r1');

  assert.equal(letta?.creataIl, ADESSO);
  // Il timestamp lo decide chi chiama: salvaRicetta non timbra.
  assert.equal(letta?.modificataIl, ADESSO);

  const PIU_TARDI = '2026-06-01T10:00:00.000Z';
  await salvaRicetta(db, {
    ...polenta('r1'),
    creataIl: '2000-01-01T00:00:00.000Z',
    modificataIl: PIU_TARDI,
  });
  const riletta = await leggiRicetta(db, 'r1');
  // Anche riscrivendo un creataIl diverso, quello della prima volta resta.
  assert.equal(riletta?.creataIl, ADESSO);
  assert.equal(riletta?.modificataIl, PIU_TARDI);
});

test('i tre filtri dividono il ricettario senza sovrapporsi', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, empanadas);
  await salvaRicetta(db, { ...polenta('r1', 'Torta di mele'), categoriaId: 'c-dolci' });
  await salvaRicetta(db, polenta('r2', 'Pane'));

  assert.deepEqual(
    (await elencoRicette(db, TUTTE)).map((r) => r.titolo),
    ['Empanadas', 'Pane', 'Torta di mele'],
  );
  assert.deepEqual(
    (await elencoRicette(db, { tipo: 'categoria', id: 'c-forno' })).map((r) => r.titolo),
    ['Empanadas'],
  );
  // 'senza' non e' "categoria uguale a niente": categoria_id = ? non pesca mai i NULL.
  assert.deepEqual((await elencoRicette(db, { tipo: 'senza' })).map((r) => r.titolo), ['Pane']);
  // Una categoria che non esiste da' l'elenco vuoto, non un errore.
  assert.deepEqual(await elencoRicette(db, { tipo: 'categoria', id: 'c-mai-creata' }), []);
});

test('i conteggi arrivano in una query sola: totale, senza categoria e per categoria', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, empanadas);
  await salvaRicetta(db, { ...polenta('r1', 'Focaccia'), categoriaId: 'c-forno' });
  await salvaRicetta(db, polenta('r2', 'Pane'));
  await salvaRicetta(db, { ...polenta('r3', 'Crostata'), categoriaId: 'c-dolci' });
  await cancellaRicetta(db, 'r3');

  const conteggi = await conteggiPerCategoria(db);
  // La cancellata non conta da nessuna parte.
  assert.equal(conteggi.totale, 3);
  assert.equal(conteggi.senza, 1);
  assert.equal(conteggi.perCategoria['c-forno'], 2);
  // Una categoria rimasta a zero non compare: chi mostra il numero usa ?? 0.
  assert.equal(conteggi.perCategoria['c-dolci'], undefined);
  assert.equal(conteggi.perCategoria['c-dolci'] ?? 0, 0);
});

test('spostare una ricetta di categoria si vede subito in elenco e conteggi', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, empanadas);

  await salvaRicetta(db, { ...empanadas, categoriaId: 'c-dolci', foto: null });

  // Se l'ON CONFLICT dimenticasse categoria_id o foto, questi tre falliscono.
  assert.deepEqual(await elencoRicette(db, { tipo: 'categoria', id: 'c-forno' }), []);
  assert.deepEqual(
    (await elencoRicette(db, { tipo: 'categoria', id: 'c-dolci' })).map((r) => r.id),
    ['r-emp'],
  );
  assert.equal((await leggiRicetta(db, 'r-emp'))?.foto, null);
  const conteggi = await conteggiPerCategoria(db);
  assert.deepEqual(conteggi, { totale: 1, senza: 0, perCategoria: { 'c-dolci': 1 } });
});

test("una ricetta cancellata sparisce dall'elenco ma resta nell'export", async () => {
  const db = await dbPronto();
  await salvaRicetta(db, polenta('r1'));
  await salvaRicetta(db, { ...polenta('r2', 'Farinata'), porzioni: 14 });

  await cancellaRicetta(db, 'r1');

  assert.deepEqual((await elencoRicette(db, TUTTE)).map((r) => r.id), ['r2']);

  const esportate = await ricetteDaEsportare(db);
  assert.deepEqual(esportate.map((r) => r.id), ['r2', 'r1']);
  const cancellata = esportate.find((r) => r.id === 'r1');
  assert.ok(cancellata?.cancellataIl !== null);
  assert.ok(Math.abs(Date.parse(cancellata!.cancellataIl!) - Date.now()) < 5000);
  // Chi cancella davvero timbra: la tombstone sposta anche modificataIl.
  assert.ok(Math.abs(Date.parse(cancellata!.modificataIl) - Date.now()) < 5000);
  // Gli ingredienti restano: la tombstone non e' una cancellazione vera.
  assert.equal(cancellata?.gruppi[0].ingredienti.length, 2);
});

test('cancellare dimentica il riscalo e azzera la foto', async () => {
  const db = await dbPronto();
  await salvaRicetta(db, empanadas);
  await db.runAsync(
    'INSERT INTO riscalo (ricetta_id, richiesta, aggiornato_il) VALUES (?, ?, ?)',
    ['r-emp', JSON.stringify({ tipo: 'porzioni', porzioni: 10 }), ADESSO],
  );

  await cancellaRicetta(db, 'r-emp');

  const rimasti = await db.getAllAsync<{ ricetta_id: string }>('SELECT ricetta_id FROM riscalo');
  assert.deepEqual(rimasti, []);
  // Il file lo cancellera' il Task 14: il database intanto smette di prometterlo.
  assert.equal((await leggiRicetta(db, 'r-emp'))?.foto, null);
});

test('un ricettario vuoto restituisce liste vuote e conteggi a zero', async () => {
  const db = await dbPronto();
  assert.deepEqual(await elencoRicette(db, TUTTE), []);
  assert.deepEqual(await elencoRicette(db, { tipo: 'senza' }), []);
  assert.deepEqual(await ricetteDaEsportare(db), []);
  // Due categorie esistono, ma nessuna ha ricette: perCategoria e' vuoto.
  assert.deepEqual(await conteggiPerCategoria(db), { totale: 0, senza: 0, perCategoria: {} });
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/data/ricette.ts' imported from .../src/data/ricette.test.ts`,
e il riepilogo con `✖ src/data/ricette.test.ts`. I sei test di
`src/data/schema.test.ts` restano verdi.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

`src/data/ricette.ts`:
```ts
/**
 * Repository delle ricette. Unico punto del progetto che conosce le tabelle:
 * le schermate parlano solo con queste funzioni.
 *
 * Le colonne SQL sono in snake_case, i campi TypeScript in camelCase: la
 * conversione avviene qui e da nessun'altra parte.
 *
 * I parametri delle query si passano sempre come array, mai sciolti: e' la
 * convenzione di tutto il progetto.
 */
import type { SQLiteDatabase } from 'expo-sqlite';
import type { Gruppo, Ingrediente, Ricetta } from '../domain/types.ts';

/** Cosa sta guardando l'utente: tutto il ricettario, una categoria, o gli avanzi. */
export type FiltroElenco =
  | { tipo: 'tutte' }
  | { tipo: 'categoria'; id: string }
  | { tipo: 'senza' };

/** I numeri della schermata principale. */
export interface Conteggi {
  /** Ricette vive, in tutto. */
  totale: number;
  /** Ricette vive senza categoria. */
  senza: number;
  /** id della categoria -> quante ricette vive ha. Le categorie a zero non compaiono. */
  perCategoria: Record<string, number>;
}

interface RigaRicetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  categoria_id: string | null;
  foto: string | null;
  creata_il: string;
  modificata_il: string;
  cancellata_il: string | null;
}
interface RigaGruppo { id: string; ricetta_id: string; nome: string | null }
interface RigaIngrediente {
  id: string;
  gruppo_id: string;
  nome: string;
  quantita: number | null;
  unita: string | null;
}

const CAMPI_RICETTA =
  'SELECT id, titolo, descrizione, porzioni, categoria_id, foto, creata_il, modificata_il, cancellata_il FROM ricette';

const segnaposto = (n: number): string => new Array(n).fill('?').join(', ');

const perTitoloItaliano = (a: RigaRicetta, b: RigaRicetta): number =>
  a.titolo.localeCompare(b.titolo, 'it');

/** Attacca gruppi e ingredienti alle righe gia' lette, in due sole query. */
async function componi(db: SQLiteDatabase, righe: RigaRicetta[]): Promise<Ricetta[]> {
  if (righe.length === 0) return [];

  const idRicette = righe.map((r) => r.id);
  const gruppi = await db.getAllAsync<RigaGruppo>(
    `SELECT id, ricetta_id, nome FROM gruppi WHERE ricetta_id IN (${segnaposto(idRicette.length)}) ORDER BY ordine`,
    idRicette,
  );
  const idGruppi = gruppi.map((g) => g.id);
  const ingredienti = idGruppi.length === 0
    ? []
    : await db.getAllAsync<RigaIngrediente>(
        `SELECT id, gruppo_id, nome, quantita, unita FROM ingredienti WHERE gruppo_id IN (${segnaposto(idGruppi.length)}) ORDER BY ordine`,
        idGruppi,
      );

  const perGruppo = new Map<string, Ingrediente[]>();
  for (const i of ingredienti) {
    const lista = perGruppo.get(i.gruppo_id) ?? [];
    lista.push({ id: i.id, nome: i.nome, quantita: i.quantita, unita: i.unita });
    perGruppo.set(i.gruppo_id, lista);
  }

  const perRicetta = new Map<string, Gruppo[]>();
  for (const g of gruppi) {
    const lista = perRicetta.get(g.ricetta_id) ?? [];
    lista.push({ id: g.id, nome: g.nome, ingredienti: perGruppo.get(g.id) ?? [] });
    perRicetta.set(g.ricetta_id, lista);
  }

  return righe.map((r) => ({
    id: r.id,
    titolo: r.titolo,
    descrizione: r.descrizione,
    porzioni: r.porzioni,
    categoriaId: r.categoria_id,
    foto: r.foto,
    gruppi: perRicetta.get(r.id) ?? [],
    creataIl: r.creata_il,
    modificataIl: r.modificata_il,
    cancellataIl: r.cancellata_il,
  }));
}

/**
 * Solo le vive, ordinate per titolo all'italiana, dentro il filtro chiesto.
 *
 * `categoria_id = ?` non pesca mai le righe con NULL: e' quello che serve,
 * perche' "senza categoria" e' un filtro a parte e non una categoria vuota.
 */
export async function elencoRicette(db: SQLiteDatabase, filtro: FiltroElenco): Promise<Ricetta[]> {
  const vive = `${CAMPI_RICETTA} WHERE cancellata_il IS NULL`;
  let righe: RigaRicetta[];
  if (filtro.tipo === 'categoria') {
    righe = await db.getAllAsync<RigaRicetta>(`${vive} AND categoria_id = ?`, [filtro.id]);
  } else if (filtro.tipo === 'senza') {
    righe = await db.getAllAsync<RigaRicetta>(`${vive} AND categoria_id IS NULL`);
  } else {
    righe = await db.getAllAsync<RigaRicetta>(vive);
  }
  return componi(db, [...righe].sort(perTitoloItaliano));
}

/**
 * I numeri della schermata principale, in una query sola.
 *
 * Il GROUP BY torna una riga per categoria che ha almeno una ricetta viva, piu'
 * la riga con categoria_id NULL per quelle senza: al massimo qualche decina di
 * righe da sommare, invece di tutto il ricettario caricato in memoria per poi
 * buttarlo. E' la differenza fra un'app che regge trecento ricette e una che no.
 */
export async function conteggiPerCategoria(db: SQLiteDatabase): Promise<Conteggi> {
  const righe = await db.getAllAsync<{ categoria_id: string | null; n: number }>(
    'SELECT categoria_id, COUNT(*) AS n FROM ricette WHERE cancellata_il IS NULL GROUP BY categoria_id',
  );

  const conteggi: Conteggi = { totale: 0, senza: 0, perCategoria: {} };
  for (const riga of righe) {
    conteggi.totale += riga.n;
    if (riga.categoria_id === null) conteggi.senza = riga.n;
    else conteggi.perCategoria[riga.categoria_id] = riga.n;
  }
  return conteggi;
}

/** Anche una cancellata, se esiste: serve all'export e all'import. */
export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null> {
  const riga = await db.getFirstAsync<RigaRicetta>(`${CAMPI_RICETTA} WHERE id = ?`, [id]);
  if (riga === null) return null;
  const [ricetta] = await componi(db, [riga]);
  return ricetta;
}

/**
 * Upsert completo dentro una transazione. Gruppi e ingredienti vengono
 * riscritti da zero nell'ordine dell'array: e' l'ordine che l'utente vede.
 *
 * `creataIl` non si tocca mai dopo il primo salvataggio. `modificataIl` invece
 * si scrive com'e' arrivato: qui non si timbra. Timbra chi modifica davvero
 * (la schermata Modifica, che passa `new Date().toISOString()`), mentre
 * l'import deve conservare le date del file, altrimenti "a parita' di id resta
 * la versione modificata piu' di recente" non funziona piu'.
 */
export async function salvaRicetta(db: SQLiteDatabase, ricetta: Ricetta): Promise<void> {
  await db.withTransactionAsync(async () => {
    await db.runAsync(
      `INSERT INTO ricette (id, titolo, descrizione, porzioni, categoria_id, foto, creata_il, modificata_il, cancellata_il)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
       ON CONFLICT(id) DO UPDATE SET
         titolo = excluded.titolo,
         descrizione = excluded.descrizione,
         porzioni = excluded.porzioni,
         categoria_id = excluded.categoria_id,
         foto = excluded.foto,
         modificata_il = excluded.modificata_il,
         cancellata_il = excluded.cancellata_il`,
      [
        ricetta.id,
        ricetta.titolo,
        ricetta.descrizione,
        ricetta.porzioni,
        ricetta.categoriaId,
        ricetta.foto,
        ricetta.creataIl,
        ricetta.modificataIl,
        ricetta.cancellataIl,
      ],
    );

    // I vecchi ingredienti se ne vanno in cascata con i vecchi gruppi.
    await db.runAsync('DELETE FROM gruppi WHERE ricetta_id = ?', [ricetta.id]);

    for (let ig = 0; ig < ricetta.gruppi.length; ig++) {
      const gruppo = ricetta.gruppi[ig];
      await db.runAsync(
        'INSERT INTO gruppi (id, ricetta_id, nome, ordine) VALUES (?, ?, ?, ?)',
        [gruppo.id, ricetta.id, gruppo.nome, ig],
      );
      for (let ii = 0; ii < gruppo.ingredienti.length; ii++) {
        const ing = gruppo.ingredienti[ii];
        await db.runAsync(
          'INSERT INTO ingredienti (id, gruppo_id, nome, quantita, unita, ordine) VALUES (?, ?, ?, ?, ?, ?)',
          [ing.id, gruppo.id, ing.nome, ing.quantita, ing.unita, ii],
        );
      }
    }
  });
}

/**
 * Tombstone. La riga resta, altrimenti la sync futura si riporterebbe indietro
 * le ricette cancellate. Il riscalo in corso invece si butta: e' roba locale e
 * senza la ricetta non significa piu' niente.
 *
 * `foto = NULL` perche' il file sta per sparire: il Task 14 aggiunge qui la
 * lettura del nome e la chiamata a `cancellaFoto`, e il database deve gia'
 * raccontare la stessa storia del disco.
 */
export async function cancellaRicetta(db: SQLiteDatabase, id: string): Promise<void> {
  const adesso = new Date().toISOString();
  await db.withTransactionAsync(async () => {
    await db.runAsync(
      'UPDATE ricette SET cancellata_il = ?, modificata_il = ?, foto = NULL WHERE id = ?',
      [adesso, adesso, id],
    );
    await db.runAsync('DELETE FROM riscalo WHERE ricetta_id = ?', [id]);
  });
}

/** Tutte, tombstone comprese: l'export deve poter cancellare anche altrove. */
export async function ricetteDaEsportare(db: SQLiteDatabase): Promise<Ricetta[]> {
  const righe = await db.getAllAsync<RigaRicetta>(CAMPI_RICETTA);
  return componi(db, [...righe].sort(perTitoloItaliano));
}
```

Quattro dettagli che sembrano cavilli e non lo sono:

- `ORDER BY ordine` nelle due query di `componi` funziona anche quando si legge
  più di una ricetta per volta: filtrando per `ricetta_id` una lista già
  ordinata, l'ordine dentro ogni ricetta resta quello giusto.
- `salvaRicetta` non tocca `modificataIl`, `cancellaRicetta` sì. Sembrano due
  pesi e due misure: `cancellaRicetta` non riceve una ricetta, riceve un id, e
  la tombstone la crea lei, quindi la data non può che essere l'ora corrente.
  Chi passa un oggetto `Ricetta` intero, invece, ha già deciso la sua data.
- `cancellaRicetta` sposta anche `modificata_il`. La tombstone è una modifica a
  tutti gli effetti: senza quella data, l'import e la sync futura non saprebbero
  che la cancellazione è più recente della versione viva che arriva da un altro
  dispositivo.
- `categoria_id` e `foto` stanno anche nella clausola `DO UPDATE`. Dimenticarli
  lì è l'errore silenzioso di questo file: il primo salvataggio andrebbe bene e
  ogni modifica successiva riporterebbe la ricetta nella vecchia categoria, con
  la vecchia foto. Il test "spostare una ricetta di categoria" esiste per questo.

- [ ] **Step 4: Esegui il test e verifica che passi**

Comandi:
```bash
node --test src/data/ricette.test.ts
npm test
```

Atteso: il primo comando stampa `ℹ pass 13` e `ℹ fail 0`, con i tredici test
verdi di `src/data/ricette.test.ts`; il secondo PASS, ℹ pass 103, ℹ fail 0 (i 90
lasciati dal Task 10 più i 13 nuovi di `ricette.test.ts`). **Questo task aggiunge
13 test al totale del progetto.**

In entrambi gli output compare anche
`ExperimentalWarning: SQLite is an experimental feature and might change at any time`:
è `node:sqlite` del database in memoria, non è un errore.

- [ ] **Step 5: Verifica i tipi**

Comando: `npm run typecheck`

Atteso: nessun output, codice di uscita 0.

- [ ] **Step 6: Commit**

```bash
git add src/data/ricette.ts src/data/ricette.test.ts
git commit -m "Aggiungi il repository delle ricette con filtri, conteggi e tombstone"
```

---

### Task 12: Repository categorie

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta è un'app
che ricalcola le dosi di una ricetta. Il ricettario sta in SQLite sul
dispositivo, senza account e senza rete. L'utente può raggruppare le sue ricette
in **categorie che si crea da solo**, ognuna con un nome libero e un'icona presa
da un catalogo che forniamo noi: la schermata principale dell'app è proprio
l'elenco delle categorie.

Le categorie sono raggruppamenti, non etichette: **una ricetta sta in una
categoria sola, oppure in nessuna**. Non c'è nessuna tabella di legame, c'è una
colonna `categoria_id` sulla riga della ricetta, e vale `NULL` quando la ricetta
non sta in nessuna categoria. Non è un errore: è lo stato normale di chi scrive
una ricetta prima di essersi fatto le categorie.

Due regole che governano tutto il file che scriverai:

- **Cancellare non rimuove la riga, la marca.** Si scrive la data in
  `cancellata_il` e la riga resta dov'è. Serve alla sincronizzazione che
  arriverà più avanti: senza questa "tombstone", un dispositivo rimasto spento
  quattro mesi si riporterebbe indietro tutto quello che era stato cancellato
  altrove. Le tombstone non si ripuliscono mai.
- **Cancellare una categoria non cancella le sue ricette.** Le ricette restano,
  perdono il legame (`categoria_id` torna a `NULL`) e finiscono fra quelle senza
  categoria. È una promessa esplicita fatta all'utente nella schermata di
  conferma, e questo task è il posto dove diventa vera.

Questo task scrive il repository: l'unico punto del progetto che sa che esiste
una tabella `categorie`. Le schermate non parlano mai col database, passano da
qui.

**Files:**
- Create: `src/data/categorie.ts`
- Test: `src/data/categorie.test.ts`

**Interfaces:**

- **Consumes**
  - `expo-sqlite`. Di tutta la sua API servono il tipo `SQLiteDatabase` e questi
    metodi, tutti asincroni:
    ```ts
    runAsync(source: string, params: (string | number | null)[]): Promise<{ lastInsertRowId: number; changes: number }>
    getFirstAsync<T>(source: string, params?: (string | number | null)[]): Promise<T | null>
    getAllAsync<T>(source: string, params?: (string | number | null)[]): Promise<T[]>
    withTransactionAsync(task: () => Promise<void>): Promise<void>
    ```
    `import type { SQLiteDatabase } from 'expo-sqlite'` è un import di **solo
    tipo**: sparisce a runtime, quindi i test in Node non provano mai a caricare
    il modulo nativo che c'è dietro.
  - `src/data/dbMemoria.ts` (Task 10):
    ```ts
    export function apriDbInMemoria(): SQLiteDatabase;
    ```
    Database SQLite in memoria costruito su `node:sqlite`, che è SQLite vero
    compilato dentro Node. È l'unico adattatore del progetto e accetta i
    parametri delle query come array, che è la forma usata qui. Stampa un
    `ExperimentalWarning` a ogni esecuzione: è innocuo e non va zittito.
  - `src/data/schema.ts` (Task 10):
    ```ts
    export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void>;
    ```
    Crea le tabelle e accende `PRAGMA foreign_keys`.
  - Lo schema creato dal Task 10, che qui serve colonna per colonna:
    ```
    categorie    id TEXT PK NOT NULL, nome TEXT NOT NULL, icona TEXT NOT NULL,
                 ordine INTEGER NOT NULL, creata_il TEXT NOT NULL,
                 modificata_il TEXT NOT NULL, cancellata_il TEXT NULL
    ricette      id TEXT PK NOT NULL, titolo TEXT NOT NULL,
                 descrizione TEXT NOT NULL DEFAULT '', porzioni REAL NULL,
                 categoria_id TEXT NULL REFERENCES categorie(id) ON DELETE SET NULL,
                 foto TEXT NULL, creata_il TEXT NOT NULL,
                 modificata_il TEXT NOT NULL, cancellata_il TEXT NULL
    ```
    `ON DELETE SET NULL` sulla chiave esterna qui non scatta **mai**, perché in
    questo progetto non si esegue nessun `DELETE FROM categorie`: è la rete di
    sicurezza per il giorno in cui qualcuno cancellasse una riga a mano. Il
    lavoro vero lo fa `cancellaCategoria`, esplicitamente.
  - `src/data/ricette.ts` (Task 11), di cui al test serve una sola funzione:
    ```ts
    export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null>;
    ```
    Restituisce anche una ricetta cancellata, se quell'id esiste; `null` se non
    esiste.
  - `src/domain/types.ts` (Task 5). A questo task servono queste due
    dichiarazioni:
    ```ts
    export interface Categoria {
      id: string;
      nome: string;
      icona: ChiaveIcona;
      ordine: number;
      creataIl: string;              // ISO 8601
      modificataIl: string;          // ISO 8601
      cancellataIl: string | null;   // tombstone, null = viva
    }

    export interface Ricetta {
      id: string;
      titolo: string;
      descrizione: string;
      porzioni: number | null;
      categoriaId: string | null;    // null = senza categoria, stato normale
      foto: string | null;           // NOME del file, non percorso assoluto
      gruppi: Gruppo[];
      creataIl: string;
      modificataIl: string;
      cancellataIl: string | null;
    }
    ```
  - `src/domain/icone.ts` (Task 5), il catalogo chiuso delle trenta icone
    disponibili. Al test servono queste dichiarazioni, per avere chiavi valide
    da scrivere nelle fixture senza inventarne:
    ```ts
    /** Le trenta voci, scritte per esteso dal Task 5 e chiuse da `as const`. */
    export const ICONE = [ /* { chiave: 'piatto', nome: 'silverware-fork-knife' }, … */ ] as const;
    /** L'unione delle trenta chiavi: un valore fuori catalogo non compila. */
    export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
    export interface VoceIcona { chiave: ChiaveIcona; nome: string }
    export const ICONA_PREDEFINITA: ChiaveIcona;
    ```
    L'`as const` non è un dettaglio di stile: senza, `ChiaveIcona` collasserebbe
    a `string` e `Categoria['icona']` accetterebbe qualunque stringa. Il test
    qui sotto pesca `ICONE[1].chiave` proprio perché il tipo garantisce che sia
    una chiave del catalogo.

- **Produces**
  - `src/data/categorie.ts`
    ```ts
    export async function elencoCategorie(db: SQLiteDatabase): Promise<Categoria[]>;
    export async function leggiCategoria(db: SQLiteDatabase, id: string): Promise<Categoria | null>;
    export async function salvaCategoria(db: SQLiteDatabase, categoria: Categoria): Promise<void>;
    export async function cancellaCategoria(db: SQLiteDatabase, id: string): Promise<void>;
    export async function riordinaCategorie(db: SQLiteDatabase, idInOrdine: string[]): Promise<void>;
    export async function categorieDaEsportare(db: SQLiteDatabase): Promise<Categoria[]>;
    ```
  - `elencoCategorie`: solo le vive (`cancellata_il IS NULL`), ordinate per
    `ordine` crescente e, a parità di `ordine`, per nome con
    `localeCompare('it')`.
  - `leggiCategoria`: restituisce anche una cancellata, se quell'id esiste;
    `null` se non esiste.
  - `salvaCategoria`: upsert con `INSERT … ON CONFLICT(id) DO UPDATE`.
    `creataIl` resta quello del primo salvataggio, e **`modificataIl` viene
    scritto così come arriva nell'oggetto**: il timestamp lo decide chi chiama,
    non questa funzione, esattamente come per le ricette.
  - `cancellaCategoria`: marca `cancellata_il` e `modificata_il` con l'ora
    corrente, non rimuove la riga, e azzera il `categoria_id` delle ricette che
    ci stavano dentro spostando anche il loro `modificata_il`.
  - `riordinaCategorie`: scrive in `ordine` la posizione di ogni id nell'array
    ricevuto (il primo id prende `0`) e timbra `modificata_il`. Un id che non
    esiste non fa danno: aggiorna zero righe.
  - `categorieDaEsportare`: tutte, tombstone comprese.

**Perché `salvaCategoria` non timbra e `cancellaCategoria` sì.** Sembrano due
pesi e due misure e non lo sono. Chi passa un oggetto `Categoria` intero ha già
deciso la sua data: la schermata che modifica ci mette
`new Date().toISOString()`, l'import ci mette la data che stava nel file, e deve
poterlo fare, altrimenti la regola "a parità di id resta la versione modificata
più di recente" smette di funzionare. `cancellaCategoria` e `riordinaCategorie`
invece non ricevono nessun oggetto, ricevono degli id: la data non può che
essere l'ora corrente.

**Perché `categorieDaEsportare` esiste.** Il file di export porta con sé anche
le categorie. Se portasse solo quelle vive, importando l'archivio su un altro
dispositivo le categorie cancellate tornerebbero a galla: la tombstone serve
proprio a dire "questa è stata cancellata, non ricrearla".

**Perché l'ordine non lo fa `ORDER BY`.** Il confronto fra nomi è quello
italiano di `localeCompare('it')`, che SQLite non sa fare: un `ORDER BY nome`
ingenuo ordina per codice carattere e mette `Zuppe` prima di `antipasti`, perché
la `Z` maiuscola viene prima della `a` minuscola. Si leggono le righe e si
ordinano in JavaScript, come già fa il repository delle ricette.

- [ ] **Step 1: Scrivi il test che fallisce, prima parte: leggere e scrivere**

`src/data/categorie.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { apriDbInMemoria } from './dbMemoria.ts';
import { applicaMigrazioni } from './schema.ts';
import { elencoCategorie, leggiCategoria, salvaCategoria } from './categorie.ts';
import { ICONA_PREDEFINITA, ICONE } from '../domain/icone.ts';
import type { Categoria } from '../domain/types.ts';

const ADESSO = '2026-01-01T00:00:00.000Z';

function categoria(
  id: string,
  nome: string,
  ordine = 0,
  icona = ICONA_PREDEFINITA,
): Categoria {
  return { id, nome, icona, ordine, creataIl: ADESSO, modificataIl: ADESSO, cancellataIl: null };
}

async function dbPronto() {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  return db;
}

test('una categoria salvata si rilegge identica', async () => {
  const db = await dbPronto();
  // Un'icona diversa dalla predefinita: cosi' il test vede davvero la colonna.
  const dolci = categoria('c-dolci', 'Dolci', 3, ICONE[1].chiave);
  await salvaCategoria(db, dolci);

  assert.deepEqual(await leggiCategoria(db, 'c-dolci'), dolci);
});

test('leggiCategoria su un id che non esiste torna null', async () => {
  const db = await dbPronto();
  assert.equal(await leggiCategoria(db, 'mai-vista'), null);
});

test("l'elenco rispetta l'ordine, e a parita' di ordine il nome all'italiana", async () => {
  const db = await dbPronto();
  await salvaCategoria(db, categoria('c1', 'Secondi', 2));
  await salvaCategoria(db, categoria('c2', 'Zuppe', 0));
  await salvaCategoria(db, categoria('c3', 'antipasti', 0));
  await salvaCategoria(db, categoria('c4', 'Primi', 1));

  const elenco = await elencoCategorie(db);
  // A ordine 0 ci sono Zuppe e antipasti: un sort() ingenuo metterebbe Zuppe
  // per prima, perche' 'Z' viene prima di 'a' nel codice carattere.
  assert.deepEqual(elenco.map((c) => c.nome), ['antipasti', 'Zuppe', 'Primi', 'Secondi']);
  assert.deepEqual(elenco.map((c) => c.ordine), [0, 0, 1, 2]);
});

test('due categorie con lo stesso nome convivono senza pestarsi i piedi', async () => {
  const db = await dbPronto();
  await salvaCategoria(db, categoria('c1', 'Dolci', 0));
  await salvaCategoria(db, categoria('c2', 'Dolci', 1));

  assert.deepEqual((await elencoCategorie(db)).map((c) => c.id), ['c1', 'c2']);

  // Rinominare l'una non tocca l'altra: l'identita' e' l'id, non il nome.
  await salvaCategoria(db, { ...categoria('c1', 'Dolci al cucchiaio', 0) });
  assert.equal((await leggiCategoria(db, 'c1'))?.nome, 'Dolci al cucchiaio');
  assert.equal((await leggiCategoria(db, 'c2'))?.nome, 'Dolci');
});

test('salvare tiene creataIl e scrive modificataIl come arriva', async () => {
  const db = await dbPronto();
  await salvaCategoria(db, categoria('c1', 'Dolci'));

  const letta = await leggiCategoria(db, 'c1');
  assert.equal(letta?.creataIl, ADESSO);
  // Il timestamp lo decide chi chiama: salvaCategoria non timbra.
  assert.equal(letta?.modificataIl, ADESSO);

  const PIU_TARDI = '2026-06-01T10:00:00.000Z';
  await salvaCategoria(db, {
    ...categoria('c1', 'Dolci'),
    creataIl: '2000-01-01T00:00:00.000Z',
    modificataIl: PIU_TARDI,
  });

  const riletta = await leggiCategoria(db, 'c1');
  // Anche riscrivendo un creataIl diverso, quello della prima volta resta.
  assert.equal(riletta?.creataIl, ADESSO);
  assert.equal(riletta?.modificataIl, PIU_TARDI);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/data/categorie.ts' imported from .../src/data/categorie.test.ts`,
e il riepilogo con `✖ src/data/categorie.test.ts`. Gli altri file di test
restano verdi.

- [ ] **Step 3: Implementa lettura e scrittura**

`src/data/categorie.ts`:
```ts
/**
 * Repository delle categorie. Unico punto del progetto che conosce la tabella
 * `categorie`: le schermate parlano solo con queste funzioni.
 *
 * Le colonne SQL sono in snake_case, i campi TypeScript in camelCase: la
 * conversione avviene qui e da nessun'altra parte.
 *
 * I parametri delle query si passano sempre come array, mai sciolti: e' la
 * convenzione di tutto il progetto.
 */
import type { SQLiteDatabase } from 'expo-sqlite';
import type { Categoria } from '../domain/types.ts';

/**
 * La riga com'e' scritta sul disco. La chiave dell'icona si rilegge com'e':
 * chi controlla che sia una delle nostre e' chi la riceve da fuori, cioe'
 * l'import, non questo strato.
 */
interface RigaCategoria {
  id: string;
  nome: string;
  icona: Categoria['icona'];
  ordine: number;
  creata_il: string;
  modificata_il: string;
  cancellata_il: string | null;
}

const CAMPI =
  'SELECT id, nome, icona, ordine, creata_il, modificata_il, cancellata_il FROM categorie';

const daRiga = (r: RigaCategoria): Categoria => ({
  id: r.id,
  nome: r.nome,
  icona: r.icona,
  ordine: r.ordine,
  creataIl: r.creata_il,
  modificataIl: r.modificata_il,
  cancellataIl: r.cancellata_il,
});

/**
 * Prima l'ordine deciso dall'utente, poi il nome all'italiana. Non e' un
 * ORDER BY perche' SQLite non sa confrontare i nomi come l'italiano vuole:
 * ordinerebbe per codice carattere e metterebbe 'Zuppe' prima di 'antipasti'.
 */
const perOrdinePoiNome = (a: RigaCategoria, b: RigaCategoria): number =>
  a.ordine - b.ordine || a.nome.localeCompare(b.nome, 'it');

/** Solo le vive, nell'ordine deciso dall'utente. */
export async function elencoCategorie(db: SQLiteDatabase): Promise<Categoria[]> {
  const righe = await db.getAllAsync<RigaCategoria>(`${CAMPI} WHERE cancellata_il IS NULL`);
  return righe.sort(perOrdinePoiNome).map(daRiga);
}

/** Anche una cancellata, se quell'id esiste: serve all'export e all'import. */
export async function leggiCategoria(db: SQLiteDatabase, id: string): Promise<Categoria | null> {
  const riga = await db.getFirstAsync<RigaCategoria>(`${CAMPI} WHERE id = ?`, [id]);
  return riga === null ? null : daRiga(riga);
}

/**
 * Upsert. `creataIl` non si tocca mai dopo il primo salvataggio: non compare
 * fra le colonne aggiornate.
 *
 * `modificataIl` invece si scrive com'e' arrivato: qui non si timbra. Timbra
 * chi modifica davvero (la schermata di gestione, che passa
 * `new Date().toISOString()`), mentre l'import deve conservare le date del
 * file, altrimenti "a parita' di id resta la versione modificata piu' di
 * recente" non funziona piu'.
 */
export async function salvaCategoria(db: SQLiteDatabase, categoria: Categoria): Promise<void> {
  await db.runAsync(
    `INSERT INTO categorie (id, nome, icona, ordine, creata_il, modificata_il, cancellata_il)
     VALUES (?, ?, ?, ?, ?, ?, ?)
     ON CONFLICT(id) DO UPDATE SET
       nome = excluded.nome,
       icona = excluded.icona,
       ordine = excluded.ordine,
       modificata_il = excluded.modificata_il,
       cancellata_il = excluded.cancellata_il`,
    [
      categoria.id,
      categoria.nome,
      categoria.icona,
      categoria.ordine,
      categoria.creataIl,
      categoria.modificataIl,
      categoria.cancellataIl,
    ],
  );
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ fail 0`, cinque test verdi in `src/data/categorie.test.ts` e
il totale di `npm test` cresciuto di cinque. Nell'output compare anche
`ExperimentalWarning: SQLite is an experimental feature and might change at any time`:
è `node:sqlite` del database in memoria, non è un errore.

- [ ] **Step 5: Commit**

```bash
git add src/data/categorie.ts src/data/categorie.test.ts
git commit -m "Aggiungi il repository delle categorie con ordine e nomi ripetibili"
```

- [ ] **Step 6: Scrivi il test che fallisce, seconda parte: cancellare e riordinare**

In `src/data/categorie.test.ts`, sostituisci la riga di import di
`./categorie.ts` con questa:
```ts
import {
  cancellaCategoria, categorieDaEsportare, elencoCategorie, leggiCategoria,
  riordinaCategorie, salvaCategoria,
} from './categorie.ts';
```

aggiungi accanto agli altri import questi due:
```ts
import { leggiRicetta } from './ricette.ts';
import type { SQLiteDatabase } from 'expo-sqlite';
```

e aggiungi in fondo al file l'aiutante e i quattro test:
```ts
/**
 * Inserisce una ricetta minima direttamente in tabella: qui interessa solo il
 * legame con la categoria, non gruppi e ingredienti.
 */
async function inserisciRicetta(
  db: SQLiteDatabase,
  id: string,
  titolo: string,
  categoriaId: string | null,
): Promise<void> {
  await db.runAsync(
    `INSERT INTO ricette (id, titolo, descrizione, porzioni, categoria_id, foto, creata_il, modificata_il, cancellata_il)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
    [id, titolo, '', null, categoriaId, null, ADESSO, ADESSO, null],
  );
}

test('cancellare una categoria non cancella le sue ricette: le lascia senza', async () => {
  const db = await dbPronto();
  await salvaCategoria(db, categoria('c-dolci', 'Dolci', 0));
  await salvaCategoria(db, categoria('c-primi', 'Primi', 1));
  await inserisciRicetta(db, 'r1', 'Tiramisu', 'c-dolci');
  await inserisciRicetta(db, 'r2', 'Carbonara', 'c-primi');
  await inserisciRicetta(db, 'r3', 'Polenta', null);

  await cancellaCategoria(db, 'c-dolci');

  // La ricetta c'e' ancora, con il suo titolo: e' solo rimasta senza categoria.
  const tiramisu = await leggiRicetta(db, 'r1');
  assert.equal(tiramisu?.titolo, 'Tiramisu');
  assert.equal(tiramisu?.cancellataIl, null);
  assert.equal(tiramisu?.categoriaId, null);
  // Perdere la categoria e' una modifica: la data si sposta, se no la sync
  // futura non saprebbe che questa versione e' piu' recente.
  assert.ok(Math.abs(Date.parse(tiramisu!.modificataIl) - Date.now()) < 5000);

  // Le altre due non le ha toccate nessuno.
  const carbonara = await leggiRicetta(db, 'r2');
  assert.equal(carbonara?.categoriaId, 'c-primi');
  assert.equal(carbonara?.modificataIl, ADESSO);
  const polenta = await leggiRicetta(db, 'r3');
  assert.equal(polenta?.categoriaId, null);
  assert.equal(polenta?.modificataIl, ADESSO);
});

test("una categoria cancellata sparisce dall'elenco ma resta nel database", async () => {
  const db = await dbPronto();
  await salvaCategoria(db, categoria('c1', 'Dolci', 0));
  await salvaCategoria(db, categoria('c2', 'Primi', 1));

  await cancellaCategoria(db, 'c1');

  assert.deepEqual((await elencoCategorie(db)).map((c) => c.id), ['c2']);

  // La riga non e' stata rimossa: e' una tombstone, e serve alla sync futura.
  const righe = await db.getAllAsync<{ id: string; cancellata_il: string | null }>(
    'SELECT id, cancellata_il FROM categorie ORDER BY id',
  );
  assert.deepEqual(righe.map((r) => r.id), ['c1', 'c2']);
  assert.ok(righe[0].cancellata_il !== null);
  assert.ok(Math.abs(Date.parse(righe[0].cancellata_il!) - Date.now()) < 5000);

  // E l'export se la porta dietro, altrimenti l'import la farebbe rinascere.
  const esportate = await categorieDaEsportare(db);
  assert.deepEqual(esportate.map((c) => c.id), ['c1', 'c2']);
  assert.ok(esportate[0].cancellataIl !== null);
  // La tombstone e' una modifica a tutti gli effetti: sposta anche modificataIl.
  assert.ok(Math.abs(Date.parse(esportate[0].modificataIl) - Date.now()) < 5000);
});

test("riordinare scrive le posizioni nuove e non tocca nient'altro", async () => {
  const db = await dbPronto();
  await salvaCategoria(db, categoria('c1', 'Antipasti', 0));
  await salvaCategoria(db, categoria('c2', 'Primi', 1));
  await salvaCategoria(db, categoria('c3', 'Dolci', 2));

  await riordinaCategorie(db, ['c3', 'c1', 'c2']);

  const elenco = await elencoCategorie(db);
  assert.deepEqual(elenco.map((c) => c.id), ['c3', 'c1', 'c2']);
  assert.deepEqual(elenco.map((c) => c.ordine), [0, 1, 2]);
  // Nomi e icone restano quelli, e la data di creazione non si muove.
  assert.deepEqual(elenco.map((c) => c.nome), ['Dolci', 'Antipasti', 'Primi']);
  assert.deepEqual(elenco.map((c) => c.creataIl), [ADESSO, ADESSO, ADESSO]);
  // Riordinare e' una modifica, e la data la decide la funzione: riceve id.
  for (const c of elenco) {
    assert.ok(Math.abs(Date.parse(c.modificataIl) - Date.now()) < 5000);
  }
});

test('un ricettario senza categorie restituisce liste vuote, non errori', async () => {
  const db = await dbPronto();
  assert.deepEqual(await elencoCategorie(db), []);
  assert.deepEqual(await categorieDaEsportare(db), []);
});
```

- [ ] **Step 7: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`SyntaxError: The requested module './categorie.ts' does not provide an export named 'cancellaCategoria'`
(oppure uno degli altri due nomi nuovi: Node ne segnala uno solo), e il
riepilogo con `✖ src/data/categorie.test.ts`. Attenzione: l'import che non si
risolve fa saltare **tutto il file**, quindi in questo passo spariscono dal
conto anche i cinque test verdi del passo 4. Gli altri file di test restano
verdi.

- [ ] **Step 8: Implementa cancellazione, riordino ed export**

Aggiungi in fondo a `src/data/categorie.ts`:
```ts
/**
 * Tombstone, come per le ricette: la riga resta, altrimenti la sync futura si
 * riporterebbe indietro le categorie cancellate.
 *
 * Le ricette che stavano qui dentro NON si cancellano: perdono il legame e
 * finiscono fra quelle senza categoria. E' la promessa che l'app fa all'utente
 * nella schermata di conferma, ed e' questa riga a mantenerla.
 *
 * Il `ON DELETE SET NULL` dello schema non c'entra e non scatta: quello vale
 * per un DELETE vero, che qui non facciamo mai.
 */
export async function cancellaCategoria(db: SQLiteDatabase, id: string): Promise<void> {
  const adesso = new Date().toISOString();
  await db.withTransactionAsync(async () => {
    await db.runAsync(
      'UPDATE categorie SET cancellata_il = ?, modificata_il = ? WHERE id = ?',
      [adesso, adesso, id],
    );
    // Anche modificata_il, non solo categoria_id: la ricetta e' cambiata, e la
    // sync futura deve accorgersene.
    await db.runAsync(
      'UPDATE ricette SET categoria_id = NULL, modificata_il = ? WHERE categoria_id = ?',
      [adesso, id],
    );
  });
}

/**
 * Scrive in `ordine` la posizione di ogni id nell'array: il primo prende 0.
 * Tutto in una transazione, cosi' o si sposta l'elenco intero o non si sposta
 * niente. Un id che non esiste piu' aggiorna zero righe e non fa danno.
 */
export async function riordinaCategorie(db: SQLiteDatabase, idInOrdine: string[]): Promise<void> {
  const adesso = new Date().toISOString();
  await db.withTransactionAsync(async () => {
    for (let i = 0; i < idInOrdine.length; i++) {
      await db.runAsync(
        'UPDATE categorie SET ordine = ?, modificata_il = ? WHERE id = ?',
        [i, adesso, idInOrdine[i]],
      );
    }
  });
}

/**
 * Tutte, tombstone comprese. Senza le tombstone, importando l'archivio su un
 * altro dispositivo le categorie cancellate tornerebbero a galla.
 */
export async function categorieDaEsportare(db: SQLiteDatabase): Promise<Categoria[]> {
  const righe = await db.getAllAsync<RigaCategoria>(CAMPI);
  return righe.sort(perOrdinePoiNome).map(daRiga);
}
```

- [ ] **Step 9: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, ℹ pass 112, ℹ fail 0 (i 103 lasciati dal Task 11 più i 9 nuovi di
`categorie.test.ts`), con i nove test verdi in `src/data/categorie.test.ts`.

- [ ] **Step 10: Verifica i tipi**

Comando: `npm run typecheck`

Atteso: nessun output, codice di uscita 0.

- [ ] **Step 11: Commit**

```bash
git add src/data/categorie.ts src/data/categorie.test.ts
git commit -m "Cancella una categoria senza portarsi via le ricette, e riordinale"
```

---

---

### Task 13: Il riscalo salvato e il suo ricalcolo

**Cosa fa questo task, per chi non conosce il progetto.** L'app riscala le dosi
di una ricetta: "voglio farne per 6 invece che per 4", oppure "di farina ne ho
250 g e non 180, quanto metto di tutto il resto?". Quando si riapre una ricetta
si devono rivedere le dosi che si stavano usando, senza rifare l'operazione.

La cosa da non sbagliare: **si salva la richiesta dell'utente, non il fattore che
ne è uscito.** Cioè si salva "250 g dell'ingrediente i-farina", non "×1,3889". Il
fattore si ricalcola all'apertura, contro la ricetta com'è adesso. Se si salvasse
il fattore, chi corregge la ricetta dopo averla riscalata si ritroverebbe numeri
sbagliati sotto un'etichetta che mente: riscali su 250 g di farina quando la
ricetta ne dichiara 180, poi ti accorgi che erano 200 e la correggi, e riaprendo
la fascia dice ancora "riscalata su 250 g di farina" mentre la riga farina ne
mostra 347. Sbagliato in silenzio, sulla schermata che si guarda cucinando.

Se la richiesta non è più risolvibile — l'ingrediente è stato cancellato, o è
diventato "q.b.", o le porzioni sono state tolte — il riscalo si butta e la
ricetta si riapre sulle dosi originali. Senza messaggi di errore: non è un
guasto, è una ricetta cambiata.

Il riscalo resta sul dispositivo e non verrà mai sincronizzato: quello che si sta
cucinando adesso riguarda il telefono che si ha in mano. Per questo non compare
nel file di export del Task 15.

**Files:**
- Create: `src/data/riscalo.ts`
- Test: `src/data/riscalo.test.ts`

**Interfaces:**

- Consumes — da `src/domain/types.ts`, che a questo punto è completo: il Task 4
  ci ha messo `Richiesta` e `Riscalo`, il Task 5 i campi `categoriaId` e `foto`
  su `Ricetta`. Qui non si tocca più:

```ts
export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }
export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  categoriaId: string | null;    // null = senza categoria, è uno stato normale
  foto: string | null;           // NOME del file, mai un percorso assoluto
  gruppi: Gruppo[];
  creataIl: string;              // ISO 8601
  modificataIl: string;          // ISO 8601
  cancellataIl: string | null;   // tombstone, null = viva
}
/** quantita === null su un Ingrediente significa "q.b.": non entra nei calcoli. */
export type Richiesta =
  | { tipo: 'porzioni'; porzioni: number }
  | { tipo: 'ingrediente'; ingredienteId: string; quantita: number };
export interface Riscalo {
  ricettaId: string;
  richiesta: Richiesta;
  aggiornatoIl: string;
}
```

  `categoriaId` e `foto` a questo task non servono per il calcolo, ma servono
  alle fixture: una `Ricetta` senza quei due campi non compila.

- Consumes — da `src/domain/scaling.ts` (Task 4):

```ts
/** Risolve una Richiesta salvata contro la ricetta ATTUALE. null = non più calcolabile. */
export function risolviRichiesta(ricetta: Ricetta, richiesta: Richiesta): number | null;
```

  Restituisce `null` quando le porzioni sono `null` o `<= 0`, quando
  l'ingrediente non esiste più, quando è diventato q.b. (`quantita === null`) e
  quando la quantità chiesta non è un numero finito e positivo.

- Consumes — da `src/data/schema.ts` (Task 10):

```ts
export const VERSIONE_SCHEMA = 1;
export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void>;
```

  Crea le tabelle `ricette`, `categorie`, `gruppi`, `ingredienti`, `riscalo` e
  accende `PRAGMA foreign_keys`. La tabella su cui lavora questo task è
  esattamente quella creata dal Task 10:

```sql
CREATE TABLE riscalo (
  ricetta_id    TEXT PRIMARY KEY NOT NULL REFERENCES ricette(id) ON DELETE CASCADE,
  richiesta     TEXT NOT NULL,
  aggiornato_il TEXT NOT NULL
);
```

  `richiesta` è la `Richiesta` serializzata in JSON, `aggiornato_il` è una data
  ISO 8601.

- Consumes — da `src/data/ricette.ts` (Task 11):

```ts
/** Upsert completo, gruppi e ingredienti inclusi. */
export async function salvaRicetta(db: SQLiteDatabase, ricetta: Ricetta): Promise<void>;
```

  Due comportamenti su cui questo task conta, entrambi verificati dai test qui
  sotto: (1) la riga di `ricette` si aggiorna con `INSERT ... ON CONFLICT(id) DO
  UPDATE`, **non** si cancella e ricrea — cancellarla porterebbe via in cascata
  la riga di `riscalo`, che ha una chiave esterna con `ON DELETE CASCADE`;
  (2) `modificataIl` viene scritto così come arriva nell'oggetto, senza
  sostituirlo con l'ora corrente: è chi chiama a decidere il timestamp (la
  schermata Modifica passa `new Date().toISOString()`, `cancellaRicetta` timbra
  da sé, e l'import del Task 15 conserva le date del file).

- Consumes — da `src/data/dbMemoria.ts` (Task 10), solo nei test:

```ts
export function apriDbInMemoria(): SQLiteDatabase;
```

  `expo-sqlite` non si carica fuori dall'app perché dietro ha un modulo nativo.
  `apriDbInMemoria` ricostruisce su `node:sqlite` (dentro Node, niente da
  installare) il pezzo di API che i repository usano davvero: un database vero,
  in memoria, che accetta i parametri sia come array sia sciolti e restituisce
  righe con prototipo normale, così `assert.deepEqual` funziona. È l'unico
  aiutante dei test dello strato dati: `src/data/dbDiProva.ts` non esiste.

- Consumes — da `expo-sqlite`, solo il tipo:

```ts
import type { SQLiteDatabase } from 'expo-sqlite';
```

  I metodi usati sono `execAsync(sql)`, `runAsync(sql, params)`,
  `getFirstAsync<T>(sql, params)`, `getAllAsync<T>(sql, params)`,
  `withTransactionAsync(fn)`, `closeAsync()`.

- Produces — `src/data/riscalo.ts`:

```ts
export async function leggiRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<Riscalo | null>;
export async function salvaRiscalo(db: SQLiteDatabase, ricettaId: string, richiesta: Richiesta): Promise<void>;
export async function dimenticaRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<void>;
export async function riscaloCorrente(db: SQLiteDatabase, ricetta: Ricetta):
  Promise<{ fattore: number; richiesta: Richiesta } | null>;
```

- [ ] **Step 1: Scrivi il test che fallisce**

`src/data/riscalo.test.ts`:

```ts
/**
 * Test del riscalo salvato. Gira su un database in memoria: `npm test`.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { apriDbInMemoria } from './dbMemoria.ts';
import { applicaMigrazioni } from './schema.ts';
import { salvaRicetta } from './ricette.ts';
import { dimenticaRiscalo, leggiRiscalo, salvaRiscalo } from './riscalo.ts';
import type { Ricetta } from '../domain/types.ts';

/** La Farinata vera dell'archivio: 14 porzioni, 180 g di farina, 580 g d'acqua. */
const farinata: Ricetta = {
  id: 'r-farinata',
  titolo: 'Farinata',
  descrizione: '',
  porzioni: 14,
  categoriaId: null,
  foto: null,
  gruppi: [{
    id: 'g1',
    nome: null,
    ingredienti: [
      { id: 'i-farina', nome: 'Farina', quantita: 180, unita: 'g' },
      { id: 'i-acqua', nome: 'Acqua', quantita: 580, unita: 'g' },
      { id: 'i-sale', nome: 'Sale', quantita: 8, unita: 'g' },
    ],
  }],
  creataIl: '2026-01-01T10:00:00.000Z',
  modificataIl: '2026-01-01T10:00:00.000Z',
  cancellataIl: null,
};

async function conFarinata() {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  await salvaRicetta(db, farinata);
  return db;
}

test('il riscalo si salva, si rilegge e si dimentica', async () => {
  const db = await conFarinata();

  assert.equal(await leggiRiscalo(db, 'r-farinata'), null);

  await salvaRiscalo(db, 'r-farinata', { tipo: 'porzioni', porzioni: 6 });
  const letto = await leggiRiscalo(db, 'r-farinata');
  assert.deepEqual(letto?.richiesta, { tipo: 'porzioni', porzioni: 6 });
  assert.equal(letto?.ricettaId, 'r-farinata');
  assert.ok(letto!.aggiornatoIl.length > 0);

  // Salvarne un altro sostituisce, non accumula.
  await salvaRiscalo(db, 'r-farinata', { tipo: 'ingrediente', ingredienteId: 'i-farina', quantita: 250 });
  assert.deepEqual((await leggiRiscalo(db, 'r-farinata'))?.richiesta, {
    tipo: 'ingrediente', ingredienteId: 'i-farina', quantita: 250,
  });
  const quante = await db.getFirstAsync<{ n: number }>('SELECT count(*) AS n FROM riscalo');
  assert.equal(quante?.n, 1);

  await dimenticaRiscalo(db, 'r-farinata');
  assert.equal(await leggiRiscalo(db, 'r-farinata'), null);
});

test('una riga di riscalo illeggibile viene buttata invece di far saltare tutto', async () => {
  const db = await conFarinata();
  await db.runAsync(
    'INSERT INTO riscalo (ricetta_id, richiesta, aggiornato_il) VALUES (?, ?, ?)',
    ['r-farinata', '{rotto', '2026-01-01T10:00:00.000Z'],
  );

  assert.equal(await leggiRiscalo(db, 'r-farinata'), null);
  const quante = await db.getFirstAsync<{ n: number }>('SELECT count(*) AS n FROM riscalo');
  assert.equal(quante?.n, 0);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/data/riscalo.ts' imported from .../src/data/riscalo.test.ts`.
Gli altri moduli importati dal test — `dbMemoria.ts` e `schema.ts` dal Task 10,
`ricette.ts` dal Task 11 — esistono già e i loro test restano verdi.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

`src/data/riscalo.ts`:

```ts
/**
 * Il riscalo corrente: che dosi si stanno usando adesso per una ricetta.
 *
 * Si salva la RICHIESTA dell'utente ("250 g di farina", "6 porzioni"), non il
 * fattore che ne è uscito. Il fattore si ricalcola ogni volta contro la ricetta
 * attuale: se dopo aver riscalato si corregge la ricetta, i numeri restano
 * giusti da soli invece di mentire sotto un'etichetta vecchia.
 *
 * Resta sul dispositivo e non verrà mai sincronizzato: quello che si sta
 * cucinando adesso riguarda il telefono che si ha in mano.
 */
import type { SQLiteDatabase } from 'expo-sqlite';
import type { Richiesta, Riscalo } from '../domain/types.ts';

interface RigaRiscalo {
  ricetta_id: string;
  richiesta: string;
  aggiornato_il: string;
}

/** La colonna è TEXT: fuori da qui non deve mai circolare una stringa JSON. */
function leggiRichiesta(json: string): Richiesta | null {
  try {
    const r = JSON.parse(json) as Richiesta;
    if (r?.tipo === 'porzioni' && typeof r.porzioni === 'number') return r;
    if (r?.tipo === 'ingrediente' && typeof r.ingredienteId === 'string' && typeof r.quantita === 'number') return r;
    return null;
  } catch {
    return null;
  }
}

export async function leggiRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<Riscalo | null> {
  const riga = await db.getFirstAsync<RigaRiscalo>(
    'SELECT ricetta_id, richiesta, aggiornato_il FROM riscalo WHERE ricetta_id = ?',
    [ricettaId],
  );
  if (riga === null) return null;

  const richiesta = leggiRichiesta(riga.richiesta);
  if (richiesta === null) {
    // Riga illeggibile: la si butta invece di far saltare la schermata.
    await dimenticaRiscalo(db, ricettaId);
    return null;
  }
  return { ricettaId: riga.ricetta_id, richiesta, aggiornatoIl: riga.aggiornato_il };
}

export async function salvaRiscalo(db: SQLiteDatabase, ricettaId: string, richiesta: Richiesta): Promise<void> {
  await db.runAsync(
    `INSERT INTO riscalo (ricetta_id, richiesta, aggiornato_il) VALUES (?, ?, ?)
     ON CONFLICT(ricetta_id) DO UPDATE SET
       richiesta = excluded.richiesta,
       aggiornato_il = excluded.aggiornato_il`,
    [ricettaId, JSON.stringify(richiesta), new Date().toISOString()],
  );
}

export async function dimenticaRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<void> {
  await db.runAsync('DELETE FROM riscalo WHERE ricetta_id = ?', [ricettaId]);
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS — `il riscalo si salva, si rilegge e si dimentica` e
`una riga di riscalo illeggibile viene buttata invece di far saltare tutto`.

- [ ] **Step 5: Scrivi il test del ricalcolo, che fallisce**

In `src/data/riscalo.test.ts` sostituisci la riga

```ts
import { dimenticaRiscalo, leggiRiscalo, salvaRiscalo } from './riscalo.ts';
```

con

```ts
import { dimenticaRiscalo, leggiRiscalo, riscaloCorrente, salvaRiscalo } from './riscalo.ts';
```

e aggiungi in fondo al file:

```ts
test('il fattore si ricalcola sui numeri nuovi della ricetta', async () => {
  const db = await conFarinata();
  await salvaRiscalo(db, 'r-farinata', { tipo: 'ingrediente', ingredienteId: 'i-farina', quantita: 250 });

  const primo = await riscaloCorrente(db, farinata);
  assert.ok(Math.abs(primo!.fattore - 250 / 180) < 1e-12);

  // L'utente corregge la ricetta: la farina era 200 g, non 180.
  const corretta: Ricetta = {
    ...farinata,
    modificataIl: '2026-02-01T10:00:00.000Z',
    gruppi: [{
      ...farinata.gruppi[0],
      ingredienti: farinata.gruppi[0].ingredienti.map((i) =>
        i.id === 'i-farina' ? { ...i, quantita: 200 } : i),
    }],
  };
  await salvaRicetta(db, corretta);

  const dopo = await riscaloCorrente(db, corretta);
  assert.equal(dopo!.fattore, 250 / 200);                     // non più 250/180
  assert.deepEqual(dopo!.richiesta, { tipo: 'ingrediente', ingredienteId: 'i-farina', quantita: 250 });
  assert.ok(await leggiRiscalo(db, 'r-farinata'));            // il riscalo resta valido
});

test('ingrediente cancellato: niente riscalo e riga ripulita', async () => {
  const db = await conFarinata();
  await salvaRiscalo(db, 'r-farinata', { tipo: 'ingrediente', ingredienteId: 'i-farina', quantita: 250 });

  const senzaFarina: Ricetta = {
    ...farinata,
    gruppi: [{
      ...farinata.gruppi[0],
      ingredienti: farinata.gruppi[0].ingredienti.filter((i) => i.id !== 'i-farina'),
    }],
  };
  await salvaRicetta(db, senzaFarina);

  assert.equal(await riscaloCorrente(db, senzaFarina), null);
  assert.equal(await leggiRiscalo(db, 'r-farinata'), null);
});

test('ingrediente diventato q.b. o porzioni tolte: niente riscalo', async () => {
  const db = await conFarinata();

  await salvaRiscalo(db, 'r-farinata', { tipo: 'ingrediente', ingredienteId: 'i-farina', quantita: 250 });
  const qb: Ricetta = {
    ...farinata,
    gruppi: [{
      ...farinata.gruppi[0],
      ingredienti: farinata.gruppi[0].ingredienti.map((i) =>
        i.id === 'i-farina' ? { ...i, quantita: null, unita: null } : i),
    }],
  };
  await salvaRicetta(db, qb);
  assert.equal(await riscaloCorrente(db, qb), null);
  assert.equal(await leggiRiscalo(db, 'r-farinata'), null);

  await salvaRiscalo(db, 'r-farinata', { tipo: 'porzioni', porzioni: 6 });
  const senzaPorzioni: Ricetta = { ...farinata, porzioni: null };
  await salvaRicetta(db, senzaPorzioni);
  assert.equal(await riscaloCorrente(db, senzaPorzioni), null);
  assert.equal(await leggiRiscalo(db, 'r-farinata'), null);
});
```

- [ ] **Step 6: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`SyntaxError: The requested module './riscalo.ts' does not provide an export named 'riscaloCorrente'`

- [ ] **Step 7: Implementa il ricalcolo**

In `src/data/riscalo.ts` sostituisci la riga

```ts
import type { Richiesta, Riscalo } from '../domain/types.ts';
```

con le due righe

```ts
import type { Ricetta, Richiesta, Riscalo } from '../domain/types.ts';
import { risolviRichiesta } from '../domain/scaling.ts';
```

e aggiungi in fondo allo stesso file:

```ts
/**
 * Riscalo utilizzabile adesso, ricalcolato sui numeri attuali della ricetta.
 * Se la richiesta non è più risolvibile (ingrediente cancellato o diventato
 * q.b., porzioni tolte) il riscalo si butta e si torna alle dosi originali:
 * non è un guasto, è una ricetta cambiata.
 */
export async function riscaloCorrente(
  db: SQLiteDatabase,
  ricetta: Ricetta,
): Promise<{ fattore: number; richiesta: Richiesta } | null> {
  const salvato = await leggiRiscalo(db, ricetta.id);
  if (salvato === null) return null;

  const fattore = risolviRichiesta(ricetta, salvato.richiesta);
  if (fattore === null) {
    await dimenticaRiscalo(db, ricetta.id);
    return null;
  }
  return { fattore, richiesta: salvato.richiesta };
}
```

- [ ] **Step 8: Esegui i test e il typecheck**

Comando: `npm test && npm run typecheck`

Atteso: PASS, ℹ pass 117, ℹ fail 0 (i 112 lasciati dal Task 12 più i 5 nuovi di
`riscalo.test.ts`). Restano verdi tutti i test dei task precedenti.
`tsc --noEmit` non stampa niente e esce con codice 0.

- [ ] **Step 9: Commit**

```bash
git add src/data/riscalo.ts src/data/riscalo.test.ts
git commit -m "Salva il riscalo come richiesta e ricalcolalo sulla ricetta attuale"
```

---

---

### Task 14: Gestione dei file foto

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta è un'app
iOS e Android che ricalcola le dosi di una ricetta. Il ricettario sta in un
SQLite sul dispositivo, non c'è account né server. Ogni ricetta può avere **una**
foto, facoltativa, presa dalla fotocamera o dalla libreria. Serve a riconoscere
la ricetta a colpo d'occhio nell'elenco: guardare un'immagine è più veloce che
leggere un titolo.

Questo task costruisce l'unico posto dell'app che tocca i file delle foto:
`src/data/foto.ts`. Nessun altro modulo apre, comprime o cancella immagini.

Tre cose vanno fatte bene, e sono tutte e tre errori classici se fatte male.

**1. Si comprime PRIMA di salvare, mai dopo.** Lato lungo a 1280 px e JPEG a
qualità 0,7. Il conto che rende sostenibile l'app: così ogni foto sta sotto i
**300 KB**, e duecento ricette stanno in una cinquantina di megabyte, che è una
quantità che un archivio spedibile regge e che la sync futura (pezzo B) regge.
Salvando l'originale della fotocamera — otto megabyte, a volte dodici — lo stesso
ricettario diventerebbe un giga e mezzo, e la sync smetterebbe di essere un
problema risolvibile. Il limite dei 300 KB non è un'intenzione scritta nella
spec: è scritto nel codice, si misura il file risultante e se sfora si ricomprime
più stretto.

**2. Nel database va SOLO il nome del file.** `a1b2c3.jpg`, mai
`/var/mobile/.../Documents/foto/a1b2c3.jpg`. Su iOS la cartella dell'app cambia
identificatore ad ogni aggiornamento: un percorso assoluto salvato oggi domani
punta al nulla, e l'utente si ritrova il ricettario senza immagini dopo un
aggiornamento dello store. È il motivo per cui `salvaFoto` restituisce un nome e
`percorsoFoto` ricostruisce il percorso ogni volta, al momento di mostrarlo.

**3. I permessi si chiedono al momento dell'uso, mai all'avvio.** Chiedere di
accedere a fotocamera e libreria a chi ha appena installato l'app, prima ancora
che abbia visto a cosa serve, è il modo migliore per farsi rispondere di no una
volta sola e per sempre. Il permesso si chiede dentro `scegliFoto`, cioè quando
l'utente ha appena toccato "scatta una foto".

Il nome del file è l'id della ricetta più `.jpg`. Ne discendono due proprietà
comode: rifare la foto sovrascrive la precedente senza lasciare orfani, e
cancellare la ricetta sa da sé quale file portarsi via.

**Cosa NON è verificabile con `npm test`, e va detto subito.** Comprimere,
scrivere, leggere e cancellare file sono chiamate ai moduli nativi di Expo: fuori
dall'app non esistono, e non c'è telefono dentro la suite di test. Per questo il
task separa il calcolo puro dalle chiamate a Expo. Sono provabili qui, e sono
provate dai 13 test di questo task:

- come si costruisce e come si valida il nome del file (`nomeFoto`,
  `nomeFotoValido`);
- quale lato ridurre e di quanto, o se non ridurre affatto (`misuraResize`);
- la decisione sul limite dei 300 KB: tenere il risultato o ricomprimere
  (`tieni`), e i valori della scala di compressione;
- che `cancellaFoto` non lanci mai, nemmeno dove Expo non c'è. È la promessa su
  cui poggia il Task 11, che cancella una ricetta con un nome di foto scritto in
  tabella e lo fa girando sotto `node --test`.

Restano senza test automatico, e si provano per la prima volta sul dispositivo al
Task 22 (schermata di modifica ricetta), le tre funzioni che parlano con Expo e
che fanno un lavoro vero: `salvaFoto`, `percorsoFoto`, `scegliFoto`. Su di loro
questo task ha comunque una verifica vera e non decorativa: `npm run typecheck`
controlla ogni chiamata contro i tipi delle librerie realmente installate, e un
nome di metodo sbagliato o un parametro inventato lì si vedono subito.

**Nota sulle API di Expo, verificate sulla versione installata.** L'SDK è il 57 e
`expo-file-system` ha cambiato faccia: `FileSystem.documentDirectory` **non
esiste più** nell'API corrente, vive solo in `expo-file-system/legacy`. L'API di
oggi è a oggetti — `File`, `Directory`, `Paths` — e la cartella documenti è
`Paths.document`, che è un `Directory`, non una stringa. È la stessa cartella di
prima, cambia solo come la si nomina: quello che la spec chiama
`FileSystem.documentDirectory + 'foto/'` qui si scrive
`new Directory(Paths.document, 'foto')`. Allo stesso modo
`ImageManipulator.manipulateAsync` esiste ancora ma è deprecata: l'API corrente è
`ImageManipulator.manipulate(sorgente)`, che restituisce un contesto
concatenabile.

**Files:**
- Create: `src/data/foto.ts`
- Modify: `package.json` (tre dipendenze nuove, aggiunte da `npx expo install`)
- Modify: `app.json` (testi dei permessi iOS e Android)
- Modify: `src/data/ricette.ts` (cancellare una ricetta cancella la sua foto)
- Test: `src/data/foto.test.ts`

**Interfaces:**

- Consumes — da `expo-image-picker` (`~57.0.11`), a tempo di esecuzione:

```ts
export type MediaType = 'images' | 'videos' | 'livePhotos';
export declare function requestCameraPermissionsAsync(): Promise<CameraPermissionResponse>;
export declare function requestMediaLibraryPermissionsAsync(writeOnly?: boolean): Promise<MediaLibraryPermissionResponse>;
export declare function launchCameraAsync(options?: ImagePickerOptions): Promise<ImagePickerResult>;
export declare function launchImageLibraryAsync(options?: ImagePickerOptions): Promise<ImagePickerResult>;
```

  Le due risposte ai permessi hanno il campo `granted: boolean`.
  `ImagePickerOptions` ha fra gli altri `mediaTypes?: MediaType | MediaType[]` e
  `quality?: number` (0-1). `ImagePickerResult` è un'unione discriminata:
  `{ canceled: false; assets: ImagePickerAsset[] }` oppure
  `{ canceled: true; assets: null }`, e `ImagePickerAsset` ha `uri: string`,
  `width: number`, `height: number`.

- Consumes — da `expo-image-manipulator` (`~57.0.11`), a tempo di esecuzione:

```ts
export { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
declare class ImageManipulator {
  manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext;
}
declare class ImageManipulatorContext {
  resize(size: { width?: number | null; height?: number | null }): ImageManipulatorContext;
  renderAsync(): Promise<ImageRef>;
}
declare class ImageRef {
  width: number;
  height: number;
  saveAsync(options?: SaveOptions): Promise<ImageResult>;
}
declare enum SaveFormat { JPEG = 'jpeg', PNG = 'png', WEBP = 'webp' }
type SaveOptions = { base64?: boolean; compress?: number; format?: SaveFormat };
type ImageResult = { uri: string; width: number; height: number; base64?: string };
```

  Due dettagli che il codice qui sotto sfrutta: `resize` con un solo lato
  valorizzato calcola l'altro da sé e conserva le proporzioni; `saveAsync`
  scrive **nella cartella cache**, quindi il file va poi spostato nei documenti,
  altrimenti il sistema lo può cancellare quando lo spazio scarseggia. E
  `manipulate` accetta come sorgente anche un `ImageRef` già reso, non solo una
  stringa: è così che i tentativi di compressione successivi al primo non
  ricaricano l'immagine da capo.

- Consumes — da `expo-file-system` (`~57.0.4`), a tempo di esecuzione:

```ts
export declare class Paths {
  static get document(): Directory;   // la cartella documenti, non una stringa
  static get cache(): Directory;
}
export declare class Directory {
  constructor(...uris: (string | File | Directory)[]);
  readonly uri: string;
  exists: boolean;
  create(options?: { idempotent?: boolean; intermediates?: boolean; overwrite?: boolean }): void;
  delete(): void;
}
export declare class File {
  constructor(...uris: (string | File | Directory)[]);
  get uri(): string;
  exists: boolean;
  size: number;                       // byte; 0 se il file non c'è o non si legge
  delete(): void;                     // sincrona, LANCIA se il file non esiste
  move(destination: File | Directory, options?: RelocationOptions): Promise<void>;
  copy(destination: File | Directory, options?: RelocationOptions): Promise<void>;
}
```

  `create` e `delete` sono sincrone e restituiscono `void`; `move` e `copy` sono
  asincrone e restituiscono `Promise<void>`. `delete()` lancia se il file non
  esiste: si controlla sempre `exists` prima.

- Consumes — da `src/data/ricette.ts` (Task 11), solo per lo Step 24:

```ts
export async function cancellaRicetta(db: SQLiteDatabase, id: string): Promise<void>;
```

  Marca la tombstone (`cancellata_il` e `modificata_il` all'ora corrente),
  azzera la colonna `foto` e butta il riscalo locale. Lo Step 24 la sostituisce
  con una versione che si porta via anche il file della foto.

  Il Task 11 dichiara questa come dipendenza in avanti e i suoi test restano
  quelli che sono: la fixture `empanadas` continua ad avere `foto: 'r-emp.jpg'`
  e il test `cancellare dimentica il riscalo e azzera la foto` continua a
  cancellarla sotto `node --test`. Regge perché `cancellaFoto` **non lancia
  mai**, nemmeno quando Expo non si carica affatto — è la decisione dello Step
  21 qui sotto, e c'è un test di questo task che la tiene ferma.

- Produces — `src/data/foto.ts`, parte pura, provabile con `npm test`:

```ts
export const LATO_MASSIMO = 1280;
export const QUALITA = 0.7;
export const DIMENSIONE_MASSIMA = 300 * 1024;
export interface Tentativo { lato: number; qualita: number }
export const TENTATIVI: readonly Tentativo[];
/** Il nome del file per una ricetta: id ripulito + '.jpg'. Lancia se l'id si svuota. */
export function nomeFoto(ricettaId: string): string;
/** Vero solo per i nomi che produce nomeFoto. */
export function nomeFotoValido(nome: string): boolean;
/** Il resize da chiedere a expo-image-manipulator, null se l'immagine è già a misura. */
export function misuraResize(larghezza: number, altezza: number, lato: number):
  { width: number | null; height: number | null } | null;
/** Il risultato del tentativo `indice`, grande `byte`, si tiene? */
export function tieni(byte: number, indice: number): boolean;
```

- Produces — `src/data/foto.ts`, parte che parla con Expo:

```ts
export type OrigineFoto = 'fotocamera' | 'libreria';
export type EsitoScelta =
  | { tipo: 'scelta'; uri: string }
  | { tipo: 'annullata' }
  | { tipo: 'permessoNegato' };
/** Chiede il permesso al momento dell'uso e apre fotocamera o libreria. */
export async function scegliFoto(origine: OrigineFoto): Promise<EsitoScelta>;
/** Comprime e salva, restituisce il NOME del file. */
export async function salvaFoto(uriOrigine: string, ricettaId: string): Promise<string>;
/** Percorso assoluto da dare a <Image>, o null se il file non c'è. */
export async function percorsoFoto(nomeFile: string | null): Promise<string | null>;
/** Cancella il file, se c'è. Non lancia MAI: niente da cancellare è il risultato voluto. */
export async function cancellaFoto(nomeFile: string | null): Promise<void>;
```

  Chi le consuma: il Task 22 (schermata di modifica) chiama `scegliFoto` e poi
  `salvaFoto`, e scrive in `ricetta.foto` il nome restituito; il Task 19 chiama
  `percorsoFoto` per la miniatura dell'elenco e il Task 22 per l'immagine
  grande. Il Task 20 (Dosatore) di proposito **non** la chiama: là la foto non
  c'è — spingerebbe in basso proprio i numeri per cui si è aperta la schermata —
  e non chiamandola si evita di toccare il disco dove il tempo di apertura conta
  di più.

  `percorsoFoto` **non tocca il database**: quando restituisce `null` perché il
  file non c'è più, la schermata mostra la ricetta senza immagine e senza
  messaggi di errore. Ad azzerare il campo ci pensa il Task 22: `FotoRicetta`,
  ricevuto `null` da `percorsoFoto` per un nome che nel database invece c'è,
  chiama `onCambia(null)`, così `bozza.foto` diventa `null` e il primo
  salvataggio della ricetta ripulisce la colonna. Nessuna query in più e nessuna
  scrittura a sorpresa: il campo si sistema quando l'utente salva.

  `EsitoScelta` distingue tre esiti perché la schermata ne fa tre cose diverse:
  su `scelta` comprime e salva, su `annullata` non dice niente, su
  `permessoNegato` mostra il messaggio che rimanda alle impostazioni di sistema
  (la chiave di traduzione arriva dal Task 16).

**Perché i moduli Expo si caricano con `import()` dentro le funzioni.**
`expo-image-picker`, `expo-image-manipulator` e `expo-file-system` hanno dietro
moduli nativi: importarli in cima al file renderebbe `foto.ts` impossibile da
caricare in Node, e con lui morirebbero i test delle funzioni pure che stanno
nello stesso file. Con l'`import()` dentro la funzione il modulo si carica solo
quando la funzione viene davvero chiamata, cioè solo dentro l'app. È la stessa
convenzione che il Task 15 usa per `expo-file-system` dentro `esporta.ts`.

Ne discende un vincolo che va rispettato scrivendo il codice: **i controlli che
possono far uscire subito da una funzione stanno prima dell'`import()`**. Per
questo `cancellaFoto(null)` e `percorsoFoto(null)` tornano senza aver caricato
niente.

Per `cancellaFoto` non basta, e la differenza è tutta in un test del Task 11:
lì si cancella `empanadas`, che ha `foto: 'r-emp.jpg'`, cioè un nome buono, che
supera i controlli e arriva dritto all'`import()`. Da qui la decisione che vale
per tutto il piano: **cancellare una foto che non c'è non è un errore**, e
`cancellaFoto` non lancia mai. Vale per il file assente, vale per il permesso
negato, e vale anche per Expo che fuori dall'app non si carica: sono tutti lo
stesso esito, cioè il file non c'è più, che è esattamente quello che il
chiamante voleva. Il prezzo è che un guasto vero del filesystem passa in
silenzio; il guadagno è che nessun chiamante deve difendersi da una funzione che
non ha niente di utile da dirgli, visto che a quel punto la riga è già una
tombstone e la foto non la cerca più nessuno. `percorsoFoto` invece resta com'è:
nessun test la chiama fuori dall'app.

- [ ] **Step 1: Installa le tre librerie**

`npx expo install` sceglie da sé le versioni giuste per l'SDK installato: non
usare `npm install`, che prenderebbe l'ultima pubblicata e non quella compatibile.

```bash
npx expo install expo-image-picker expo-image-manipulator expo-file-system
```

Comando di verifica:

```bash
node -p "const d=require('./package.json').dependencies; ['expo-file-system','expo-image-manipulator','expo-image-picker'].map(k=>k+' '+d[k]).join('\n')"
```

Atteso: tre righe, `expo-file-system ~57.0.4`, `expo-image-manipulator ~57.0.11`,
`expo-image-picker ~57.0.11`. Se `expo install` propone versioni diverse sono
quelle giuste per l'SDK che hai: prendi le sue, non queste.

- [ ] **Step 2: Dichiara i testi dei permessi in `app.json`**

Senza questi testi iOS non mostra la richiesta di permesso: l'app va in crash al
primo tocco su "scatta una foto", e la revisione dello store la rifiuta. Vanno
messi adesso, non alla fine, perché il Task 22 li userà per davvero.

**Il blocco `plugins` di `app.json` lo scrive solo questo task**, che è quello
che introduce `expo-image-picker` ed è il primo ad arrivarci. Il Task 17
(impalcatura) non lo riscrive e non lo tocca: si limita a rieseguire il comando
di verifica qui sotto, aspettandosi parola per parola questi testi. Due task che
scrivono la stessa chiave con testi diversi darebbero un `app.json` con due
`plugins`, cioè JSON non valido, oppure i permessi buoni sovrascritti con altri.

In `app.json`, dentro l'oggetto `expo`, aggiungi la chiave `plugins` subito dopo
`"scheme": "quantobasta"`:

```json
    "scheme": "quantobasta",
    "plugins": [
      [
        "expo-image-picker",
        {
          "photosPermission": "Quanto Basta accede alle foto per usarne una come immagine di una ricetta.",
          "cameraPermission": "Quanto Basta usa la fotocamera per scattare l'immagine di una ricetta.",
          "microphonePermission": false
        }
      ]
    ]
```

`microphonePermission: false` blocca il permesso `RECORD_AUDIO` su Android:
scattiamo foto e non registriamo video, quindi quel permesso nel manifest sarebbe
solo una domanda in più a cui l'utente deve rispondere.

I testi sono in italiano e iOS li mostra così come sono, in tutte le lingue.
Localizzarli vuol dire aggiungere le stringhe tradotte all'`Info.plist` con
`CFBundleLocalizations`, che è lavoro dei materiali per gli store: sta nel pezzo
D, non qui.

Comando di verifica:

```bash
node -p "JSON.stringify(require('./app.json').expo.plugins)"
```

Atteso: `[["expo-image-picker",{"photosPermission":"Quanto Basta accede alle foto per usarne una come immagine di una ricetta.","cameraPermission":"Quanto Basta usa la fotocamera per scattare l'immagine di una ricetta.","microphonePermission":false}]]`

- [ ] **Step 3: Scrivi il test dei nomi file, che fallisce**

`src/data/foto.test.ts`:

```ts
/**
 * Test della parte pura di src/data/foto.ts: gira con `npm test`.
 *
 * Comprimere e scrivere file sono chiamate ai moduli nativi di Expo e qui non
 * esistono. Quello che si prova qui è la logica che decide: come si chiama il
 * file, quale lato si riduce, e quando il risultato è abbastanza piccolo.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { nomeFoto, nomeFotoValido } from './foto.ts';

test("il nome del file è l'id della ricetta con .jpg", () => {
  assert.equal(nomeFoto('a1b2c3'), 'a1b2c3.jpg');
  assert.equal(nomeFoto('id-k3j9x1-lz8'), 'id-k3j9x1-lz8.jpg');
  // Un uuid di crypto.randomUUID passa intero.
  assert.equal(
    nomeFoto('3f2504e0-4f89-41d3-9a0c-0305e82c3301'),
    '3f2504e0-4f89-41d3-9a0c-0305e82c3301.jpg',
  );
});

test('un id sporco non può diventare un percorso', () => {
  // L'import accetta file scritti da altri: l'id nel file può essere qualunque
  // cosa, e non deve poter uscire dalla cartella delle foto.
  assert.equal(nomeFoto('../../etc/passwd'), 'etcpasswd.jpg');
  assert.equal(nomeFoto('foto/../../fuori'), 'fotofuori.jpg');
  assert.equal(nomeFoto('R1 Farinata'), 'r1farinata.jpg');
  // E non diventa nemmeno un nome lungo a piacere.
  assert.equal(nomeFoto('z'.repeat(200)), 'z'.repeat(64) + '.jpg');
});

test('un id che si svuota del tutto è un errore, non un file senza nome', () => {
  assert.throws(() => nomeFoto('///'), /inutilizzabile/);
  assert.throws(() => nomeFoto(''), /inutilizzabile/);
});

test('si accettano solo i nomi che produciamo noi', () => {
  assert.equal(nomeFotoValido('a1b2c3.jpg'), true);
  assert.equal(nomeFotoValido(nomeFoto('id-k3j9x1')), true);
  assert.equal(nomeFotoValido('a1b2c3.png'), false);
  assert.equal(nomeFotoValido('../a1b2c3.jpg'), false);
  assert.equal(nomeFotoValido('foto/a1b2c3.jpg'), false);
  assert.equal(nomeFotoValido('A1B2C3.jpg'), false);
  assert.equal(nomeFotoValido('.jpg'), false);
  assert.equal(nomeFotoValido(''), false);
});
```

- [ ] **Step 4: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/data/foto.ts' imported from .../src/data/foto.test.ts`.
Tutti gli altri file di test restano verdi: `foto.ts` non esiste ancora e nessuno
lo importa.

- [ ] **Step 5: Implementa il minimo che fa passare il test**

`src/data/foto.ts`:

```ts
/**
 * I file delle foto delle ricette. È l'unico posto dell'app che li tocca.
 *
 * Una foto per ricetta, facoltativa. Si comprime SEMPRE prima di salvare, mai
 * dopo: lato lungo a 1280 px e JPEG a qualità 0,7 tengono ogni immagine sotto i
 * 300 KB, così duecento ricette stanno in una cinquantina di megabyte. Salvare
 * l'originale della fotocamera vorrebbe dire un ricettario da un giga e mezzo e
 * una sincronizzazione impossibile.
 *
 * Nel database va SOLO il nome del file, mai il percorso assoluto: su iOS la
 * cartella dell'app cambia identificatore ad ogni aggiornamento e un percorso
 * salvato ieri oggi punta al nulla.
 *
 * I moduli Expo si caricano con import() DENTRO le funzioni, non in cima al
 * file: dietro hanno moduli nativi che fuori dall'app non esistono, e così
 * questo modulo si carica anche in Node e le funzioni pure qui sotto restano
 * provabili con `npm test`. Per lo stesso motivo i controlli che fanno uscire
 * subito da una funzione stanno PRIMA dell'import().
 */

/** Solo minuscole, cifre e trattini: è esattamente quello che produce newId(). */
const NOME_VALIDO = /^[a-z0-9-]{1,64}\.jpg$/;

/**
 * Il nome del file per una ricetta. L'id viene ripulito perché l'import accetta
 * file scritti da altri: un id come `../../qualcosa` non deve poter diventare un
 * percorso fuori dalla cartella delle foto.
 */
export function nomeFoto(ricettaId: string): string {
  const pulito = ricettaId.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 64);
  if (pulito === '') {
    throw new Error(`Id ricetta inutilizzabile come nome di file: ${JSON.stringify(ricettaId)}`);
  }
  return `${pulito}.jpg`;
}

/**
 * Vero solo per i nomi che produce nomeFoto. Quello che arriva dal database può
 * essere qualunque cosa: ci finisce anche il contenuto dei file importati.
 */
export function nomeFotoValido(nome: string): boolean {
  return NOME_VALIDO.test(nome);
}
```

- [ ] **Step 6: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ fail 0`. In `src/data/foto.test.ts` compaiono quattro ✔:
"il nome del file è l'id della ricetta con .jpg", "un id sporco non può diventare
un percorso", "un id che si svuota del tutto è un errore, non un file senza
nome", "si accettano solo i nomi che produciamo noi". Il totale di `npm test`
sale di 4 rispetto a quello lasciato dal Task 13.

- [ ] **Step 7: Commit**

```bash
git add src/data/foto.ts src/data/foto.test.ts package.json package-lock.json app.json
git commit -m "Nome del file foto ricavato dall'id, con l'id ripulito"
```

- [ ] **Step 8: Scrivi il test della misura del resize, che fallisce**

Il ridimensionamento tocca **il lato lungo**, e quale sia dipende dalla foto:
orizzontale è la larghezza, verticale è l'altezza. `expo-image-manipulator`
vuole un solo lato valorizzato e calcola l'altro da sé, conservando le
proporzioni. E non si ingrandisce mai: una foto già piccola resta com'è.

In `src/data/foto.test.ts` sostituisci la riga

```ts
import { nomeFoto, nomeFotoValido } from './foto.ts';
```

con

```ts
import { misuraResize, nomeFoto, nomeFotoValido } from './foto.ts';
```

e aggiungi in fondo al file:

```ts
test('la foto orizzontale si stringe in larghezza', () => {
  // 4032x3024 è una foto standard di fotocamera, 4:3 orizzontale.
  assert.deepEqual(misuraResize(4032, 3024, 1280), { width: 1280, height: null });
  assert.deepEqual(misuraResize(1281, 20, 1280), { width: 1280, height: null });
});

test('la foto verticale si stringe in altezza', () => {
  assert.deepEqual(misuraResize(3024, 4032, 1280), { width: null, height: 1280 });
  assert.deepEqual(misuraResize(20, 1281, 1280), { width: null, height: 1280 });
});

test('una foto già piccola non si ingrandisce', () => {
  assert.equal(misuraResize(800, 600, 1280), null);
  assert.equal(misuraResize(600, 800, 1280), null);
  assert.equal(misuraResize(1, 1, 1280), null);
});

test('una foto quadrata: al limite si lascia stare, sopra si stringe in larghezza', () => {
  assert.equal(misuraResize(1280, 1280, 1280), null);
  assert.deepEqual(misuraResize(2000, 2000, 1280), { width: 1280, height: null });
  // Il lato lo decide chi chiama: la scala dei tentativi ne usa più d'uno.
  assert.deepEqual(misuraResize(2000, 2000, 900), { width: 900, height: null });
});
```

- [ ] **Step 9: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`SyntaxError: The requested module './foto.ts' does not provide an export named 'misuraResize'`.
I quattro test dello Step 3 stanno nello stesso file e non partono: è normale,
il file non si carica proprio.

- [ ] **Step 10: Implementa la misura del resize**

Aggiungi in fondo a `src/data/foto.ts`:

```ts
/**
 * Il resize da chiedere a expo-image-manipulator. Si tocca solo il lato lungo e
 * l'altro si calcola da sé, così le proporzioni restano quelle. null significa
 * immagine già a misura: non si ingrandisce mai, allargare una foto piccola
 * gonfia il file senza aggiungere un pixel di informazione.
 */
export function misuraResize(
  larghezza: number,
  altezza: number,
  lato: number,
): { width: number | null; height: number | null } | null {
  if (Math.max(larghezza, altezza) <= lato) return null;
  return larghezza >= altezza
    ? { width: lato, height: null }
    : { width: null, height: lato };
}
```

- [ ] **Step 11: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ fail 0`. In `src/data/foto.test.ts` gli ✔ sono adesso otto: ai
quattro dello Step 6 si aggiungono "la foto orizzontale si stringe in larghezza",
"la foto verticale si stringe in altezza", "una foto già piccola non si
ingrandisce", "una foto quadrata: al limite si lascia stare, sopra si stringe in
larghezza". Il totale di `npm test` sale di 8 rispetto al Task 13.

- [ ] **Step 12: Commit**

```bash
git add src/data/foto.ts src/data/foto.test.ts
git commit -m "Ridimensiona il lato lungo della foto, senza mai ingrandire"
```

- [ ] **Step 13: Scrivi il test del limite dei 300 KB, che fallisce**

Qui sta la regola che tiene in piedi il conto della spec. Dopo aver compresso si
**misura il file** che ne è uscito: se sfora i 300 KB si ricomprime più stretto,
scendendo lungo una scala di tentativi. La scala finisce a 900 px e qualità 0,45,
che su una foto di fotocamera produce meno di 100 KB: sotto non serve andare.
All'ultimo gradino il risultato si tiene comunque, perché buttare via la foto che
l'utente ha appena scattato per rispettare un budget di byte sarebbe peggio del
problema che risolve. È un ramo che in pratica non si percorre.

In `src/data/foto.test.ts` sostituisci la riga

```ts
import { misuraResize, nomeFoto, nomeFotoValido } from './foto.ts';
```

con questo blocco:

```ts
import {
  DIMENSIONE_MASSIMA, LATO_MASSIMO, QUALITA, TENTATIVI,
  misuraResize, nomeFoto, nomeFotoValido, tieni,
} from './foto.ts';
```

e aggiungi in fondo al file:

```ts
test('sotto il limite il primo tentativo basta', () => {
  assert.equal(tieni(120 * 1024, 0), true);
  assert.equal(tieni(DIMENSIONE_MASSIMA, 0), true);
  assert.equal(tieni(0, 0), true);
});

test('sopra il limite si ritenta, finché ci sono tentativi', () => {
  assert.equal(tieni(DIMENSIONE_MASSIMA + 1, 0), false);
  assert.equal(tieni(2 * 1024 * 1024, 0), false);
  assert.equal(tieni(2 * 1024 * 1024, 1), false);
  // Ultimo gradino: si tiene comunque, meglio una foto grossa che nessuna foto.
  assert.equal(tieni(2 * 1024 * 1024, TENTATIVI.length - 1), true);
});

test('i valori decisi nella spec sono quelli scritti nel codice', () => {
  assert.equal(LATO_MASSIMO, 1280);
  assert.equal(QUALITA, 0.7);
  assert.equal(DIMENSIONE_MASSIMA, 307200); // 300 KB
  assert.deepEqual(TENTATIVI[0], { lato: LATO_MASSIMO, qualita: QUALITA });
});

test('la scala dei tentativi non peggiora mai', () => {
  assert.ok(TENTATIVI.length >= 2, 'senza un secondo tentativo il limite non è applicabile');
  for (let i = 1; i < TENTATIVI.length; i++) {
    assert.ok(
      TENTATIVI[i].lato <= TENTATIVI[i - 1].lato,
      `il tentativo ${i} ingrandisce invece di stringere`,
    );
    assert.ok(
      TENTATIVI[i].qualita <= TENTATIVI[i - 1].qualita,
      `il tentativo ${i} alza la qualità invece di abbassarla`,
    );
    assert.ok(TENTATIVI[i].qualita > 0 && TENTATIVI[i].qualita <= 1);
  }
});
```

- [ ] **Step 14: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`SyntaxError: The requested module './foto.ts' does not provide an export named 'DIMENSIONE_MASSIMA'`.
Node segnala il primo nome mancante che incontra: se al posto di
`DIMENSIONE_MASSIMA` nomina `LATO_MASSIMO`, `QUALITA`, `TENTATIVI` o `tieni` va
bene lo stesso, sono i quattro nomi nuovi dello Step 15.

- [ ] **Step 15: Implementa il limite e la scala dei tentativi**

In `src/data/foto.ts` aggiungi in cima, subito sotto il commento di
intestazione e prima di `const NOME_VALIDO`:

```ts
/** Lato lungo massimo dell'immagine salvata, in pixel. */
export const LATO_MASSIMO = 1280;

/** Qualità JPEG del primo tentativo. 1 = nessuna compressione, 0 = massima. */
export const QUALITA = 0.7;

/**
 * Il tetto per foto, in byte. Trecento kilobyte, e non è negoziabile: è il
 * numero da cui discende che duecento ricette stiano in una cinquantina di
 * megabyte invece che in un giga e mezzo.
 */
export const DIMENSIONE_MASSIMA = 300 * 1024;

export interface Tentativo {
  lato: number;
  qualita: number;
}

/**
 * La scala di compressione. Si parte dai valori della spec e si stringe solo se
 * il file misurato sfora. L'ultimo gradino, 900 px a qualità 0,45, sta sotto i
 * 100 KB su qualunque foto di fotocamera: sotto non serve andare.
 */
export const TENTATIVI: readonly Tentativo[] = [
  { lato: LATO_MASSIMO, qualita: QUALITA },
  { lato: LATO_MASSIMO, qualita: 0.5 },
  { lato: 900, qualita: 0.45 },
];
```

e in fondo allo stesso file:

```ts
/**
 * Il risultato del tentativo numero `indice`, grande `byte`, si tiene?
 *
 * Sì se sta sotto il tetto. E si' comunque all'ultimo gradino: buttare via la
 * foto che l'utente ha appena scattato per rispettare un budget di byte sarebbe
 * peggio del problema che risolve. In pratica quel ramo non si percorre, perché
 * 900 px a qualità 0,45 danno meno di 100 KB.
 */
export function tieni(byte: number, indice: number): boolean {
  return byte <= DIMENSIONE_MASSIMA || indice >= TENTATIVI.length - 1;
}
```

- [ ] **Step 16: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ fail 0`. In `src/data/foto.test.ts` gli ✔ sono adesso dodici:
agli otto dello Step 11 si aggiungono "sotto il limite il primo tentativo basta",
"sopra il limite si ritenta, finché ci sono tentativi", "i valori decisi nella
spec sono quelli scritti nel codice", "la scala dei tentativi non peggiora mai".
Il totale di `npm test` sale di 12 rispetto al Task 13. Ne manca uno solo, il
tredicesimo, e arriva allo Step 19: è l'unica funzione che parla con Expo ad
avere un test qui, perché è l'unica che deve funzionare anche dove Expo non c'è.

- [ ] **Step 17: Commit**

```bash
git add src/data/foto.ts src/data/foto.test.ts
git commit -m "Limite di 300 KB per foto, con la scala dei tentativi di compressione"
```

- [ ] **Step 18: Scrivi `salvaFoto`**

Da qui in avanti si scrive il codice che parla con Expo. Quasi tutto senza test
automatici — i moduli nativi non esistono fuori dall'app — ma con una verifica
vera allo Step 25: `npm run typecheck` controlla ogni chiamata contro i tipi
delle librerie installate allo Step 1. L'eccezione è `cancellaFoto`, che un test
ce l'ha e lo trova agli Step 19-22.

L'ordine delle operazioni non è casuale: si rende l'immagine una prima volta per
sapere quanto è grande davvero (`ImageRef` porta `width` e `height`, l'uri da
solo no), poi si comprime, poi si **misura il file**, e solo alla fine si sposta
dalla cache ai documenti. La destinazione si cancella all'ultimo momento
possibile: se la compressione fallisce a metà, la foto vecchia è ancora lì.

Aggiungi in fondo a `src/data/foto.ts`:

```ts
/**
 * Comprime e salva la foto di una ricetta, restituisce il NOME del file — mai il
 * percorso: quello si ricostruisce ogni volta con percorsoFoto().
 *
 * Sovrascrive la foto precedente, perché il nome dipende solo dall'id della
 * ricetta: non restano mai file orfani in giro.
 */
export async function salvaFoto(uriOrigine: string, ricettaId: string): Promise<string> {
  const nome = nomeFoto(ricettaId);

  const { ImageManipulator, SaveFormat } = await import('expo-image-manipulator');
  const { Directory, File, Paths } = await import('expo-file-system');

  const cartella = new Directory(Paths.document, 'foto');
  if (!cartella.exists) cartella.create({ intermediates: true });
  const destinazione = new File(cartella, nome);

  // Una prima resa serve a sapere quanto è grande l'immagine: il lato da
  // stringere è il lungo, e quale sia lo si scopre solo qui. L'ImageRef si
  // riusa come sorgente dei tentativi successivi, così non si ricarica.
  const originale = await ImageManipulator.manipulate(uriOrigine).renderAsync();

  for (let i = 0; i < TENTATIVI.length; i++) {
    const tentativo = TENTATIVI[i];
    const contesto = ImageManipulator.manipulate(originale);
    const misura = misuraResize(originale.width, originale.height, tentativo.lato);
    if (misura !== null) contesto.resize(misura);

    const reso = await contesto.renderAsync();
    // saveAsync scrive nella cache: di lì il sistema può portarselo via.
    const salvato = await reso.saveAsync({
      format: SaveFormat.JPEG,
      compress: tentativo.qualita,
    });
    const temporaneo = new File(salvato.uri);

    if (tieni(temporaneo.size, i)) {
      if (destinazione.exists) destinazione.delete();
      await temporaneo.move(destinazione);
      return nome;
    }
    temporaneo.delete();
  }

  // Irraggiungibile: tieni() è vero per forza all'ultimo giro.
  throw new Error('Compressione della foto senza risultato');
}
```

- [ ] **Step 19: Scrivi il test che cancellare una foto assente non è un errore, che fallisce**

Delle quattro funzioni che parlano con Expo questa è l'unica che deve funzionare
anche dove Expo non c'è, e quindi l'unica che si può provare qui. Il motivo sta
nel Task 11: il test `cancellare dimentica il riscalo e azzera la foto` salva
`empanadas`, che ha `foto: 'r-emp.jpg'`, e poi chiama `cancellaRicetta`. Dallo
Step 24 quella funzione legge il nome e lo passa a `cancellaFoto`; `'r-emp.jpg'`
è un nome buono, supera `nomeFotoValido` e arriva dritto all'`import()`. Se
`cancellaFoto` lasciasse uscire l'errore, il test del Task 11 morirebbe e questo
task romperebbe invece di aggiungere.

In `src/data/foto.test.ts` sostituisci il blocco di import

```ts
import {
  DIMENSIONE_MASSIMA, LATO_MASSIMO, QUALITA, TENTATIVI,
  misuraResize, nomeFoto, nomeFotoValido, tieni,
} from './foto.ts';
```

con

```ts
import {
  DIMENSIONE_MASSIMA, LATO_MASSIMO, QUALITA, TENTATIVI,
  cancellaFoto, misuraResize, nomeFoto, nomeFotoValido, tieni,
} from './foto.ts';
```

e aggiungi in fondo al file:

```ts
test("cancellare una foto che non c'è non è un errore", async () => {
  // Tre modi diversi di non avere niente da cancellare, e un esito solo: si
  // torna senza lanciare. Il terzo è quello che conta: 'r-emp.jpg' è un nome
  // buono, supera i controlli e arriva fino a expo-file-system, che qui non si
  // carica. Anche quello è "il file non c'è", ed è la promessa su cui poggia
  // cancellaRicetta del Task 11, che sotto node --test cancella proprio una
  // ricetta con questo nome scritto in tabella.
  await assert.doesNotReject(() => cancellaFoto(null));
  await assert.doesNotReject(() => cancellaFoto('../fuori.jpg'));
  await assert.doesNotReject(() => cancellaFoto('r-emp.jpg'));
});
```

- [ ] **Step 20: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`SyntaxError: The requested module './foto.ts' does not provide an export named 'cancellaFoto'`.
I dodici test degli Step precedenti stanno nello stesso file e non partono: è
normale, il file non si carica proprio. `salvaFoto`, scritta allo Step 18, non
c'entra: nessun test la importa.

- [ ] **Step 21: Scrivi `percorsoFoto` e `cancellaFoto`**

Le due funzioni che leggono e cancellano. Entrambe cominciano con i controlli sul
nome, che stanno **prima** dell'`import()`: un nome nullo o non nostro esce
subito, senza toccare Expo.

Poi le due si separano. `percorsoFoto` serve a mostrare un'immagine, e se
qualcosa va storto la risposta onesta è `null`: il file non si vede. Su di lei
non si può dire altro, e nessun test la chiama fuori dall'app. `cancellaFoto`
invece **non lancia mai**, e per questo il `try` le avvolge anche l'`import()`:
fuori dall'app `expo-file-system` non si carica affatto, e per chi ha chiesto di
cancellare quel file è la stessa identica cosa che trovarlo già assente.

Il nome arriva dal database, e nel database ci finisce anche il contenuto dei
file importati: `nomeFotoValido` è il controllo che impedisce a un nome scritto
da altri di andare a leggere o cancellare fuori dalla cartella delle foto.

Aggiungi in fondo a `src/data/foto.ts`:

```ts
/**
 * Percorso assoluto da dare a <Image>, o null se il file non c'è.
 *
 * Si ricostruisce ad ogni chiamata e non si salva da nessuna parte: su iOS la
 * cartella dell'app cambia ad ogni aggiornamento, e un percorso messo via ieri
 * oggi punta al nulla.
 *
 * Non tocca il database. Quando torna null perché il file è sparito, la
 * schermata mostra la ricetta senza immagine e senza messaggi di errore.
 */
export async function percorsoFoto(nomeFile: string | null): Promise<string | null> {
  if (nomeFile === null || !nomeFotoValido(nomeFile)) return null;

  const { File, Paths } = await import('expo-file-system');
  const file = new File(Paths.document, 'foto', nomeFile);
  return file.exists ? file.uri : null;
}

/**
 * Cancella il file della foto, se c'è. Non lancia MAI.
 *
 * Cancellare qualcosa che è già assente è il risultato voluto, non un guasto:
 * chi chiama vuole che quel file non ci sia più, e se non c'è si è già a posto.
 * Per lo stesso motivo il try avvolge anche l'import(): fuori dall'app
 * expo-file-system non si carica nemmeno, che è un altro modo di non avere
 * niente da cancellare, ed è quello che permette a cancellaRicetta (Task 11) di
 * girare sotto `node --test` senza un filesystem finto.
 *
 * Il prezzo è che un guasto vero del disco passa in silenzio. Si paga
 * volentieri: quando si arriva qui la riga della ricetta è già una tombstone,
 * quel file non lo cerca più nessuna schermata, e non c'è niente che il
 * chiamante possa fare con l'errore.
 */
export async function cancellaFoto(nomeFile: string | null): Promise<void> {
  if (nomeFile === null || !nomeFotoValido(nomeFile)) return;

  try {
    const { File, Paths } = await import('expo-file-system');
    const file = new File(Paths.document, 'foto', nomeFile);
    // delete() lancia se il file non esiste: si guarda prima.
    if (file.exists) file.delete();
  } catch {
    // Niente da cancellare, o niente con cui cancellarlo. Per chi chiama è
    // uguale: quel file non c'è.
  }
}
```

- [ ] **Step 22: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, `ℹ fail 0`. In `src/data/foto.test.ts` gli ✔ sono adesso tredici:
ai dodici dello Step 16 si aggiunge "cancellare una foto che non c'è non è un
errore". Il totale di `npm test` sale di 13 rispetto al Task 13, ed è il totale
definitivo di questo task: gli step che restano non aggiungono test.

- [ ] **Step 23: Scrivi `scegliFoto`**

Il permesso si chiede qui dentro, cioè quando l'utente ha appena toccato "scatta
una foto" e sa perfettamente perché glielo stiamo chiedendo. Mai all'avvio.

`quality: 1` dice al picker di non comprimere: la compressione la facciamo noi in
`salvaFoto`, con parametri che conosciamo, e comprimere due volte un JPEG
peggiora l'immagine senza far risparmiare byte.

Aggiungi in fondo a `src/data/foto.ts`:

```ts
export type OrigineFoto = 'fotocamera' | 'libreria';

/**
 * Tre esiti perché la schermata ne fa tre cose diverse: comprime e salva,
 * oppure tace, oppure spiega che il permesso serve e dove si cambia.
 */
export type EsitoScelta =
  | { tipo: 'scelta'; uri: string }
  | { tipo: 'annullata' }
  | { tipo: 'permessoNegato' };

/**
 * Apre fotocamera o libreria e restituisce l'uri temporaneo dell'immagine
 * scelta, da passare subito a salvaFoto().
 *
 * Il permesso si chiede QUI, al momento dell'uso, e mai all'avvio: chiedere di
 * accedere alla fotocamera a chi ha appena installato l'app, prima di avergli
 * fatto vedere a cosa serve, è il modo migliore per farsi dire di no una volta
 * sola e per sempre.
 */
export async function scegliFoto(origine: OrigineFoto): Promise<EsitoScelta> {
  const ImagePicker = await import('expo-image-picker');

  const permesso = origine === 'fotocamera'
    ? await ImagePicker.requestCameraPermissionsAsync()
    : await ImagePicker.requestMediaLibraryPermissionsAsync();
  if (!permesso.granted) return { tipo: 'permessoNegato' };

  // quality: 1 = il picker non comprime. Ci pensa salvaFoto, con parametri
  // nostri: comprimere due volte un JPEG peggiora l'immagine e non fa
  // risparmiare niente.
  const esito = origine === 'fotocamera'
    ? await ImagePicker.launchCameraAsync({ mediaTypes: 'images', quality: 1 })
    : await ImagePicker.launchImageLibraryAsync({ mediaTypes: 'images', quality: 1 });

  if (esito.canceled) return { tipo: 'annullata' };
  return { tipo: 'scelta', uri: esito.assets[0].uri };
}
```

- [ ] **Step 24: Cancellare una ricetta cancella la sua foto**

Senza questo passo i file restano sul dispositivo per sempre: la riga della
ricetta diventa una tombstone, ma la sua immagine da 300 KB no. Su un ricettario
che si usa per anni sono decine di megabyte che nessuno può più recuperare,
perché nessuna schermata sa più che esistono.

Il posto giusto è `cancellaRicetta`, non le schermate: chiunque cancelli una
ricetta passa di lì, e mettere la cancellazione del file nell'unico punto
comune vuol dire che nessun chiamante può dimenticarsene.

In `src/data/ricette.ts` aggiungi fra gli import, subito dopo l'import dei tipi
dal dominio:

```ts
import { cancellaFoto } from './foto.ts';
```

e sostituisci per intero la funzione `cancellaRicetta` con questa:

```ts
/**
 * Tombstone. La riga resta, altrimenti la sync futura si riporterebbe indietro
 * le ricette cancellate. Il riscalo in corso invece si butta: è roba locale e
 * senza la ricetta non significa più niente. E il file della foto si cancella
 * davvero: la riga sopravvive, l'immagine da 300 KB no.
 */
export async function cancellaRicetta(db: SQLiteDatabase, id: string): Promise<void> {
  const riga = await db.getFirstAsync<{ foto: string | null }>(
    'SELECT foto FROM ricette WHERE id = ?',
    [id],
  );

  const adesso = new Date().toISOString();
  await db.withTransactionAsync(async () => {
    await db.runAsync(
      'UPDATE ricette SET cancellata_il = ?, modificata_il = ?, foto = NULL WHERE id = ?',
      [adesso, adesso, id],
    );
    await db.runAsync('DELETE FROM riscalo WHERE ricetta_id = ?', [id]);
  });

  // Fuori dalla transazione: il filesystem non è transazionale, e se il file
  // non c'è cancellaFoto non fa niente e non lancia.
  await cancellaFoto(riga?.foto ?? null);
}
```

I test del Task 11 continuano a girare in Node e nessuno di loro va toccato. Uno
cancella `empanadas`, che ha `foto: 'r-emp.jpg'`: il nome è buono, quindi
`cancellaFoto` arriva davvero all'`import()` di `expo-file-system`, che sotto
`node --test` non si carica — e non succede niente, perché dallo Step 21 quella
funzione non lancia mai. È esattamente il caso coperto dal test scritto allo
Step 19.

- [ ] **Step 25: Esegui i test e il typecheck**

Comando: `npm test && npm run typecheck`

Atteso: PASS, ℹ pass 130, ℹ fail 0 (i 117 lasciati dal Task 13 più i 13 nuovi di
`foto.test.ts`), con i 13 test di `src/data/foto.test.ts` tutti ✔ e tutti i test
dei task precedenti ancora verdi — compresi quelli di `src/data/ricette.test.ts`,
che dopo lo Step 24 passano da `cancellaFoto` e ne escono in silenzio. E
`tsc --noEmit` senza errori: è la verifica vera delle funzioni che parlano con
Expo, perché controlla ogni chiamata contro i tipi di `expo-image-picker`,
`expo-image-manipulator` e `expo-file-system` realmente installati.

- [ ] **Step 26: Commit**

```bash
git add src/data/foto.ts src/data/foto.test.ts src/data/ricette.ts
git commit -m "Scelta, compressione, salvataggio e cancellazione delle foto"
```

---

### Task 15: Archivio di export e import additivo

**Cosa fa questo task, per chi non conosce il progetto.** L'app tiene ricette e
categorie in un SQLite locale e non ha account né server. L'unico modo di
portarsi il ricettario altrove è un file.

Il file è un **archivio zip** con dentro `ricettario.json` e la cartella `foto/`.
Le ricette possono avere una foto ciascuna — un JPEG già compresso sotto i
300 KB, scritto dal Task 14 — e le immagini in un JSON non ci stanno senza
gonfiarlo di un terzo in base64.

`ricettario.json` ha un'intestazione con formato, versione e data, seguita da
ricette e categorie complete di id. L'intestazione serve a riconoscere e
rifiutare i file che non sono nostri, e a leggere i file scritti dalle versioni
vecchie dell'app.

**Le due versioni del formato.** La versione 1 aveva solo le ricette, senza
categoria e senza foto. La versione 2, quella di adesso, ha le categorie e i due
campi nuovi sulle ricette. **Un file di versione 1 deve continuare a entrare**:
le sue ricette arrivano con `categoriaId` e `foto` a `null` e senza nessuna
categoria. È una promessa verso i file già in giro, non un dettaglio, ed è
inchiodata da due test.

L'import accetta **sia l'archivio zip sia un JSON nudo**, perché i file scritti a
mano e quelli esportati dalle versioni precedenti devono continuare a funzionare.

Quattro regole che vengono dalla spec e che i test qui sotto inchiodano:

- **Un file rotto o di un'altra app viene rifiutato senza toccare niente di
  esistente.** Chi importa non deve mai avere paura di perdere quello che ha.
- **L'import è additivo, mai sostitutivo.** Ricette e categorie nuove si
  aggiungono, e a parità di id resta la versione modificata più di recente. Vale
  identico per le due entità.
- **Prima di importare si fa una copia del database.** Finché non c'è la sync è
  l'unica rete di sicurezza.
- **Una ricetta non deve mai diventare irraggiungibile.** Se punta a una
  categoria che nel file non c'è e in locale nemmeno, il suo `categoriaId` si
  azzera e la ricetta finisce in "Senza categoria": lasciandolo com'è non
  comparirebbe in nessuna categoria e nemmeno fra quelle senza.

Escono anche le ricette e le categorie cancellate, che nel modello non spariscono
ma portano una data in `cancellataIl` (tombstone). Senza di loro, reimportando il
file su un altro dispositivo quello che era stato buttato resusciterebbe.

Il confronto "chi è più recente" funziona solo perché `salvaRicetta` e
`salvaCategoria` scrivono `modificataIl` così come arriva: se timbrassero l'ora
corrente, ogni voce importata risulterebbe modificata adesso e la regola non
deciderebbe più niente.

Il riscalo corrente (Task 13) **non entra nel file**: resta sul dispositivo.

**Files:**
- Create: `src/io/formato.ts`
- Create: `src/io/esporta.ts`
- Create: `src/io/importa.ts`
- Test: `src/io/formato.test.ts`
- Test: `src/io/esporta.test.ts`
- Test: `src/io/importa.test.ts`
- Modify: `package.json` (aggiunge la dipendenza `jszip`)

**Interfaces:**

- Consumes — da `src/domain/types.ts`, che il Task 4 e il Task 5 hanno lasciato
  completo. Le due dichiarazioni che contano qui:

```ts
export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  categoriaId: string | null;    // null = senza categoria
  foto: string | null;           // NOME del file dentro la cartella foto/, mai un percorso
  gruppi: Gruppo[];
  creataIl: string;              // ISO 8601
  modificataIl: string;          // ISO 8601
  cancellataIl: string | null;   // tombstone, null = viva
}
export interface Categoria {
  id: string;
  nome: string;
  icona: ChiaveIcona;            // chiave del catalogo del Task 5
  ordine: number;
  creataIl: string;
  modificataIl: string;
  cancellataIl: string | null;
}
```

  Quello che conta per questo task: ricette e categorie hanno un `id: string`
  stabile, `modificataIl` in ISO 8601 UTC e `cancellataIl: string | null`.

- Consumes — da `src/domain/icone.ts` (Task 5):

```ts
/**
 * Le trenta voci del catalogo. `as const` senza nessuna annotazione di tipo:
 * annotarla (`readonly VoceIcona[]`, o anche solo `readonly { chiave: string }[]`)
 * allargherebbe `chiave` a `string` e `ChiaveIcona`, che è derivata da qui,
 * collasserebbe a `string`.
 */
export const ICONE = [ /* le trenta voci, chiave nostra + nome MaterialCommunityIcons */ ] as const;
/** L'unione delle trenta chiavi, NON `string`: un valore fuori catalogo non compila. */
export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
export function iconaValida(c: string): c is ChiaveIcona;
export const ICONA_PREDEFINITA: ChiaveIcona;
```

  Serve a una cosa sola: un file scritto da una versione più nuova dell'app può
  portare l'icona di una categoria che qui non esiste. Un nome sconosciuto non dà
  errore, dà un quadratino vuoto a schermo, quindi si sostituisce con
  `ICONA_PREDEFINITA`.

  Che `ChiaveIcona` sia l'unione e non `string` conta anche qui: `iconaValida` è
  una guardia di tipo, e senza l'unione dietro non restringerebbe niente. È il
  motivo per cui `leggiCategoria` può assegnare `c.icona` a `Categoria.icona`
  solo dopo averla passata da quella guardia.

- Consumes — da `src/data/ricette.ts` (Task 11):

```ts
export type FiltroElenco =
  | { tipo: 'tutte' }
  | { tipo: 'categoria'; id: string }
  | { tipo: 'senza' };
/** Solo le vive, ordinate per titolo con localeCompare('it'). */
export async function elencoRicette(db: SQLiteDatabase, filtro: FiltroElenco): Promise<Ricetta[]>;
/** Restituisce anche una cancellata, se quell'id esiste; null se non esiste. */
export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null>;
/** Tutte, tombstone incluse: serve all'export e alla sync futura. */
export async function ricetteDaEsportare(db: SQLiteDatabase): Promise<Ricetta[]>;
/** Upsert completo, gruppi e ingredienti inclusi. Scrive modificataIl come arriva. */
export async function salvaRicetta(db: SQLiteDatabase, ricetta: Ricetta): Promise<void>;
```

  `salvaRicetta` non timbra: il timestamp lo decide chi chiama. È esattamente
  quello che serve qui, perché l'import deve conservare le date scritte nel file.

- Consumes — da `src/data/categorie.ts` (Task 12), simmetrico a `ricette.ts`:

```ts
/** Solo le vive, per `ordine` crescente e, a parità di ordine, per nome italiano. */
export async function elencoCategorie(db: SQLiteDatabase): Promise<Categoria[]>;
/** Tutte, tombstone incluse. */
export async function categorieDaEsportare(db: SQLiteDatabase): Promise<Categoria[]>;
/** Upsert. Scrive modificataIl come arriva, come salvaRicetta. */
export async function salvaCategoria(db: SQLiteDatabase, categoria: Categoria): Promise<void>;
```

- Consumes — da `src/data/foto.ts` (Task 14):

```ts
/** Vero solo per i nomi che produce nomeFoto: /^[a-z0-9-]{1,64}\.jpg$/ */
export function nomeFotoValido(nome: string): boolean;
```

  È il guardiano dei nomi di file, e questo task è il motivo per cui esiste: un
  archivio arriva da fuori e può contenere una voce chiamata `../../qualcosa`.
  Vale anche al contrario: `percorsoFoto` restituisce `null` per ogni nome che
  non passa questo controllo, quindi una foto importata con un nome storto non
  si vedrebbe mai. Meglio non scriverla proprio.

  `src/data/foto.ts` si carica anche in Node: i moduli Expo li tira dentro con
  `import()` dentro le funzioni, apposta.

- Consumes — da `src/data/schema.ts` (Task 10):

```ts
export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void>;
```

- Consumes — da `src/data/db.ts` (Task 10):

```ts
/** Copia il file del database accanto all'originale, sovrascrivendo. */
export async function copiaDiSicurezza(): Promise<void>;
```

  Attenzione: `src/data/db.ts` importa `expo-sqlite` e `expo-file-system` a
  tempo di esecuzione, quindi **non si carica in Node**. Per questo `importa.ts`
  lo carica con un `import()` dinamico dentro la funzione e non in cima al file:
  altrimenti l'intero modulo diventerebbe non provabile. Stessa cosa per
  `expo-file-system` dentro `esporta.ts` e `importa.ts`. `jszip` invece è
  JavaScript puro e si importa normalmente in cima.

- Consumes — da `src/data/dbMemoria.ts` (Task 10), solo nei test:

```ts
export function apriDbInMemoria(): SQLiteDatabase;
```

  Database SQLite vero in memoria, costruito su `node:sqlite`: è l'unico
  aiutante dei test che toccano il database.

- Consumes — da `expo-file-system` (già installato dal Task 10), solo dentro
  `import()` dinamici. Le parti usate:

```ts
class File { constructor(genitore: Directory | string, ...pezzi: string[]);
             readonly exists: boolean; readonly uri: string;
             create(opzioni?: { overwrite?: boolean; intermediates?: boolean }): void;
             write(contenuto: string | Uint8Array): void;
             bytesSync(): Uint8Array }
class Directory { constructor(genitore: Directory | string, ...pezzi: string[]);
                  create(opzioni?: { intermediates?: boolean; idempotent?: boolean }): void }
const Paths: { document: Directory; cache: Directory };
```

  La cartella delle foto sul disco è `Paths.document/foto`, la stessa in cui
  scrive `src/data/foto.ts` del Task 14, e il nome del file è quello che sta in
  `Ricetta.foto`.

- Consumes — `jszip`, la sola dipendenza nuova di questo task. Le parti usate:

```ts
new JSZip();
zip.file(nome: string, dato: string | Uint8Array): JSZip;
zip.file(nome: string): JSZip.JSZipObject | null;
zip.forEach(cb: (percorsoRelativo: string, voce: JSZip.JSZipObject) => void): void;
zip.generateAsync({ type: 'uint8array' }): Promise<Uint8Array>;
JSZip.loadAsync(dati: Uint8Array): Promise<JSZip>;   // lancia se non è uno zip
voce.name: string; voce.dir: boolean;
voce.async('string'): Promise<string>;
voce.async('uint8array'): Promise<Uint8Array>;
```

- Produces — `src/io/formato.ts`:

```ts
export const FORMATO = 'quantobasta/ricettario';
export const VERSIONE_FILE = 2;
export const NOME_JSON = 'ricettario.json';
export const CARTELLA_FOTO = 'foto';
export interface FileRicettario {
  formato: typeof FORMATO;
  versione: number;
  esportatoIl: string;
  ricette: Ricetta[];
  categorie: Categoria[];
}
/** null se il file non è nostro o è malformato. Non lancia mai. */
export function validaFile(json: unknown): FileRicettario | null;
```

- Produces — `src/io/esporta.ts`:

```ts
export function creaFile(ricette: Ricetta[], categorie: Categoria[], adesso: string): FileRicettario;
/** Lo zip: ricettario.json più una voce foto/<nome> per ogni immagine. */
export async function creaArchivio(file: FileRicettario, foto: Map<string, Uint8Array>): Promise<Uint8Array>;
export interface EsitoExport {
  uri: string;       // il file zip da consegnare a expo-sharing
  ricette: number;   // quante ne sono uscite, tombstone escluse dal conteggio
  foto: number;      // quante immagini sono finite nell'archivio
}
/** Scrive l'archivio nella cartella temporanea e dice cosa è uscito. */
export async function esporta(db: SQLiteDatabase): Promise<EsitoExport>;
```

  `esporta` restituisce l'oggetto e non solo l'uri perché dopo un export l'utente
  vuole sapere **cosa** è uscito, non solo dove: il messaggio del Task 23
  (`io.export.pronto`) ha i segnaposto `{ricette}` e `{foto}`, e `t()` lancia se
  un segnaposto resta senza valore. I due numeri sono già in mano alla funzione,
  farli ricalcolare a chi chiama sarebbe rifare due volte lo stesso giro.

  Nel conteggio le tombstone non entrano: nel file ci vanno — servono a non far
  resuscitare le ricette buttate — ma chi legge il messaggio vuole sapere quante
  ricette ha in mano, non quante ne ha cancellate.

- Produces — `src/io/importa.ts`:

```ts
export interface ContiCategorie { aggiunte: number; aggiornate: number; ignorate: number }
export type EsitoImport =
  | { ok: true; aggiunte: number; aggiornate: number; ignorate: number; categorie: ContiCategorie }
  /** `motivo` è un identificatore, non una frase: vedi sotto. */
  | { ok: false; motivo: string };
/** Quello che c'è dentro un archivio, già validato. null se non è roba nostra. */
export async function leggiArchivio(zip: Uint8Array):
  Promise<{ file: FileRicettario; foto: Map<string, Uint8Array> } | null>;
/** Import da archivio zip: ricette, categorie e foto. */
export async function importaArchivio(db: SQLiteDatabase, zip: Uint8Array): Promise<EsitoImport>;
/** Import da JSON nudo: i file scritti a mano e quelli di versione 1. */
export async function importaJson(db: SQLiteDatabase, testo: string): Promise<EsitoImport>;
```

  I tre conteggi delle ricette restano al primo livello perché sono quelli che
  finiscono nel messaggio dell'interfaccia (Task 23), che usa i segnaposto
  `{aggiunte}`, `{aggiornate}`, `{ignorate}`.

  **`motivo` è un identificatore, non un messaggio.** Questo modulo ne produce
  due e due soltanto:

  - `'file-non-valido'` — non è JSON, non è uno zip, dentro non c'è
    `ricettario.json`, oppure l'intestazione non è la nostra;
  - `'copia-di-sicurezza-fallita'` — la copia del database prima di scrivere non
    è riuscita, quindi non si è importato niente.

  Non sono frasi e **non vanno mostrate così come sono**: «File non importato:
  file-non-valido» è codice a schermo, in tutte e due le lingue. Chi mostra il
  messaggio (Task 23) mappa questi due slug sulle rispettive chiavi i18n del
  Task 16 e passa a `t()` il testo già tradotto, tenendo lo slug come ripiego per
  un motivo che non conoscesse.

  La traduzione non può stare qui: `src/io` non sa in che lingua è l'app, e
  darglielo vorrebbe dire far passare la lingua attraverso tutto lo strato dati
  per due stringhe. Il Task 15 arriva anche prima del Task 16, quindi `Chiave`
  qui non esiste nemmeno.

  **Due porte d'ingresso invece di una che indovina.** Chi chiama sa che file ha
  scelto l'utente, perché il selettore di documenti gli restituisce il nome: una
  riga sola in Task 23, `nome.endsWith('.zip') ? importaArchivio(db, await
  f.bytes()) : importaJson(db, await f.text())`. Un'unica funzione che prova
  prima lo zip e poi il JSON dovrebbe ricevere lo stesso file nelle due forme,
  oppure decodificare il base64 a mano: più codice per indovinare una cosa che il
  chiamante già sa.

- [ ] **Step 1: Installa jszip e accerta che vada bene in React Native**

`jszip` è JavaScript puro: non ha moduli nativi, non richiede `pod install` e non
cambia niente nelle build EAS. Per questo si installa con `npm install` e non con
`npx expo install`, che serve a scegliere le versioni dei pacchetti nativi
compatibili con l'SDK.

```bash
npm install jszip
```

Atteso: `jszip` compare in `dependencies` dentro `package.json`. Porta con sé
quattro pacchetti, tutti JavaScript puro: `pako` (zlib riscritto in JS), `lie`,
`setimmediate`, `readable-stream`.

Poi le due verifiche che dicono se in React Native funziona davvero. Non sono
formalità: `readable-stream` in quell'elenco è esattamente il tipo di dipendenza
che fa esplodere Metro.

```bash
node -e "const p=require('jszip/package.json'); console.log(p.version); console.log(JSON.stringify(p.browser));"
```

Atteso, esattamente:

```
3.10.1
{"./lib/index":"./dist/jszip.min.js","readable-stream":"./lib/readable-stream-browser.js"}
```

Il campo `browser` è la risposta. Metro risolve i moduli con
`resolverMainFields: ['react-native', 'browser', 'main']`, quindi
`import JSZip from 'jszip'` dentro l'app **non** prende `lib/index.js` ma
`dist/jszip.min.js`, che è un bundle UMD di 95 kB già chiuso su sé stesso.
`readable-stream` non ci entra.

```bash
node -e "const s=require('fs').readFileSync('node_modules/jszip/dist/jszip.min.js','utf8'); console.log(/require\(['\"][^'\"]+['\"]\)/.test(s));"
```

Atteso: `false`. Dentro quel bundle non esiste una sola chiamata `require` con
una stringa letterale, quindi Metro non ha niente da risolvere: né `stream`, né
`buffer`, né `fs`. È la prova che serviva.

Restano due dettagli, già verificati leggendo `node_modules/jszip/lib/support.js`
e tenuti fuori dai guai dal codice di questo task:

- jszip prova a capire se sa fare i `Blob` costruendone uno di prova. In React
  Native il tentativo fallisce, ma sta dentro un `try/catch` e finisce con
  `support.blob = false`. Qui non si chiede mai `type: 'blob'`, quindi non
  cambia niente.
- `generateNodeStream` è l'unica funzione di jszip che ha davvero bisogno di
  `readable-stream`, e non la chiama nessuno. Si usano solo
  `generateAsync({ type: 'uint8array' })` e `loadAsync(Uint8Array)`, che
  funzionano ovunque esistano i typed array, Hermes compreso.

Nessuna alternativa da proporre: jszip va bene così com'è.

- [ ] **Step 2: Scrivi il test dell'intestazione, che fallisce**

`src/io/formato.test.ts`:

```ts
/**
 * Test dell'intestazione del file di ricettario: serve a riconoscere i file
 * nostri, a rifiutare tutti gli altri, e a far entrare quelli di versione 1.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { FORMATO, VERSIONE_FILE, validaFile } from './formato.ts';
import { ICONA_PREDEFINITA } from '../domain/icone.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';

const farinata: Ricetta = {
  id: 'r-farinata', titolo: 'Farinata', descrizione: '', porzioni: 14,
  categoriaId: 'c-primi', foto: 'r-farinata.jpg',
  gruppi: [{
    id: 'g1', nome: null,
    ingredienti: [{ id: 'i-farina', nome: 'Farina', quantita: 180, unita: 'g' }],
  }],
  creataIl: '2026-01-01T10:00:00.000Z',
  modificataIl: '2026-01-01T10:00:00.000Z',
  cancellataIl: null,
};

// L'icona è `ICONA_PREDEFINITA` e non una chiave scritta a mano: il catalogo lo
// decide il Task 5, e una chiave inventata qui verrebbe sostituita da validaFile
// facendo fallire il confronto per un motivo che non c'entra niente.
const primi: Categoria = {
  id: 'c-primi', nome: 'Primi', icona: ICONA_PREDEFINITA, ordine: 0,
  creataIl: '2026-01-01T10:00:00.000Z',
  modificataIl: '2026-01-01T10:00:00.000Z',
  cancellataIl: null,
};

const intestazione = { formato: FORMATO, versione: VERSIONE_FILE, esportatoIl: '2026-08-18T09:00:00.000Z' };

test('un ricettario nostro passa e torna intero, categorie comprese', () => {
  const esito = validaFile({ ...intestazione, ricette: [farinata], categorie: [primi] });
  assert.ok(esito);
  assert.equal(esito.versione, 2);
  assert.equal(esito.esportatoIl, '2026-08-18T09:00:00.000Z');
  assert.deepEqual(esito.ricette, [farinata]);
  assert.deepEqual(esito.categorie, [primi]);
});

test("quello che non è un nostro ricettario viene rifiutato", () => {
  assert.equal(validaFile(null), null);
  assert.equal(validaFile('quantobasta/ricettario'), null);
  assert.equal(validaFile(42), null);
  assert.equal(validaFile([farinata]), null);                       // array, non oggetto
  assert.equal(validaFile({ ricette: [farinata] }), null);          // senza intestazione
  assert.equal(validaFile({ ...intestazione, formato: 'ricettario-pro/backup', ricette: [], categorie: [] }), null);
  assert.equal(validaFile({ ...intestazione, versione: VERSIONE_FILE + 1, ricette: [], categorie: [] }), null);
  assert.equal(validaFile({ ...intestazione, versione: '2', ricette: [], categorie: [] }), null);
  assert.equal(validaFile({ ...intestazione, versione: 0, ricette: [], categorie: [] }), null);
  assert.equal(validaFile({ ...intestazione, ricette: {}, categorie: [] }), null);
  assert.equal(validaFile({ ...intestazione, ricette: [], categorie: {} }), null);
  assert.equal(validaFile({ ...intestazione }), null);              // ricette assenti
});

test('le voci che non sono ricette o categorie si scartano, il file resta buono', () => {
  const esito = validaFile({
    ...intestazione,
    ricette: [farinata, { titolo: 'senza id' }, null, 'Farinata'],
    categorie: [primi, { nome: 'senza id' }, null, 7],
  });
  assert.equal(esito!.ricette.length, 1);
  assert.equal(esito!.ricette[0].id, 'r-farinata');
  assert.equal(esito!.categorie.length, 1);
  assert.equal(esito!.categorie[0].id, 'c-primi');
});

test('la data di esportazione mancante non fa buttare il file', () => {
  const esito = validaFile({ formato: FORMATO, versione: VERSIONE_FILE, ricette: [farinata], categorie: [] });
  assert.ok(esito);
  assert.equal(esito.esportatoIl, '');
});

test('un file di versione 1 entra lo stesso, senza categorie e coi campi nuovi a null', () => {
  // È la promessa verso i file già in giro: la versione 1 non aveva né le
  // categorie né `categoriaId` e `foto` sulle ricette.
  const v1 = {
    formato: FORMATO,
    versione: 1,
    esportatoIl: '2026-03-01T09:00:00.000Z',
    ricette: [{
      id: 'r-polenta', titolo: 'Polenta', descrizione: '', porzioni: 5,
      gruppi: [{
        id: 'g1', nome: null,
        ingredienti: [{ id: 'i1', nome: 'Polenta', quantita: 180, unita: 'g' }],
      }],
      creataIl: '2026-01-01T10:00:00.000Z',
      modificataIl: '2026-01-01T10:00:00.000Z',
      cancellataIl: null,
    }],
  };

  const esito = validaFile(v1);
  assert.ok(esito);
  assert.equal(esito.versione, 1);
  assert.deepEqual(esito.categorie, []);
  assert.equal(esito.ricette.length, 1);
  assert.equal(esito.ricette[0].titolo, 'Polenta');
  assert.equal(esito.ricette[0].categoriaId, null);
  assert.equal(esito.ricette[0].foto, null);
});

test("un'icona che non conosciamo diventa quella predefinita", () => {
  // Un file scritto da una versione più nuova dell'app: un nome sconosciuto non
  // dà errore, dà un quadratino vuoto a schermo.
  const esito = validaFile({ ...intestazione, ricette: [], categorie: [{ ...primi, icona: 'astronave' }] });
  assert.equal(esito!.categorie[0].icona, ICONA_PREDEFINITA);
});
```

- [ ] **Step 3: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/io/formato.ts' imported from .../src/io/formato.test.ts`

- [ ] **Step 4: Implementa il formato**

`src/io/formato.ts`:

```ts
/**
 * Il formato del file di ricettario.
 *
 * L'intestazione serve a due cose: riconoscere che il file è nostro e rifiutare
 * quello di qualcun altro, e sapere come leggere i file scritti dalle versioni
 * vecchie dell'app.
 *
 * Versione 1: solo ricette, senza categoria e senza foto.
 * Versione 2: ricette con `categoriaId` e `foto`, più l'elenco delle categorie.
 *
 * Un file di versione 1 continua a entrare, con `categoriaId` e `foto` a null e
 * nessuna categoria: è una promessa verso i file già in giro. Un file di
 * versione più alta di quella che sappiamo leggere invece si rifiuta,
 * interpretarlo a metà è peggio che dire di no.
 */
import { ICONA_PREDEFINITA, iconaValida } from '../domain/icone.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';

export const FORMATO = 'quantobasta/ricettario';
export const VERSIONE_FILE = 2;

/** Come è fatto l'archivio zip dentro. */
export const NOME_JSON = 'ricettario.json';
export const CARTELLA_FOTO = 'foto';

export interface FileRicettario {
  formato: typeof FORMATO;
  versione: number;
  esportatoIl: string;
  ricette: Ricetta[];
  categorie: Categoria[];
}

/**
 * null se non è una ricetta leggibile, altrimenti una ricetta completa.
 * Controlli minimi ma sufficienti: senza id o senza data di modifica non si può
 * né identificare né confrontare la ricetta, e l'import è tutto lì. Il resto si
 * riempie, ed è così che entrano i file di versione 1.
 */
function leggiRicetta(x: unknown): Ricetta | null {
  if (typeof x !== 'object' || x === null) return null;
  const r = x as Record<string, unknown>;
  if (typeof r.id !== 'string' || r.id === '') return null;
  if (typeof r.titolo !== 'string') return null;
  if (typeof r.modificataIl !== 'string' || r.modificataIl === '') return null;
  if (!Array.isArray(r.gruppi)) return null;

  return {
    id: r.id,
    titolo: r.titolo,
    descrizione: typeof r.descrizione === 'string' ? r.descrizione : '',
    porzioni: typeof r.porzioni === 'number' ? r.porzioni : null,
    categoriaId: typeof r.categoriaId === 'string' && r.categoriaId !== '' ? r.categoriaId : null,
    foto: typeof r.foto === 'string' && r.foto !== '' ? r.foto : null,
    gruppi: r.gruppi as Ricetta['gruppi'],
    creataIl: typeof r.creataIl === 'string' ? r.creataIl : r.modificataIl,
    modificataIl: r.modificataIl,
    cancellataIl: typeof r.cancellataIl === 'string' ? r.cancellataIl : null,
  };
}

/** null se non è una categoria leggibile. */
function leggiCategoria(x: unknown): Categoria | null {
  if (typeof x !== 'object' || x === null) return null;
  const c = x as Record<string, unknown>;
  if (typeof c.id !== 'string' || c.id === '') return null;
  if (typeof c.nome !== 'string') return null;
  if (typeof c.modificataIl !== 'string' || c.modificataIl === '') return null;

  return {
    id: c.id,
    nome: c.nome,
    // Un file scritto da una versione più nuova può portare un'icona che qui non
    // esiste: meglio quella predefinita che un quadratino vuoto a schermo.
    icona: typeof c.icona === 'string' && iconaValida(c.icona) ? c.icona : ICONA_PREDEFINITA,
    ordine: typeof c.ordine === 'number' ? c.ordine : 0,
    creataIl: typeof c.creataIl === 'string' ? c.creataIl : c.modificataIl,
    modificataIl: c.modificataIl,
    cancellataIl: typeof c.cancellataIl === 'string' ? c.cancellataIl : null,
  };
}

function nonNullo<T>(x: T | null): x is T {
  return x !== null;
}

/** null se il file non è nostro o è malformato. Non lancia mai. */
export function validaFile(json: unknown): FileRicettario | null {
  if (typeof json !== 'object' || json === null || Array.isArray(json)) return null;

  const f = json as Record<string, unknown>;
  if (f.formato !== FORMATO) return null;
  if (typeof f.versione !== 'number' || !Number.isInteger(f.versione)) return null;
  if (f.versione < 1 || f.versione > VERSIONE_FILE) return null;
  if (!Array.isArray(f.ricette)) return null;
  // Le categorie mancano in ogni file di versione 1: assenti vuol dire nessuna,
  // non file rotto. Se però ci sono devono essere un array.
  if (f.categorie !== undefined && !Array.isArray(f.categorie)) return null;
  const categorie = Array.isArray(f.categorie) ? f.categorie : [];

  return {
    formato: FORMATO,
    versione: f.versione,
    esportatoIl: typeof f.esportatoIl === 'string' ? f.esportatoIl : '',
    ricette: f.ricette.map(leggiRicetta).filter(nonNullo),
    categorie: categorie.map(leggiCategoria).filter(nonNullo),
  };
}
```

- [ ] **Step 5: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, i **6 test** di `src/io/formato.test.ts` verdi:
`un ricettario nostro passa e torna intero, categorie comprese`,
`quello che non è un nostro ricettario viene rifiutato`,
`le voci che non sono ricette o categorie si scartano, il file resta buono`,
`la data di esportazione mancante non fa buttare il file`,
`un file di versione 1 entra lo stesso, senza categorie e coi campi nuovi a null`,
`un'icona che non conosciamo diventa quella predefinita`.

- [ ] **Step 6: Commit**

```bash
git add package.json package-lock.json src/io/formato.ts src/io/formato.test.ts
git commit -m "Definisci il formato del ricettario alla versione 2, con le categorie"
```

- [ ] **Step 7: Scrivi il test dell'export, che fallisce**

`esporta` in sé legge e scrive su disco con `expo-file-system` e si prova a mano
sul dispositivo, come dichiarato nella spec. Qui si prova quello che finisce
dentro all'archivio, cioè la parte che può sbagliare in silenzio.

`src/io/esporta.test.ts`:

```ts
/**
 * Test della confezione dell'archivio. `esporta` in sé tocca il disco con
 * expo-file-system e si prova a mano sul dispositivo: qui si prova quello che
 * finisce dentro al file.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { creaFile } from './esporta.ts';
import { FORMATO, VERSIONE_FILE, validaFile } from './formato.ts';
import { ICONA_PREDEFINITA } from '../domain/icone.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';

const ADESSO = '2026-08-18T09:00:00.000Z';

const farinata: Ricetta = {
  id: 'r-farinata', titolo: 'Farinata', descrizione: '', porzioni: 14,
  categoriaId: 'c-primi', foto: 'r-farinata.jpg',
  gruppi: [{
    id: 'g1', nome: null,
    ingredienti: [{ id: 'i-farina', nome: 'Farina', quantita: 180, unita: 'g' }],
  }],
  creataIl: '2026-01-01T10:00:00.000Z',
  modificataIl: '2026-01-01T10:00:00.000Z',
  cancellataIl: null,
};

// Vedi formato.test.ts: il catalogo delle icone lo decide il Task 5, quindi qui
// si usa quella predefinita invece di una chiave scritta a mano.
const primi: Categoria = {
  id: 'c-primi', nome: 'Primi', icona: ICONA_PREDEFINITA, ordine: 0,
  creataIl: '2026-01-01T10:00:00.000Z',
  modificataIl: '2026-01-01T10:00:00.000Z',
  cancellataIl: null,
};

test('il file esportato porta intestazione, ricette e categorie', () => {
  const file = creaFile([farinata], [primi], ADESSO);
  assert.equal(file.formato, FORMATO);
  assert.equal(file.versione, VERSIONE_FILE);
  assert.equal(file.versione, 2);
  assert.equal(file.esportatoIl, ADESSO);
  assert.deepEqual(file.ricette, [farinata]);
  assert.deepEqual(file.categorie, [primi]);
});

test('quello che esportiamo lo sappiamo rileggere', () => {
  // Il giro vero: oggetto -> JSON su file -> JSON riletto -> validazione.
  const scritto = JSON.stringify(creaFile([farinata], [primi], ADESSO));
  const riletto = validaFile(JSON.parse(scritto));
  assert.ok(riletto);
  assert.deepEqual(riletto.ricette, [farinata]);
  assert.deepEqual(riletto.categorie, [primi]);
});

test('una ricetta cancellata resta nel file con la sua tombstone', () => {
  // Senza, reimportando il file altrove le ricette buttate resusciterebbero.
  const buttata: Ricetta = { ...farinata, cancellataIl: '2026-07-01T10:00:00.000Z' };
  const riletto = validaFile(JSON.parse(JSON.stringify(creaFile([buttata], [], ADESSO))));
  assert.equal(riletto!.ricette[0].cancellataIl, '2026-07-01T10:00:00.000Z');
});
```

La prova dello zip vero e proprio arriva al passo 16, quando esisterà anche la
funzione che lo rilegge: un archivio che si scrive e non si rilegge non prova
niente, e tenere un file di test rosso in mezzo a due commit è peggio che
aspettare un giro.

- [ ] **Step 8: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/io/esporta.ts' imported from .../src/io/esporta.test.ts`

- [ ] **Step 9: Implementa l'export**

`src/io/esporta.ts`:

```ts
/**
 * Export del ricettario in un archivio da mandare dove si vuole.
 *
 * L'archivio è uno zip con dentro `ricettario.json` e la cartella `foto/`: le
 * immagini sono file binari e in un JSON non ci stanno senza gonfiarlo di un
 * terzo in base64.
 *
 * Escono anche le ricette e le categorie cancellate, che portano la loro data di
 * cancellazione: senza di loro, reimportando il file su un altro dispositivo
 * quello che era stato buttato resusciterebbe.
 */
import JSZip from 'jszip';
import type { SQLiteDatabase } from 'expo-sqlite';

import { categorieDaEsportare } from '../data/categorie.ts';
import { nomeFotoValido } from '../data/foto.ts';
import { ricetteDaEsportare } from '../data/ricette.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';
import { CARTELLA_FOTO, FORMATO, NOME_JSON, VERSIONE_FILE, type FileRicettario } from './formato.ts';

export function creaFile(ricette: Ricetta[], categorie: Categoria[], adesso: string): FileRicettario {
  return { formato: FORMATO, versione: VERSIONE_FILE, esportatoIl: adesso, ricette, categorie };
}

/**
 * Lo zip: `ricettario.json` più una voce `foto/<nome>` per ogni immagine.
 *
 * Le foto ci vanno dentro senza comprimere (STORE, il modo predefinito di
 * jszip): sono già JPEG, spremerle costa tempo e non toglie niente.
 */
export async function creaArchivio(file: FileRicettario, foto: Map<string, Uint8Array>): Promise<Uint8Array> {
  const zip = new JSZip();
  zip.file(NOME_JSON, JSON.stringify(file, null, 2));
  for (const [nome, byte] of foto) zip.file(`${CARTELLA_FOTO}/${nome}`, byte);
  return zip.generateAsync({ type: 'uint8array' });
}

/** Quello che è uscito da un export: dove sta il file e cosa c'è dentro. */
export interface EsitoExport {
  /** Il file zip da consegnare a expo-sharing. */
  uri: string;
  /** Quante ricette sono uscite, tombstone escluse: è un numero da mostrare. */
  ricette: number;
  /** Quante immagini sono finite nell'archivio. */
  foto: number;
}

/** Scrive l'archivio nella cartella temporanea e dice cosa è uscito. */
export async function esporta(db: SQLiteDatabase): Promise<EsitoExport> {
  const adesso = new Date().toISOString();
  const file = creaFile(await ricetteDaEsportare(db), await categorieDaEsportare(db), adesso);

  // Caricato qui e non in cima al file: expo-file-system è un modulo nativo e
  // non si carica fuori dall'app, mentre `creaFile` e `creaArchivio` devono
  // restare provabili in Node. jszip invece è JavaScript puro e sta in cima.
  const { Directory, File, Paths } = await import('expo-file-system');

  const cartella = new Directory(Paths.document, CARTELLA_FOTO);
  const foto = new Map<string, Uint8Array>();
  for (const ricetta of file.ricette) {
    // Il nome viene dal database, dove può esserci finito importando un file di
    // qualcun altro: si costruisce un percorso solo con quelli che passano il
    // controllo di `foto.ts`.
    if (ricetta.foto === null || !nomeFotoValido(ricetta.foto)) continue;
    const immagine = new File(cartella, ricetta.foto);
    // Una foto che il database dichiara ma che sul disco non c'è si salta: è uno
    // stato previsto dalla spec, non un guasto.
    if (immagine.exists) foto.set(ricetta.foto, immagine.bytesSync());
  }

  const uscita = new File(Paths.cache, `quantobasta-${adesso.slice(0, 10)}.zip`);
  uscita.create({ overwrite: true });
  uscita.write(await creaArchivio(file, foto));

  // I due conteggi li sa solo chi ha appena confezionato l'archivio, e a chi
  // chiama servono per il messaggio: tornano insieme all'uri invece di far
  // rifare il giro. Le tombstone stanno nel file ma fuori dal numero: chi legge
  // vuole sapere quante ricette ha in mano, non quante ne ha buttate.
  return {
    uri: uscita.uri,
    ricette: file.ricette.filter((r) => r.cancellataIl === null).length,
    foto: foto.size,
  };
}
```

- [ ] **Step 10: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, i **3 test** di `src/io/esporta.test.ts` verdi:
`il file esportato porta intestazione, ricette e categorie`,
`quello che esportiamo lo sappiamo rileggere`,
`una ricetta cancellata resta nel file con la sua tombstone`.

- [ ] **Step 11: Scrivi il test dell'import da JSON, che fallisce**

`src/io/importa.test.ts`:

```ts
/**
 * Test dell'import. Additivo: non deve mai far sparire quello che c'era.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { elencoCategorie, salvaCategoria } from '../data/categorie.ts';
import { apriDbInMemoria } from '../data/dbMemoria.ts';
import { elencoRicette, leggiRicetta, salvaRicetta } from '../data/ricette.ts';
import { applicaMigrazioni } from '../data/schema.ts';
import { creaFile } from './esporta.ts';
import { FORMATO } from './formato.ts';
import { importaJson } from './importa.ts';
import { ICONA_PREDEFINITA } from '../domain/icone.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';

function ricetta(id: string, titolo: string, modificataIl: string, extra: Partial<Ricetta> = {}): Ricetta {
  return {
    id, titolo, descrizione: '', porzioni: 4, categoriaId: null, foto: null,
    gruppi: [{
      id: `g-${id}`, nome: null,
      ingredienti: [{ id: `i-${id}`, nome: 'Farina', quantita: 200, unita: 'g' }],
    }],
    creataIl: '2026-01-01T10:00:00.000Z',
    modificataIl,
    cancellataIl: null,
    ...extra,
  };
}

/** L'icona è quella predefinita: il catalogo lo decide il Task 5, non questo test. */
function categoria(id: string, nome: string, ordine: number, modificataIl: string): Categoria {
  return {
    id, nome, icona: ICONA_PREDEFINITA, ordine,
    creataIl: '2026-01-01T10:00:00.000Z',
    modificataIl,
    cancellataIl: null,
  };
}

/** Nessuna categoria toccata: il conteggio che si ripete in mezzo test. */
const NIENTE = { aggiunte: 0, aggiornate: 0, ignorate: 0 };

/** Il file come arriva davvero: testo JSON, non oggetti nostri. */
const comeFile = (ricette: Ricetta[], categorie: Categoria[] = []): string =>
  JSON.stringify(creaFile(ricette, categorie, '2026-08-18T09:00:00.000Z'), null, 2);

async function dbCon(ricette: Ricetta[] = [], categorie: Categoria[] = []) {
  const db = apriDbInMemoria();
  await applicaMigrazioni(db);
  for (const c of categorie) await salvaCategoria(db, c);
  for (const r of ricette) await salvaRicetta(db, r);
  return db;
}

test("il file di un'altra app viene rifiutato e non tocca il ricettario", async () => {
  const db = await dbCon([ricetta('r1', 'Farinata', '2026-01-01T10:00:00.000Z')]);

  const esito = await importaJson(db, JSON.stringify({
    formato: 'ricettario-pro/backup',
    versione: 2,
    esportatoIl: '2026-08-18T09:00:00.000Z',
    ricette: [ricetta('r1', 'Roba di un altro', '2027-01-01T10:00:00.000Z')],
    categorie: [],
  }));

  assert.deepEqual(esito, { ok: false, motivo: 'file-non-valido' });
  const dopo = await elencoRicette(db, { tipo: 'tutte' });
  assert.equal(dopo.length, 1);
  assert.equal(dopo[0].titolo, 'Farinata');
});

test('un testo che non è nemmeno JSON viene rifiutato senza lanciare', async () => {
  const db = await dbCon();
  assert.deepEqual(await importaJson(db, 'non sono JSON'), { ok: false, motivo: 'file-non-valido' });
});

test('importare due volte lo stesso file non duplica niente', async () => {
  const db = await dbCon();
  const file = comeFile([
    ricetta('r1', 'Farinata', '2026-01-01T10:00:00.000Z'),
    ricetta('r2', 'Polenta', '2026-01-01T10:00:00.000Z'),
  ]);

  assert.deepEqual(await importaJson(db, file),
    { ok: true, aggiunte: 2, aggiornate: 0, ignorate: 0, categorie: NIENTE });
  assert.deepEqual(await importaJson(db, file),
    { ok: true, aggiunte: 0, aggiornate: 0, ignorate: 2, categorie: NIENTE });

  const dopo = await elencoRicette(db, { tipo: 'tutte' });
  assert.deepEqual(dopo.map((r) => r.titolo), ['Farinata', 'Polenta']);
});

test('le date del file si conservano, altrimenti non si può confrontare niente', async () => {
  // salvaRicetta non timbra: senza questo, ogni ricetta importata risulterebbe
  // modificata adesso e il test qui sotto non proverebbe più niente.
  const db = await dbCon();
  await importaJson(db, comeFile([ricetta('r1', 'Farinata', '2026-01-01T10:00:00.000Z')]));
  assert.equal((await elencoRicette(db, { tipo: 'tutte' }))[0].modificataIl, '2026-01-01T10:00:00.000Z');
});

test('a parità di id vince la modificata più di recente, nei due versi', async () => {
  const db = await dbCon([ricetta('r1', 'Titolo locale', '2026-03-01T10:00:00.000Z')]);

  const vecchia = await importaJson(db, comeFile([ricetta('r1', 'Titolo vecchio', '2026-01-01T10:00:00.000Z')]));
  assert.deepEqual(vecchia, { ok: true, aggiunte: 0, aggiornate: 0, ignorate: 1, categorie: NIENTE });
  assert.equal((await elencoRicette(db, { tipo: 'tutte' }))[0].titolo, 'Titolo locale');

  const recente = await importaJson(db, comeFile([ricetta('r1', 'Titolo nuovo', '2026-06-01T10:00:00.000Z')]));
  assert.deepEqual(recente, { ok: true, aggiunte: 0, aggiornate: 1, ignorate: 0, categorie: NIENTE });
  const dopo = await elencoRicette(db, { tipo: 'tutte' });
  assert.equal(dopo.length, 1);
  assert.equal(dopo[0].titolo, 'Titolo nuovo');
});

test('le ricette nuove si aggiungono a quelle che ci sono già', async () => {
  const db = await dbCon([ricetta('r1', 'Farinata', '2026-01-01T10:00:00.000Z')]);

  const esito = await importaJson(db, comeFile([ricetta('r2', 'Polenta', '2026-01-01T10:00:00.000Z')]));
  assert.deepEqual(esito, { ok: true, aggiunte: 1, aggiornate: 0, ignorate: 0, categorie: NIENTE });
  assert.deepEqual((await elencoRicette(db, { tipo: 'tutte' })).map((r) => r.titolo), ['Farinata', 'Polenta']);
});

test('le categorie si importano con le stesse regole delle ricette', async () => {
  const db = await dbCon([], [categoria('c1', 'Nome locale', 0, '2026-03-01T10:00:00.000Z')]);

  const primo = await importaJson(db, comeFile([], [
    categoria('c1', 'Nome vecchio', 0, '2026-01-01T10:00:00.000Z'),
    categoria('c2', 'Dolci', 1, '2026-01-01T10:00:00.000Z'),
  ]));
  assert.deepEqual(primo, {
    ok: true, aggiunte: 0, aggiornate: 0, ignorate: 0,
    categorie: { aggiunte: 1, aggiornate: 0, ignorate: 1 },
  });
  // elencoCategorie ordina per `ordine`: prima c1, poi c2.
  assert.deepEqual((await elencoCategorie(db)).map((c) => c.nome), ['Nome locale', 'Dolci']);

  const secondo = await importaJson(db, comeFile([], [categoria('c1', 'Nome nuovo', 0, '2026-06-01T10:00:00.000Z')]));
  assert.deepEqual(secondo, {
    ok: true, aggiunte: 0, aggiornate: 0, ignorate: 0,
    categorie: { aggiunte: 0, aggiornate: 1, ignorate: 0 },
  });
  assert.deepEqual((await elencoCategorie(db)).map((c) => c.nome), ['Nome nuovo', 'Dolci']);
});

test("una ricetta che punta a una categoria che non c'è finisce senza categoria", async () => {
  // Lasciandole l'id addosso non comparirebbe in nessuna categoria e nemmeno
  // fra quelle senza: sarebbe una ricetta irraggiungibile.
  const db = await dbCon();
  await importaJson(db, comeFile(
    [
      ricetta('r1', 'Farinata', '2026-01-01T10:00:00.000Z', { categoriaId: 'c-primi' }),
      ricetta('r2', 'Polenta', '2026-01-01T10:00:00.000Z', { categoriaId: 'c-fantasma' }),
    ],
    [categoria('c-primi', 'Primi', 0, '2026-01-01T10:00:00.000Z')],
  ));

  assert.equal((await leggiRicetta(db, 'r1'))?.categoriaId, 'c-primi');
  assert.equal((await leggiRicetta(db, 'r2'))?.categoriaId, null);
  assert.deepEqual((await elencoRicette(db, { tipo: 'senza' })).map((r) => r.titolo), ['Polenta']);
});

test('un JSON di versione 1 entra, con categoria e foto a null', async () => {
  // È la promessa di compatibilità verso i file già in giro.
  const db = await dbCon();
  const v1 = JSON.stringify({
    formato: FORMATO,
    versione: 1,
    esportatoIl: '2026-03-01T09:00:00.000Z',
    ricette: [{
      id: 'r-polenta', titolo: 'Polenta', descrizione: '', porzioni: 5,
      gruppi: [{
        id: 'g1', nome: null,
        ingredienti: [{ id: 'i1', nome: 'Polenta', quantita: 180, unita: 'g' }],
      }],
      creataIl: '2026-01-01T10:00:00.000Z',
      modificataIl: '2026-01-01T10:00:00.000Z',
      cancellataIl: null,
    }],
  });

  assert.deepEqual(await importaJson(db, v1),
    { ok: true, aggiunte: 1, aggiornate: 0, ignorate: 0, categorie: NIENTE });

  const dentro = await leggiRicetta(db, 'r-polenta');
  assert.equal(dentro?.titolo, 'Polenta');
  assert.equal(dentro?.categoriaId, null);
  assert.equal(dentro?.foto, null);
  assert.equal(dentro?.modificataIl, '2026-01-01T10:00:00.000Z');
});
```

- [ ] **Step 12: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/io/importa.ts' imported from .../src/io/importa.test.ts`.
`formato.test.ts` ed `esporta.test.ts` restano verdi.

- [ ] **Step 13: Implementa l'import**

`src/io/importa.ts`:

```ts
/**
 * Import di un ricettario, da archivio zip o da JSON nudo.
 *
 * Additivo, mai sostitutivo: ricette e categorie nuove si aggiungono, e a parità
 * di id resta la versione modificata più di recente. Chi importa non deve avere
 * paura di perdere quello che ha già.
 *
 * Le date si salvano come stanno nel file, non come "adesso": `salvaRicetta` e
 * `salvaCategoria` non timbrano apposta, perché è su quelle date che si decide
 * chi vince, qui e nella sync futura.
 *
 * Entra anche un JSON di versione 1, che non ha né le categorie né i campi nuovi
 * delle ricette: `validaFile` li riempie con null.
 */
import JSZip from 'jszip';
import type { SQLiteDatabase } from 'expo-sqlite';

import { categorieDaEsportare, salvaCategoria } from '../data/categorie.ts';
import { ricetteDaEsportare, salvaRicetta } from '../data/ricette.ts';
import { CARTELLA_FOTO, NOME_JSON, validaFile, type FileRicettario } from './formato.ts';

export interface ContiCategorie {
  aggiunte: number;
  aggiornate: number;
  ignorate: number;
}

/**
 * `motivo` è un identificatore, non un messaggio: questo modulo ne produce due
 * soli, `'file-non-valido'` e `'copia-di-sicurezza-fallita'`. Chi mostra
 * qualcosa a schermo li traduce prima di stamparli — `src/io` non sa in che
 * lingua è l'app, e non deve saperlo.
 */
export type EsitoImport =
  | { ok: true; aggiunte: number; aggiornate: number; ignorate: number; categorie: ContiCategorie }
  | { ok: false; motivo: string };

/**
 * Copia del database prima di scriverci sopra: finché non c'è la sync è l'unica
 * rete di sicurezza. Se il modulo non si carica siamo fuori dall'app (i test in
 * Node) e non esiste nessun file da mettere al sicuro; se invece si carica e la
 * copia fallisce, l'import non si fa.
 */
async function copiaPrima(): Promise<boolean> {
  let copia: (() => Promise<void>) | undefined;
  try {
    copia = (await import('../data/db.ts')).copiaDiSicurezza;
  } catch {
    return true;
  }
  if (copia === undefined) return true;
  try {
    await copia();
    return true;
  } catch {
    return false;
  }
}

/**
 * Scrive sul disco le foto delle sole ricette che l'import ha davvero salvato.
 * Quelle delle ricette ignorate no: la versione locale è più recente e la sua
 * foto non va sostituita con una più vecchia.
 *
 * Fuori dall'app expo-file-system non si carica e non c'è nessuna cartella dove
 * scrivere: le foto si saltano e le ricette restano importate. Una foto che
 * manca sul disco è uno stato previsto dalla spec — la ricetta si mostra senza
 * immagine — e capita comunque importando un JSON nudo, che le foto non le ha.
 *
 * I nomi che finiscono in `foto` sono già passati da `nomeFotoValido`: qui non
 * si controlla di nuovo, e un nome che non ci fosse dentro non trova byte e si
 * salta da solo.
 */
async function scriviFoto(foto: Map<string, Uint8Array>, daScrivere: Set<string>): Promise<void> {
  if (daScrivere.size === 0) return;

  let fs: typeof import('expo-file-system');
  try {
    fs = await import('expo-file-system');
  } catch {
    return;
  }

  const cartella = new fs.Directory(fs.Paths.document, CARTELLA_FOTO);
  cartella.create({ intermediates: true, idempotent: true });
  for (const nome of daScrivere) {
    const byte = foto.get(nome);
    if (byte === undefined) continue;
    const immagine = new fs.File(cartella, nome);
    immagine.create({ overwrite: true });
    immagine.write(byte);
  }
}

/** Il cuore condiviso dai due modi di importare. Riceve un file già validato. */
async function applica(
  db: SQLiteDatabase,
  file: FileRicettario,
  foto: Map<string, Uint8Array>,
): Promise<EsitoImport> {
  if (!(await copiaPrima())) return { ok: false, motivo: 'copia-di-sicurezza-fallita' };

  // Le categorie prima delle ricette: bisogna sapere quali id esistono davvero
  // prima di assegnarli. Anche le cancellate: senza di loro una categoria
  // buttata qui tornerebbe viva.
  const localiCat = new Map((await categorieDaEsportare(db)).map((c) => [c.id, c]));
  const categorie: ContiCategorie = { aggiunte: 0, aggiornate: 0, ignorate: 0 };

  for (const dalFile of file.categorie) {
    const locale = localiCat.get(dalFile.id);
    if (locale === undefined) {
      await salvaCategoria(db, dalFile);
      categorie.aggiunte++;
    } else if (dalFile.modificataIl > locale.modificataIl) {
      await salvaCategoria(db, dalFile);
      categorie.aggiornate++;
    } else {
      categorie.ignorate++;
    }
  }

  // Rilette dopo averle applicate: una categoria del file può aver risuscitato
  // una tombstone locale, e una locale più recente può aver vinto su quella del
  // file. Solo adesso si sa quali id sono davvero vivi.
  const vive = new Set(
    (await categorieDaEsportare(db)).filter((c) => c.cancellataIl === null).map((c) => c.id),
  );

  const locali = new Map((await ricetteDaEsportare(db)).map((r) => [r.id, r]));
  let aggiunte = 0;
  let aggiornate = 0;
  let ignorate = 0;
  const daScrivere = new Set<string>();

  for (const dalFile of file.ricette) {
    // Una ricetta che punta a una categoria che non esiste sarebbe
    // irraggiungibile: non comparirebbe in nessuna categoria e nemmeno fra
    // quelle senza. Meglio senza categoria che invisibile.
    const arrivata = {
      ...dalFile,
      categoriaId: dalFile.categoriaId !== null && vive.has(dalFile.categoriaId) ? dalFile.categoriaId : null,
    };

    const locale = locali.get(arrivata.id);
    if (locale === undefined) {
      await salvaRicetta(db, arrivata);
      aggiunte++;
    } else if (arrivata.modificataIl > locale.modificataIl) {
      // Date ISO 8601 in UTC: il confronto fra stringhe è il confronto fra date.
      await salvaRicetta(db, arrivata);
      aggiornate++;
    } else {
      ignorate++;
      continue;
    }
    if (arrivata.foto !== null) daScrivere.add(arrivata.foto);
  }

  await scriviFoto(foto, daScrivere);

  return { ok: true, aggiunte, aggiornate, ignorate, categorie };
}

/** Import da JSON nudo: i file scritti a mano e quelli di versione 1. */
export async function importaJson(db: SQLiteDatabase, testo: string): Promise<EsitoImport> {
  let json: unknown;
  try {
    json = JSON.parse(testo);
  } catch {
    return { ok: false, motivo: 'file-non-valido' };
  }
  const file = validaFile(json);
  if (file === null) return { ok: false, motivo: 'file-non-valido' };
  return applica(db, file, new Map());
}
```

- [ ] **Step 14: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, i **9 test** di `src/io/importa.test.ts` verdi, e i 6 di
`formato.test.ts` più i 3 di `esporta.test.ts` ancora verdi.

- [ ] **Step 15: Commit**

```bash
git add src/io/esporta.ts src/io/esporta.test.ts src/io/importa.ts src/io/importa.test.ts
git commit -m "Confeziona il ricettario da esportare e importalo in modo additivo"
```

- [ ] **Step 16: Scrivi i test dell'archivio zip, che falliscono**

Due file, un giro solo: `esporta.test.ts` prova che l'archivio è fatto come deve,
`importa.test.ts` prova che rientra.

In `src/io/esporta.test.ts` sostituisci le tre righe

```ts
import { creaFile } from './esporta.ts';
import { FORMATO, VERSIONE_FILE, validaFile } from './formato.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';
```

con

```ts
import JSZip from 'jszip';

import { creaArchivio, creaFile } from './esporta.ts';
import { CARTELLA_FOTO, FORMATO, NOME_JSON, VERSIONE_FILE, validaFile } from './formato.ts';
import { leggiArchivio } from './importa.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';
```

e aggiungi in fondo allo stesso file:

```ts
test("l'archivio è uno zip con dentro ricettario.json e la cartella foto", async () => {
  const jpeg = new Uint8Array([255, 216, 255, 224, 0, 16]);   // l'inizio di un JPEG vero
  const zip = await creaArchivio(creaFile([farinata], [primi], ADESSO), new Map([['r-farinata.jpg', jpeg]]));

  // "PK": la firma di uno zip. Se manca non è un archivio.
  assert.equal(zip[0], 0x50);
  assert.equal(zip[1], 0x4b);

  const dentro = await JSZip.loadAsync(zip);
  assert.ok(dentro.file(NOME_JSON));
  assert.ok(dentro.file(`${CARTELLA_FOTO}/r-farinata.jpg`));

  const letto = await leggiArchivio(zip);
  assert.ok(letto);
  assert.deepEqual(letto.file.ricette, [farinata]);
  assert.deepEqual(letto.file.categorie, [primi]);
  assert.deepEqual(letto.foto.get('r-farinata.jpg'), jpeg);
});
```

Poi, in `src/io/importa.test.ts`, sostituisci le due righe

```ts
import { creaFile } from './esporta.ts';
import { importaJson } from './importa.ts';
```

con

```ts
import { creaArchivio, creaFile } from './esporta.ts';
import { importaArchivio, importaJson } from './importa.ts';
```

e aggiungi in fondo al file:

```ts
test("l'archivio zip entra come il JSON, con le categorie e le foto", async () => {
  const db = await dbCon();
  const file = creaFile(
    [ricetta('r1', 'Farinata', '2026-01-01T10:00:00.000Z', { categoriaId: 'c-primi', foto: 'r1.jpg' })],
    [categoria('c-primi', 'Primi', 0, '2026-01-01T10:00:00.000Z')],
    '2026-08-18T09:00:00.000Z',
  );
  const zip = await creaArchivio(file, new Map([['r1.jpg', new Uint8Array([255, 216, 255, 224])]]));

  assert.deepEqual(await importaArchivio(db, zip), {
    ok: true, aggiunte: 1, aggiornate: 0, ignorate: 0,
    categorie: { aggiunte: 1, aggiornate: 0, ignorate: 0 },
  });
  const dentro = await leggiRicetta(db, 'r1');
  assert.equal(dentro?.categoriaId, 'c-primi');
  assert.equal(dentro?.foto, 'r1.jpg');

  // Qualcosa che non è nemmeno uno zip non deve far saltare niente.
  assert.deepEqual(await importaArchivio(db, new Uint8Array([1, 2, 3])),
    { ok: false, motivo: 'file-non-valido' });
});
```

- [ ] **Step 17: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL su tutti e due i file, con
`SyntaxError: The requested module './importa.ts' does not provide an export named 'importaArchivio'`
da `importa.test.ts` e
`SyntaxError: The requested module './importa.ts' does not provide an export named 'leggiArchivio'`
da `esporta.test.ts`. `formato.test.ts` resta verde.

- [ ] **Step 18: Aggiungi la lettura dell'archivio**

In `src/io/importa.ts` sostituisci la riga

```ts
import { ricetteDaEsportare, salvaRicetta } from '../data/ricette.ts';
```

con le due righe

```ts
import { nomeFotoValido } from '../data/foto.ts';
import { ricetteDaEsportare, salvaRicetta } from '../data/ricette.ts';
```

e aggiungi in fondo allo stesso file:

```ts
/**
 * Quello che c'è dentro un archivio, già validato: il ricettario e le foto in
 * byte. null se non è uno zip, se dentro non c'è `ricettario.json`, o se quel
 * JSON non è un ricettario nostro. Non lancia mai.
 */
export async function leggiArchivio(
  zip: Uint8Array,
): Promise<{ file: FileRicettario; foto: Map<string, Uint8Array> } | null> {
  let archivio: JSZip;
  try {
    archivio = await JSZip.loadAsync(zip);
  } catch {
    return null;   // non è nemmeno uno zip
  }

  const voce = archivio.file(NOME_JSON);
  if (voce === null) return null;

  let file: FileRicettario | null;
  try {
    file = validaFile(JSON.parse(await voce.async('string')));
  } catch {
    return null;
  }
  if (file === null) return null;

  const prefisso = `${CARTELLA_FOTO}/`;
  const voci: JSZip.JSZipObject[] = [];
  // forEach di jszip è sincrono: si raccolgono prima le voci e poi si leggono.
  archivio.forEach((percorso, v) => {
    if (!v.dir && percorso.startsWith(prefisso)) voci.push(v);
  });

  const foto = new Map<string, Uint8Array>();
  for (const v of voci) {
    const nome = v.name.slice(prefisso.length);
    // L'archivio arriva da fuori: una voce chiamata `../../qualcosa` non deve
    // poter uscire dalla cartella delle foto. `nomeFotoValido` è lo stesso
    // controllo che usa `percorsoFoto` per decidere cosa mostrare, quindi un
    // nome che non lo passa non si vedrebbe comunque mai.
    if (!nomeFotoValido(nome)) continue;
    foto.set(nome, await v.async('uint8array'));
  }

  return { file, foto };
}

/** Import da archivio zip: ricette, categorie e foto. */
export async function importaArchivio(db: SQLiteDatabase, zip: Uint8Array): Promise<EsitoImport> {
  const letto = await leggiArchivio(zip);
  if (letto === null) return { ok: false, motivo: 'file-non-valido' };
  return applica(db, letto.file, letto.foto);
}
```

- [ ] **Step 19: Esegui i test e il typecheck**

Comando: `npm test && npm run typecheck`

Atteso: PASS, ℹ pass 150, ℹ fail 0 (i 130 lasciati dal Task 14 più i 20 nuovi di
`formato.test.ts`, `esporta.test.ts` e `importa.test.ts`). I 20 sono 6 in
`src/io/formato.test.ts`, 4 in `src/io/esporta.test.ts`, 10 in
`src/io/importa.test.ts`, e restano verdi tutti i test dei task precedenti.
`tsc --noEmit` non stampa niente e esce con codice 0.

- [ ] **Step 20: Commit**

```bash
git add src/io/importa.ts src/io/importa.test.ts src/io/esporta.test.ts
git commit -m "Leggi il ricettario dall'archivio zip, foto comprese"
```

---

### Task 16: Testi dell'interfaccia in italiano e inglese

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta è un'app
iOS e Android che ricalcola le dosi di una ricetta: cambi le porzioni, oppure
dici "ho 250 g di farina invece di 180" e tutti gli altri ingredienti si
adeguano. Il ricettario sta in SQLite sul dispositivo, senza account e senza
rete. L'utente raggruppa le ricette in **categorie che si crea da solo**, ognuna
con un nome libero e un'icona presa da un catalogo che forniamo noi, e ogni
ricetta può avere **una foto**, facoltativa.

Questo task scrive tutto quello che l'app sa dire, nelle due lingue. Non tocca
il dominio, non tocca il database, non disegna niente: produce tre file di testi
e la funzione che li legge.

**Files:**
- Create: `src/i18n/index.ts`
- Create: `src/i18n/it.ts`
- Create: `src/i18n/en.ts`
- Test: `src/i18n/i18n.test.ts`

**Interfaces:**

- **Consumes** — da `src/domain/lingua/index.ts` (Task 1), già scritto:
  ```ts
  export type Lingua = 'it' | 'en';
  export const LINGUE: Lingua[];   // ['it', 'en']
  ```
  Nient'altro. Questo task non tocca il dominio né il database.

- **Consumes** — da `expo-localization`, pacchetto installato nel Task 17. Qui
  viene caricato con un `require` protetto, quindi il task funziona e i test
  passano anche prima che il pacchetto esista. L'unica API usata è:
  ```ts
  getLocales(): { languageCode: string | null; languageTag: string | null }[]
  ```
  Restituisce le lingue preferite dell'utente, in ordine di preferenza.

- **Produces** — da `src/i18n/index.ts`:
  ```ts
  export type Chiave =
    | 'app.nome'
    | 'categorie.cerca' | 'categorie.tutte' | 'categorie.senza'
    | 'categorie.conteggio.zero' | 'categorie.conteggio.una'
    | 'categorie.conteggio.molte'
    | 'categorie.gestisci' | 'categorie.crea' | 'categorie.nome'
    | 'categorie.nome.duplicato' | 'categorie.icona' | 'categorie.rinomina'
    | 'categorie.elimina' | 'categorie.conferma.elimina'
    | 'categorie.sposta.su' | 'categorie.sposta.giu'
    | 'categorie.vuoto.titolo' | 'categorie.vuoto.invito'
    | 'elenco.cerca' | 'elenco.nuova'
    | 'elenco.titolo.tutte' | 'elenco.titolo.senza'
    | 'elenco.vuoto.titolo' | 'elenco.vuoto.invito'
    | 'dosatore.porzioni' | 'dosatore.originali' | 'dosatore.modifica'
    | 'dosatore.fascia.porzioni' | 'dosatore.fascia.ingrediente'
    | 'modifica.titolo' | 'modifica.descrizione' | 'modifica.porzioni'
    | 'modifica.categoria' | 'modifica.categoria.nessuna' | 'modifica.ingredienti'
    | 'modifica.incolla' | 'modifica.salva' | 'modifica.annulla' | 'modifica.elimina'
    | 'modifica.conferma.elimina' | 'modifica.sezione.aggiungi' | 'modifica.riga.incerta'
    | 'foto.aggiungi' | 'foto.scatta' | 'foto.scegli' | 'foto.togli'
    | 'foto.permesso.fotocamera' | 'foto.permesso.libreria'
    | 'qb'
    | 'io.esporta' | 'io.importa' | 'io.export.pronto' | 'io.import.ok'
    | 'io.import.rifiutato'
    | 'errore.db';

  export type Testi = Record<Chiave, string>;

  /**
   * Testo della chiave nella lingua chiesta, coi segnaposto sostituiti dai
   * valori passati. **Lancia** se un segnaposto resta senza valore.
   */
  export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;

  /** 'it-IT' -> 'it'. null se il tag non è una lingua che conosciamo. */
  export function linguaDaTag(tag: string | null | undefined): Lingua | null;

  /** Lingua di sistema, con ripiego su 'it'. */
  export function linguaDispositivo(): Lingua;
  ```
- **Produces** — da `src/i18n/it.ts`: `export const IT: Testi`.
- **Produces** — da `src/i18n/en.ts`: `export const EN: Testi`.

**L'unione `Chiave` è chiusa e questo task è l'unico posto dove si allarga.** Chi
scrive una schermata usa i nomi che stanno qui e non ne inventa altri: una chiave
inventata non compila. I nomi su cui è facile sbagliare, perché nascono qui e
vengono usati altrove:

| Chiave | Chi la usa | A cosa serve |
|---|---|---|
| `categorie.vuoto.titolo` | Task 18 | titolo dello stato vuoto della schermata Categorie |
| `categorie.vuoto.invito` | Task 18 | il gesto offerto lì sotto, «Scrivi la prima ricetta» |
| `elenco.titolo.tutte` | Task 19 | intestazione dell'elenco quando il filtro è `tutte` |
| `elenco.titolo.senza` | Task 19 | intestazione dell'elenco quando il filtro è `senza` |
| `dosatore.modifica` | Task 20 | etichetta di accessibilità della matita in alto a destra |

`categorie.vuoto.invito` si scrive con la **o**: `categorie.vuote.invito` non
esiste. `dosatore.modifica` c'è perché senza la matita nel dosatore non
esisterebbe nessun modo di correggere una ricetta già scritta.

Le chiavi con segnaposto e i nomi esatti dei segnaposto, perché chi chiama `t()`
da un'altra schermata deve poterli scrivere senza aprire questo file:

```
categorie.conteggio.molte     {conteggio}
categorie.conferma.elimina    {nome}
dosatore.fascia.porzioni      {porzioni}
dosatore.fascia.ingrediente   {quantita}, {nome}
modifica.conferma.elimina     {titolo}
io.export.pronto              {ricette}, {foto}
io.import.ok                  {aggiunte}, {aggiornate}, {ignorate}
io.import.rifiutato           {motivo}
```

Sono otto, e il numero è scritto dentro un test: aggiungere una chiave con un
segnaposto senza accorgersene è esattamente il modo in cui si arriva a leggere
`{porzioni}` a schermo.

Perché due dizionari e un test che li confronta: una traduzione dimenticata deve
far fallire `npm test`, non comparire a schermo davanti all'utente. Il tipo
`Testi = Record<Chiave, string>` la fa già fallire in `npm run typecheck`, ma i
test girano senza controllo dei tipi, quindi il confronto a runtime serve lo
stesso.

Perché `t()` lancia invece di lasciare il segnaposto in chiaro: un segnaposto
senza valore è un errore del chiamante, e degradare in silenzio significa
leggere «riscalata per {porzioni} porzioni» mentre si cucina, senza che nessun
test se ne sia accorto. Un'eccezione la si vede la prima volta che si prova la
schermata.

Perché il conteggio delle ricette **ha tre chiavi** pur essendo un numero e
basta: `categorie.conteggio.zero`, `categorie.conteggio.una` e
`categorie.conteggio.molte`. A schermo accanto a ogni voce della schermata
Categorie c'è solo la cifra, ed è giusto così. Ma l'etichetta che legge il lettore
di schermo non può essere «Dolci, 12»: deve dire «Dolci, 12 ricette», e in
italiano dodici ricette, una ricetta e nessuna ricetta si dicono in tre modi
diversi. Le tre chiavi servono lì, e solo lì.

Non è la stessa cosa del testo visibile, ed è il motivo per cui una prima stesura
di questo task le aveva tolte pensando fossero peso morto: chi guarda la
schermata non le vede mai. Chi la ascolta sì.

Perché `elenco.titolo.tutte` ed `elenco.titolo.senza` ripetono il testo di
`categorie.tutte` e `categorie.senza`: sono due cose diverse che oggi si dicono
uguali. Le prime due sono l'intestazione della schermata Elenco, le altre due
sono le voci toccabili della schermata Categorie. Tenerle separate costa due
righe di dizionario e permette di accorciare l'intestazione senza accorciare la
voce, che è la prima cosa che si vuole fare la prima volta che si guarda l'app
su un telefono stretto.

Perché la conferma di eliminazione della categoria è una frase lunga: cancellare
una categoria **non cancella le sue ricette**, che perdono il legame e finiscono
in «Senza categoria». Se la conferma non lo dice, l'utente non cancella mai
niente per paura, oppure cancella e passa dieci minuti a cercare le ricette. La
promessa è verificata da un test qui e resa vera dal repository nel Task 12.

- [ ] **Step 1: Scrivi i test che falliscono, dizionari e sostituzione**

Crea `src/i18n/i18n.test.ts`:

```ts
/**
 * Test dei testi dell'interfaccia.
 *
 * Il grosso del lavoro qui è impedire che una traduzione dimenticata o un
 * segnaposto senza valore arrivino fino allo schermo: le due lingue devono
 * avere le stesse chiavi e gli stessi segnaposto, e `t()` deve lanciare quando
 * un segnaposto resta scoperto.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { LINGUE } from '../domain/lingua/index.ts';
import { IT } from './it.ts';
import { EN } from './en.ts';
import type { Chiave } from './index.ts';
import { t } from './index.ts';

test('italiano e inglese hanno esattamente le stesse chiavi', () => {
  assert.deepEqual(Object.keys(EN).sort(), Object.keys(IT).sort());
});

test('nessun testo è vuoto', () => {
  for (const [chiave, testo] of [...Object.entries(IT), ...Object.entries(EN)]) {
    assert.ok(testo.trim().length > 0, `testo vuoto per la chiave ${chiave}`);
  }
});

test('gli stessi segnaposto in tutte e due le lingue', () => {
  const segnaposto = (s: string) => (s.match(/\{\w+\}/g) ?? []).sort();
  for (const chiave of Object.keys(IT) as Chiave[]) {
    assert.deepEqual(
      segnaposto(EN[chiave]),
      segnaposto(IT[chiave]),
      `segnaposto diversi fra le lingue per la chiave ${chiave}`,
    );
  }
});

test('t sostituisce i segnaposto con i valori passati', () => {
  assert.equal(
    t('dosatore.fascia.porzioni', 'it', { porzioni: '6' }),
    'riscalata per 6 porzioni',
  );
  assert.equal(
    t('dosatore.fascia.porzioni', 'en', { porzioni: '6' }),
    'scaled for 6 servings',
  );
  assert.equal(
    t('dosatore.fascia.ingrediente', 'it', { quantita: '250 g', nome: 'farina' }),
    'riscalata su 250 g di farina',
  );
  assert.equal(
    t('dosatore.fascia.ingrediente', 'en', { quantita: '250 g', nome: 'flour' }),
    'scaled to 250 g of flour',
  );
  assert.equal(
    t('modifica.conferma.elimina', 'it', { titolo: 'Farinata' }),
    'Vuoi eliminare «Farinata»?',
  );
  assert.equal(
    t('io.export.pronto', 'it', { ricette: '12', foto: '5' }),
    'Archivio pronto: 12 ricette e 5 foto.',
  );
});

test('ogni chiave con segnaposto lancia se non le si passano i valori', () => {
  // Meglio accorgersene in sviluppo che leggere "{porzioni}" mentre si cucina.
  let provate = 0;
  for (const chiave of Object.keys(IT) as Chiave[]) {
    if (!/\{\w+\}/.test(IT[chiave])) continue;
    provate++;
    for (const lingua of LINGUE) {
      assert.throws(
        () => t(chiave, lingua),
        /segnaposto senza valore/,
        `${chiave} in ${lingua} non ha lanciato`,
      );
    }
  }
  assert.equal(provate, 8, 'le chiavi con segnaposto sono otto');
});

test('lancia anche quando manca un solo valore su due', () => {
  assert.throws(
    () => t('dosatore.fascia.ingrediente', 'it', { quantita: '250 g' }),
    /\{nome\}/,
  );
});

test('le due lingue restituiscono testi davvero diversi', () => {
  assert.equal(t('modifica.salva', 'it'), 'Salva');
  assert.equal(t('modifica.salva', 'en'), 'Save');
  assert.equal(t('qb', 'it'), 'q.b.');
  assert.equal(t('qb', 'en'), 'to taste');
  assert.equal(t('categorie.tutte', 'it'), 'Tutte le ricette');
  assert.equal(t('categorie.tutte', 'en'), 'All recipes');
});

test('la conferma di eliminazione della categoria promette che le ricette restano', () => {
  // Non è pignoleria linguistica: è la promessa che il Task 12 mantiene
  // azzerando categoria_id invece di cancellare le righe. Se il testo smette di
  // dirlo, l'utente non ha più modo di saperlo prima di toccare "Elimina".
  const italiano = t('categorie.conferma.elimina', 'it', { nome: 'Dolci' });
  assert.ok(italiano.includes('Dolci'), 'manca il nome della categoria');
  assert.ok(
    italiano.includes('non vengono cancellate'),
    'la conferma italiana non dice che le ricette restano',
  );
  assert.ok(
    italiano.includes(IT['categorie.senza']),
    'la conferma italiana non dice dove finiscono le ricette',
  );

  const inglese = t('categorie.conferma.elimina', 'en', { nome: 'Desserts' });
  assert.ok(inglese.includes('Desserts'), 'manca il nome della categoria');
  assert.ok(
    inglese.includes('are not deleted'),
    'la conferma inglese non dice che le ricette restano',
  );
  assert.ok(
    inglese.includes(EN['categorie.senza']),
    'la conferma inglese non dice dove finiscono le ricette',
  );
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL. `node --test` non riesce a caricare `src/i18n/i18n.test.ts` e
riporta `Cannot find module` (`ERR_MODULE_NOT_FOUND`) su `src/i18n/it.ts`: il
file di test c'è, i dizionari no.

- [ ] **Step 3: Implementa i due dizionari e `t()`**

Crea `src/i18n/it.ts`:

```ts
/**
 * Testi dell'interfaccia in italiano.
 *
 * I segnaposto sono scritti fra graffe e hanno nomi parlanti, uguali in tutte e
 * due le lingue: {porzioni}, {quantita}, {nome}, {titolo}, {ricette}, {foto},
 * {motivo}, {aggiunte}, {aggiornate}, {ignorate}. Chi chiama
 * `t()` non deve sapere in che lingua sta scrivendo, e non deve indovinare se il
 * segnaposto si chiama {n} o {porzioni}.
 */
import type { Testi } from './index.ts';

export const IT: Testi = {
  'app.nome': 'Quanto Basta',

  // La ricerca della schermata principale cerca fra tutte le ricette,
  // ignorando le categorie: chi sa come si chiama quello che cerca non deve
  // passare da nessuna parte.
  'categorie.cerca': 'Cerca in tutte le ricette',
  'categorie.tutte': 'Tutte le ricette',
  'categorie.senza': 'Senza categoria',
  // Solo per il lettore di schermo: a video c'è la cifra e basta.
  'categorie.conteggio.zero': 'nessuna ricetta',
  'categorie.conteggio.una': 'una ricetta',
  'categorie.conteggio.molte': '{conteggio} ricette',
  'categorie.gestisci': 'Gestisci le categorie',
  'categorie.crea': 'Nuova categoria',
  'categorie.nome': 'Nome della categoria',
  'categorie.nome.duplicato': 'Esiste già una categoria con questo nome.',
  'categorie.icona': 'Icona',
  'categorie.rinomina': 'Rinomina',
  'categorie.elimina': 'Elimina categoria',
  // Dice per intero cosa succede: "elimina categoria" fa paura, e la paura si
  // toglie solo spiegando che le ricette non si toccano.
  'categorie.conferma.elimina':
    'Vuoi eliminare la categoria «{nome}»? Le ricette che contiene non vengono cancellate: finiscono in «Senza categoria».',
  'categorie.sposta.su': 'Sposta in alto',
  'categorie.sposta.giu': 'Sposta in basso',
  // Ricettario vuoto: non si dice "nessuna categoria", che a chi ha appena
  // installato l'app non serve a niente. Si dice che non c'è ancora nulla e si
  // offre l'unico gesto sensato, scrivere una ricetta.
  'categorie.vuoto.titolo': 'Il ricettario è vuoto',
  'categorie.vuoto.invito': 'Scrivi la prima ricetta',

  'elenco.cerca': 'Cerca una ricetta',
  'elenco.nuova': 'Nuova ricetta',
  // Intestazione della schermata Elenco per le due viste che non sono una
  // categoria. Oggi dicono la stessa cosa delle voci `categorie.tutte` e
  // `categorie.senza`, ma sono chiavi diverse perché sono posti diversi.
  'elenco.titolo.tutte': 'Tutte le ricette',
  'elenco.titolo.senza': 'Senza categoria',
  // La schermata vuota invita a incollare la prima ricetta invece di dire
  // "nessuna ricetta": è la prima cosa che vede chi installa l'app.
  'elenco.vuoto.titolo': 'Qui non c\'è ancora niente',
  'elenco.vuoto.invito': 'Incolla la tua prima ricetta: titolo e ingredienti, uno per riga.',

  'dosatore.porzioni': 'Porzioni',
  'dosatore.originali': 'Dosi originali',
  // Etichetta di accessibilità della matita in alto a destra nel dosatore: da
  // lì si torna a correggere la ricetta che si sta cucinando.
  'dosatore.modifica': 'Modifica la ricetta',
  'dosatore.fascia.porzioni': 'riscalata per {porzioni} porzioni',
  'dosatore.fascia.ingrediente': 'riscalata su {quantita} di {nome}',

  'modifica.titolo': 'Titolo',
  'modifica.descrizione': 'Descrizione',
  'modifica.porzioni': 'Porzioni (facoltative)',
  'modifica.categoria': 'Categoria',
  'modifica.categoria.nessuna': 'Nessuna categoria',
  'modifica.ingredienti': 'Ingredienti',
  'modifica.incolla': 'Incolla qui gli ingredienti, uno per riga',
  'modifica.salva': 'Salva',
  'modifica.annulla': 'Annulla',
  'modifica.elimina': 'Elimina ricetta',
  'modifica.conferma.elimina': 'Vuoi eliminare «{titolo}»?',
  'modifica.sezione.aggiungi': 'Aggiungi sezione',
  'modifica.riga.incerta': 'Da controllare',

  'foto.aggiungi': 'Aggiungi una foto',
  'foto.scatta': 'Scatta una foto',
  'foto.scegli': 'Scegli dalla libreria',
  'foto.togli': 'Togli la foto',
  // I permessi si chiedono al momento dell'uso, non all'avvio: questi due testi
  // compaiono solo dopo un rifiuto, e devono dire dove si rimedia.
  'foto.permesso.fotocamera': 'Per scattare la foto serve il permesso di usare la fotocamera. Puoi darlo dalle impostazioni del telefono.',
  'foto.permesso.libreria': 'Per scegliere una foto serve il permesso di leggere la libreria. Puoi darlo dalle impostazioni del telefono.',

  'qb': 'q.b.',

  'io.esporta': 'Esporta ricettario e foto',
  'io.importa': 'Importa ricettario',
  'io.export.pronto': 'Archivio pronto: {ricette} ricette e {foto} foto.',
  'io.import.ok': 'Aggiunte {aggiunte}, aggiornate {aggiornate}, ignorate {ignorate}.',
  'io.import.rifiutato': 'File non importato: {motivo}',

  'errore.db': 'Non riesco ad aprire il ricettario. Le ricette non sono state toccate: chiudi e riapri l\'app.',
};
```

Crea `src/i18n/en.ts`:

```ts
/**
 * Testi dell'interfaccia in inglese.
 *
 * Stesse chiavi e stessi segnaposto dell'italiano: i nomi dei segnaposto
 * restano italiani perché sono parte del codice, non del testo.
 */
import type { Testi } from './index.ts';

export const EN: Testi = {
  'app.nome': 'Quanto Basta',

  'categorie.cerca': 'Search all recipes',
  'categorie.tutte': 'All recipes',
  'categorie.senza': 'Uncategorized',
  // Screen reader only: the visible label is just the number.
  'categorie.conteggio.zero': 'no recipes',
  'categorie.conteggio.una': 'one recipe',
  'categorie.conteggio.molte': '{conteggio} recipes',
  'categorie.gestisci': 'Manage categories',
  'categorie.crea': 'New category',
  'categorie.nome': 'Category name',
  'categorie.nome.duplicato': 'A category with this name already exists.',
  'categorie.icona': 'Icon',
  'categorie.rinomina': 'Rename',
  'categorie.elimina': 'Delete category',
  'categorie.conferma.elimina':
    'Delete the “{nome}” category? The recipes inside are not deleted: they move to “Uncategorized”.',
  'categorie.sposta.su': 'Move up',
  'categorie.sposta.giu': 'Move down',
  'categorie.vuoto.titolo': 'Your recipe book is empty',
  'categorie.vuoto.invito': 'Write your first recipe',

  'elenco.cerca': 'Search recipes',
  'elenco.nuova': 'New recipe',
  'elenco.titolo.tutte': 'All recipes',
  'elenco.titolo.senza': 'Uncategorized',
  'elenco.vuoto.titolo': 'Nothing here yet',
  'elenco.vuoto.invito': 'Paste your first recipe: a title and the ingredients, one per line.',

  'dosatore.porzioni': 'Servings',
  'dosatore.originali': 'Original amounts',
  'dosatore.modifica': 'Edit recipe',
  'dosatore.fascia.porzioni': 'scaled for {porzioni} servings',
  'dosatore.fascia.ingrediente': 'scaled to {quantita} of {nome}',

  'modifica.titolo': 'Title',
  'modifica.descrizione': 'Description',
  'modifica.porzioni': 'Servings (optional)',
  'modifica.categoria': 'Category',
  'modifica.categoria.nessuna': 'No category',
  'modifica.ingredienti': 'Ingredients',
  'modifica.incolla': 'Paste the ingredients here, one per line',
  'modifica.salva': 'Save',
  'modifica.annulla': 'Cancel',
  'modifica.elimina': 'Delete recipe',
  'modifica.conferma.elimina': 'Delete “{titolo}”?',
  'modifica.sezione.aggiungi': 'Add section',
  'modifica.riga.incerta': 'Needs a check',

  'foto.aggiungi': 'Add a photo',
  'foto.scatta': 'Take a photo',
  'foto.scegli': 'Choose from library',
  'foto.togli': 'Remove photo',
  'foto.permesso.fotocamera': 'Taking a photo needs permission to use the camera. You can grant it in your phone settings.',
  'foto.permesso.libreria': 'Choosing a photo needs permission to read your library. You can grant it in your phone settings.',

  'qb': 'to taste',

  'io.esporta': 'Export recipes and photos',
  'io.importa': 'Import recipes',
  'io.export.pronto': 'Archive ready: {ricette} recipes and {foto} photos.',
  'io.import.ok': 'Added {aggiunte}, updated {aggiornate}, skipped {ignorate}.',
  'io.import.rifiutato': 'File not imported: {motivo}',

  'errore.db': 'I can\'t open your recipe book. Nothing has been changed: close the app and open it again.',
};
```

Crea `src/i18n/index.ts`:

```ts
/**
 * Testi dell'interfaccia nelle due lingue.
 *
 * `Chiave` è l'elenco chiuso delle cose che l'app sa dire. Ogni dizionario è
 * tipato `Record<Chiave, string>`, quindi una chiave dimenticata è un errore di
 * compilazione; il test confronta anche gli insiemi a runtime, perché i test
 * girano senza controllo dei tipi.
 */
import type { Lingua } from '../domain/lingua/index.ts';
import { IT } from './it.ts';
import { EN } from './en.ts';

export type Chiave =
  | 'app.nome'
  | 'categorie.cerca' | 'categorie.tutte' | 'categorie.senza'
  | 'categorie.conteggio.zero' | 'categorie.conteggio.una'
  | 'categorie.conteggio.molte'
  | 'categorie.gestisci' | 'categorie.crea' | 'categorie.nome'
  | 'categorie.nome.duplicato' | 'categorie.icona' | 'categorie.rinomina'
  | 'categorie.elimina' | 'categorie.conferma.elimina'
  | 'categorie.sposta.su' | 'categorie.sposta.giu'
  | 'categorie.vuoto.titolo' | 'categorie.vuoto.invito'
  | 'elenco.cerca' | 'elenco.nuova'
  | 'elenco.titolo.tutte' | 'elenco.titolo.senza'
  | 'elenco.vuoto.titolo' | 'elenco.vuoto.invito'
  | 'dosatore.porzioni' | 'dosatore.originali' | 'dosatore.modifica'
  | 'dosatore.fascia.porzioni' | 'dosatore.fascia.ingrediente'
  | 'modifica.titolo' | 'modifica.descrizione' | 'modifica.porzioni'
  | 'modifica.categoria' | 'modifica.categoria.nessuna' | 'modifica.ingredienti'
  | 'modifica.incolla' | 'modifica.salva' | 'modifica.annulla' | 'modifica.elimina'
  | 'modifica.conferma.elimina' | 'modifica.sezione.aggiungi' | 'modifica.riga.incerta'
  | 'foto.aggiungi' | 'foto.scatta' | 'foto.scegli' | 'foto.togli'
  | 'foto.permesso.fotocamera' | 'foto.permesso.libreria'
  | 'qb'
  | 'io.esporta' | 'io.importa' | 'io.export.pronto' | 'io.import.ok'
  | 'io.import.rifiutato'
  | 'errore.db';

export type Testi = Record<Chiave, string>;

const TESTI: Record<Lingua, Testi> = { it: IT, en: EN };

/**
 * Testo della chiave nella lingua chiesta. I segnaposto {nome} vengono
 * sostituiti coi valori passati; se ne resta anche uno solo senza valore la
 * funzione lancia, invece di mandare a schermo una frase con un buco dentro.
 */
export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string {
  const grezzo = TESTI[lingua][chiave];
  const reso = grezzo.replace(/\{(\w+)\}/g, (intero: string, nome: string) =>
    valori && nome in valori ? valori[nome] : intero);
  const rimasti = reso.match(/\{\w+\}/g);
  if (rimasti) {
    // Meglio accorgersene in sviluppo che leggere "{porzioni}" mentre si cucina.
    throw new Error(`Testo "${chiave}": segnaposto senza valore ${rimasti.join(', ')}`);
  }
  return reso;
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, nessun fallimento. Gli otto test di `src/i18n/i18n.test.ts` sono
verdi insieme a tutti quelli dei task precedenti.

- [ ] **Step 5: Scrivi i test che falliscono, lingua del dispositivo**

In `src/i18n/i18n.test.ts` sostituisci la riga

```ts
import { t } from './index.ts';
```

con

```ts
import { linguaDaTag, linguaDispositivo, t } from './index.ts';
```

e aggiungi in fondo al file:

```ts
test('linguaDaTag riconosce le lingue che conosciamo', () => {
  assert.equal(linguaDaTag('it-IT'), 'it');
  assert.equal(linguaDaTag('it'), 'it');
  assert.equal(linguaDaTag('IT'), 'it');
  assert.equal(linguaDaTag('en-US'), 'en');
  assert.equal(linguaDaTag('en_GB'), 'en');
});

test('linguaDaTag non inventa lingue', () => {
  assert.equal(linguaDaTag('de-DE'), null);
  assert.equal(linguaDaTag(''), null);
  assert.equal(linguaDaTag(null), null);
  assert.equal(linguaDaTag(undefined), null);
  // Comincia per "it" ma non è italiano: si confronta la sottoetichetta intera.
  assert.equal(linguaDaTag('iterativo'), null);
});

test('linguaDispositivo ripiega su italiano quando expo-localization non c\'è', () => {
  // Nei test gira Node: il modulo nativo non è caricabile e deve valere il
  // ripiego, senza eccezioni.
  assert.equal(linguaDispositivo(), 'it');
});
```

- [ ] **Step 6: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `SyntaxError: The requested module './index.ts' does not
provide an export named 'linguaDaTag'`.

- [ ] **Step 7: Implementa la lettura della lingua di sistema**

In `src/i18n/index.ts` aggiungi `LINGUE` all'import dal dominio, cioè sostituisci

```ts
import type { Lingua } from '../domain/lingua/index.ts';
```

con

```ts
import type { Lingua } from '../domain/lingua/index.ts';
import { LINGUE } from '../domain/lingua/index.ts';
```

e aggiungi in fondo al file:

```ts
/**
 * Lingua da un tag BCP-47: 'it-IT' -> 'it'. Si confronta la sottoetichetta
 * primaria per intero, non il prefisso, altrimenti 'iterativo' passerebbe per
 * italiano.
 */
export function linguaDaTag(tag: string | null | undefined): Lingua | null {
  if (!tag) return null;
  const primaria = tag.split(/[-_]/)[0].toLowerCase();
  return (LINGUE as string[]).includes(primaria) ? (primaria as Lingua) : null;
}

/**
 * Prima lingua di sistema fra quelle che conosciamo, con ripiego sull'italiano.
 *
 * expo-localization è un modulo nativo: nei test gira Node, dove non si può
 * caricare. Il `require` protetto tiene insieme i due ambienti; il `typeof`
 * serve perché in un modulo ES di Node `require` non esiste proprio.
 *
 * ponytail: require al posto di un import statico solo per questo motivo. Se un
 * giorno i test girassero in un runtime React Native, torna un import normale.
 */
function linguaDaSistema(): Lingua | null {
  try {
    if (typeof require !== 'function') return null;
    const localization = require('expo-localization') as {
      getLocales?: () => { languageCode?: string | null; languageTag?: string | null }[];
    };
    const locali = localization.getLocales?.() ?? [];
    for (const locale of locali) {
      const lingua = linguaDaTag(locale.languageCode ?? locale.languageTag);
      if (lingua) return lingua;
    }
    return null;
  } catch {
    return null;
  }
}

export function linguaDispositivo(): Lingua {
  return linguaDaSistema() ?? 'it';
}
```

- [ ] **Step 8: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, ℹ pass 161, ℹ fail 0 (i 150 lasciati dal Task 15 più gli 11 nuovi
di `i18n.test.ts`). Gli undici test di `src/i18n/i18n.test.ts` sono verdi.

- [ ] **Step 9: Controlla i tipi**

Comando: `npm run typecheck`
Atteso: nessun errore. È il controllo che dice se un dizionario ha una chiave in
meno: `Record<Chiave, string>` non lo perdona.

- [ ] **Step 10: Commit**

```bash
git add src/i18n/index.ts src/i18n/it.ts src/i18n/en.ts src/i18n/i18n.test.ts
git commit -m "Aggiungi i testi dell'interfaccia in italiano e inglese"
```

---

---

### Task 17: Impalcatura dell'app, navigazione e apertura del database

**Cosa fa questo task, per chi non conosce il progetto.** Finora esistono il
motore di calcolo (`src/domain`), il database (`src/data`), l'export e l'import
(`src/io`) e i testi (`src/i18n`), ma non esiste nessuna app: `App.tsx` è ancora
quello generato da Expo. Questo task installa le dipendenze native, apre il
database all'avvio, monta la navigazione e crea cinque schermate vuote, una per
rotta. Alla fine l'app si avvia sul telefono, mostra la schermata delle
categorie e naviga fra tutte e cinque. Quello che c'è dentro le schermate arriva
nei task successivi.

La schermata iniziale è **Categorie**, non l'elenco delle ricette: l'utente
raggruppa le ricette in categorie che si crea da solo, e la prima cosa che vede
aprendo l'app sono quelle, con sopra la ricerca globale e in cima le due voci
fisse «Tutte le ricette» e «Senza categoria».

**Files:**
- Modify: `package.json` (e `package-lock.json`, scritto da npm)
- Modify: `src/domain/id.ts`
- Modify: `App.tsx` (alla radice del progetto)
- Create: `src/ui/navigazione.ts`
- Create: `src/ui/contesto.ts`
- Create: `src/ui/App.tsx`
- Create: `src/ui/schermate/Categorie.tsx` (guscio provvisorio, il Task 18 lo sostituisce per intero)
- Create: `src/ui/schermate/Elenco.tsx` (guscio provvisorio, il Task 19 lo sostituisce per intero)
- Create: `src/ui/schermate/Dosatore.tsx` (guscio provvisorio, il Task 20 lo sostituisce per intero)
- Create: `src/ui/schermate/GestioneCategorie.tsx` (guscio provvisorio, il Task 21 lo sostituisce per intero)
- Create: `src/ui/schermate/Modifica.tsx` (guscio provvisorio, il Task 22 lo sostituisce per intero)
- Test: `src/domain/id.test.ts`

**Interfaces:**

- **Consumes** — da `src/data/db.ts` (Task 10), già scritto:
  ```ts
  import type { SQLiteDatabase } from 'expo-sqlite';
  /** Nessun campo nostro: la causa viaggia nel campo standard `cause` di Error. */
  export class ErroreDatabase extends Error {}
  /** Apre il database, applica le migrazioni. Lancia ErroreDatabase se non ci riesce. */
  export async function apriDb(nome?: string): Promise<SQLiteDatabase>;
  ```
- **Consumes** — da `src/data/ricette.ts` (Task 11), già scritto. Serve un solo
  tipo, importato con `import type` perché la navigazione non chiama il
  repository, lo tipa soltanto:
  ```ts
  export type FiltroElenco =
    | { tipo: 'tutte' }
    | { tipo: 'categoria'; id: string }
    | { tipo: 'senza' };
  ```
- **Consumes** — da `src/i18n/index.ts` (Task 16), già scritto:
  ```ts
  export type Chiave = 'app.nome' | 'categorie.tutte' | /* … */ | 'errore.db';
  /** Lancia se un segnaposto resta senza valore. */
  export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;
  export function linguaDispositivo(): Lingua;
  ```
  Le chiavi usate qui sono: `app.nome`, `categorie.tutte`, `categorie.senza`,
  `categorie.gestisci`, `categorie.crea`, `categorie.vuoto.titolo`,
  `categorie.vuoto.invito`, `elenco.nuova`, `elenco.vuoto.titolo`,
  `elenco.vuoto.invito`, `modifica.annulla`, `errore.db`. Nessuna ha segnaposto,
  quindi si chiamano tutte senza `valori`. Attenzione alla `o` di
  `categorie.vuoto.invito`: non esiste nessuna `categorie.vuote.invito`.
- **Consumes** — da `src/domain/lingua/index.ts` (Task 1), già scritto:
  ```ts
  export type Lingua = 'it' | 'en';
  ```
- **Consumes** — `src/domain/id.ts` esporta oggi `export function newId(): string`,
  usata da `src/domain/migrate.ts` e dai repository. La firma **non cambia**:
  cambia solo cosa c'è dentro.

- **Produces** — da `src/domain/id.ts`:
  ```ts
  export function newId(): string;        // uuid v4
  export function uuidCasuale(): string;  // il ripiego, esportato per poterlo provare
  ```
- **Produces** — da `src/ui/navigazione.ts`:
  ```ts
  export type ParametriNav = {
    Categorie: undefined;
    Elenco: { filtro: FiltroElenco };
    Dosatore: { ricettaId: string };
    Modifica: { ricettaId: string | null };   // null = ricetta nuova
    GestioneCategorie: undefined;
  };
  export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<ParametriNav, N>;
  ```
- **Produces** — da `src/ui/contesto.ts`:
  ```ts
  export interface StatoApp {
    db: SQLiteDatabase;
    lingua: Lingua;
    testo: (chiave: Chiave, valori?: Record<string, string>) => string;
  }
  export const ContestoApp: React.Context<StatoApp | null>;
  /** Lancia se usata fuori dal Provider. */
  export function useApp(): StatoApp;
  ```
- **Produces** — `src/ui/App.tsx` esporta **soltanto** il componente `App` come
  default. Non esporta tipi: i tipi delle rotte stanno in
  `src/ui/navigazione.ts`, perché le schermate li importano e `App.tsx` importa
  le schermate.
- **Produces** — `src/ui/schermate/Categorie.tsx`, `Elenco.tsx`, `Dosatore.tsx`,
  `GestioneCategorie.tsx`, `Modifica.tsx` esportano di default un componente
  ciascuno, tipato rispettivamente `PropsSchermata<'Categorie'>`,
  `PropsSchermata<'Elenco'>`, `PropsSchermata<'Dosatore'>`,
  `PropsSchermata<'GestioneCategorie'>`, `PropsSchermata<'Modifica'>`. Sono
  gusci provvisori: i Task 18, 19, 20, 21 e 22 sostituiscono il contenuto dei
  cinque file mantenendo esattamente queste firme.

**Regole d'interfaccia fissate qui e valide per tutte le schermate del progetto:**

1. I tipi delle rotte si importano da `src/ui/navigazione.ts`
   (`import type { PropsSchermata } from '../navigazione.ts';`). Non esiste
   nessun `ParametriStack`.
2. Il database, la lingua e la funzione `testo()` si prendono da `useApp()` di
   `src/ui/contesto.ts`. **Non si monta nessun `SQLiteProvider`** e non si chiama
   mai `useSQLiteContext()`. Il motivo è la sezione 9 della spec, «database che
   non si apre»: vogliamo essere noi a intercettare l'errore di `apriDb()` e a
   mostrare `errore.db`, e il provider di expo-sqlite lo nasconde dietro un throw
   che non controlliamo.
3. Nessuna schermata riceve `db` o `lingua` come props: tutte si registrano con
   `<Stack.Screen component={...} />`, senza funzioni figlie.
4. Nelle schermate si scrive `testo(chiave, valori?)`, non `t(chiave, lingua,
   valori)`: `testo` è già legata alla lingua scelta all'avvio.
5. Il parametro della rotta Modifica si chiama `ricettaId`, e `null` significa
   ricetta nuova. Non esiste nessun parametro `id`.
6. La rotta Elenco riceve **sempre** un `filtro`, anche quando è
   `{ tipo: 'tutte' }`. Non esiste un elenco senza filtro: la schermata sa cosa
   sta mostrando perché glielo dice chi la apre.

- [ ] **Step 1: Installa le dipendenze native**

Comando (una riga sola):

```bash
npx expo install expo-sqlite expo-crypto expo-keep-awake expo-file-system expo-sharing expo-document-picker expo-localization expo-image-picker expo-image-manipulator @react-navigation/native @react-navigation/native-stack react-native-screens react-native-safe-area-context
```

`npx expo install` sceglie le versioni compatibili con la versione di Expo del
progetto (SDK 57), cosa che `npm install` non fa.

Atteso: `package.json` elenca i tredici pacchetti fra le `dependencies`, accanto
a `expo`, `react`, `react-native`, `expo-status-bar` e `@expo/vector-icons` che
c'erano già.

- [ ] **Step 2: Installa jszip**

Comando:

```bash
npm install jszip
```

jszip non è un pacchetto Expo e non ha una versione legata all'SDK: si installa
con npm. È JavaScript puro, senza moduli nativi, quindi non tocca le build EAS,
e porta con sé i propri tipi TypeScript (`index.d.ts`), quindi non serve nessun
`@types/jszip`.

Atteso: `package.json` elenca `jszip` fra le `dependencies`.

- [ ] **Step 3: Verifica che l'installazione non abbia rotto niente**

Comandi:

```bash
npm ls expo-sqlite expo-crypto expo-localization expo-image-picker expo-image-manipulator jszip @react-navigation/native-stack
npm test
```

Atteso: `npm ls` stampa le sette versioni senza righe `UNMET DEPENDENCY`, e
`npm test` è ancora PASS su tutti i test esistenti: fin qui non è stata toccata
una riga di codice nostro.

- [ ] **Step 4: Verifica che i permessi delle foto siano già dichiarati**

Questo task **non tocca `app.json`**. I permessi della fotocamera e della
libreria li ha già scritti il Task 14, che è quello che introduce
`expo-image-picker`: il blocco `plugins` con le sue frasi sta lì, in un posto
solo. Qui si controlla soltanto che ci sia e sia intatto, perché `expo install`
appena eseguito rimaneggia `package.json` e vale la pena accorgersi subito se
qualcosa ha rovinato la configurazione.

Comando:

```bash
node -p "JSON.stringify(require('./app.json').expo.plugins)"
```

Atteso, su una riga sola, identico a quello dichiarato dal Task 14:

```
[["expo-image-picker",{"photosPermission":"Quanto Basta accede alle foto per usarne una come immagine di una ricetta.","cameraPermission":"Quanto Basta usa la fotocamera per scattare l'immagine di una ricetta.","microphonePermission":false}]]
```

Se il comando stampa `undefined` il blocco `plugins` non c'è: torna al Task 14
e aggiungilo lì, non qui. Se il JSON è malformato il comando fallisce invece di
stampare, che è il motivo per cui si controlla adesso e non alla prima build.

- [ ] **Step 5: Scrivi il test che fallisce, id**

Crea `src/domain/id.test.ts`:

```ts
/**
 * Test del generatore di id.
 *
 * Un id sbagliato non si nota subito: si nota mesi dopo, quando due ricette
 * finiscono con lo stesso id e la sync ne cancella una. Qui si controlla la
 * forma (uuid v4, variante compresa) e l'unicità, sia della strada normale sia
 * del ripiego, che in Node è l'unica riga di codice mai eseguita in produzione.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { newId, uuidCasuale } from './id.ts';

const FORMA_UUID_V4 =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

test('newId produce un uuid v4', () => {
  assert.match(newId(), FORMA_UUID_V4);
});

test('newId non si ripete su 5000 chiamate', () => {
  const visti = new Set<string>();
  for (let i = 0; i < 5000; i++) visti.add(newId());
  assert.equal(visti.size, 5000);
});

test('il ripiego produce comunque uuid v4 distinti', () => {
  const visti = new Set<string>();
  for (let i = 0; i < 5000; i++) {
    const id = uuidCasuale();
    assert.match(id, FORMA_UUID_V4);
    visti.add(id);
  }
  assert.equal(visti.size, 5000);
});
```

- [ ] **Step 6: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `SyntaxError: The requested module './id.ts' does not provide
an export named 'uuidCasuale'`.

- [ ] **Step 7: Riscrivi `src/domain/id.ts` su expo-crypto**

Sostituisci per intero il contenuto di `src/domain/id.ts`:

```ts
/**
 * Generatore di id stabili per ricette, categorie, gruppi e ingredienti.
 *
 * Dentro l'app usa expo-crypto. Nei test gira Node, dove expo-crypto è un
 * modulo nativo che non si carica: il `require` protetto tiene insieme i due
 * ambienti, e dietro ci sono due ripieghi, la Web Crypto (che Node ha) e un
 * uuid v4 costruito a mano.
 *
 * La scelta del generatore si fa una volta sola e si ricorda: `require` è
 * economico ma non gratis, e newId() viene chiamata una volta per ingrediente
 * mentre si incolla una ricetta.
 *
 * ponytail: require dinamico al posto di un import statico solo perché un
 * import di expo-crypto in cima farebbe fallire `npm test`. Il nome del modulo
 * resta una costante di testo, altrimenti Metro non lo troverebbe in fase di
 * bundle e sul telefono mancherebbe.
 */

type Generatore = () => string;

/** expo-crypto: la strada normale quando il codice gira dentro l'app. */
function daExpoCrypto(): Generatore | null {
  try {
    // In un modulo ES di Node `require` non esiste: il typeof evita il crash.
    if (typeof require !== 'function') return null;
    const crypto = require('expo-crypto') as { randomUUID?: () => string };
    if (typeof crypto.randomUUID !== 'function') return null;
    return () => crypto.randomUUID!();
  } catch {
    return null;
  }
}

/** Web Crypto: è quello che usano i test in Node, e i browser. */
function daWebCrypto(): Generatore | null {
  const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
  if (typeof c?.randomUUID !== 'function') return null;
  return () => c.randomUUID!();
}

/**
 * Ultimo ripiego: uuid v4 da Math.random. Non è crittografico, ma serve solo a
 * distinguere righe di un ricettario personale, non a proteggere niente.
 */
export function uuidCasuale(): string {
  const cifre = '0123456789abcdef';
  let out = '';
  for (let i = 0; i < 36; i++) {
    if (i === 8 || i === 13 || i === 18 || i === 23) {
      out += '-';
    } else if (i === 14) {
      out += '4';                       // versione
    } else {
      const r = Math.floor(Math.random() * 16);
      out += i === 19 ? cifre[(r & 0x3) | 0x8] : cifre[r];   // variante
    }
  }
  return out;
}

let generatore: Generatore | null = null;

export function newId(): string {
  generatore ??= daExpoCrypto() ?? daWebCrypto() ?? uuidCasuale;
  return generatore();
}
```

- [ ] **Step 8: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, nessun fallimento. Passano i tre test nuovi di
`src/domain/id.test.ts` e restano verdi quelli che usano `newId()` di rimbalzo,
cioè i test della migrazione e quelli dei repository.

- [ ] **Step 9: Commit degli id e delle dipendenze**

`app.json` non compare: non è stato toccato qui, l'ha già committato il Task 14.

```bash
git add package.json package-lock.json src/domain/id.ts src/domain/id.test.ts
git commit -m "Installa le dipendenze native e genera gli id con expo-crypto"
```

- [ ] **Step 10: Crea i tipi della navigazione e il contesto dell'app**

Crea `src/ui/navigazione.ts`:

```ts
/**
 * Rotte dell'app e loro parametri.
 *
 * Sta in un file suo e non dentro App.tsx perché le schermate lo importano, e
 * App.tsx importa le schermate: mettendolo lì si girerebbe in tondo. Per lo
 * stesso motivo App.tsx non esporta tipi: chi tipa una schermata importa da qui.
 *
 * La schermata iniziale è Categorie. L'elenco delle ricette non è più la porta
 * d'ingresso: ci si arriva toccando una categoria, «Tutte le ricette» o «Senza
 * categoria», e riceve il filtro che dice cosa mostrare. È lo stesso tipo che
 * il repository accetta in `elencoRicette()`, così la schermata lo gira senza
 * tradurlo; è un oggetto semplice, quindi React Navigation lo serializza senza
 * problemi.
 */
import type { NativeStackScreenProps } from '@react-navigation/native-stack';

import type { FiltroElenco } from '../data/ricette.ts';

export type ParametriNav = {
  Categorie: undefined;
  Elenco: { filtro: FiltroElenco };
  Dosatore: { ricettaId: string };
  /** ricettaId null significa ricetta nuova. */
  Modifica: { ricettaId: string | null };
  GestioneCategorie: undefined;
};

export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<
  ParametriNav,
  N
>;
```

Crea `src/ui/contesto.ts`:

```ts
/**
 * Quello che ogni schermata ha bisogno di avere sottomano: il database aperto,
 * la lingua scelta all'avvio, e una `testo()` che è già legata a quella lingua
 * così le schermate non se la passano dietro a ogni chiamata.
 *
 * È l'unica sorgente del database nell'interfaccia: nessuna schermata riceve
 * `db` come prop e nessuna usa `useSQLiteContext()`, perché non montiamo il
 * provider di expo-sqlite (vedi il commento in App.tsx).
 *
 * Le schermate non parlano mai col database direttamente: usano le funzioni di
 * src/data. Il contesto trasporta la connessione, non le query.
 */
import { createContext, useContext } from 'react';
import type { SQLiteDatabase } from 'expo-sqlite';

import type { Lingua } from '../domain/lingua/index.ts';
import type { Chiave } from '../i18n/index.ts';

export interface StatoApp {
  db: SQLiteDatabase;
  lingua: Lingua;
  testo: (chiave: Chiave, valori?: Record<string, string>) => string;
}

export const ContestoApp = createContext<StatoApp | null>(null);

export function useApp(): StatoApp {
  const stato = useContext(ContestoApp);
  if (stato === null) {
    throw new Error('useApp() chiamata fuori da ContestoApp.Provider');
  }
  return stato;
}
```

- [ ] **Step 11: Crea le cinque schermate vuote**

Sono gusci: servono a far girare la navigazione oggi. I Task 18, 19, 20, 21 e 22
sostituiscono il contenuto dei cinque file tenendo le stesse firme.

Crea `src/ui/schermate/Categorie.tsx`:

```tsx
/**
 * Schermata principale: le categorie. Per ora è un guscio che porta alle altre
 * schermate; il Task 18 la riscrive con la ricerca globale, i conteggi, le
 * icone e le categorie vere lette dal database.
 */
import { Pressable, StyleSheet, Text, View } from 'react-native';

import { useApp } from '../contesto.ts';
import type { PropsSchermata } from '../navigazione.ts';

export default function Categorie({ navigation }: PropsSchermata<'Categorie'>) {
  const { testo } = useApp();

  return (
    <View style={stili.contenitore}>
      <Pressable
        style={stili.voce}
        accessibilityRole="button"
        onPress={() => navigation.navigate('Elenco', { filtro: { tipo: 'tutte' } })}
      >
        <Text style={stili.testoVoce}>{testo('categorie.tutte')}</Text>
      </Pressable>

      <Pressable
        style={stili.voce}
        accessibilityRole="button"
        onPress={() => navigation.navigate('Elenco', { filtro: { tipo: 'senza' } })}
      >
        <Text style={stili.testoVoce}>{testo('categorie.senza')}</Text>
      </Pressable>

      <Pressable
        style={stili.voce}
        accessibilityRole="button"
        onPress={() => navigation.navigate('GestioneCategorie')}
      >
        <Text style={stili.testoVoce}>{testo('categorie.gestisci')}</Text>
      </Pressable>

      {/* Lo stato vuoto: il Task 18 lo mostrerà solo a ricettario vuoto, qui sta
          sempre. Non dice "nessuna categoria", dice che non c'è ancora niente e
          invita a scrivere una ricetta. */}
      <Text style={stili.vuotoTitolo}>{testo('categorie.vuoto.titolo')}</Text>
      <Text style={stili.invito}>{testo('categorie.vuoto.invito')}</Text>

      <Pressable
        style={stili.tondo}
        accessibilityRole="button"
        accessibilityLabel={testo('elenco.nuova')}
        onPress={() => navigation.navigate('Modifica', { ricettaId: null })}
      >
        <Text style={stili.piu}>+</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  contenitore: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 12 },
  voce: { paddingVertical: 12 },
  testoVoce: { fontSize: 18 },
  vuotoTitolo: { fontSize: 20, fontWeight: '600', textAlign: 'center' },
  invito: { fontSize: 15, textAlign: 'center', opacity: 0.7 },
  tondo: {
    marginTop: 24,
    width: 64,
    height: 64,
    borderRadius: 32,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#1f6feb',
  },
  piu: { color: '#fff', fontSize: 32, lineHeight: 36 },
});
```

Crea `src/ui/schermate/Elenco.tsx`:

```tsx
/**
 * Elenco delle ricette filtrato. Per ora mostra il filtro ricevuto e lo stato
 * vuoto: la ricerca, l'ordine alfabetico e le miniature arrivano col Task 19.
 */
import { Pressable, StyleSheet, Text, View } from 'react-native';

import { useApp } from '../contesto.ts';
import type { PropsSchermata } from '../navigazione.ts';

export default function Elenco({ navigation, route }: PropsSchermata<'Elenco'>) {
  const { testo } = useApp();
  const { filtro } = route.params;

  return (
    <View style={stili.contenitore}>
      {/* Provvisorio: dimostra che il filtro arriva davvero dalla rotta.
          Sparisce quando l'elenco mostra le ricette vere. */}
      <Text style={stili.prova}>
        Filtro: {filtro.tipo === 'categoria' ? `categoria ${filtro.id}` : filtro.tipo}
      </Text>

      <Text style={stili.titolo}>{testo('elenco.vuoto.titolo')}</Text>
      <Text style={stili.invito}>{testo('elenco.vuoto.invito')}</Text>

      <Pressable
        style={stili.tondo}
        accessibilityRole="button"
        accessibilityLabel={testo('elenco.nuova')}
        onPress={() => navigation.navigate('Modifica', { ricettaId: null })}
      >
        <Text style={stili.piu}>+</Text>
      </Pressable>

      {/* Provvisorio: serve solo a provare la navigazione finché non c'è
          nessuna ricetta da toccare. */}
      <Pressable onPress={() => navigation.navigate('Dosatore', { ricettaId: 'prova' })}>
        <Text style={stili.prova}>Dosatore (prova)</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  contenitore: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 12 },
  titolo: { fontSize: 22, fontWeight: '600', textAlign: 'center' },
  invito: { fontSize: 16, textAlign: 'center', opacity: 0.7 },
  tondo: {
    marginTop: 24,
    width: 64,
    height: 64,
    borderRadius: 32,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#1f6feb',
  },
  piu: { color: '#fff', fontSize: 32, lineHeight: 36 },
  prova: { marginTop: 24, fontSize: 14, textDecorationLine: 'underline', opacity: 0.5 },
});
```

Crea `src/ui/schermate/Dosatore.tsx`:

```tsx
/**
 * Schermata dosatore: il cuore dell'app. Per ora è un guscio che dimostra di
 * aver ricevuto l'id della ricetta; il Task 20 la riscrive.
 */
import { StyleSheet, Text, View } from 'react-native';

import type { PropsSchermata } from '../navigazione.ts';

export default function Dosatore({ route }: PropsSchermata<'Dosatore'>) {
  return (
    <View style={stili.contenitore}>
      <Text style={stili.testo}>Dosatore: {route.params.ricettaId}</Text>
    </View>
  );
}

const stili = StyleSheet.create({
  contenitore: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
  testo: { fontSize: 16 },
});
```

Crea `src/ui/schermate/GestioneCategorie.tsx`:

```tsx
/**
 * Gestione delle categorie: creare, rinominare, cambiare icona, riordinare,
 * cancellare. Per ora è un guscio; il Task 21 la riscrive, griglia delle icone
 * compresa.
 */
import { Pressable, StyleSheet, Text, View } from 'react-native';

import { useApp } from '../contesto.ts';
import type { PropsSchermata } from '../navigazione.ts';

export default function GestioneCategorie({ navigation }: PropsSchermata<'GestioneCategorie'>) {
  const { testo } = useApp();

  return (
    <View style={stili.contenitore}>
      {/* Nessun testo di stato vuoto: la frase «non hai ancora categorie» non ha
          più una chiave sua, e inventarne una qui vorrebbe dire allargare
          l'unione `Chiave` per un guscio che il Task 21 butta via. */}
      <Pressable
        style={stili.tondo}
        accessibilityRole="button"
        accessibilityLabel={testo('categorie.crea')}
        onPress={() => navigation.goBack()}
      >
        <Text style={stili.piu}>+</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  contenitore: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 16 },
  tondo: {
    width: 64,
    height: 64,
    borderRadius: 32,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#1f6feb',
  },
  piu: { color: '#fff', fontSize: 32, lineHeight: 36 },
});
```

Crea `src/ui/schermate/Modifica.tsx`:

```tsx
/**
 * Scrittura e modifica di una ricetta. Per ora è un guscio: dimostra di sapere
 * se sta creando o modificando, e sa tornare indietro. Il Task 22 la riscrive,
 * con la scelta della categoria e la foto.
 */
import { Pressable, StyleSheet, Text, View } from 'react-native';

import { useApp } from '../contesto.ts';
import type { PropsSchermata } from '../navigazione.ts';

export default function Modifica({ navigation, route }: PropsSchermata<'Modifica'>) {
  const { testo } = useApp();
  const { ricettaId } = route.params;

  return (
    <View style={stili.contenitore}>
      <Text style={stili.testo}>
        {ricettaId === null ? 'Ricetta nuova' : `Modifica: ${ricettaId}`}
      </Text>
      <Pressable onPress={() => navigation.goBack()}>
        <Text style={stili.annulla}>{testo('modifica.annulla')}</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  contenitore: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 16 },
  testo: { fontSize: 16 },
  annulla: { fontSize: 16, color: '#1f6feb' },
});
```

- [ ] **Step 12: Crea `src/ui/App.tsx` con apertura del database e navigazione**

Crea `src/ui/App.tsx`:

```tsx
/**
 * Radice dell'app: apre il database, sceglie la lingua, monta la navigazione.
 *
 * Il database si apre una volta all'avvio. Se non si apre, l'app lo dice e si
 * ferma: non parte vuota fingendo che vada tutto bene, perché un ricettario
 * vuoto sembra un ricettario perso (spec, sezione 9).
 *
 * Per lo stesso motivo qui NON si monta `<SQLiteProvider>`: il provider di
 * expo-sqlite apre il database per conto suo e trasforma il fallimento in un
 * throw che noi non intercettiamo, mentre a noi serve mostrare `errore.db`.
 * `apriDb()` la chiamiamo noi, e la connessione la distribuiamo con ContestoApp.
 *
 * La rotta iniziale è Categorie: la prima cosa che si vede aprendo l'app sono i
 * raggruppamenti, non l'elenco.
 *
 * Questo file esporta solo il componente: i tipi delle rotte stanno in
 * navigazione.ts, che le schermate importano.
 */
import { useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import type { SQLiteDatabase } from 'expo-sqlite';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import { apriDb } from '../data/db.ts';
import type { Chiave } from '../i18n/index.ts';
import { linguaDispositivo, t } from '../i18n/index.ts';
import { ContestoApp } from './contesto.ts';
import type { StatoApp } from './contesto.ts';
import type { ParametriNav } from './navigazione.ts';
import Categorie from './schermate/Categorie.tsx';
import Elenco from './schermate/Elenco.tsx';
import Dosatore from './schermate/Dosatore.tsx';
import GestioneCategorie from './schermate/GestioneCategorie.tsx';
import Modifica from './schermate/Modifica.tsx';

const Stack = createNativeStackNavigator<ParametriNav>();

type Fase =
  | { nome: 'apertura' }
  | { nome: 'pronto'; db: SQLiteDatabase }
  | { nome: 'errore' };

export default function App() {
  const lingua = useMemo(() => linguaDispositivo(), []);
  const [fase, setFase] = useState<Fase>({ nome: 'apertura' });

  useEffect(() => {
    let vivo = true;
    apriDb()
      .then((db) => {
        if (vivo) setFase({ nome: 'pronto', db });
      })
      .catch((errore) => {
        console.warn('apertura del database fallita', errore);
        if (vivo) setFase({ nome: 'errore' });
      });
    return () => {
      vivo = false;
    };
  }, []);

  const contesto = useMemo<StatoApp | null>(
    () =>
      fase.nome === 'pronto'
        ? {
            db: fase.db,
            lingua,
            testo: (chiave: Chiave, valori?: Record<string, string>) =>
              t(chiave, lingua, valori),
          }
        : null,
    [fase, lingua],
  );

  if (fase.nome === 'apertura') {
    return (
      <View style={stili.centro}>
        <ActivityIndicator />
      </View>
    );
  }

  if (contesto === null) {
    return (
      <View style={stili.centro}>
        <Text style={stili.errore}>{t('errore.db', lingua)}</Text>
      </View>
    );
  }

  return (
    <SafeAreaProvider>
      <ContestoApp.Provider value={contesto}>
        <NavigationContainer>
          <Stack.Navigator initialRouteName="Categorie">
            <Stack.Screen
              name="Categorie"
              component={Categorie}
              options={{ title: t('app.nome', lingua) }}
            />
            {/* Il titolo dell'elenco dipende dal filtro e lo mette il Task 19. */}
            <Stack.Screen name="Elenco" component={Elenco} options={{ title: '' }} />
            <Stack.Screen name="Dosatore" component={Dosatore} options={{ title: '' }} />
            <Stack.Screen
              name="GestioneCategorie"
              component={GestioneCategorie}
              options={{ title: t('categorie.gestisci', lingua) }}
            />
            <Stack.Screen name="Modifica" component={Modifica} options={{ title: '' }} />
          </Stack.Navigator>
        </NavigationContainer>
      </ContestoApp.Provider>
    </SafeAreaProvider>
  );
}

const stili = StyleSheet.create({
  centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
  errore: { fontSize: 16, textAlign: 'center' },
});
```

- [ ] **Step 13: Fai puntare il `App.tsx` della radice a quello nuovo**

Sostituisci per intero il contenuto di `App.tsx` (alla radice del progetto, non
quello in `src/ui/`):

```tsx
// L'app vera sta in src/ui/App.tsx. Questo file resta perché index.ts, generato
// da Expo, importa './App' per registrare il componente radice.
export { default } from './src/ui/App.tsx';
```

- [ ] **Step 14: Controlla i tipi**

Comando: `npm run typecheck`
Atteso: nessun errore. È il controllo che tiene insieme i parametri delle rotte:
sbagliare `navigation.navigate('Dosatore', { id: 'x' })` invece di `ricettaId`,
oppure aprire l'elenco senza `filtro`, fallisce qui.

- [ ] **Step 15: Esegui i test**

Comando: `npm test`
Atteso: PASS, ℹ pass 164, ℹ fail 0 (i 161 lasciati dal Task 16 più i 3 nuovi di
`id.test.ts`). I tre test di `src/domain/id.test.ts` sono
verdi insieme a tutti quelli dei task precedenti. Le schermate non hanno test
automatici e i file `.tsx` non vengono raccolti dal comando: la verifica
dell'interfaccia si fa a mano, allo step seguente.

- [ ] **Step 16: Avvia l'app e prova la navigazione**

Comando: `npx expo start --ios` (oppure `--android`).

Tutti i pacchetti installati qui sono fra quelli che Expo Go porta già con sé, e
jszip è JavaScript puro: per provare basta Expo Go, non serve una build nativa.
Il blocco `plugins` che il Task 14 ha aggiunto ad `app.json` riguarda la build
nativa, non Expo Go, che ha già le sue stringhe di permesso.

Atteso, a mano:
1. l'app parte sulla schermata delle categorie, con l'intestazione "Quanto
   Basta", le voci "Tutte le ricette", "Senza categoria" e "Gestisci le
   categorie", e sotto lo stato vuoto: "Il ricettario è vuoto" e "Scrivi la
   prima ricetta";
2. "Tutte le ricette" apre l'elenco, che scrive `Filtro: tutte`; il tasto
   indietro riporta alle categorie;
3. "Senza categoria" apre lo stesso elenco, che stavolta scrive `Filtro: senza`;
4. "Gestisci le categorie" apre la gestione categorie, con l'intestazione
   "Gestisci le categorie" e il solo tondo blu, che riporta indietro;
5. il tondo blu con il `+`, da entrambe le schermate che ce l'hanno, apre la
   modifica, che dice "Ricetta nuova"; "Annulla" riporta indietro;
6. dall'elenco, "Dosatore (prova)" apre la schermata dosatore, che scrive
   `Dosatore: prova`;
7. nessun avviso rosso in console.

Se compare il testo di `errore.db` al posto delle categorie, il database non si
è aperto: guarda in console il messaggio "apertura del database fallita".

- [ ] **Step 17: Commit**

```bash
git add App.tsx src/ui/App.tsx src/ui/contesto.ts src/ui/navigazione.ts src/ui/schermate
git commit -m "Aggiungi navigazione, contesto e apertura del database all'avvio"
```

---

### Task 18: Schermata Categorie, la principale

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta ricalcola
le dosi di una ricetta: cambi le porzioni, oppure dici "ho 250 g di farina
invece di 180" e tutti gli altri ingredienti si adeguano. Il ricettario sta in
SQLite sul dispositivo, senza account e senza rete.

L'utente raggruppa le sue ricette in **categorie che si crea da solo**, ognuna
con un nome libero e un'icona presa da un catalogo chiuso di trenta che
forniamo noi. Sono raggruppamenti, non etichette: **una ricetta sta in una
categoria sola, oppure in nessuna**, e "in nessuna" è lo stato normale di chi
scrive una ricetta prima di essersi fatto le categorie.

Questo task scrive la schermata che si apre all'avvio dell'app.

**Com'è fatta, dall'alto in basso.**

1. **La ricerca**, in cima. Cerca fra **tutte** le ricette ignorando le
   categorie, e appena si scrive qualcosa il corpo della schermata diventa
   l'elenco delle ricette trovate. Chi sa come si chiama quello che cerca non
   deve passare da nessuna parte: tocca il risultato e va dritto al dosatore.
2. **Tutte le ricette**, col conteggio. Sempre presente, non si cancella. È la
   scorciatoia per chi ha poche ricette e le categorie non gli servono.
3. **Senza categoria**, col conteggio, **solo se c'è almeno una ricetta senza**.
   Una voce che dice zero non serve a nessuno, e a ricettario ordinato sparisce
   da sola.
4. **Le categorie dell'utente**, con icona, nome e conteggio, nel loro `ordine`.
   Una categoria senza ricette si mostra lo stesso, con zero accanto: l'utente
   l'ha creata apposta e vederla sparire sarebbe sconcertante.

**A ricettario vuoto non si scrive "nessuna categoria"**: si invita a scrivere
la prima ricetta e si va dritti alla schermata di scrittura. "Vuoto" qui
significa nessuna ricetta **e** nessuna categoria: se le categorie ci sono, si
mostrano coi loro zeri, perché nascondere il lavoro che l'utente ha già fatto è
peggio che mostrare dei numeri a zero.

In alto a destra un pulsante che porta alla gestione delle categorie. In basso a
destra il tondo col `+` che apre una ricetta nuova, sempre presente: il bottone
dello stato vuoto sparisce appena c'è una ricetta o una categoria, e da questa
schermata scrivere deve restare possibile anche a ricettario pieno.

**I conteggi arrivano da `conteggiPerCategoria()`, che li prende tutti e tre con
una query sola** (`GROUP BY categoria_id`): il totale, quelle senza categoria e
quelle di ogni categoria. Non si conta lato schermata scorrendo le ricette, e
non si fa una query per categoria.

**Files:**
- Create: `src/ui/logica-elenco.ts`
- Create: `src/ui/componenti/Icona.tsx`
- Create: `src/ui/logica-categorie.ts`
- Test: `src/ui/logica-elenco.test.ts` (nuovo, 8 test)
- Test: `src/ui/logica-categorie.test.ts` (nuovo, 5 test)
- Modify: `src/ui/schermate/Categorie.tsx` — sostituisce per intero il guscio
  scritto dal Task 17. Il vecchio contenuto va buttato.

`src/ui/logica-categorie.ts` e il suo test **li crea questo task** (addendum 3
§2). Il Task 21 li trova già scritti e ci **aggiunge** in fondo le sue funzioni e
i suoi test: non li ricrea e non li sovrascrive, e `VoceCategorie` e
`vociCategorie` restano dove sono perché `Categorie.tsx` li importa.

`src/ui/schermate/Categorie.tsx` è anche l'**unico padrone dell'`headerRight`
della rotta Categorie**: il `setOptions` scritto qui allo Step 12 vince su
qualunque `headerRight` dichiarato nelle `options` di `<Stack.Screen>`. Il Task
23 lo sa e aggiunge `<BarraIo />` dentro questo stesso `setOptions`, accanto a
"Gestisci", invece di montarla da `App.tsx`. Lo Step 12 tiene il ricarico dei
dati in una funzione con un nome, `ricarica`, perché è quella che passa a
`useFocusEffect`: `useFocusEffect` vuole un callback stabile, e con un nome si
legge cosa fa.

**Interfaces:**

- **Consumes**

  - `src/domain/types.ts` (Task 5), il modello dati. Servono queste
    dichiarazioni, complete dei campi aggiunti per categorie e foto:

    ```ts
    /** quantita === null significa "q.b." (quanto basta): non entra nei calcoli. */
    export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
    /** nome === null: gruppo unico, l'intestazione non va mostrata. */
    export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }

    export interface Ricetta {
      id: string;
      titolo: string;
      descrizione: string;
      porzioni: number | null;
      categoriaId: string | null;    // null = senza categoria, stato normale
      foto: string | null;           // NOME del file, non percorso assoluto
      gruppi: Gruppo[];
      creataIl: string;              // ISO 8601
      modificataIl: string;          // ISO 8601
      cancellataIl: string | null;   // tombstone, null = viva
    }

    export interface Categoria {
      id: string;
      nome: string;
      icona: ChiaveIcona;
      ordine: number;
      creataIl: string;
      modificataIl: string;
      cancellataIl: string | null;
    }
    ```

  - `src/domain/icone.ts` (Task 5), il catalogo chiuso delle trenta icone. Le
    prime due righe si ricopiano alla lettera, non si parafrasano:

    ```ts
    export const ICONE = [ /* trenta voci { chiave, nome } */ ] as const;
    /** L'unione delle trenta chiavi: un valore fuori catalogo non compila. */
    export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
    /** nome è il glifo di MaterialCommunityIcons, chiave è il nostro identificatore stabile. */
    export interface VoceIcona { chiave: ChiaveIcona; nome: string }
    export function iconaValida(c: string): c is ChiaveIcona;
    export const ICONA_PREDEFINITA: ChiaveIcona;
    /** Il nome da dare a <MaterialCommunityIcons name=…>. Chiave sconosciuta -> predefinita. */
    export function nomeIcona(c: string): string;
    ```

    **`as const` obbligatorio, annotazione di tipo su `ICONE` vietata.** Scrivere
    `export const ICONE: readonly VoceIcona[]` o `export type ChiaveIcona =
    string` farebbe collassare a `string` la derivazione
    `(typeof ICONE)[number]['chiave']`, e la garanzia che il Task 5 dimostra con
    la sua prova negativa sparirebbe in silenzio.

    Il Task 5 lascia un test che verifica che tutte e trenta le `nome` esistano
    davvero nel glyphmap installato: è quello che rende legittimo il cast dentro
    `Icona.tsx`, qui sotto.

  - `src/data/ricette.ts` (Task 11):

    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';

    export type FiltroElenco =
      | { tipo: 'tutte' }
      | { tipo: 'categoria'; id: string }
      | { tipo: 'senza' };

    /** Solo le ricette vive che passano il filtro, già ordinate per titolo con localeCompare('it'). */
    export async function elencoRicette(db: SQLiteDatabase, filtro: FiltroElenco): Promise<Ricetta[]>;

    export interface Conteggi {
      /** Ricette vive in tutto il ricettario. */
      totale: number;
      /** Ricette vive con categoriaId a null. */
      senza: number;
      /** Ricette vive per id di categoria. Una categoria senza ricette NON compare. */
      perCategoria: Record<string, number>;
    }
    /** Tutti e tre i numeri con una query sola: SELECT categoria_id, COUNT(*) … GROUP BY categoria_id. */
    export async function conteggiPerCategoria(db: SQLiteDatabase): Promise<Conteggi>;
    ```

    I nomi sono quelli del Task 11, che è chi li produce: il tipo si chiama
    `FiltroElenco` e i due contatori si chiamano `totale` e `senza`. Non
    esistono `FiltroRicette`, `Conteggi.tutte` né `Conteggi.senzaCategoria`.

    L'ordinamento alfabetico è del repository: qui non si riordina niente, e il
    filtro di ricerca conserva l'ordine che riceve. Le categorie assenti da
    `perCategoria` valgono zero, e chi legge deve mettere il `?? 0`.

  - `src/data/categorie.ts` (Task 12):

    ```ts
    /** Solo le categorie vive, ordinate per `ordine` crescente e, a parità, per nome all'italiana. */
    export async function elencoCategorie(db: SQLiteDatabase): Promise<Categoria[]>;
    ```

  - `src/i18n/index.ts` (Task 16):

    ```ts
    /** Unione chiusa: la scrive il Task 16, qui non si allarga. */
    export type Chiave = 'app.nome' | 'categorie.cerca' | /* … */ | 'errore.db';
    ```

    Chiavi che questa schermata chiama, tutte già nell'unione del Task 16:

    ```
    categorie.cerca            categorie.tutte            categorie.senza
    categorie.gestisci         categorie.vuoto.titolo     categorie.vuoto.invito
    categorie.conteggio.zero   categorie.conteggio.una    categorie.conteggio.molte
    elenco.nuova               errore.db
    ```

    `elenco.nuova` («Nuova ricetta») è l'etichetta di accessibilità del tondo
    col `+`: il segno `+` da solo un lettore di schermo lo legge «più».

    **I testi stanno in `src/i18n/it.ts` e `src/i18n/en.ts` e li scrive il Task
    16: qui non si ricopiano e non si riscrivono.** Duplicare un dizionario è il
    modo con cui diverge, e chi eseguisse questo task leggendo una tabella
    riscriverebbe i testi del Task 16 facendone fallire i test.

    `categorie.vuoto.titolo` e `categorie.vuoto.invito` sono due delle quattro
    chiavi che l'**addendum 3 §4** fa aggiungere al Task 16. Attenzione alla
    vocale: si chiama `categorie.vuoto.invito`, non `categorie.vuote.invito`, ed
    è un nome solo, non due varianti.

    Una sola chiave ha un segnaposto: `categorie.conteggio.molte` vuole
    `{conteggio}`. Le altre si chiamano senza il secondo argomento.

    Il conteggio **scritto** accanto a ogni voce è il numero e basta: una riga di
    elenco con "Dolci" e "3" si legge a colpo d'occhio, "3 ricette" no. Le tre
    chiavi `categorie.conteggio.*` servono all'**etichetta di accessibilità**,
    dove le parole invece servono: un lettore di schermo che dice "Dolci, 3" non
    dice cosa sono quei tre, e "1 ricette" in italiano è sbagliato, che è
    esattamente il motivo per cui il Task 16 quelle tre chiavi le ha scritte.

  - `src/ui/navigazione.ts` (Task 17):

    ```ts
    import type { NativeStackScreenProps } from '@react-navigation/native-stack';
    import type { FiltroElenco } from '../data/ricette.ts';

    export type ParametriNav = {
      Categorie: undefined;
      Elenco: { filtro: FiltroElenco };
      Dosatore: { ricettaId: string };
      /** ricettaId null significa ricetta nuova. */
      Modifica: { ricettaId: string | null };
      GestioneCategorie: undefined;
    };

    export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<ParametriNav, N>;
    ```

    `Categorie` è la rotta iniziale registrata dal Task 17.

  - `src/ui/contesto.ts` (Task 17):

    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';

    export interface StatoApp {
      db: SQLiteDatabase;
      lingua: Lingua;
      testo: (chiave: Chiave, valori?: Record<string, string>) => string;
    }
    /** Lancia se usata fuori dal Provider montato da src/ui/App.tsx. */
    export function useApp(): StatoApp;
    ```

  - Dipendenze già installate dal Task 17:

    ```ts
    import { useFocusEffect } from '@react-navigation/native';        // effetto ad ogni ritorno sulla schermata
    import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
    ```

    `@expo/vector-icons` è installato dal Task 5: non arriva da solo con
    `expo@57.0.14`, e il Task 5 lo verifica e lo installa esplicitamente.

- **Produces**

  ```ts
  // src/ui/logica-elenco.ts
  /** Filtra per titolo ignorando maiuscole, accenti e spazi ai bordi. Conserva l'ordine. */
  export function filtraRicette(ricette: Ricetta[], query: string): Ricetta[];

  // src/ui/logica-categorie.ts
  export type VoceCategorie =
    | { tipo: 'tutte'; conteggio: number }
    | { tipo: 'senza'; conteggio: number }
    | { tipo: 'categoria'; categoria: Categoria; conteggio: number };
  /** Le voci della schermata principale, nell'ordine in cui si mostrano. */
  export function vociCategorie(categorie: Categoria[], conteggi: Conteggi): VoceCategorie[];

  // src/ui/componenti/Icona.tsx
  export interface PropsIcona { chiave: ChiaveIcona; dimensione: number; colore: string }
  export default function Icona(props: PropsIcona);

  // src/ui/schermate/Categorie.tsx
  export default function Categorie(props: PropsSchermata<'Categorie'>);
  ```

  `src/ui/logica-elenco.ts` e `src/ui/logica-categorie.ts` sono moduli puri:
  importano solo tipi e roba di `src/domain/`, mai React né react-native. Il
  Task 19 ci importa dentro `filtraRicette`, quindi il file deve restare
  caricabile da `node --test`.

  `src/ui/App.tsx` registra già il default export come
  `<Stack.Screen name="Categorie" component={Categorie} />`: la registrazione è
  del Task 17 e qui non si tocca.

---

- [ ] **Step 1: Scrivi il test che fallisce (il filtro di ricerca)**

Crea `src/ui/logica-elenco.test.ts`:

```ts
/**
 * Test del filtro di ricerca. Gira con `npm test`, cioè `node --test`, senza
 * framework e senza dispositivo: il modulo sotto test non importa React né
 * react-native.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { filtraRicette } from './logica-elenco.ts';
import type { Ricetta } from '../domain/types.ts';

const ric = (id: string, titolo: string): Ricetta => ({
  id,
  titolo,
  descrizione: '',
  porzioni: null,
  categoriaId: null,
  foto: null,
  gruppi: [],
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
});

/** Titoli veri del ricettario da migrare, nell'ordine che dà elencoRicette. */
const elenco: Ricetta[] = [
  ric('r1', 'Baci di Dama (Angela)'),
  ric('r2', 'Besciamella'),
  ric('r3', 'Ciambella Bertolini'),
  ric('r4', 'Farinata'),
  ric('r5', 'Minestra di zucca'),
  ric('r6', 'Purè di patate'),
  ric('r7', 'Riso zucca castelmagno'),
  ric('r8', 'Tozzetti'),
];

const titoli = (r: Ricetta[]): string[] => r.map((x) => x.titolo);

test('query vuota: passano tutte, nello stesso ordine', () => {
  assert.deepEqual(titoli(filtraRicette(elenco, '')), titoli(elenco));
  assert.deepEqual(titoli(filtraRicette(elenco, '   ')), titoli(elenco));
});

test('filtra sul titolo e conserva l ordine ricevuto', () => {
  assert.deepEqual(titoli(filtraRicette(elenco, 'zucca')), [
    'Minestra di zucca',
    'Riso zucca castelmagno',
  ]);
});

test('la ricerca è indifferente alle maiuscole e agli spazi ai bordi', () => {
  assert.deepEqual(titoli(filtraRicette(elenco, 'ZUCCA')), [
    'Minestra di zucca',
    'Riso zucca castelmagno',
  ]);
  assert.deepEqual(titoli(filtraRicette(elenco, '  zucca  ')), [
    'Minestra di zucca',
    'Riso zucca castelmagno',
  ]);
});

test('la ricerca è indifferente agli accenti, da tutt e due i lati', () => {
  // Chi cerca "pure" deve trovare "Purè", e chi scrive l'accento pure.
  assert.deepEqual(titoli(filtraRicette(elenco, 'pure')), ['Purè di patate']);
  assert.deepEqual(titoli(filtraRicette(elenco, 'PURÈ')), ['Purè di patate']);
});

test('cerca anche dentro il titolo, non solo all inizio', () => {
  assert.deepEqual(titoli(filtraRicette(elenco, 'castelmagno')), ['Riso zucca castelmagno']);
});

test('nessuna corrispondenza: elenco vuoto, non tutto l elenco', () => {
  assert.deepEqual(filtraRicette(elenco, 'sushi'), []);
});

test('i titoli duplicati restano due ricette distinte', () => {
  // È il difetto della vecchia app: la ricetta si ritrovava per titolo.
  const doppie = [ric('t1', 'Torta di mele'), ric('t2', 'Torta di mele')];
  const trovate = filtraRicette(doppie, 'torta');
  assert.equal(trovate.length, 2);
  assert.deepEqual(trovate.map((r) => r.id), ['t1', 't2']);
});

test('l elenco di partenza non viene toccato', () => {
  const prima = titoli(elenco);
  filtraRicette(elenco, 'zucca');
  assert.deepEqual(titoli(elenco), prima);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `Cannot find module '…/src/ui/logica-elenco.ts'` (il modulo non
esiste ancora).

- [ ] **Step 3: Implementa il minimo che fa passare il test**

Crea `src/ui/logica-elenco.ts`:

```ts
/**
 * Filtro di ricerca sulle ricette, tenuto fuori dai .tsx così si può verificare
 * con `node --test`: qui non entrano React né react-native.
 *
 * Lo usano tutte e due le schermate che cercano: la principale, dove cerca fra
 * tutte le ricette ignorando le categorie, e l'elenco, dove cerca dentro quello
 * che si sta guardando. È lo stesso gesto e deve comportarsi allo stesso modo.
 *
 * L'ordine alfabetico italiano non si fa qui: arriva già da elencoRicette(),
 * che ordina con localeCompare('it'). Il filtro si limita a conservarlo.
 */
import type { Ricetta } from '../domain/types.ts';

/**
 * Accenti ridotti alla lettera nuda. Si usa una tabella invece di
 * String.normalize('NFD') perché il supporto di normalize dipende dal motore
 * JavaScript del dispositivo, e la ricerca deve comportarsi allo stesso modo su
 * iOS, su Android e sotto Node nei test.
 */
const ACCENTI: Record<string, string> = {
  à: 'a', á: 'a', â: 'a', ä: 'a', ã: 'a',
  è: 'e', é: 'e', ê: 'e', ë: 'e',
  ì: 'i', í: 'i', î: 'i', ï: 'i',
  ò: 'o', ó: 'o', ô: 'o', ö: 'o', õ: 'o',
  ù: 'u', ú: 'u', û: 'u', ü: 'u',
  ç: 'c', ñ: 'n',
};

/** Forma confrontabile: minuscole, senza accenti, senza spazi ai bordi. */
function chiaveRicerca(testo: string): string {
  return testo
    .trim()
    .toLowerCase()
    .replace(/[àáâäãèéêëìíîïòóôöõùúûüçñ]/g, (c) => ACCENTI[c] ?? c);
}

/**
 * Filtra per titolo. Query vuota significa "tutte": la ricerca non nasconde
 * niente finché non si scrive qualcosa.
 */
export function filtraRicette(ricette: Ricetta[], query: string): Ricetta[] {
  const q = chiaveRicerca(query);
  if (q === '') return ricette;
  return ricette.filter((r) => chiaveRicerca(r.titolo).includes(q));
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, `ℹ fail 0`, con gli 8 test nuovi di `logica-elenco.test.ts`
compresi: il totale sale di 8 rispetto a quello lasciato dal Task 17.

- [ ] **Step 5: Commit**

```bash
git add src/ui/logica-elenco.ts src/ui/logica-elenco.test.ts
git commit -m "Aggiungi il filtro di ricerca sulle ricette, indifferente ad accenti e maiuscole"
```

- [ ] **Step 6: Scrivi il test che fallisce (le voci della schermata)**

Crea `src/ui/logica-categorie.test.ts`:

```ts
/**
 * Test della composizione delle voci della schermata principale. Gira con
 * `npm test`: il modulo sotto test non importa React né react-native, e i tipi
 * che vengono da src/data/ si importano con `import type`, che sparisce a
 * runtime e non fa mai caricare expo-sqlite.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { vociCategorie } from './logica-categorie.ts';
import { ICONA_PREDEFINITA } from '../domain/icone.ts';
import type { Categoria } from '../domain/types.ts';
import type { Conteggi } from '../data/ricette.ts';

const ADESSO = '2026-01-01T00:00:00.000Z';

const cat = (id: string, nome: string, ordine: number): Categoria => ({
  id,
  nome,
  icona: ICONA_PREDEFINITA,
  ordine,
  creataIl: ADESSO,
  modificataIl: ADESSO,
  cancellataIl: null,
});

const conteggi = (
  totale: number,
  senza: number,
  perCategoria: Record<string, number> = {},
): Conteggi => ({ totale, senza, perCategoria });

test('la prima voce è sempre "tutte", col totale del ricettario', () => {
  const voci = vociCategorie([], conteggi(12, 0));
  assert.equal(voci.length, 1);
  assert.deepEqual(voci[0], { tipo: 'tutte', conteggio: 12 });
});

test('"senza categoria" compare solo se c è almeno una ricetta senza', () => {
  // Ricettario tutto sistemato: la voce non deve comparire a zero.
  const sistemato = vociCategorie([cat('c1', 'Dolci', 0)], conteggi(12, 0, { c1: 12 }));
  assert.deepEqual(sistemato.map((v) => v.tipo), ['tutte', 'categoria']);

  const misto = vociCategorie([cat('c1', 'Dolci', 0)], conteggi(12, 5, { c1: 7 }));
  assert.deepEqual(misto.map((v) => v.tipo), ['tutte', 'senza', 'categoria']);
  assert.deepEqual(misto[1], { tipo: 'senza', conteggio: 5 });
});

test('le categorie restano nell ordine ricevuto dal repository', () => {
  // elencoCategorie ordina già per `ordine`: qui non si riordina niente.
  const voci = vociCategorie(
    [cat('c3', 'Primi', 0), cat('c1', 'Secondi', 1), cat('c2', 'Dolci', 2)],
    conteggi(9, 0, { c1: 3, c2: 4, c3: 2 }),
  );
  assert.deepEqual(
    voci.filter((v) => v.tipo === 'categoria').map((v) => (v.tipo === 'categoria' ? v.categoria.nome : '')),
    ['Primi', 'Secondi', 'Dolci'],
  );
});

test('una categoria senza ricette si mostra lo stesso, con zero', () => {
  // conteggiPerCategoria non restituisce le categorie vuote: il ?? 0 sta qui.
  // Farla sparire sarebbe sconcertante: l'utente l'ha appena creata.
  const voci = vociCategorie([cat('c1', 'Conserve', 0)], conteggi(4, 4, {}));
  assert.deepEqual(voci[2], { tipo: 'categoria', categoria: cat('c1', 'Conserve', 0), conteggio: 0 });
});

test('ricettario vuoto: resta la sola voce "tutte" a zero', () => {
  // È il caso in cui la schermata mostra l'invito invece dell'elenco, e la
  // decisione la prende il .tsx guardando anche categorie.length.
  const voci = vociCategorie([], conteggi(0, 0));
  assert.deepEqual(voci, [{ tipo: 'tutte', conteggio: 0 }]);
});
```

- [ ] **Step 7: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `Cannot find module '…/src/ui/logica-categorie.ts'`.

- [ ] **Step 8: Implementa il minimo che fa passare il test**

Crea `src/ui/logica-categorie.ts`:

```ts
/**
 * Le voci della schermata principale, composte fuori dal .tsx così si possono
 * verificare con `node --test`: qui non entrano React né react-native.
 *
 * L'ordine è fisso e non si discute: "tutte le ricette", poi "senza categoria"
 * se serve, poi le categorie dell'utente come le dà il repository.
 */
import type { Categoria } from '../domain/types.ts';
import type { Conteggi } from '../data/ricette.ts';

export type VoceCategorie =
  | { tipo: 'tutte'; conteggio: number }
  | { tipo: 'senza'; conteggio: number }
  | { tipo: 'categoria'; categoria: Categoria; conteggio: number };

/**
 * Le voci nell'ordine in cui si mostrano.
 *
 * "Senza categoria" compare solo quando c'è almeno una ricetta senza: a
 * ricettario sistemato la voce sparisce da sola, e una voce che dice zero non
 * serve a nessuno. Una categoria dell'utente invece si mostra sempre, anche a
 * zero: `conteggiPerCategoria` non restituisce le categorie vuote, quindi il
 * `?? 0` è quello che le tiene in elenco.
 */
export function vociCategorie(categorie: Categoria[], conteggi: Conteggi): VoceCategorie[] {
  const voci: VoceCategorie[] = [{ tipo: 'tutte', conteggio: conteggi.totale }];

  if (conteggi.senza > 0) {
    voci.push({ tipo: 'senza', conteggio: conteggi.senza });
  }

  for (const categoria of categorie) {
    voci.push({
      tipo: 'categoria',
      categoria,
      conteggio: conteggi.perCategoria[categoria.id] ?? 0,
    });
  }

  return voci;
}
```

- [ ] **Step 9: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, `ℹ fail 0`, con i 5 test nuovi di `logica-categorie.test.ts`
compresi: il totale sale di altri 5, cioè di 13 rispetto al Task 17.

- [ ] **Step 10: Commit**

```bash
git add src/ui/logica-categorie.ts src/ui/logica-categorie.test.ts
git commit -m "Componi le voci della schermata principale a partire dai conteggi"
```

- [ ] **Step 11: Scrivi il componente che disegna un'icona del catalogo**

Crea `src/ui/componenti/Icona.tsx`. Serve perché nel database sta la **chiave**
dell'icona (il nostro identificatore stabile) mentre `MaterialCommunityIcons`
vuole il **nome del glifo**. La corrispondenza però non si riscrive qui: la fa
già `nomeIcona()`, che il Task 5 produce e copre con un test, ripiego sulla
predefinita compreso. Questo componente è solo il cast di tipo messo in un posto
solo, così a chiamare `<MaterialCommunityIcons name={… as NomeGlifo}>` è una riga
sola dell'app invece di ogni schermata che disegna una categoria.

`GestioneCategorie.tsx` (Task 21) e `SceltaCategoria.tsx` (Task 22) **non** lo
usano: chiamano `nomeIcona()` e fanno il cast per conto loro, ed è una scelta
loro che qui non si promette diversamente.

```tsx
/**
 * Un'icona del catalogo, dalla chiave salvata nel database al glifo disegnato.
 *
 * Nel database sta `Categoria.icona`, cioè la CHIAVE del catalogo, che è nostra
 * e non cambia mai. La traduzione verso il nome del glifo è tutta in
 * `nomeIcona()`, che ripiega sulla predefinita quando la chiave non sta più nel
 * catalogo — succede importando un archivio scritto da una versione diversa.
 */
import type { ComponentProps } from 'react';
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';

import type { ChiaveIcona } from '../../domain/icone.ts';
import { nomeIcona } from '../../domain/icone.ts';

/** Il tipo del prop `name`: l'unione di tutti i glifi del font. */
type NomeGlifo = ComponentProps<typeof MaterialCommunityIcons>['name'];

export interface PropsIcona {
  chiave: ChiaveIcona;
  dimensione: number;
  colore: string;
}

export default function Icona({ chiave, dimensione, colore }: PropsIcona) {
  // Il cast è legittimo: il Task 5 lascia un test che verifica che ogni `nome`
  // del catalogo esista davvero nel glyphmap di MaterialCommunityIcons. Senza
  // quel test un nome inventato non darebbe errore di compilazione, darebbe un
  // quadratino vuoto a schermo.
  return (
    <MaterialCommunityIcons name={nomeIcona(chiave) as NomeGlifo} size={dimensione} color={colore} />
  );
}
```

- [ ] **Step 12: Scrivi la schermata**

`src/ui/schermate/Categorie.tsx` esiste già come guscio dal Task 17.
**Sostituisci il file per intero** con quello qui sotto.

I due glifi usati per le voci fisse, `book-open-variant` e `tray`, esistono
entrambi nel glyphmap installato: sono stati verificati contro
`node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/MaterialCommunityIcons.json`.

```tsx
/**
 * La schermata principale: le categorie del ricettario.
 *
 * In cima la ricerca, che cerca fra TUTTE le ricette ignorando le categorie.
 * Appena si scrive qualcosa il corpo diventa l'elenco delle ricette trovate:
 * chi sa come si chiama quello che cerca non deve passare da nessuna parte.
 *
 * Sotto, sempre in quest'ordine: "tutte le ricette" col totale, "senza
 * categoria" solo se c'è almeno una ricetta senza, e le categorie dell'utente
 * con icona, nome e conteggio nel loro ordine.
 *
 * A ricettario vuoto non si scrive "nessuna categoria": si invita a scrivere la
 * prima ricetta. Vuoto significa nessuna ricetta E nessuna categoria: se le
 * categorie ci sono si mostrano coi loro zeri, perché nascondere il lavoro che
 * l'utente ha già fatto è peggio che mostrare dei numeri a zero.
 *
 * I risultati della ricerca sono righe di solo titolo, senza miniatura: la
 * ricerca è la scorciatoia di chi il nome lo sa già, e la foto lì non aiuta a
 * riconoscere niente. Le miniature stanno nell'elenco, che è dove si sfoglia.
 */
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';

import type { PropsSchermata } from '../navigazione.ts';
import { useApp } from '../contesto.ts';
import type { Categoria, Ricetta } from '../../domain/types.ts';
import type { Conteggi, FiltroElenco } from '../../data/ricette.ts';
import { conteggiPerCategoria, elencoRicette } from '../../data/ricette.ts';
import { elencoCategorie } from '../../data/categorie.ts';
import { filtraRicette } from '../logica-elenco.ts';
import type { VoceCategorie } from '../logica-categorie.ts';
import { vociCategorie } from '../logica-categorie.ts';
import Icona from '../componenti/Icona.tsx';

const CONTEGGI_VUOTI: Conteggi = { totale: 0, senza: 0, perCategoria: {} };

/** Il filtro con cui aprire l'elenco toccando una voce. */
function filtroDaVoce(voce: VoceCategorie): FiltroElenco {
  if (voce.tipo === 'categoria') return { tipo: 'categoria', id: voce.categoria.id };
  if (voce.tipo === 'senza') return { tipo: 'senza' };
  return { tipo: 'tutte' };
}

/** Chiave di lista stabile: gli id delle categorie non collidono con le due voci fisse. */
function chiaveVoce(voce: VoceCategorie): string {
  return voce.tipo === 'categoria' ? `categoria:${voce.categoria.id}` : voce.tipo;
}

export default function Categorie({ navigation }: PropsSchermata<'Categorie'>) {
  const { db, testo } = useApp();

  const [categorie, setCategorie] = useState<Categoria[]>([]);
  const [conteggi, setConteggi] = useState<Conteggi>(CONTEGGI_VUOTI);
  const [ricette, setRicette] = useState<Ricetta[]>([]);
  const [ricerca, setRicerca] = useState('');
  const [errore, setErrore] = useState(false);
  // Finché il primo caricamento non è finito non si mostra l'invito: senza
  // questa sentinella il ricettario pieno lampeggerebbe "è vuoto" per un frame.
  const [caricato, setCaricato] = useState(false);

  /**
   * Rilegge tutto quello che la schermata mostra. La passa `useFocusEffect`, che
   * la richiama ad ogni ritorno sulla schermata: tornando dal dosatore o dalla
   * gestione categorie, i conteggi e le categorie devono essere già aggiornati.
   * Ha un nome perché `useFocusEffect` vuole un callback stabile — `useCallback`
   * con `db` come sola dipendenza — e perché scritta in linea non si leggerebbe.
   */
  const ricarica = useCallback(() => {
    let vivo = true;
    Promise.all([
      elencoCategorie(db),
      conteggiPerCategoria(db),
      // Le ricette servono alla ricerca, che ignora le categorie e quindi le
      // vuole tutte. Si caricano in memoria una volta per visita: su
      // ricettari da qualche centinaio di ricette non si sente. Se un giorno
      // si sentisse, si caricano solo quando la ricerca non è vuota.
      elencoRicette(db, { tipo: 'tutte' }),
    ])
      .then(([cat, cont, ric]) => {
        if (!vivo) return;
        setCategorie(cat);
        setConteggi(cont);
        setRicette(ric);
        setErrore(false);
        setCaricato(true);
      })
      .catch(() => {
        if (vivo) setErrore(true);
      });
    return () => {
      vivo = false;
    };
  }, [db]);

  useFocusEffect(ricarica);

  // L'`headerRight` di questa rotta lo comanda la schermata, non `App.tsx`:
  // `setOptions` vince per chiave sulle `options` dello `<Stack.Screen>`, quindi
  // due padroni vorrebbe dire che uno dei due non compare mai. Il Task 23
  // aggiunge `<BarraIo />` qui dentro, accanto a "Gestisci", e non tocca
  // `App.tsx`.
  useLayoutEffect(() => {
    navigation.setOptions({
      headerRight: () => (
        <Pressable
          onPress={() => navigation.navigate('GestioneCategorie')}
          hitSlop={12}
          accessibilityRole="button"
        >
          <Text style={stili.gestisci}>{testo('categorie.gestisci')}</Text>
        </Pressable>
      ),
    });
  }, [navigation, testo]);

  const cercando = ricerca.trim() !== '';
  const trovate = useMemo(() => filtraRicette(ricette, ricerca), [ricette, ricerca]);
  const voci = useMemo(() => vociCategorie(categorie, conteggi), [categorie, conteggi]);
  const ricettarioVuoto = caricato && !errore && conteggi.totale === 0 && categorie.length === 0;

  const scrivine = () => navigation.navigate('Modifica', { ricettaId: null });

  const nomeVoce = (voce: VoceCategorie): string => {
    if (voce.tipo === 'categoria') return voce.categoria.nome;
    return voce.tipo === 'senza' ? testo('categorie.senza') : testo('categorie.tutte');
  };

  /**
   * Il conteggio a parole, per chi la schermata la ascolta invece di guardarla.
   * A schermo resta il numero nudo, che si legge a colpo d'occhio; il lettore di
   * schermo però deve dire di cosa sono quei tre, e in italiano "1 ricette" è
   * sbagliato: per questo le forme sono tre e non una con il numero davanti.
   */
  const conteggioAParole = (n: number): string => {
    if (n === 0) return testo('categorie.conteggio.zero');
    if (n === 1) return testo('categorie.conteggio.una');
    return testo('categorie.conteggio.molte', { conteggio: String(n) });
  };

  return (
    <View style={stili.schermo}>
      <View style={stili.barra}>
        <TextInput
          style={stili.campo}
          value={ricerca}
          onChangeText={setRicerca}
          placeholder={testo('categorie.cerca')}
          placeholderTextColor="#8e8e93"
          autoCorrect={false}
          autoCapitalize="none"
          returnKeyType="search"
          clearButtonMode="never"
        />
        {ricerca !== '' && (
          <Pressable
            style={stili.svuota}
            onPress={() => setRicerca('')}
            hitSlop={12}
            accessibilityRole="button"
            accessibilityLabel={testo('categorie.cerca')}
          >
            <Text style={stili.svuotaSegno}>✕</Text>
          </Pressable>
        )}
      </View>

      {errore && <Text style={stili.errore}>{testo('errore.db')}</Text>}

      {cercando ? (
        <FlatList
          data={trovate}
          keyExtractor={(r) => r.id}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="on-drag"
          renderItem={({ item }) => (
            <Pressable
              style={stili.riga}
              accessibilityRole="button"
              onPress={() => navigation.navigate('Dosatore', { ricettaId: item.id })}
            >
              <Text style={stili.titolo} numberOfLines={2}>
                {item.titolo}
              </Text>
            </Pressable>
          )}
        />
      ) : ricettarioVuoto ? (
        <View style={stili.vuoto}>
          <Text style={stili.vuotoTitolo}>{testo('categorie.vuoto.titolo')}</Text>
          <Pressable style={stili.vuotoBottone} onPress={scrivine} accessibilityRole="button">
            <Text style={stili.vuotoInvito}>{testo('categorie.vuoto.invito')}</Text>
          </Pressable>
        </View>
      ) : (
        <FlatList
          data={voci}
          keyExtractor={chiaveVoce}
          keyboardShouldPersistTaps="handled"
          renderItem={({ item }) => (
            <Pressable
              style={stili.voce}
              accessibilityRole="button"
              accessibilityLabel={`${nomeVoce(item)}, ${conteggioAParole(item.conteggio)}`}
              onPress={() => navigation.navigate('Elenco', { filtro: filtroDaVoce(item) })}
            >
              {item.tipo === 'categoria' ? (
                <Icona chiave={item.categoria.icona} dimensione={24} colore="#0a7ea4" />
              ) : (
                <MaterialCommunityIcons
                  name={item.tipo === 'tutte' ? 'book-open-variant' : 'tray'}
                  size={24}
                  color="#0a7ea4"
                />
              )}
              <Text style={stili.voceNome} numberOfLines={1}>
                {nomeVoce(item)}
              </Text>
              <Text style={stili.voceConteggio}>{item.conteggio}</Text>
            </Pressable>
          )}
        />
      )}

      {/* Sempre a schermo, sopra tutti e tre i rami: il bottone dello stato
          vuoto sparisce appena c'è una ricetta o una categoria, e senza questo
          tondo da qui non si scriverebbe più niente. Stessa posizione e stesso
          gesto dell'elenco, così scrivere una ricetta si fa sempre allo stesso
          modo. */}
      <Pressable
        style={stili.tondo}
        onPress={scrivine}
        accessibilityRole="button"
        accessibilityLabel={testo('elenco.nuova')}
      >
        <Text style={stili.tondoSegno}>+</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  schermo: { flex: 1, backgroundColor: '#ffffff' },
  gestisci: { fontSize: 17, color: '#0a7ea4' },
  barra: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#f2f2f7',
    borderRadius: 12,
    marginHorizontal: 16,
    marginTop: 12,
    marginBottom: 4,
    paddingHorizontal: 12,
  },
  campo: { flex: 1, paddingVertical: 12, fontSize: 17, color: '#1c1c1e' },
  svuota: { paddingHorizontal: 4, paddingVertical: 4 },
  svuotaSegno: { fontSize: 16, color: '#6b6b70' },
  errore: { margin: 16, fontSize: 15, color: '#b00020' },
  voce: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingVertical: 16,
    paddingHorizontal: 16,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#d1d1d6',
  },
  voceNome: { flex: 1, fontSize: 18, color: '#1c1c1e', marginLeft: 14 },
  voceConteggio: { marginLeft: 12, fontSize: 17, color: '#6b6b70' },
  riga: {
    paddingVertical: 16,
    paddingHorizontal: 16,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#d1d1d6',
  },
  titolo: { fontSize: 18, color: '#1c1c1e' },
  vuoto: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 },
  vuotoTitolo: { fontSize: 22, fontWeight: '600', color: '#1c1c1e', textAlign: 'center' },
  vuotoBottone: {
    marginTop: 16,
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 12,
    backgroundColor: '#0a7ea4',
  },
  vuotoInvito: { fontSize: 17, color: '#ffffff', textAlign: 'center' },
  tondo: {
    position: 'absolute',
    right: 24,
    bottom: 24,
    width: 60,
    height: 60,
    borderRadius: 30,
    backgroundColor: '#0a7ea4',
    alignItems: 'center',
    justifyContent: 'center',
    elevation: 4,
    shadowColor: '#000000',
    shadowOpacity: 0.2,
    shadowRadius: 6,
    shadowOffset: { width: 0, height: 3 },
  },
  tondoSegno: { fontSize: 34, lineHeight: 38, color: '#ffffff' },
});
```

- [ ] **Step 13: Verifica che il progetto compili e i test restino verdi**

Comando: `npm run typecheck && npm test`
Atteso: PASS, ℹ pass 177, ℹ fail 0 (i 164 lasciati dal Task 17 più i 13 nuovi,
8 di `logica-elenco.test.ts` e 5 di `logica-categorie.test.ts`), nessun errore
di TypeScript. `PropsSchermata`, `useApp`, `elencoCategorie`, `elencoRicette`,
`conteggiPerCategoria` e `nomeIcona` esistono già dai task precedenti, quindi non
c'è niente da aspettarsi di rosso.

Le tre cose che qui possono diventare rosse sono tutte nomi presi da un altro
task, e vanno confrontate con la loro fonte prima di cercare l'errore altrove:
`FiltroElenco` e `Conteggi { totale, senza, perCategoria }` sono del Task 11
(`src/data/ricette.ts`), le undici chiavi i18n sono del Task 16 — comprese
`categorie.vuoto.titolo` e `categorie.vuoto.invito`, che l'addendum 3 §4 gli fa
aggiungere — e `ChiaveIcona` è l'unione dei trenta letterali del Task 5, non
`string`.

- [ ] **Step 14: Commit**

```bash
git add src/ui/componenti/Icona.tsx src/ui/schermate/Categorie.tsx
git commit -m "Riempi la schermata Categorie con ricerca globale, conteggi e stato vuoto"
```

---

---

### Task 19: Schermata Elenco, filtrata e con le miniature

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta ricalcola
le dosi di una ricetta. Il ricettario sta in SQLite sul dispositivo, senza
account e senza rete, e l'utente raggruppa le sue ricette in categorie che si
crea da solo: **una ricetta sta in una categoria sola, oppure in nessuna**.

Questa è la schermata che elenca le ricette. **Non è più la schermata
principale**: quella è Categorie, e ci si arriva da lì toccando "tutte le
ricette", "senza categoria" oppure una categoria. Il tocco porta con sé un
filtro, che arriva qui come parametro di rotta ed è l'unica cosa che decide
quali ricette si vedono.

Cosa fa la schermata:

- chiede al repository le ricette che passano il filtro, già ordinate
  alfabeticamente all'italiana. **Qui non si riordina niente**;
- ha la sua ricerca, che filtra **dentro quello che si sta guardando**: dentro
  una categoria cerca dentro quella categoria. È l'opposto della ricerca della
  schermata principale, che invece ignora le categorie di proposito;
- ogni riga mostra la **miniatura della foto**, quando la ricetta ce l'ha e il
  file c'è davvero;
- in basso a destra un pulsante tondo per la ricetta nuova;
- a elenco vuoto, e solo se non si sta cercando, invita a scrivere la prima
  ricetta e porta dritti alla schermata di scrittura. Quando invece è la ricerca
  a non trovare niente non si scrive nulla: la parola cercata è lì nel campo e
  si spiega da sola.

**I titoli possono essere duplicati** (l'identità di una ricetta è il suo id,
non il titolo): la navigazione passa **sempre** l'id, mai il titolo.

**La foto è un nome di file, non un percorso.** Nel database sta `a1b2c3.jpg`,
e il percorso assoluto lo ricostruisce `percorsoFoto()` al momento dell'uso. È
una regola che non si aggira: su iOS la cartella dell'app cambia ad ogni
aggiornamento, e un percorso assoluto salvato il giorno dopo punta al nulla.

**Files:**
- Modify: `src/ui/logica-elenco.ts` — si aggiungono `StatoElenco` e
  `statoElenco`, senza toccare `filtraRicette`
- Modify: `src/ui/logica-elenco.test.ts` — si aggiunge in fondo un blocco di 4
  test
- Modify: `src/ui/schermate/Elenco.tsx` — sostituisce per intero il guscio
  scritto dal Task 17

**Interfaces:**

- **Consumes**

  - `src/domain/types.ts` (Task 5):

    ```ts
    export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
    export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }

    export interface Ricetta {
      id: string;
      titolo: string;
      descrizione: string;
      porzioni: number | null;
      categoriaId: string | null;    // null = senza categoria, stato normale
      foto: string | null;           // NOME del file, non percorso assoluto
      gruppi: Gruppo[];
      creataIl: string;              // ISO 8601
      modificataIl: string;          // ISO 8601
      cancellataIl: string | null;   // tombstone, null = viva
    }

    export interface Categoria {
      id: string;
      nome: string;
      icona: ChiaveIcona;
      ordine: number;
      creataIl: string;
      modificataIl: string;
      cancellataIl: string | null;
    }
    ```

  - `src/data/ricette.ts` (Task 11):

    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';

    export type FiltroElenco =
      | { tipo: 'tutte' }
      | { tipo: 'categoria'; id: string }
      | { tipo: 'senza' };

    /** Solo le ricette vive che passano il filtro, già ordinate per titolo con localeCompare('it'). */
    export async function elencoRicette(db: SQLiteDatabase, filtro: FiltroElenco): Promise<Ricetta[]>;
    ```

  - `src/data/categorie.ts` (Task 12):

    ```ts
    /** Restituisce anche una categoria cancellata, se quell'id esiste; null se non esiste. */
    export async function leggiCategoria(db: SQLiteDatabase, id: string): Promise<Categoria | null>;
    ```

    Serve solo per scrivere il nome della categoria nell'intestazione. Il filtro
    non dipende da questa lettura: continua a funzionare anche se torna `null`.

  - `src/data/foto.ts` (Task 14):

    ```ts
    /**
     * Percorso da dare a <Image>, cioè un URI `file:///…/foto/<nome>`, oppure
     * null se il file non c'è. Riceve il NOME salvato nel database, non un
     * percorso.
     */
    export async function percorsoFoto(nomeFile: string | null): Promise<string | null>;
    ```

  - `src/ui/logica-elenco.ts` (Task 18):

    ```ts
    /** Filtra per titolo ignorando maiuscole, accenti e spazi ai bordi. Conserva l'ordine. */
    export function filtraRicette(ricette: Ricetta[], query: string): Ricetta[];
    ```

  - `src/i18n/index.ts` (Task 16). Chiavi chiamate qui, tutte **senza
    segnaposto**, quindi si chiamano senza il secondo argomento:

    ```
    elenco.cerca            elenco.titolo.tutte     elenco.titolo.senza
    elenco.vuoto.titolo     elenco.vuoto.invito     errore.db
    ```

    **I testi li scrive il Task 16, in `src/i18n/it.ts` e `src/i18n/en.ts`: qui
    non si ricopiano.** Una tabella di traduzioni in questo blocco è solo un
    secondo dizionario che diverge dal primo.

    `elenco.titolo.tutte` ed `elenco.titolo.senza` sono due delle quattro chiavi
    che l'**addendum 3 §4** fa aggiungere al Task 16, e servono all'intestazione
    delle due viste che non sono una categoria. Non si riusano `categorie.tutte`
    e `categorie.senza`: quelle sono le etichette di due voci di elenco, e un
    titolo di schermata che cambiasse insieme a una voce di menu è un legame che
    non regge la prima volta che si vuole ritoccare l'uno senza l'altro.

    `elenco.vuoto.invito` fa anche da etichetta del pulsante tondo per i lettori
    di schermo.

  - `src/ui/navigazione.ts` (Task 17):

    ```ts
    import type { NativeStackScreenProps } from '@react-navigation/native-stack';
    import type { FiltroElenco } from '../data/ricette.ts';

    export type ParametriNav = {
      Categorie: undefined;
      Elenco: { filtro: FiltroElenco };
      Dosatore: { ricettaId: string };
      /** ricettaId null significa ricetta nuova. */
      Modifica: { ricettaId: string | null };
      GestioneCategorie: undefined;
    };

    export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<ParametriNav, N>;
    ```

  - `src/ui/contesto.ts` (Task 17):

    ```ts
    export interface StatoApp {
      db: SQLiteDatabase;
      lingua: Lingua;
      testo: (chiave: Chiave, valori?: Record<string, string>) => string;
    }
    /** Lancia se usata fuori dal Provider montato da src/ui/App.tsx. */
    export function useApp(): StatoApp;
    ```

  - Dipendenze già installate dal Task 17:

    ```ts
    import { useFocusEffect } from '@react-navigation/native';   // effetto ad ogni ritorno sulla schermata
    ```

- **Produces**

  ```ts
  // src/ui/logica-elenco.ts, in aggiunta a filtraRicette
  export type StatoElenco = 'ricette' | 'nessun-risultato' | 'vuoto';
  /** Cosa mostrare nel corpo della schermata. */
  export function statoElenco(visibili: number, ricerca: string): StatoElenco;

  // src/ui/schermate/Elenco.tsx
  export default function Elenco(props: PropsSchermata<'Elenco'>);
  ```

  `src/ui/App.tsx` registra già il default export come
  `<Stack.Screen name="Elenco" component={Elenco} />`: la registrazione è del
  Task 17 e qui non si tocca.

---

- [ ] **Step 1: Scrivi il test che fallisce**

Aggiungi in fondo a `src/ui/logica-elenco.test.ts` questo blocco. Gli import di
un modulo ES valgono ovunque a livello di file, quindi l'`import` in mezzo al
file è legittimo e tiene il blocco insieme al suo argomento.

```ts
// --- cosa mostrare quando non c è niente da mostrare -------------------------

import { statoElenco } from './logica-elenco.ts';

test('con delle ricette visibili si mostra l elenco', () => {
  assert.equal(statoElenco(8, ''), 'ricette');
  assert.equal(statoElenco(2, 'zucca'), 'ricette');
});

test('elenco vuoto e nessuna ricerca: si invita a scrivere la prima ricetta', () => {
  // È il caso della categoria appena creata e ancora senza ricette, e quello
  // del ricettario nuovo. In tutti e due il gesto giusto è lo stesso.
  assert.equal(statoElenco(0, ''), 'vuoto');
});

test('ricerca senza risultati: non si scrive niente', () => {
  // La parola cercata è lì nel campo e si spiega da sola. "Nessun risultato"
  // sarebbe rumore, e "scrivi la prima ricetta" sarebbe proprio sbagliato.
  assert.equal(statoElenco(0, 'sushi'), 'nessun-risultato');
});

test('una ricerca fatta di soli spazi non conta come ricerca', () => {
  // Chi ha svuotato il campo lasciando uno spazio si aspetta l'elenco di prima.
  assert.equal(statoElenco(0, '   '), 'vuoto');
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `The requested module './logica-elenco.ts' does not provide an
export named 'statoElenco'`.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

Aggiungi in fondo a `src/ui/logica-elenco.ts`, senza toccare quello che c'è
sopra:

```ts
/** Cosa occupa il corpo della schermata Elenco. */
export type StatoElenco = 'ricette' | 'nessun-risultato' | 'vuoto';

/**
 * Le tre uscite si escludono a vicenda e non sono simmetriche.
 *
 * - `ricette`: c'è qualcosa da mostrare;
 * - `nessun-risultato`: si sta cercando e non si è trovato niente. La schermata
 *   non scrive nulla: la parola cercata è nel campo e si spiega da sola;
 * - `vuoto`: non si sta cercando e non c'è niente. Qui, e solo qui, si invita a
 *   scrivere la prima ricetta.
 *
 * Distinguere `vuoto` da `nessun-risultato` è il punto: invitare a scrivere una
 * ricetta perché una ricerca non ha trovato niente sarebbe un non sequitur.
 */
export function statoElenco(visibili: number, ricerca: string): StatoElenco {
  if (visibili > 0) return 'ricette';
  return ricerca.trim() === '' ? 'vuoto' : 'nessun-risultato';
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, `ℹ fail 0`, con i 4 test nuovi compresi: il totale sale di 4
rispetto a quello lasciato dal Task 18.

- [ ] **Step 5: Commit**

```bash
git add src/ui/logica-elenco.ts src/ui/logica-elenco.test.ts
git commit -m "Distingui l'elenco vuoto dalla ricerca senza risultati"
```

- [ ] **Step 6: Scrivi la schermata**

`src/ui/schermate/Elenco.tsx` esiste già come guscio dal Task 17.
**Sostituisci il file per intero** con quello qui sotto.

```tsx
/**
 * Elenco delle ricette che passano il filtro ricevuto dalla rotta.
 *
 * Il filtro arriva da Categorie: tutte, una categoria, oppure quelle senza. È
 * l'unica cosa che decide quali ricette si vedono, e non si tocca qui.
 *
 * La ricerca filtra dentro quello che si sta guardando: dentro una categoria
 * cerca dentro quella categoria. Quella che ignora le categorie sta sulla
 * schermata principale ed è un gesto diverso, di proposito.
 *
 * L'ordine alfabetico italiano arriva già fatto da elencoRicette(): qui non si
 * riordina.
 *
 * I titoli possono essere duplicati: alla schermata Dosatore si passa sempre
 * l'id, mai il titolo.
 */
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { FlatList, Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';

import type { PropsSchermata } from '../navigazione.ts';
import { useApp } from '../contesto.ts';
import type { Categoria, Ricetta } from '../../domain/types.ts';
import { elencoRicette } from '../../data/ricette.ts';
import { leggiCategoria } from '../../data/categorie.ts';
import { percorsoFoto } from '../../data/foto.ts';
import { filtraRicette, statoElenco } from '../logica-elenco.ts';

export default function Elenco({ route, navigation }: PropsSchermata<'Elenco'>) {
  const { db, testo } = useApp();
  const { filtro } = route.params;

  const [ricette, setRicette] = useState<Ricetta[]>([]);
  const [categoria, setCategoria] = useState<Categoria | null>(null);
  /** id della ricetta -> URI della sua miniatura. Solo quelle che esistono davvero. */
  const [miniature, setMiniature] = useState<Record<string, string>>({});
  const [ricerca, setRicerca] = useState('');
  const [errore, setErrore] = useState(false);
  // Finché il primo caricamento non è finito non si mostra l'invito: senza
  // questa sentinella un elenco pieno lampeggerebbe "nessuna ricetta".
  const [caricato, setCaricato] = useState(false);

  // Si ricarica ad ogni ritorno sulla schermata: tornando da Modifica, la
  // ricetta nuova, il titolo corretto, la foto cambiata o la cancellazione
  // devono comparire subito. La sentinella `vivo` evita di scrivere lo stato
  // dopo lo smontaggio.
  useFocusEffect(
    useCallback(() => {
      let vivo = true;
      (async () => {
        try {
          const elenco = await elencoRicette(db, filtro);
          const cat = filtro.tipo === 'categoria' ? await leggiCategoria(db, filtro.id) : null;

          // Le miniature si risolvono qui, una volta per caricamento, perché
          // percorsoFoto() controlla che il file esista davvero e <Image> non
          // può aspettare una promessa. Si chiede solo per le ricette che una
          // foto ce l'hanno: le altre non toccano il disco.
          const conFoto = elenco.filter((r) => r.foto !== null);
          const percorsi = await Promise.all(conFoto.map((r) => percorsoFoto(r.foto)));

          if (!vivo) return;

          const mappa: Record<string, string> = {};
          conFoto.forEach((r, i) => {
            const p = percorsi[i];
            // Se il file non c'è la riga resta senza miniatura e non si dice
            // niente: succede importando un archivio senza le foto, e non è un
            // guasto. L'elenco non scrive nel database per correggere il campo:
            // è una schermata di lettura.
            if (p !== null) mappa[r.id] = p;
          });

          setRicette(elenco);
          setCategoria(cat);
          setMiniature(mappa);
          setErrore(false);
          setCaricato(true);
        } catch {
          if (vivo) setErrore(true);
        }
      })();
      return () => {
        vivo = false;
      };
    }, [db, filtro]),
  );

  // L'intestazione: il nome della categoria che si sta guardando, oppure il
  // titolo fisso delle due viste che categoria non sono. Finché il nome non è
  // arrivato non si chiama setOptions, così si passa dal titolo registrato dal
  // Task 17 a quello vero senza sostare su una stringa vuota.
  useLayoutEffect(() => {
    if (filtro.tipo === 'tutte') {
      navigation.setOptions({ title: testo('elenco.titolo.tutte') });
    } else if (filtro.tipo === 'senza') {
      navigation.setOptions({ title: testo('elenco.titolo.senza') });
    } else if (categoria !== null) {
      navigation.setOptions({ title: categoria.nome });
    }
  }, [navigation, filtro, categoria, testo]);

  const visibili = useMemo(() => filtraRicette(ricette, ricerca), [ricette, ricerca]);
  const stato = caricato ? statoElenco(visibili.length, ricerca) : 'ricette';

  const scrivine = () => navigation.navigate('Modifica', { ricettaId: null });

  return (
    <View style={stili.schermo}>
      <View style={stili.barra}>
        <TextInput
          style={stili.campo}
          value={ricerca}
          onChangeText={setRicerca}
          placeholder={testo('elenco.cerca')}
          placeholderTextColor="#8e8e93"
          autoCorrect={false}
          autoCapitalize="none"
          returnKeyType="search"
          clearButtonMode="never"
        />
        {ricerca !== '' && (
          <Pressable
            style={stili.svuota}
            onPress={() => setRicerca('')}
            hitSlop={12}
            accessibilityRole="button"
            accessibilityLabel={testo('elenco.cerca')}
          >
            <Text style={stili.svuotaSegno}>✕</Text>
          </Pressable>
        )}
      </View>

      {errore && <Text style={stili.errore}>{testo('errore.db')}</Text>}

      {stato === 'vuoto' ? (
        <View style={stili.vuoto}>
          <Text style={stili.vuotoTitolo}>{testo('elenco.vuoto.titolo')}</Text>
          <Pressable style={stili.vuotoBottone} onPress={scrivine} accessibilityRole="button">
            <Text style={stili.vuotoInvito}>{testo('elenco.vuoto.invito')}</Text>
          </Pressable>
        </View>
      ) : (
        // Con 'nessun-risultato' la lista è vuota e non si scrive niente: la
        // parola cercata è lì nel campo e si spiega da sola.
        <FlatList
          data={visibili}
          keyExtractor={(r) => r.id}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="on-drag"
          renderItem={({ item }) => (
            <Pressable
              style={stili.riga}
              accessibilityRole="button"
              onPress={() => navigation.navigate('Dosatore', { ricettaId: item.id })}
            >
              {miniature[item.id] !== undefined ? (
                <Image source={{ uri: miniature[item.id] }} style={stili.miniatura} />
              ) : (
                // Un riquadro vuoto al posto della foto: i titoli restano
                // allineati anche in un elenco dove solo alcune ricette hanno
                // l'immagine.
                <View style={[stili.miniatura, stili.miniaturaVuota]} />
              )}
              <Text style={stili.titolo} numberOfLines={2}>
                {item.titolo}
              </Text>
            </Pressable>
          )}
        />
      )}

      <Pressable
        style={stili.tondo}
        onPress={scrivine}
        accessibilityRole="button"
        accessibilityLabel={testo('elenco.vuoto.invito')}
      >
        <Text style={stili.tondoSegno}>+</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  schermo: { flex: 1, backgroundColor: '#ffffff' },
  barra: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#f2f2f7',
    borderRadius: 12,
    marginHorizontal: 16,
    marginTop: 12,
    marginBottom: 4,
    paddingHorizontal: 12,
  },
  campo: { flex: 1, paddingVertical: 12, fontSize: 17, color: '#1c1c1e' },
  svuota: { paddingHorizontal: 4, paddingVertical: 4 },
  svuotaSegno: { fontSize: 16, color: '#6b6b70' },
  errore: { margin: 16, fontSize: 15, color: '#b00020' },
  riga: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingVertical: 12,
    paddingHorizontal: 16,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#d1d1d6',
  },
  miniatura: { width: 52, height: 52, borderRadius: 8, marginRight: 14 },
  miniaturaVuota: { backgroundColor: '#f2f2f7' },
  titolo: { flex: 1, fontSize: 18, color: '#1c1c1e' },
  vuoto: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 },
  vuotoTitolo: { fontSize: 22, fontWeight: '600', color: '#1c1c1e', textAlign: 'center' },
  vuotoBottone: {
    marginTop: 16,
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 12,
    backgroundColor: '#0a7ea4',
  },
  vuotoInvito: { fontSize: 17, color: '#ffffff', textAlign: 'center' },
  tondo: {
    position: 'absolute',
    right: 24,
    bottom: 24,
    width: 60,
    height: 60,
    borderRadius: 30,
    backgroundColor: '#0a7ea4',
    alignItems: 'center',
    justifyContent: 'center',
    elevation: 4,
    shadowColor: '#000000',
    shadowOpacity: 0.2,
    shadowRadius: 6,
    shadowOffset: { width: 0, height: 3 },
  },
  tondoSegno: { fontSize: 34, lineHeight: 38, color: '#ffffff' },
});
```

- [ ] **Step 7: Verifica che il progetto compili e i test restino verdi**

Comando: `npm run typecheck && npm test`
Atteso: PASS, ℹ pass 181, ℹ fail 0 (i 177 lasciati dal Task 18 più i 4 nuovi di
`logica-elenco.test.ts`), nessun errore di TypeScript.
`PropsSchermata`, `useApp`, `elencoRicette`,
`leggiCategoria` e `percorsoFoto` esistono già dai task precedenti, quindi non
c'è niente da aspettarsi di rosso.

- [ ] **Step 8: Commit**

```bash
git add src/ui/schermate/Elenco.tsx
git commit -m "Filtra l'elenco per categoria e mostra le miniature delle foto"
```

---

---

### Task 20: Schermata Dosatore

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta ricalcola
le dosi di una ricetta: cambi le porzioni, oppure dici "ho 250 g di farina
invece di 180" e tutti gli altri ingredienti si adeguano. Il ricettario sta in
SQLite sul dispositivo, senza account e senza rete.

Questa è la schermata più importante dell'app: quella che si guarda mentre si
cucina. Ci si arriva toccando una ricetta, dall'elenco filtrato o direttamente
dai risultati della ricerca sulla schermata principale.

In cima il titolo e il controllo delle porzioni, che **non compare** se la
ricetta non dichiara le porzioni (2 ricette su 12 dell'archivio vero non le
hanno). Sotto gli ingredienti, con la quantità in evidenza e toccabile:
toccandola si scrive quanta se ne ha davvero e tutto il resto si ricalcola. Le
intestazioni dei gruppi compaiono solo se i gruppi sono più di uno.

Quando la ricetta è riscalata una fascia dice cosa si sta guardando ("per 6
porzioni", "su 250 g di farina"), con accanto il ritorno alle dosi originali.
Lo schermo non si spegne. All'apertura si chiede `riscaloCorrente` per
riprendere dove si era rimasti: se torna `null` si mostrano le dosi originali
senza dire niente, perché non è un guasto, è una ricetta cambiata. Ogni riscalo
si salva con `salvaRiscalo`.

**In alto a destra la matita**, che apre questa ricetta in modifica. È il posto
giusto perché è qui che ci si accorge che una dose è sbagliata, e nell'app finita
è l'**unico** ingresso alla schermata di scrittura per una ricetta che esiste già:
dall'elenco e dalla ricerca si arriva al dosatore, non alla modifica. Con lei
sarebbe altrimenti irraggiungibile anche "Elimina ricetta", che vive in fondo a
`Modifica.tsx`. La decisione è dell'addendum 3 §6.

**Il dosatore non mostra le ricette cancellate.** `leggiRicetta` restituisce
anche le tombstone — servono all'export e all'import, e il Task 11 lo dichiara —
quindi non basta controllare `null`: si guarda anche `cancellataIl`, e se la
ricetta è stata cancellata mentre eravamo altrove si torna indietro.

**Perché la foto della ricetta qui non c'è.** La ricetta può avere una foto, e
si vede in due posti: nell'elenco, dove serve a riconoscere cosa si sta
scegliendo, e nella schermata di scrittura, dove si scatta e si sostituisce. Qui
no. Il dosatore è la schermata che si legge con le mani sporche di farina: una
foto in cima spinge in basso proprio i numeri per cui si è aperta la schermata e
costa uno scorrimento in più esattamente quando serve di meno. La foto la si è
appena vista nella riga che si è toccata, e riguardarla non aggiunge niente.
Tenerla fuori evita anche di chiamare `percorsoFoto()`, che tocca il disco,
sulla schermata dove il tempo di apertura conta di più.

**Il trabocchetto di questa schermata.** La riga mostra la quantità nell'unità
più leggibile della famiglia: la ricetta dice `580 g` di acqua, ma raddoppiata
la riga dice `1,16 kg`. Se l'utente tocca quella riga e scrive `2` intende due
chili, mentre la `Richiesta` va salvata nell'unità in cui la ricetta è scritta,
cioè `2000 g`, altrimenti il fattore esce diviso per mille. La conversione non
richiede nessuna tabella: `IngredienteScalato` porta con sé sia la quantità
mostrata sia `quantitaEsatta`, che è nell'unità originale, e il loro rapporto è
esattamente il fattore di conversione.

**Files:**
- Create: `src/ui/logica-dosatore.ts`
- Create: `src/ui/componenti/CampoQuantita.tsx`
- Create: `src/ui/componenti/FasciaRiscalo.tsx`
- Test: `src/ui/logica-dosatore.test.ts` (nuovo, 9 test)
- Modify: `src/ui/schermate/Dosatore.tsx` — sostituisce per intero il guscio
  scritto dal Task 17 (quello che stampava `Dosatore: {route.params.ricettaId}`)

**Interfaces:**

- **Consumes**

  - `src/domain/types.ts` (Task 5). I campi `categoriaId` e `foto` esistono sulla
    `Ricetta` e vanno riempiti nelle fixture dei test, ma questa schermata non li
    usa: sono il legame con la categoria e il nome del file della foto, e nessuno
    dei due entra nel calcolo delle dosi.

    ```ts
    export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
    export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }

    export interface Ricetta {
      id: string;
      titolo: string;
      descrizione: string;
      porzioni: number | null;
      categoriaId: string | null;    // null = senza categoria, stato normale
      foto: string | null;           // NOME del file, non percorso assoluto
      gruppi: Gruppo[];
      creataIl: string;
      modificataIl: string;
      cancellataIl: string | null;
    }

    /** Cosa ha chiesto l'utente. Si salva questa, non il fattore che ne esce. */
    export type Richiesta =
      | { tipo: 'porzioni'; porzioni: number }
      | { tipo: 'ingrediente'; ingredienteId: string; quantita: number };

    export interface IngredienteScalato {
      id: string;
      nome: string;
      quantita: number | null;        // null = q.b., già arrotondata e nell'unità mostrata
      quantitaEsatta: number | null;  // non arrotondata, nell'unità in cui la ricetta è scritta
      unita: string | null;           // unità mostrata, può differire da quella scritta (580 g -> 1,16 kg)
    }
    export interface RicettaScalata {
      fattore: number;
      porzioni: number | null;
      gruppi: { id: string; nome: string | null; ingredienti: IngredienteScalato[] }[];
    }

    /** Tutti gli ingredienti in un array piatto, nell'ordine di visualizzazione. */
    export const tuttiGliIngredienti: (r: Ricetta) => Ingrediente[];
    ```

  - `src/domain/lingua/index.ts` (Task 1):

    ```ts
    export type Lingua = 'it' | 'en';
    export type Famiglia = 'precisione' | 'misurino' | 'discreta';
    export interface Vocabolario {
      lingua: Lingua;
      unita: VoceUnita[];
      numeriAParole: Record<string, number>;
      quantoBasta: string[];
      riempitivi: string[];
      stop: string[];
      intestazioneNeutra: string[];
      separatoreDecimale: ',' | '.';
    }
    export function vocabolario(lingua: Lingua): Vocabolario;
    ```

    In `src/domain/lingua/it.ts` c'è `export const IT: Vocabolario` e in
    `src/domain/lingua/en.ts` `export const EN: Vocabolario`: servono al test.

  - `src/domain/format.ts` (Task 3):

    ```ts
    /** Separatore decimale secondo la lingua, zeri finali tolti. */
    export function numero(q: number, voc: Vocabolario): string;
    /** Quantità + unità accordata. quantita === null è "q.b." nella lingua del vocabolario. */
    export function formattaQuantita(quantita: number | null, unita: string | null, voc: Vocabolario): string;
    ```

  - `src/domain/scaling.ts` (Task 4):

    ```ts
    export const FATTORE_ORIGINALE = 1;
    export function fattoreDaIngrediente(ricetta: Ricetta, ingredienteId: string, quantitaDisponibile: number): number | null;
    /** Risolve una Richiesta contro la ricetta ATTUALE. null = non più calcolabile. */
    export function risolviRichiesta(ricetta: Ricetta, richiesta: Richiesta): number | null;
    /** Applica il fattore senza toccare la ricetta di partenza. */
    export function scala(ricetta: Ricetta, fattore: number, voc: Vocabolario): RicettaScalata;
    ```

  - `src/domain/parser/numeri.ts` (Task 6):

    ```ts
    export interface NumeroLetto {
      valore: number;
      /** Caratteri consumati dall'inizio della stringa passata. */
      lunghezza: number;
    }
    /** Legge un numero a inizio stringa: 200 | 1,5 | 1.5 | 1/2 | 2 1/2 | 2-3 (primo) | un | half */
    export function leggiNumero(testo: string, voc: Vocabolario): NumeroLetto | null;
    ```

  - `src/data/ricette.ts` (Task 11) e `src/data/riscalo.ts` (Task 13):

    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';

    /**
     * null solo se quell'id non esiste. Una ricetta cancellata la RESTITUISCE,
     * col suo `cancellataIl` valorizzato: export e import hanno bisogno delle
     * tombstone, e il Task 11 ha un test che lo inchioda.
     */
    export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null>;

    export async function salvaRiscalo(db: SQLiteDatabase, ricettaId: string, richiesta: Richiesta): Promise<void>;
    export async function dimenticaRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<void>;
    /**
     * Riscalo utilizzabile adesso: ricalcola il fattore dalla richiesta salvata
     * contro la ricetta attuale. Se non è più calcolabile lo dimentica e torna null.
     */
    export async function riscaloCorrente(db: SQLiteDatabase, ricetta: Ricetta):
      Promise<{ fattore: number; richiesta: Richiesta } | null>;
    ```

  - `src/i18n/index.ts` (Task 16):

    ```ts
    export type Chiave =
      | 'app.nome' | 'elenco.cerca' | /* … */ | 'dosatore.porzioni' | 'dosatore.originali'
      | 'dosatore.modifica'
      | 'dosatore.fascia.porzioni' | 'dosatore.fascia.ingrediente' | /* … */ | 'errore.db';

    /**
     * Testo della chiave, coi segnaposto sostituiti. Un segnaposto lasciato senza
     * valore fa lanciare: meglio accorgersene in sviluppo che leggere "{porzioni}"
     * mentre si cucina.
     */
    export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;
    ```

    Chiavi usate qui: `dosatore.porzioni` (etichetta del controllo, senza
    segnaposto), `dosatore.originali` (il ritorno alle dosi originali, senza
    segnaposto), `dosatore.modifica` (etichetta di accessibilità della matita in
    alto a destra, senza segnaposto), `dosatore.fascia.porzioni` con il
    segnaposto `{porzioni}`, `dosatore.fascia.ingrediente` con i segnaposti
    `{quantita}` e `{nome}`.

    `dosatore.modifica` è la chiave che l'**addendum 3 §6** fa aggiungere al Task
    16 insieme al pulsante di modifica. Serve una chiave sua: `modifica.titolo`
    è l'etichetta del campo del titolo dentro la schermata di scrittura, e usarla
    qui farebbe leggere "Titolo" a chi si affida al lettore di schermo. I testi
    li scrive il Task 16; il gesto che descrivono è "modifica questa ricetta".

    Nei dizionari i due testi della fascia sono:

    ```
    it: 'dosatore.fascia.porzioni'    -> 'riscalata per {porzioni} porzioni'
    it: 'dosatore.fascia.ingrediente' -> 'riscalata su {quantita} di {nome}'
    en: 'dosatore.fascia.porzioni'    -> 'scaled for {porzioni} servings'
    en: 'dosatore.fascia.ingrediente' -> 'scaled to {quantita} of {nome}'
    ```

    I nomi dei segnaposti sono esattamente questi tre: `porzioni`, `quantita`,
    `nome`. È il contratto fra `descriviRiscalo` e i dizionari, e va rispettato
    alla lettera perché `t()` lancia su un segnaposto senza valore.

  - `src/ui/navigazione.ts` e `src/ui/contesto.ts` (Task 17):

    ```ts
    // src/ui/navigazione.ts
    import type { FiltroElenco } from '../data/ricette.ts';

    export type ParametriNav = {
      Categorie: undefined;
      Elenco: { filtro: FiltroElenco };
      Dosatore: { ricettaId: string };
      /** ricettaId null significa ricetta nuova. */
      Modifica: { ricettaId: string | null };
      GestioneCategorie: undefined;
    };
    export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<ParametriNav, N>;

    // src/ui/contesto.ts
    export interface StatoApp {
      db: SQLiteDatabase;
      lingua: Lingua;
      testo: (chiave: Chiave, valori?: Record<string, string>) => string;
    }
    export function useApp(): StatoApp;
    ```

    Il database e la lingua arrivano da `useApp()`: `src/ui/App.tsx` apre il
    database con `apriDb()` e lo mette nel contesto, non monta nessun
    `SQLiteProvider` e non passa props alle schermate.

  - Dipendenze già installate dal Task 17 (`expo-keep-awake`,
    `@react-navigation/native`, `@react-navigation/native-stack`):

    ```ts
    import { useKeepAwake } from 'expo-keep-awake';   // tiene acceso lo schermo finché il componente è montato
    import { useFocusEffect } from '@react-navigation/native';
    ```

    E `@expo/vector-icons`, installato dal Task 5, per il glifo della matita:

    ```ts
    import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
    ```

    Il glifo `pencil` esiste nel glyphmap installato: verificato contro
    `node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/MaterialCommunityIcons.json`.
    Qui non serve nessun cast, perché `pencil` è un letterale che sta già
    nell'unione dei nomi accettata dal prop `name`.

- **Produces**

  ```ts
  // src/ui/logica-dosatore.ts
  /**
   * Quantità digitata in un campo dove l'unità sta in un campo a parte. Se dopo
   * il numero resta del testo il risultato è null: '12 g' non è una quantità.
   * La riesporta anche src/ui/bozza.ts (Task 22) col nome `quantitaDaTesto`,
   * invece di riscriverla: è lo stesso gesto, e due letture diverse dello stesso
   * campo sarebbero due comportamenti diversi.
   */
  export function leggiQuantita(testo: string, voc: Vocabolario): number | null;
  export function quantitaPerRichiesta(digitato: number, mostrata: number, esatta: number): number;
  export interface DescrizioneRiscalo {
    chiave: 'dosatore.fascia.porzioni' | 'dosatore.fascia.ingrediente';
    valori: Record<string, string>;
  }
  export function descriviRiscalo(richiesta: Richiesta, ricetta: Ricetta, voc: Vocabolario): DescrizioneRiscalo | null;

  // src/ui/componenti/CampoQuantita.tsx
  export interface PropsCampoQuantita {
    testo: string;                          // quantità già formattata con l'unità, es. "1,16 kg"
    attivo: boolean;                        // false per i q.b.: non si tocca
    proposta: string;                       // valore proposto nell'input, senza unità
    unita: string | null;                   // unità mostrata accanto all'input
    voc: Vocabolario;
    onQuantita: (quantita: number) => void; // quantità letta, nell'unità mostrata
  }
  export default function CampoQuantita(props: PropsCampoQuantita);

  // src/ui/componenti/FasciaRiscalo.tsx
  export interface PropsFasciaRiscalo {
    richiesta: Richiesta | null;   // null = dosi originali: la fascia non si mostra
    ricetta: Ricetta;
    voc: Vocabolario;
    onOriginali: () => void;
  }
  export default function FasciaRiscalo(props: PropsFasciaRiscalo);

  // src/ui/schermate/Dosatore.tsx
  export default function Dosatore(props: PropsSchermata<'Dosatore'>);
  ```

  `src/ui/App.tsx` registra già il default export come
  `<Stack.Screen name="Dosatore" component={Dosatore} />`: la registrazione è
  del Task 17 e qui non si tocca.

  `src/ui/logica-dosatore.ts` è un modulo puro: importa solo roba di
  `src/domain/`, né React né react-native. Per questo il Task 22 può importarci
  dentro `leggiQuantita` da `src/ui/bozza.ts` senza che `node --test` si trovi a
  caricare un modulo nativo.

---

- [ ] **Step 1: Scrivi il test che fallisce (lettura della quantità digitata)**

Crea `src/ui/logica-dosatore.test.ts`:

```ts
/**
 * Test della logica pura della schermata Dosatore. Gira con `npm test`, cioè
 * `node --test`: il modulo sotto test non importa React né react-native.
 *
 * La fixture è la Farinata vera del vecchio archivio: 14 porzioni, 180 g di
 * farina, 580 g di acqua, 8 g di sale, rosmarino q.b. Non sta in nessuna
 * categoria e non ha foto, che è lo stato normale di una ricetta appena
 * incollata.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { leggiQuantita, quantitaPerRichiesta } from './logica-dosatore.ts';
import { fattoreDaIngrediente, scala } from '../domain/scaling.ts';
import { IT } from '../domain/lingua/it.ts';
import { EN } from '../domain/lingua/en.ts';
import type { Ricetta } from '../domain/types.ts';

const farinata: Ricetta = {
  id: 'r1',
  titolo: 'Farinata',
  descrizione: '',
  porzioni: 14,
  categoriaId: null,
  foto: null,
  gruppi: [
    {
      id: 'g1',
      nome: null,
      ingredienti: [
        { id: 'i1', nome: 'Farina', quantita: 180, unita: 'g' },
        { id: 'i2', nome: 'Acqua', quantita: 580, unita: 'g' },
        { id: 'i3', nome: 'Sale', quantita: 8, unita: 'g' },
        { id: 'i4', nome: 'Rosmarino', quantita: null, unita: null },
      ],
    },
  ],
  creataIl: '2026-01-01T00:00:00.000Z',
  modificataIl: '2026-01-01T00:00:00.000Z',
  cancellataIl: null,
};

test('legge i numeri che si scrivono davvero in cucina', () => {
  assert.equal(leggiQuantita('250', IT), 250);
  assert.equal(leggiQuantita('  250  ', IT), 250);
  assert.equal(leggiQuantita('1,5', IT), 1.5);
  assert.equal(leggiQuantita('1.5', EN), 1.5);
  assert.equal(leggiQuantita('1/2', IT), 0.5);
});

test('quello che non è una quantità usabile diventa null', () => {
  // Spec, casi limite: su zero, negativi e testo l'interfaccia non fa nulla.
  assert.equal(leggiQuantita('', IT), null);
  assert.equal(leggiQuantita('   ', IT), null);
  assert.equal(leggiQuantita('0', IT), null);
  assert.equal(leggiQuantita('-3', IT), null);
  assert.equal(leggiQuantita('abc', IT), null);
});

test('dopo il numero non deve restare altro', () => {
  // "12 g" non è una quantità: è un ripensamento a metà. Meglio non fare nulla
  // che riscalare su un numero che l'utente non ha finito di scrivere.
  assert.equal(leggiQuantita('12 g', IT), null);
  assert.equal(leggiQuantita('250 farina', IT), null);
});

test('si digita nell unità MOSTRATA, si salva in quella scritta nella ricetta', () => {
  // La riga raddoppiata dice "1,16 kg" mentre la ricetta dice 580 g: se l'utente
  // scrive 2 intende 2 kg. Senza conversione il fattore uscirebbe diviso mille.
  const doppia = scala(farinata, 2, IT);
  const acqua = doppia.gruppi[0].ingredienti[1];
  assert.equal(acqua.unita, 'kg');
  assert.equal(acqua.quantita, 1.16);
  assert.equal(acqua.quantitaEsatta, 1160);

  const q = quantitaPerRichiesta(2, acqua.quantita ?? 0, acqua.quantitaEsatta ?? 0);
  // Tolleranza e non assert.equal: q è 2 * (1160 / 1,16), e in doppia
  // precisione 1160 / 1,16 fa 1000.0000000000001, quindi q vale
  // 2000.0000000000002. La funzione è giusta; è l'uguaglianza esatta a non
  // potersi usare sul risultato di una divisione fra decimali.
  assert.ok(Math.abs(q - 2000) < 1e-6, 'q vale ' + q);

  const f = fattoreDaIngrediente(farinata, 'i2', q);
  assert.ok(f !== null);
  const s = scala(farinata, f, IT);
  // Qui l'uguaglianza esatta va bene: queste quantità escono da
  // arrotondaPerCucina, che chiude la coda della divisione.
  assert.equal(s.gruppi[0].ingredienti[1].quantita, 2);     // i 2 kg che si hanno
  assert.equal(s.gruppi[0].ingredienti[1].unita, 'kg');
  assert.equal(s.gruppi[0].ingredienti[0].quantita, 621);   // 180 * 2000/580 = 620,69
});

test('quando l unità mostrata è quella scritta, la conversione non tocca niente', () => {
  const doppia = scala(farinata, 2, IT);
  const farina = doppia.gruppi[0].ingredienti[0];
  assert.equal(farina.unita, 'g');
  assert.equal(quantitaPerRichiesta(500, farina.quantita ?? 0, farina.quantitaEsatta ?? 0), 500);
});

test('la conversione non esplode su una quantità mostrata a zero', () => {
  assert.equal(quantitaPerRichiesta(5, 0, 0), 5);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `Cannot find module '…/src/ui/logica-dosatore.ts'`.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

Crea `src/ui/logica-dosatore.ts`:

```ts
/**
 * Logica pura della schermata Dosatore, tenuta fuori dai .tsx così si può
 * verificare con `node --test`: qui non entrano React né react-native.
 */
import type { Vocabolario } from '../domain/lingua/index.ts';
import { leggiNumero } from '../domain/parser/numeri.ts';

/**
 * Legge la quantità digitata sulla riga di un ingrediente.
 *
 * Accetta quello che il parser accetta a inizio riga (250, 1,5, 1/2, 2 1/2),
 * ma pretende che dopo il numero non resti altro: "12 g" non è una quantità.
 * null per tutto ciò che non è un numero positivo, e su null la schermata non
 * fa nulla.
 */
export function leggiQuantita(testo: string, voc: Vocabolario): number | null {
  const pulito = testo.trim();
  if (pulito === '') return null;

  const letto = leggiNumero(pulito, voc);
  if (letto === null) return null;
  if (pulito.slice(letto.lunghezza).trim() !== '') return null;

  return letto.valore > 0 ? letto.valore : null;
}

/**
 * Riporta la quantità digitata nell'unità in cui la ricetta è scritta.
 *
 * La riga mostra la resa più leggibile della famiglia: la ricetta dice 580 g,
 * la riga raddoppiata dice "1,16 kg". Chi scrive 2 su quella riga intende due
 * chili, ma la Richiesta va confrontata con i 580 g scritti nella ricetta.
 * Non serve nessuna tabella di conversione: `quantitaEsatta` è già nell'unità
 * originale e `quantita` è lo stesso valore nell'unità mostrata, quindi il loro
 * rapporto È la conversione.
 */
export function quantitaPerRichiesta(digitato: number, mostrata: number, esatta: number): number {
  if (!(mostrata > 0) || !Number.isFinite(esatta)) return digitato;
  return digitato * (esatta / mostrata);
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, `ℹ fail 0`, con i 6 test nuovi di `logica-dosatore.test.ts`
compresi: il totale sale di 6 rispetto a quello lasciato dal Task 19.

- [ ] **Step 5: Commit**

```bash
git add src/ui/logica-dosatore.ts src/ui/logica-dosatore.test.ts
git commit -m "Leggi la quantità digitata e riportala nell'unità scritta nella ricetta"
```

- [ ] **Step 6: Scrivi il test che fallisce (testo della fascia)**

Aggiungi in fondo a `src/ui/logica-dosatore.test.ts` questo blocco. Gli import
di un modulo ES valgono ovunque a livello di file, come già fa
`src/domain/domain.test.ts` a metà file.

I nomi delle chiavi dei `valori` sono la parte che conta: devono essere
esattamente i segnaposti scritti in `src/i18n/it.ts` e `src/i18n/en.ts`
(`{porzioni}`, `{quantita}`, `{nome}`), altrimenti `t()` lancia quando la fascia
prova a comporre il testo.

```ts
// --- cosa dice la fascia quando la ricetta è riscalata -----------------------

import { descriviRiscalo } from './logica-dosatore.ts';

test('la fascia descrive la richiesta, non il moltiplicatore', () => {
  // "per 6 porzioni" e "su 250 g di farina" dicono qualcosa; "x1,39" no.
  assert.deepEqual(descriviRiscalo({ tipo: 'porzioni', porzioni: 6 }, farinata, IT), {
    chiave: 'dosatore.fascia.porzioni',
    valori: { porzioni: '6' },
  });

  assert.deepEqual(
    descriviRiscalo({ tipo: 'ingrediente', ingredienteId: 'i1', quantita: 250 }, farinata, IT),
    { chiave: 'dosatore.fascia.ingrediente', valori: { quantita: '250 g', nome: 'Farina' } },
  );
});

test('la quantità della fascia è formattata nella lingua del vocabolario', () => {
  const d = descriviRiscalo({ tipo: 'ingrediente', ingredienteId: 'i2', quantita: 1.5 }, farinata, IT);
  assert.ok(d !== null);
  assert.equal(d.valori.quantita, '1,5 g');
});

test('richiesta non più descrivibile: niente fascia, nessun messaggio di errore', () => {
  // L'ingrediente è stato cancellato o è diventato q.b. dopo il riscalo.
  assert.equal(
    descriviRiscalo({ tipo: 'ingrediente', ingredienteId: 'sparito', quantita: 250 }, farinata, IT),
    null,
  );
  assert.equal(
    descriviRiscalo({ tipo: 'ingrediente', ingredienteId: 'i4', quantita: 250 }, farinata, IT),
    null,
  );
});
```

- [ ] **Step 7: Esegui il test e verifica che fallisca**

Comando: `npm test`
Atteso: FAIL con `The requested module './logica-dosatore.ts' does not provide an
export named 'descriviRiscalo'`.

- [ ] **Step 8: Implementa il minimo che fa passare il test**

Sostituisci `src/ui/logica-dosatore.ts` con questo, che aggiunge
`descriviRiscalo` e i due import che le servono:

```ts
/**
 * Logica pura della schermata Dosatore, tenuta fuori dai .tsx così si può
 * verificare con `node --test`: qui non entrano React né react-native.
 */
import type { Ricetta, Richiesta } from '../domain/types.ts';
import { tuttiGliIngredienti } from '../domain/types.ts';
import type { Vocabolario } from '../domain/lingua/index.ts';
import { formattaQuantita, numero } from '../domain/format.ts';
import { leggiNumero } from '../domain/parser/numeri.ts';

/**
 * Legge la quantità digitata sulla riga di un ingrediente.
 *
 * Accetta quello che il parser accetta a inizio riga (250, 1,5, 1/2, 2 1/2),
 * ma pretende che dopo il numero non resti altro: "12 g" non è una quantità.
 * null per tutto ciò che non è un numero positivo, e su null la schermata non
 * fa nulla.
 */
export function leggiQuantita(testo: string, voc: Vocabolario): number | null {
  const pulito = testo.trim();
  if (pulito === '') return null;

  const letto = leggiNumero(pulito, voc);
  if (letto === null) return null;
  if (pulito.slice(letto.lunghezza).trim() !== '') return null;

  return letto.valore > 0 ? letto.valore : null;
}

/**
 * Riporta la quantità digitata nell'unità in cui la ricetta è scritta.
 *
 * La riga mostra la resa più leggibile della famiglia: la ricetta dice 580 g,
 * la riga raddoppiata dice "1,16 kg". Chi scrive 2 su quella riga intende due
 * chili, ma la Richiesta va confrontata con i 580 g scritti nella ricetta.
 * Non serve nessuna tabella di conversione: `quantitaEsatta` è già nell'unità
 * originale e `quantita` è lo stesso valore nell'unità mostrata, quindi il loro
 * rapporto È la conversione.
 */
export function quantitaPerRichiesta(digitato: number, mostrata: number, esatta: number): number {
  if (!(mostrata > 0) || !Number.isFinite(esatta)) return digitato;
  return digitato * (esatta / mostrata);
}

/**
 * Chiave i18n e valori da interpolare per la fascia in cima al dosatore.
 *
 * Le chiavi di `valori` sono i segnaposti dei testi: `porzioni` per
 * 'dosatore.fascia.porzioni', `quantita` e `nome` per
 * 'dosatore.fascia.ingrediente'. Sbagliarne uno non passa inosservato: `t()`
 * lancia su un segnaposto rimasto senza valore.
 */
export interface DescrizioneRiscalo {
  chiave: 'dosatore.fascia.porzioni' | 'dosatore.fascia.ingrediente';
  valori: Record<string, string>;
}

/**
 * Cosa scrivere nella fascia. Si parte dalla RICHIESTA salvata, non dal fattore:
 * "per 6 porzioni" e "su 250 g di farina" dicono qualcosa, "x1,39" no.
 *
 * Torna null quando la richiesta non è più descrivibile, cioè l'ingrediente è
 * stato cancellato o è diventato q.b.: la fascia semplicemente non compare, e
 * non si mostra nessun errore perché non è un guasto, è una ricetta cambiata.
 */
export function descriviRiscalo(
  richiesta: Richiesta,
  ricetta: Ricetta,
  voc: Vocabolario,
): DescrizioneRiscalo | null {
  if (richiesta.tipo === 'porzioni') {
    return {
      chiave: 'dosatore.fascia.porzioni',
      valori: { porzioni: numero(richiesta.porzioni, voc) },
    };
  }

  const ing = tuttiGliIngredienti(ricetta).find((i) => i.id === richiesta.ingredienteId);
  if (ing === undefined || ing.quantita === null) return null;

  return {
    chiave: 'dosatore.fascia.ingrediente',
    valori: {
      quantita: formattaQuantita(richiesta.quantita, ing.unita, voc),
      nome: ing.nome,
    },
  };
}
```

- [ ] **Step 9: Esegui il test e verifica che passi**

Comando: `npm test`
Atteso: PASS, `ℹ fail 0`, con i 9 test di `logica-dosatore.test.ts` compresi: il
totale sale di altri 3, cioè di 9 rispetto al Task 19.

- [ ] **Step 10: Commit**

```bash
git add src/ui/logica-dosatore.ts src/ui/logica-dosatore.test.ts
git commit -m "Descrivi il riscalo partendo dalla richiesta salvata"
```

- [ ] **Step 11: Scrivi il campo della quantità**

Crea `src/ui/componenti/CampoQuantita.tsx`:

```tsx
/**
 * La quantità di un ingrediente: in evidenza e toccabile.
 *
 * Toccandola diventa un campo di testo dove si scrive quanta se ne ha davvero.
 * Si conferma con il tasto della tastiera o toccando fuori; su un valore
 * assurdo (zero, negativo, testo) il campo si chiude e non succede niente,
 * come vuole la spec.
 *
 * Il numero digitato è nell'unità MOSTRATA sulla riga, che può differire da
 * quella scritta nella ricetta: la conversione la fa la schermata Dosatore con
 * quantitaPerRichiesta().
 */
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';

import type { Vocabolario } from '../../domain/lingua/index.ts';
import { leggiQuantita } from '../logica-dosatore.ts';

export interface PropsCampoQuantita {
  /** Quantità già formattata con l'unità, es. "1,16 kg" oppure "q.b.". */
  testo: string;
  /** false per i q.b.: non si tocca e non riscala. */
  attivo: boolean;
  /** Valore proposto nel campo, senza unità, già nella lingua giusta. */
  proposta: string;
  /** Unità mostrata accanto al campo, così si sa in che unità si sta scrivendo. */
  unita: string | null;
  voc: Vocabolario;
  /** Quantità letta, nell'unità mostrata. Non viene chiamata su valori assurdi. */
  onQuantita: (quantita: number) => void;
}

export default function CampoQuantita({
  testo,
  attivo,
  proposta,
  unita,
  voc,
  onQuantita,
}: PropsCampoQuantita) {
  // null = non si sta scrivendo: si vede la quantità, non il campo.
  const [bozza, setBozza] = useState<string | null>(null);

  if (bozza === null) {
    return (
      <Pressable
        disabled={!attivo}
        onPress={() => setBozza(proposta)}
        hitSlop={8}
        accessibilityRole={attivo ? 'button' : 'text'}
        accessibilityLabel={testo}
      >
        <Text style={attivo ? stili.quantitaToccabile : stili.quantita}>{testo}</Text>
      </Pressable>
    );
  }

  const conferma = () => {
    const q = leggiQuantita(bozza, voc);
    setBozza(null);
    if (q !== null) onQuantita(q);
  };

  return (
    <View style={stili.modifica}>
      <TextInput
        style={stili.campo}
        value={bozza}
        onChangeText={setBozza}
        keyboardType="decimal-pad"
        autoFocus
        selectTextOnFocus
        returnKeyType="done"
        onSubmitEditing={conferma}
        onBlur={conferma}
      />
      {unita !== null && <Text style={stili.unita}>{unita}</Text>}
    </View>
  );
}

const stili = StyleSheet.create({
  quantita: { fontSize: 18, color: '#6b6b70' },
  quantitaToccabile: {
    fontSize: 18,
    fontWeight: '600',
    color: '#0a7ea4',
    textDecorationLine: 'underline',
  },
  modifica: { flexDirection: 'row', alignItems: 'center' },
  campo: {
    minWidth: 72,
    paddingVertical: 4,
    paddingHorizontal: 8,
    fontSize: 18,
    fontWeight: '600',
    color: '#1c1c1e',
    textAlign: 'right',
    backgroundColor: '#f2f2f7',
    borderRadius: 8,
  },
  unita: { marginLeft: 6, fontSize: 18, color: '#6b6b70' },
});
```

- [ ] **Step 12: Scrivi la fascia del riscalo**

Crea `src/ui/componenti/FasciaRiscalo.tsx`:

```tsx
/**
 * La fascia che dice cosa si sta guardando quando la ricetta è riscalata:
 * "per 6 porzioni" oppure "su 250 g di farina", con accanto il ritorno alle
 * dosi originali.
 *
 * Con le dosi originali non si mostra affatto, e nemmeno quando la richiesta
 * non è più descrivibile: sparisce in silenzio, senza messaggi di errore.
 *
 * Qui si chiama `t()` con `voc.lingua` invece della `testo()` del contesto
 * perché il vocabolario è già una prop: il componente resta indipendente
 * dall'albero React e si può montare ovunque.
 */
import { Pressable, StyleSheet, Text, View } from 'react-native';

import type { Ricetta, Richiesta } from '../../domain/types.ts';
import type { Vocabolario } from '../../domain/lingua/index.ts';
import { t } from '../../i18n/index.ts';
import { descriviRiscalo } from '../logica-dosatore.ts';

export interface PropsFasciaRiscalo {
  /** null = dosi originali. */
  richiesta: Richiesta | null;
  ricetta: Ricetta;
  voc: Vocabolario;
  onOriginali: () => void;
}

export default function FasciaRiscalo({
  richiesta,
  ricetta,
  voc,
  onOriginali,
}: PropsFasciaRiscalo) {
  const descrizione = richiesta === null ? null : descriviRiscalo(richiesta, ricetta, voc);
  if (descrizione === null) return null;

  return (
    <View style={stili.fascia}>
      <Text style={stili.testo} numberOfLines={2}>
        {t(descrizione.chiave, voc.lingua, descrizione.valori)}
      </Text>
      <Pressable onPress={onOriginali} hitSlop={8} accessibilityRole="button">
        <Text style={stili.originali}>{t('dosatore.originali', voc.lingua)}</Text>
      </Pressable>
    </View>
  );
}

const stili = StyleSheet.create({
  fascia: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#fff4e5',
    borderRadius: 12,
    paddingVertical: 10,
    paddingHorizontal: 14,
    marginHorizontal: 16,
    marginBottom: 12,
  },
  testo: { flex: 1, fontSize: 15, color: '#7a4b00' },
  originali: { marginLeft: 12, fontSize: 15, fontWeight: '600', color: '#0a7ea4' },
});
```

- [ ] **Step 13: Scrivi la schermata**

`src/ui/schermate/Dosatore.tsx` esiste già come guscio dal Task 17: ci sta un
`Text` che stampa l'id della rotta. **Sostituisci il file per intero** con quello
qui sotto.

```tsx
/**
 * Il dosatore: la schermata che si guarda mentre si cucina.
 *
 * Due modi di riscalare, entrambi salvati come RICHIESTA e non come fattore:
 *   1. il controllo delle porzioni, che non compare se la ricetta non le
 *      dichiara (2 ricette su 12 dell'archivio vero);
 *   2. il tocco sulla quantità di un ingrediente, cioè "ne ho davvero
 *      questa quantità": è la funzione che dà valore all'app.
 *
 * All'apertura, e ad ogni ritorno sulla schermata, la ricetta si rilegge e il
 * fattore si ricalcola con riscaloCorrente() dalla richiesta salvata. Se la
 * ricetta nel frattempo è cambiata e la richiesta non è più risolvibile si
 * torna alle dosi originali senza dire niente: non è un guasto.
 *
 * In alto a destra la matita, che apre questa ricetta in modifica: è l'unico
 * ingresso alla scrittura di una ricetta che esiste già, e quindi anche l'unica
 * via verso «Elimina ricetta».
 *
 * La foto della ricetta qui non si mostra: una fotografia in cima spinge in
 * basso i numeri per cui si è aperta la schermata. La si vede nell'elenco, dove
 * serve a riconoscere, e nella schermata di scrittura, dove si scatta. Per lo
 * stesso motivo questa schermata non chiama percorsoFoto(), che tocca il disco.
 *
 * Lo schermo non si spegne: qui si hanno le mani sporche.
 */
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useKeepAwake } from 'expo-keep-awake';
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';

import type { PropsSchermata } from '../navigazione.ts';
import { useApp } from '../contesto.ts';
import type { Ricetta, Richiesta } from '../../domain/types.ts';
import { leggiRicetta } from '../../data/ricette.ts';
import { dimenticaRiscalo, riscaloCorrente, salvaRiscalo } from '../../data/riscalo.ts';
import { FATTORE_ORIGINALE, risolviRichiesta, scala } from '../../domain/scaling.ts';
import { formattaQuantita, numero } from '../../domain/format.ts';
import { vocabolario } from '../../domain/lingua/index.ts';
import { quantitaPerRichiesta } from '../logica-dosatore.ts';
import CampoQuantita from '../componenti/CampoQuantita.tsx';
import FasciaRiscalo from '../componenti/FasciaRiscalo.tsx';

export default function Dosatore({ route, navigation }: PropsSchermata<'Dosatore'>) {
  useKeepAwake();

  const { db, lingua, testo } = useApp();
  const voc = useMemo(() => vocabolario(lingua), [lingua]);
  const { ricettaId } = route.params;

  const [ricetta, setRicetta] = useState<Ricetta | null>(null);
  const [richiesta, setRichiesta] = useState<Richiesta | null>(null);
  const [fattore, setFattore] = useState(FATTORE_ORIGINALE);

  useFocusEffect(
    useCallback(() => {
      let vivo = true;
      (async () => {
        const r = await leggiRicetta(db, ricettaId);
        if (!vivo) return;
        if (r === null || r.cancellataIl !== null) {
          // Sparita o cancellata mentre eravamo altrove: si torna da dove si
          // veniva, che sia l'elenco filtrato o la ricerca della schermata
          // principale. Il controllo su `cancellataIl` non è ridondante:
          // leggiRicetta restituisce anche le tombstone, perché servono
          // all'export, e senza questo ramo il dosatore mostrerebbe le dosi di
          // una ricetta che nell'elenco non c'è più.
          navigation.goBack();
          return;
        }
        const corrente = await riscaloCorrente(db, r);
        if (!vivo) return;
        setRicetta(r);
        setRichiesta(corrente === null ? null : corrente.richiesta);
        setFattore(corrente === null ? FATTORE_ORIGINALE : corrente.fattore);
      })();
      return () => {
        vivo = false;
      };
    }, [db, ricettaId, navigation]),
  );

  // La matita in alto a destra: l'ingresso alla modifica di una ricetta che
  // esiste già. Sta qui e non nell'elenco perché è qui che ci si accorge che una
  // dose è sbagliata, cioè mentre si cucina. È anche l'unica via verso «Elimina
  // ricetta», che vive in fondo a Modifica.tsx: senza questo pulsante una
  // ricetta scritta male non si potrebbe né correggere né buttare.
  useLayoutEffect(() => {
    navigation.setOptions({
      headerRight: () => (
        <Pressable
          onPress={() => navigation.navigate('Modifica', { ricettaId })}
          hitSlop={12}
          accessibilityRole="button"
          accessibilityLabel={testo('dosatore.modifica')}
        >
          <MaterialCommunityIcons name="pencil" size={24} color="#0a7ea4" />
        </Pressable>
      ),
    });
  }, [navigation, ricettaId, testo]);

  /** Un riscalo nuovo: si salva la richiesta, il fattore si ricalcola da lei. */
  const applica = useCallback(
    (nuova: Richiesta) => {
      if (ricetta === null) return;
      const f = risolviRichiesta(ricetta, nuova);
      if (f === null) return; // valori assurdi: non si fa nulla
      setRichiesta(nuova);
      setFattore(f);
      void salvaRiscalo(db, ricetta.id, nuova);
    },
    [db, ricetta],
  );

  const tornaAgliOriginali = useCallback(() => {
    if (ricetta === null) return;
    setRichiesta(null);
    setFattore(FATTORE_ORIGINALE);
    void dimenticaRiscalo(db, ricetta.id);
  }, [db, ricetta]);

  const scalata = useMemo(
    () => (ricetta === null ? null : scala(ricetta, fattore, voc)),
    [ricetta, fattore, voc],
  );

  if (ricetta === null || scalata === null) return <View style={stili.schermo} />;

  // Le porzioni: null significa che la ricetta non le dichiara e il controllo
  // non compare. La costante serve anche a tenere il restringimento di tipo
  // dentro le callback dei pulsanti.
  const porzioni = scalata.porzioni;
  // Le intestazioni dei gruppi hanno senso solo se i gruppi sono più di uno.
  const conIntestazioni = scalata.gruppi.length > 1;

  return (
    <ScrollView
      style={stili.schermo}
      contentContainerStyle={stili.contenuto}
      keyboardShouldPersistTaps="handled"
    >
      <Text style={stili.titolo}>{ricetta.titolo}</Text>

      <FasciaRiscalo
        richiesta={richiesta}
        ricetta={ricetta}
        voc={voc}
        onOriginali={tornaAgliOriginali}
      />

      {porzioni !== null && (
        <View style={stili.porzioni}>
          <Text style={stili.porzioniEtichetta}>{testo('dosatore.porzioni')}</Text>
          <Pressable
            style={stili.passo}
            hitSlop={8}
            accessibilityRole="button"
            onPress={() => applica({ tipo: 'porzioni', porzioni: Math.max(1, porzioni - 1) })}
          >
            <Text style={stili.passoSegno}>-</Text>
          </Pressable>
          <Text style={stili.porzioniNumero}>{porzioni}</Text>
          <Pressable
            style={stili.passo}
            hitSlop={8}
            accessibilityRole="button"
            onPress={() => applica({ tipo: 'porzioni', porzioni: porzioni + 1 })}
          >
            <Text style={stili.passoSegno}>+</Text>
          </Pressable>
        </View>
      )}

      {scalata.gruppi.map((gruppo) => (
        <View key={gruppo.id}>
          {conIntestazioni && gruppo.nome !== null && (
            <Text style={stili.gruppo}>{gruppo.nome}</Text>
          )}
          {gruppo.ingredienti.map((ing) => (
            <View key={ing.id} style={stili.riga}>
              <Text style={stili.nome} numberOfLines={2}>
                {ing.nome}
              </Text>
              <CampoQuantita
                testo={formattaQuantita(ing.quantita, ing.unita, voc)}
                attivo={ing.quantita !== null}
                proposta={ing.quantita === null ? '' : numero(ing.quantita, voc)}
                unita={ing.unita}
                voc={voc}
                onQuantita={(digitato) =>
                  applica({
                    tipo: 'ingrediente',
                    ingredienteId: ing.id,
                    // Il numero digitato è nell'unità mostrata sulla riga
                    // ("1,16 kg"), la richiesta va salvata in quella scritta
                    // nella ricetta (580 g).
                    quantita: quantitaPerRichiesta(
                      digitato,
                      ing.quantita ?? 0,
                      ing.quantitaEsatta ?? 0,
                    ),
                  })
                }
              />
            </View>
          ))}
        </View>
      ))}
    </ScrollView>
  );
}

const stili = StyleSheet.create({
  schermo: { flex: 1, backgroundColor: '#ffffff' },
  contenuto: { paddingTop: 16, paddingBottom: 48 },
  titolo: {
    fontSize: 26,
    fontWeight: '700',
    color: '#1c1c1e',
    paddingHorizontal: 16,
    marginBottom: 12,
  },
  porzioni: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    marginBottom: 16,
  },
  porzioniEtichetta: { flex: 1, fontSize: 17, color: '#1c1c1e' },
  porzioniNumero: {
    minWidth: 48,
    textAlign: 'center',
    fontSize: 20,
    fontWeight: '600',
    color: '#1c1c1e',
  },
  passo: {
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: '#f2f2f7',
    alignItems: 'center',
    justifyContent: 'center',
  },
  passoSegno: { fontSize: 24, lineHeight: 28, color: '#0a7ea4' },
  gruppo: {
    fontSize: 13,
    fontWeight: '700',
    letterSpacing: 0.6,
    textTransform: 'uppercase',
    color: '#6b6b70',
    paddingHorizontal: 16,
    marginTop: 20,
    marginBottom: 4,
  },
  riga: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    paddingVertical: 14,
    paddingHorizontal: 16,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#d1d1d6',
  },
  nome: { flex: 1, fontSize: 17, color: '#1c1c1e', marginRight: 12 },
});
```

- [ ] **Step 14: Verifica che il progetto compili e i test restino verdi**

Comando: `npm run typecheck && npm test`
Atteso: PASS, ℹ pass 190, ℹ fail 0 (i 181 lasciati dal Task 19 più i 9 nuovi di
`logica-dosatore.test.ts`), nessun errore di TypeScript. `PropsSchermata`,
`useApp`, `leggiRicetta` e le tre funzioni di `src/data/riscalo.ts` esistono già
dai task precedenti, e `dosatore.modifica` è nell'unione `Chiave` del Task 16 per
decisione dell'addendum 3 §6.

Prova a mano, che i test non coprono: apri una ricetta dall'elenco, tocca la
matita in alto a destra. Si apre la rotta Modifica, che a questo punto è ancora
il guscio del Task 17 e scrive soltanto l'id ricevuto: la cosa da verificare qui
è che **l'id sia quello giusto**, non che la schermata sia piena. Tornando
indietro si torna al dosatore.

I campi compilati e il tasto "Elimina ricetta" arrivano col Task 22, che li
verifica nella sua prova a mano. Quello che questo task chiude è il percorso, che
fino a qui non esisteva del tutto.

- [ ] **Step 15: Commit**

```bash
git add src/ui/componenti/CampoQuantita.tsx src/ui/componenti/FasciaRiscalo.tsx src/ui/schermate/Dosatore.tsx
git commit -m "Riempi la schermata Dosatore con porzioni, riscalo per ingrediente e matita di modifica"
```

---

### Task 21: Gestione categorie e scelta icona

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta è un'app
iOS e Android che ricalcola le dosi di una ricetta. Il ricettario sta in un
SQLite sul dispositivo, senza account e senza rete. L'utente raggruppa le sue
ricette in **categorie che si crea da solo**, ognuna con un nome libero e
un'icona presa da un catalogo chiuso di trenta che forniamo noi: la schermata
principale dell'app è proprio l'elenco delle categorie.

Le categorie sono raggruppamenti, non etichette: **una ricetta sta in una
categoria sola, oppure in nessuna**. Non c'è nessuna tabella di legame, c'è una
colonna `categoria_id` sulla riga della ricetta.

Questo task scrive la schermata dove le categorie si gestiscono: crearle,
rinominarle, cambiargli icona, riordinarle, cancellarle. Più la griglia con cui
si sceglie l'icona.

Due cose vanno fatte bene.

**La prima: la conferma di cancellazione deve dire che le ricette non si
perdono.** «Elimina categoria» spaventa, e la paura è ragionevole: da qualche
parte ci sono venti ricette dentro. Cancellare una categoria **non** cancella le
sue ricette — perdono il legame e finiscono in «Senza categoria» — e questo va
detto nella finestra di conferma, cioè nel punto e nel momento in cui la paura
nasce. Detto altrove non serve a niente: o l'utente non cancella mai più
niente, oppure cancella e poi passa dieci minuti a cercare le ricette
convinto di averle perse. Il testo è già scritto (chiave
`categorie.conferma.elimina`, Task 16) e la promessa è già resa vera dal
repository (Task 12): questo task deve solo mostrarla nel posto giusto.

**La seconda: il nome duplicato si segnala, non si vieta.** L'identità di una
categoria è il suo id, quindi due categorie che si chiamano «Dolci» sono
ammesse (spec, sezione 9). Quasi sempre però è una svista, e mentre si scrive
compare l'avviso. Un avviso, non un blocco.

**Files:**
- Modify: `src/ui/logica-categorie.ts` (**il file esiste già**: l'ha creato il
  Task 18 con `VoceCategorie` e `vociCategorie`. Qui si aggiunge in fondo, non si
  sostituisce niente)
- Create: `src/ui/componenti/SceltaIcona.tsx`
- Modify: `src/ui/schermate/GestioneCategorie.tsx` (il Task 17 l'ha creata come
  guscio provvisorio; qui se ne sostituisce per intero il contenuto, tenendo la
  firma)
- Test (Modify): `src/ui/logica-categorie.test.ts` (**esiste già** con i 5 test
  di `vociCategorie` scritti dal Task 18: questo task ne aggiunge 9 in fondo, e
  alla fine nel file ce ne sono 14)

**Interfaces:**

- **Consumes**

  - `src/domain/types.ts` (Task 5):
    ```ts
    export interface Categoria {
      id: string;
      nome: string;
      icona: ChiaveIcona;
      /** Posizione nell'elenco, scelta dall'utente riordinando. */
      ordine: number;
      creataIl: string;              // ISO 8601
      modificataIl: string;          // ISO 8601
      cancellataIl: string | null;   // tombstone, null = viva
    }
    ```
  - `src/domain/icone.ts` (Task 5), il catalogo chiuso delle trenta icone:
    ```ts
    /**
     * Niente annotazione di tipo su ICONE, e non è una dimenticanza: `as const`
     * è quello che tiene le chiavi come letterali. Scrivere
     * `const ICONE: readonly VoceIcona[]` farebbe collassare ChiaveIcona a
     * string, e la promessa «un valore fuori catalogo non compila» diventerebbe
     * falsa.
     */
    export const ICONE = [ /* trenta voci { chiave, nome } */ ] as const;
    /** L'unione delle trenta chiavi: un valore fuori catalogo non compila. */
    export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
    export interface VoceIcona { chiave: ChiaveIcona; nome: string }
    /** Icona di una categoria che l'utente non ha scelto diversamente. */
    export const ICONA_PREDEFINITA: ChiaveIcona;
    /** Guardia per il confine coi dati. */
    export function iconaValida(c: string): c is ChiaveIcona;
    /** Il nome da dare a <MaterialCommunityIcons name=…>. Chiave sconosciuta -> predefinita. */
    export function nomeIcona(c: string): string;
    ```
    Le trenta chiavi sono, in ordine: `piatto`, `pasta`, `riso`, `pizza`,
    `pane`, `zuppa`, `panino`, `griglia`, `pentola`, `colazione`, `torta`,
    `biscotti`, `dolcetti`, `gelato`, `pesce`, `carne`, `salumi`, `uova`,
    `formaggi`, `verdure`, `funghi`, `frutta`, `erbe`, `caffe`, `te`, `vino`,
    `cocktail`, `stella`, `cuore`, `segnalibro`. Il Task 5 ha un test che
    rilegge il glyphmap del pacchetto installato e verifica che tutti e trenta i
    nomi `MaterialCommunityIcons` esistano davvero: un nome inventato non dà
    errore di compilazione, dà un quadratino vuoto a schermo.
  - `src/domain/id.ts` — `export function newId(): string` (uuid v4). Il Task 17
    l'ha riscritta su `expo-crypto` con un `require` protetto e due ripieghi:
    resta caricabile da `node --test`.
  - `src/data/categorie.ts` (Task 12):
    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';
    /** Solo le vive, per `ordine` crescente e, a parità, per nome all'italiana. */
    export async function elencoCategorie(db: SQLiteDatabase): Promise<Categoria[]>;
    /** Upsert. NON timbra: modificataIl viene scritto così come arriva nell'oggetto. */
    export async function salvaCategoria(db: SQLiteDatabase, categoria: Categoria): Promise<void>;
    /**
     * Marca la tombstone con l'ora corrente e azzera il categoria_id delle
     * ricette che ci stavano dentro. Le ricette NON vengono cancellate.
     */
    export async function cancellaCategoria(db: SQLiteDatabase, id: string): Promise<void>;
    /** Scrive in `ordine` la posizione di ogni id nell'array (il primo prende 0) e timbra. */
    export async function riordinaCategorie(db: SQLiteDatabase, idInOrdine: string[]): Promise<void>;
    ```
    Siccome `salvaCategoria` non timbra, è questa schermata a mettere la data:
    `new Date().toISOString()`.
  - `src/i18n/index.ts` (Task 16):
    ```ts
    export type Chiave = 'app.nome' | 'categorie.crea' | /* … */ | 'errore.db';
    /** Lancia se un segnaposto della chiave resta senza valore. */
    export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;
    ```
    Chiavi usate da questo task, tutte già presenti nell'unione `Chiave`:
    `app.nome`, `categorie.crea`, `categorie.nome`, `categorie.nome.duplicato`,
    `categorie.icona`, `categorie.rinomina`, `categorie.elimina`,
    `categorie.conferma.elimina`, `categorie.sposta.su`, `categorie.sposta.giu`,
    `modifica.salva`, `modifica.annulla`, `errore.db`.
    Qui **non** si usa `categorie.vuoto.invito`: quella dice «Scrivi la prima
    ricetta» ed è lo stato vuoto della schermata principale, non di questa.
    **`categorie.conferma.elimina` ha il segnaposto `{nome}`** e va sempre
    chiamata passandolo, altrimenti `t()` lancia. Il suo testo italiano è
    `'Vuoi eliminare la categoria «{nome}»? Le ricette che contiene non vengono
    cancellate: finiscono in «Senza categoria».'`. Tutte le altre chiavi di
    questo elenco non hanno segnaposto. **L'unione `Chiave` non va allargata**:
    l'elenco è fissato dal Task 16.
  - `src/ui/contesto.ts` (Task 17):
    ```ts
    export interface StatoApp {
      db: SQLiteDatabase;
      lingua: Lingua;
      /** t() già legata alla lingua dell'app. */
      testo: (chiave: Chiave, valori?: Record<string, string>) => string;
    }
    /** Lancia se chiamata fuori dal provider montato in src/ui/App.tsx. */
    export function useApp(): StatoApp;
    ```
  - `src/ui/navigazione.ts` (Task 17):
    ```ts
    import type { NativeStackScreenProps } from '@react-navigation/native-stack';
    export type ParametriNav = {
      Categorie: undefined;
      Elenco: { filtro: FiltroElenco };
      Dosatore: { ricettaId: string };
      Modifica: { ricettaId: string | null };   // null = ricetta nuova
      GestioneCategorie: undefined;
    };
    export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<ParametriNav, N>;
    ```
  - `src/ui/App.tsx` (Task 17) registra la schermata così, senza passarle
    niente oltre alla navigazione, e il titolo dell'intestazione è già a posto:
    ```tsx
    <Stack.Screen
      name="GestioneCategorie"
      component={GestioneCategorie}
      options={{ title: t('categorie.gestisci', lingua) }}
    />
    ```
    Ci si arriva dalla schermata Categorie con
    `navigation.navigate('GestioneCategorie')`.
  - `src/ui/logica-categorie.ts` (Task 18). **Il file esiste già**, e questo task
    ci aggiunge roba in fondo invece di crearlo:
    ```ts
    export type VoceCategorie =
      | { tipo: 'tutte'; conteggio: number }
      | { tipo: 'senza'; conteggio: number }
      | { tipo: 'categoria'; categoria: Categoria; conteggio: number };
    export function vociCategorie(categorie: Categoria[], conteggi: Conteggi): VoceCategorie[];
    ```
    `Categorie.tsx` importa `vociCategorie` da lì: sovrascrivere il file la
    romperebbe. Vale anche per `src/ui/logica-categorie.test.ts`, che dal Task 18
    contiene 5 test e le fixture `ADESSO` e `cat(id, nome, ordine)` — quelle si
    riusano, non si riscrivono.
  - `src/ui/componenti/Icona.tsx` (Task 18). Traduce la chiave del catalogo nel
    nome del glifo, una volta sola per tutta l'app:
    ```tsx
    export interface PropsIcona { chiave: ChiaveIcona; dimensione: number; colore: string }
    export default function Icona(props: PropsIcona);
    ```
    Questo task la usa in tutti e due i posti dove disegna un'icona
    (`SceltaIcona.tsx` e `GestioneCategorie.tsx`): il cast verso il tipo `name` di
    `MaterialCommunityIcons` sta scritto lì dentro e non si ripete qui.
  - `@expo/vector-icons`, installato dal Task 5. Questo task **non lo importa
    direttamente**: ci passa attraverso `Icona.tsx`.
  - `@react-navigation/native`, installato dal Task 17:
    `export function useFocusEffect(effetto: React.EffectCallback): void;`
    Esegue l'effetto ad ogni ritorno sulla schermata.

- **Produces**
  - `src/ui/logica-categorie.ts` — cinque funzioni **aggiunte in fondo** al file
    del Task 18, che resta con dentro anche `VoceCategorie` e `vociCategorie`. È
    la logica delle categorie senza React intorno:
    ```ts
    /** Senza nome non si salva. */
    export function siPuoSalvare(nome: string): boolean;
    /** Esiste già una categoria con questo nome? Avvisa, non impedisce. */
    export function nomeDuplicato(nome: string, categorie: Categoria[], idEscluso: string | null): boolean;
    /** Categoria nuova, in fondo all'elenco. */
    export function categoriaNuova(nome: string, icona: ChiaveIcona, categorie: Categoria[], adesso: string): Categoria;
    /** Rinomina e cambia icona: creataIl resta, modificataIl passa ad adesso. */
    export function categoriaModificata(c: Categoria, nome: string, icona: ChiaveIcona, adesso: string): Categoria;
    /**
     * L'elenco con la categoria in posizione `indice` spostata di un posto.
     * Ai bordi restituisce **lo stesso array**, non una copia.
     */
    export function sposta(categorie: Categoria[], indice: number, passi: -1 | 1): Categoria[];
    ```
    Lo consuma anche il Task 22, che da qui prende `categoriaNuova`,
    `nomeDuplicato` e `siPuoSalvare` per creare una categoria al volo mentre si
    scrive una ricetta.
  - `src/ui/componenti/SceltaIcona.tsx`
    ```tsx
    export type PropsSceltaIcona = {
      scelta: ChiaveIcona;
      onScegli: (chiave: ChiaveIcona) => void;
    };
    export function SceltaIcona(props: PropsSceltaIcona);
    ```
    Lo monta anche il Task 22, dentro la finestra per creare una categoria al
    volo.
  - `src/ui/schermate/GestioneCategorie.tsx`
    ```tsx
    export default function GestioneCategorie(props: PropsSchermata<'GestioneCategorie'>);
    ```
    Non esporta nessun tipo di props proprio: la firma è quella comune a tutte
    le schermate, e `db`, `lingua` e `testo` arrivano da `useApp()`.

**Due decisioni da tenere a mente mentre si legge il codice**

1. `sposta` ai bordi restituisce **lo stesso array che ha ricevuto**, non una
   copia uguale. La schermata se ne serve: `if (nuovo === categorie) return;` è
   il modo più corto per non scrivere sul database quando il tasto "su" della
   prima riga viene toccato. I tasti restano visibili e spenti invece di
   sparire, altrimenti le righe ballerebbero sotto il dito.
2. `sposta` rinumera `ordine` con la posizione nell'array, cioè scrive
   esattamente quello che scriverà `riordinaCategorie`. Così lo stato locale e
   il database restano d'accordo senza aspettare la rilettura, e la riga si
   muove subito sotto il dito invece che dopo il giro sul disco.

- [ ] **Step 1: Scrivi il test che fallisce, prima parte: nomi e creazione**

`src/ui/logica-categorie.test.ts` **esiste già**: l'ha scritto il Task 18 con i 5
test di `vociCategorie`, e ci si aggiunge in fondo. Le sue fixture si riusano
invece di riscriverle: `cat(id, nome, ordine)` costruisce già la `Categoria` che
serve, e `ADESSO` è la data che mette in `creataIl` e `modificataIl`.

Prima le due righe di import. Sotto quella che c'è già,

```ts
import { vociCategorie } from './logica-categorie.ts';
```

aggiungi

```ts
import { siPuoSalvare, nomeDuplicato, categoriaNuova } from './logica-categorie.ts';
```

(nell'ordine in cui le funzioni stanno nel modulo, così l'errore dello Step 2
nomina la prima delle tre). Poi allarga l'import delle icone, che oggi è
`import { ICONA_PREDEFINITA } from '../domain/icone.ts';`, in

```ts
import { ICONA_PREDEFINITA, ICONE } from '../domain/icone.ts';
```

Infine aggiungi in fondo al file:

```ts
/** Un istante dopo ADESSO, la data che `cat` scrive in creataIl e modificataIl. */
const DOPO = '2026-08-19T09:00:00.000Z';

const elenco = [cat('c1', 'Antipasti', 0), cat('c2', 'Primi', 1), cat('c3', 'Dolci', 2)];

test('senza nome non si salva', () => {
  assert.equal(siPuoSalvare(''), false);
  assert.equal(siPuoSalvare('   '), false);
  assert.equal(siPuoSalvare('Dolci'), true);
});

test('un nome già usato si segnala, spazi e maiuscole comprese', () => {
  assert.equal(nomeDuplicato('dolci', elenco, null), true);
  assert.equal(nomeDuplicato('  DOLCI  ', elenco, null), true);
  assert.equal(nomeDuplicato('Contorni', elenco, null), false);
  // Il campo vuoto non è un duplicato: è un campo vuoto, e lo dice siPuoSalvare.
  assert.equal(nomeDuplicato('   ', elenco, null), false);
});

test('rinominare una categoria non la rende duplicato di sé stessa', () => {
  assert.equal(nomeDuplicato('Dolci', elenco, 'c3'), false);
  assert.equal(nomeDuplicato('Primi', elenco, 'c3'), true);
});

test("la categoria nuova va in fondo, anche con dei buchi nell'ordine", () => {
  // Le cancellazioni lasciano buchi nella numerazione: contare le righe darebbe
  // un ordine già occupato.
  const conBuco = [cat('a', 'Antipasti', 0), cat('b', 'Bevande', 7)];
  const nuova = categoriaNuova('  Contorni  ', ICONE[1].chiave, conBuco, DOPO);
  assert.equal(nuova.ordine, 8);
  assert.equal(nuova.nome, 'Contorni');           // il nome arriva ripulito
  assert.equal(nuova.icona, ICONE[1].chiave);
  assert.equal(nuova.creataIl, DOPO);
  assert.equal(nuova.modificataIl, DOPO);
  assert.equal(nuova.cancellataIl, null);
  assert.notEqual(nuova.id, '');
  assert.ok(!conBuco.some((c) => c.id === nuova.id));

  // Primo ricettario, nessuna categoria: si parte da zero.
  assert.equal(categoriaNuova('Dolci', ICONA_PREDEFINITA, [], DOPO).ordine, 0);
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con
`SyntaxError: The requested module './logica-categorie.ts' does not provide an export named 'siPuoSalvare'`.
Il modulo esiste dal Task 18 — quindi niente `ERR_MODULE_NOT_FOUND` — ma le tre
funzioni nuove non ci sono ancora.

- [ ] **Step 3: Implementa il minimo che fa passare il test**

`src/ui/logica-categorie.ts` esiste già col `VoceCategorie` e la `vociCategorie`
del Task 18: si aggiunge in fondo, non si sostituisce niente. In cima, accanto
agli import che ci sono, aggiungi i due che servono alle funzioni nuove:

```ts
import { newId } from '../domain/id.ts';
import type { ChiaveIcona } from '../domain/icone.ts';
```

(`Categoria` è già importata dal Task 18.) Poi, in fondo al file:

```ts
// --- Le categorie mentre l'utente le manipola: crearle, rinominarle,
// cambiargli icona, riordinarle. Nessuna di queste funzioni muta gli argomenti:
// la schermata tiene il suo stato con useState e lo sostituisce.

/** Senza nome non si salva: nell'elenco resterebbe una riga muta. */
export function siPuoSalvare(nome: string): boolean {
  return nome.trim() !== '';
}

/**
 * Esiste già una categoria con questo nome?
 *
 * Non lo impedisce e non deve impedirlo: l'identità di una categoria è il suo
 * id, e due «Dolci» sono ammesse. Serve solo ad avvisare mentre si scrive,
 * perché quasi sempre è una svista.
 *
 * `idEscluso` è la categoria che si sta modificando: rinominare «Dolci» in
 * «Dolci» non è un duplicato di sé stessa.
 */
export function nomeDuplicato(
  nome: string,
  categorie: Categoria[],
  idEscluso: string | null,
): boolean {
  const cercato = nome.trim().toLocaleLowerCase('it');
  if (cercato === '') return false;
  return categorie.some(
    (c) => c.id !== idEscluso && c.nome.trim().toLocaleLowerCase('it') === cercato,
  );
}

/**
 * Categoria nuova, in fondo all'elenco.
 *
 * L'ordine è il massimo esistente più uno, non il numero di righe: le
 * cancellazioni lasciano buchi nella numerazione, e contando le righe si
 * finirebbe per assegnare un ordine già occupato.
 */
export function categoriaNuova(
  nome: string,
  icona: ChiaveIcona,
  categorie: Categoria[],
  adesso: string,
): Categoria {
  return {
    id: newId(),
    nome: nome.trim(),
    icona,
    ordine: categorie.reduce((massimo, c) => Math.max(massimo, c.ordine + 1), 0),
    creataIl: adesso,
    modificataIl: adesso,
    cancellataIl: null,
  };
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, 9 test verdi nel file `src/ui/logica-categorie.test.ts` (i 5 di
`vociCategorie` lasciati dal Task 18 più i 4 aggiunti allo Step 1), 0 falliti. I
test dei task precedenti restano verdi.

- [ ] **Step 5: Commit**

```bash
git add src/ui/logica-categorie.ts src/ui/logica-categorie.test.ts
git commit -m "Aggiungi la logica dei nomi e della creazione delle categorie"
```

- [ ] **Step 6: Scrivi il test che fallisce, seconda parte: modifica e riordino**

In `src/ui/logica-categorie.test.ts` sostituisci la riga di import aggiunta allo
Step 1 (quella con `siPuoSalvare`, non quella di `vociCategorie` del Task 18)
con questa:

```ts
import {
  siPuoSalvare,
  nomeDuplicato,
  categoriaNuova,
  categoriaModificata,
  sposta,
} from './logica-categorie.ts';
```

Poi aggiungi in fondo al file:

```ts
test('modificare tiene la data di creazione e sposta quella di modifica', () => {
  const modificata = categoriaModificata(elenco[2], '  Dolci al cucchiaio  ', ICONE[10].chiave, DOPO);
  assert.equal(modificata.id, 'c3');            // l'id non cambia mai
  assert.equal(modificata.nome, 'Dolci al cucchiaio');
  assert.equal(modificata.icona, ICONE[10].chiave);
  assert.equal(modificata.ordine, 2);           // il posto nell'elenco non si tocca qui
  assert.equal(modificata.creataIl, ADESSO);    // la data di creazione che ci ha messo `cat`
  assert.equal(modificata.modificataIl, DOPO);
  assert.equal(modificata.cancellataIl, null);
  assert.equal(elenco[2].nome, 'Dolci');        // l'originale non è mutato
});

test("spostare in su scambia due righe e rinumera l'ordine", () => {
  const dopo = sposta(elenco, 2, -1);
  assert.deepEqual(
    dopo.map((c) => c.id),
    ['c1', 'c3', 'c2'],
  );
  // L'ordine diventa la posizione: è quello che scriverà riordinaCategorie.
  assert.deepEqual(
    dopo.map((c) => c.ordine),
    [0, 1, 2],
  );
});

test('spostare in giù fa il contrario', () => {
  const dopo = sposta(elenco, 0, 1);
  assert.deepEqual(
    dopo.map((c) => c.id),
    ['c2', 'c1', 'c3'],
  );
  assert.deepEqual(
    dopo.map((c) => c.ordine),
    [0, 1, 2],
  );
});

test("ai bordi lo spostamento restituisce l'elenco identico", () => {
  // Stesso array, non una copia: la schermata usa `nuovo === categorie` per
  // non scrivere sul database quando non c'è niente da scrivere.
  assert.equal(sposta(elenco, 0, -1), elenco);
  assert.equal(sposta(elenco, 2, 1), elenco);
  assert.equal(sposta(elenco, -1, 1), elenco);
  assert.equal(sposta(elenco, 9, -1), elenco);
  const vuoto: Categoria[] = [];
  assert.equal(sposta(vuoto, 0, 1), vuoto);     // elenco vuoto: non lancia
});

test("spostare non tocca l'elenco di partenza", () => {
  const partenza = [cat('c1', 'Antipasti', 0), cat('c2', 'Primi', 1)];
  sposta(partenza, 0, 1);
  assert.deepEqual(
    partenza.map((c) => c.id),
    ['c1', 'c2'],
  );
  assert.deepEqual(
    partenza.map((c) => c.ordine),
    [0, 1],
  );
});
```

- [ ] **Step 7: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `does not provide an export named 'categoriaModificata'`
(`SyntaxError` al caricamento del modulo: le due funzioni nuove non esistono
ancora).

- [ ] **Step 8: Implementa la modifica e lo spostamento**

Aggiungi in fondo a `src/ui/logica-categorie.ts`:

```ts
/** Rinomina e cambia icona. creataIl resta, modificataIl passa ad adesso. */
export function categoriaModificata(
  c: Categoria,
  nome: string,
  icona: ChiaveIcona,
  adesso: string,
): Categoria {
  return { ...c, nome: nome.trim(), icona, modificataIl: adesso };
}

/**
 * L'elenco con la categoria in posizione `indice` spostata di un posto.
 *
 * Fuori dai bordi restituisce **lo stesso array**, non una copia: la schermata
 * se ne serve per accorgersi che non c'è niente da scrivere sul database. I
 * tasti su e giù restano visibili anche sulla prima e sull'ultima riga, spenti,
 * perché farli sparire farebbe ballare le righe sotto il dito.
 *
 * L'ordine viene rinumerato con la posizione, cioè con quello che scriverà
 * riordinaCategorie: lo stato locale e il database restano d'accordo senza
 * aspettare la rilettura.
 */
export function sposta(categorie: Categoria[], indice: number, passi: -1 | 1): Categoria[] {
  const destinazione = indice + passi;
  if (indice < 0 || indice >= categorie.length) return categorie;
  if (destinazione < 0 || destinazione >= categorie.length) return categorie;
  const spostato = [...categorie];
  [spostato[indice], spostato[destinazione]] = [spostato[destinazione], spostato[indice]];
  return spostato.map((c, i) => ({ ...c, ordine: i }));
}
```

- [ ] **Step 9: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, 14 test verdi nel file `src/ui/logica-categorie.test.ts` (i 9
dello Step 4 più i 5 aggiunti allo Step 6), 0 falliti.

- [ ] **Step 10: Commit**

```bash
git add src/ui/logica-categorie.ts src/ui/logica-categorie.test.ts
git commit -m "Aggiungi la modifica e il riordino delle categorie"
```

- [ ] **Step 11: Scrivi la griglia delle icone**

Crea `src/ui/componenti/SceltaIcona.tsx`:

```tsx
/**
 * La griglia delle trenta icone del catalogo.
 *
 * Le icone sono nostre e sono trenta: niente ricerca, niente caricamento,
 * niente rete. Si scorrono e si toccano.
 *
 * Il glifo lo disegna Icona.tsx (Task 18): la traduzione fra la chiave che sta
 * nel database e il nome che vuole MaterialCommunityIcons è scritta una volta
 * sola, lì, e qui non si ripete.
 */
import { Pressable, StyleSheet, View } from 'react-native';

import Icona from './Icona.tsx';
import { ICONE } from '../../domain/icone.ts';
import type { ChiaveIcona } from '../../domain/icone.ts';

export type PropsSceltaIcona = {
  scelta: ChiaveIcona;
  onScegli: (chiave: ChiaveIcona) => void;
};

export function SceltaIcona({ scelta, onScegli }: PropsSceltaIcona) {
  return (
    <View style={stili.griglia}>
      {ICONE.map((voce) => {
        const attiva = voce.chiave === scelta;
        return (
          <Pressable
            key={voce.chiave}
            onPress={() => onScegli(voce.chiave)}
            style={[stili.cella, attiva ? stili.cellaScelta : null]}
            accessibilityRole="button"
            accessibilityState={{ selected: attiva }}
            accessibilityLabel={voce.chiave}
          >
            <Icona chiave={voce.chiave} dimensione={26} colore={attiva ? '#1b6b4a' : '#444'} />
          </Pressable>
        );
      })}
    </View>
  );
}

const stili = StyleSheet.create({
  griglia: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
  cella: {
    width: 44,
    height: 44,
    borderRadius: 10,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#f2f2f2',
  },
  cellaScelta: { backgroundColor: '#dff0e7', borderWidth: 2, borderColor: '#1b6b4a' },
});
```

- [ ] **Step 12: Verifica che i tipi tornino**

Comando: `npm run typecheck`

Atteso: nessun errore (nessun output, uscita 0). È qui che si vedrebbe un prop
di `Icona` scritto male o un `ChiaveIcona` fuori catalogo.

- [ ] **Step 13: Scrivi la schermata di gestione**

Sostituisci per intero il contenuto di `src/ui/schermate/GestioneCategorie.tsx`
(il Task 17 l'ha lasciata come guscio che mostra solo l'invito e un tondo che
torna indietro):

```tsx
/**
 * Gestione delle categorie: crearle, rinominarle, cambiargli icona,
 * riordinarle, cancellarle.
 *
 * Ci si arriva dalla schermata principale. db, lingua e testo arrivano da
 * useApp(): la schermata è registrata con `component={GestioneCategorie}` e non
 * riceve props oltre a quelle della navigazione.
 *
 * La conferma di cancellazione dice per intero che cosa succede alle ricette
 * che stavano dentro: non vengono cancellate, finiscono in «Senza categoria».
 * «Elimina categoria» spaventa, e la promessa serve nel punto in cui la paura
 * nasce, non in una nota da qualche altra parte.
 */
import { useCallback, useState } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import {
  Alert,
  Modal,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';

import {
  cancellaCategoria,
  elencoCategorie,
  riordinaCategorie,
  salvaCategoria,
} from '../../data/categorie.ts';
import { ICONA_PREDEFINITA } from '../../domain/icone.ts';
import Icona from '../componenti/Icona.tsx';
import { SceltaIcona } from '../componenti/SceltaIcona.tsx';
import { useApp } from '../contesto.ts';
import {
  categoriaModificata,
  categoriaNuova,
  nomeDuplicato,
  siPuoSalvare,
  sposta,
} from '../logica-categorie.ts';
import type { ChiaveIcona } from '../../domain/icone.ts';
import type { Categoria } from '../../domain/types.ts';
import type { PropsSchermata } from '../navigazione.ts';

/** null = finestra chiusa. Dentro la finestra, id null significa categoria nuova. */
type Editor = { id: string | null; nome: string; icona: ChiaveIcona } | null;

export default function GestioneCategorie(_props: PropsSchermata<'GestioneCategorie'>) {
  const { db, testo } = useApp();
  const [categorie, setCategorie] = useState<Categoria[]>([]);
  const [errore, setErrore] = useState(false);
  const [editor, setEditor] = useState<Editor>(null);

  const ricarica = useCallback(async () => {
    try {
      setCategorie(await elencoCategorie(db));
      setErrore(false);
    } catch {
      setErrore(true);
    }
  }, [db]);

  // Si ricarica ad ogni ritorno sulla schermata: una categoria creata al volo
  // dalla schermata di modifica ricetta deve comparire anche qui.
  useFocusEffect(
    useCallback(() => {
      void ricarica();
    }, [ricarica]),
  );

  const muovi = async (indice: number, passi: -1 | 1) => {
    const nuovo = sposta(categorie, indice, passi);
    // Ai bordi sposta() restituisce lo stesso array: non c'è niente da scrivere.
    if (nuovo === categorie) return;
    // La riga si muove subito sotto il dito, il disco insegue.
    setCategorie(nuovo);
    try {
      await riordinaCategorie(db, nuovo.map((c) => c.id));
    } catch {
      setCategorie(categorie);
      Alert.alert(testo('app.nome'), testo('errore.db'));
    }
  };

  const elimina = (c: Categoria) => {
    // Il nome va passato: 'categorie.conferma.elimina' ha il segnaposto {nome},
    // e senza valore t() lancia invece di mostrarlo in chiaro.
    Alert.alert(testo('categorie.elimina'), testo('categorie.conferma.elimina', { nome: c.nome }), [
      { text: testo('modifica.annulla'), style: 'cancel' },
      {
        text: testo('categorie.elimina'),
        style: 'destructive',
        onPress: async () => {
          try {
            await cancellaCategoria(db, c.id);
            await ricarica();
          } catch {
            Alert.alert(testo('app.nome'), testo('errore.db'));
          }
        },
      },
    ]);
  };

  const salva = async () => {
    if (editor === null || !siPuoSalvare(editor.nome)) return;
    const adesso = new Date().toISOString();
    const esistente = editor.id === null ? null : categorie.find((c) => c.id === editor.id) ?? null;
    // salvaCategoria non timbra: la data di modifica la mette chi modifica.
    const categoria =
      esistente === null
        ? categoriaNuova(editor.nome, editor.icona, categorie, adesso)
        : categoriaModificata(esistente, editor.nome, editor.icona, adesso);
    try {
      await salvaCategoria(db, categoria);
      setEditor(null);
      await ricarica();
    } catch {
      Alert.alert(testo('app.nome'), testo('errore.db'));
    }
  };

  return (
    <View style={stili.schermo}>
      <ScrollView contentContainerStyle={stili.contenuto}>
        {errore ? <Text style={stili.errore}>{testo('errore.db')}</Text> : null}

        {/* Senza categorie si nomina il gesto che il tondo qui sotto offre,
            «Nuova categoria»: l'invito a scrivere la prima ricetta è dello stato
            vuoto della schermata principale e qui non c'entra niente. */}
        {!errore && categorie.length === 0 ? (
          <Text style={stili.invito}>{testo('categorie.crea')}</Text>
        ) : null}

        {categorie.map((c, i) => (
          <View key={c.id} style={stili.riga}>
            <Icona chiave={c.icona} dimensione={24} colore="#444" />
            <Pressable
              style={stili.nome}
              onPress={() => setEditor({ id: c.id, nome: c.nome, icona: c.icona })}
              accessibilityRole="button"
              accessibilityLabel={`${testo('categorie.rinomina')} ${c.nome}`}
            >
              <Text style={stili.nomeTesto} numberOfLines={1}>
                {c.nome}
              </Text>
            </Pressable>
            <Pressable
              onPress={() => void muovi(i, -1)}
              hitSlop={8}
              accessibilityRole="button"
              accessibilityLabel={testo('categorie.sposta.su')}
            >
              <Text style={[stili.freccia, i === 0 ? stili.frecciaSpenta : null]}>↑</Text>
            </Pressable>
            <Pressable
              onPress={() => void muovi(i, 1)}
              hitSlop={8}
              accessibilityRole="button"
              accessibilityLabel={testo('categorie.sposta.giu')}
            >
              <Text
                style={[stili.freccia, i === categorie.length - 1 ? stili.frecciaSpenta : null]}
              >
                ↓
              </Text>
            </Pressable>
            <Pressable
              onPress={() => elimina(c)}
              hitSlop={8}
              accessibilityRole="button"
              accessibilityLabel={`${testo('categorie.elimina')} ${c.nome}`}
            >
              <Text style={stili.cancella}>×</Text>
            </Pressable>
          </View>
        ))}
      </ScrollView>

      <Pressable
        style={stili.tondo}
        accessibilityRole="button"
        accessibilityLabel={testo('categorie.crea')}
        onPress={() => setEditor({ id: null, nome: '', icona: ICONA_PREDEFINITA })}
      >
        <Text style={stili.piu}>+</Text>
      </Pressable>

      <Modal
        visible={editor !== null}
        animationType="slide"
        transparent
        onRequestClose={() => setEditor(null)}
      >
        {editor !== null ? (
          <View style={stili.fondale}>
            <View style={stili.foglio}>
              <Text style={stili.titoloFoglio}>
                {editor.id === null ? testo('categorie.crea') : testo('categorie.rinomina')}
              </Text>

              <TextInput
                style={stili.campo}
                value={editor.nome}
                onChangeText={(v) => setEditor({ ...editor, nome: v })}
                placeholder={testo('categorie.nome')}
                placeholderTextColor="#999"
                autoFocus
              />
              {/* Avviso, non divieto: due categorie con lo stesso nome sono
                  ammesse, perché l'identità è l'id. Quasi sempre però è una svista. */}
              {nomeDuplicato(editor.nome, categorie, editor.id) ? (
                <Text style={stili.avviso}>{testo('categorie.nome.duplicato')}</Text>
              ) : null}

              <Text style={stili.intestazione}>{testo('categorie.icona')}</Text>
              <ScrollView style={stili.griglia}>
                <SceltaIcona
                  scelta={editor.icona}
                  onScegli={(icona) => setEditor({ ...editor, icona })}
                />
              </ScrollView>

              <View style={stili.azioni}>
                <Pressable
                  onPress={() => setEditor(null)}
                  style={stili.tastoChiaro}
                  accessibilityRole="button"
                >
                  <Text style={stili.tastoChiaroTesto}>{testo('modifica.annulla')}</Text>
                </Pressable>
                <Pressable
                  onPress={salva}
                  disabled={!siPuoSalvare(editor.nome)}
                  style={[stili.tasto, siPuoSalvare(editor.nome) ? null : stili.tastoSpento]}
                  accessibilityRole="button"
                >
                  <Text style={stili.tastoTesto}>{testo('modifica.salva')}</Text>
                </Pressable>
              </View>
            </View>
          </View>
        ) : null}
      </Modal>
    </View>
  );
}

const stili = StyleSheet.create({
  schermo: { flex: 1, backgroundColor: '#fff' },
  contenuto: { padding: 16, paddingBottom: 96, gap: 4 },
  invito: { fontSize: 15, textAlign: 'center', color: '#777', paddingVertical: 32 },
  errore: { fontSize: 15, color: '#b00020', paddingVertical: 16 },
  riga: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 10 },
  nome: { flex: 1 },
  nomeTesto: { fontSize: 17, color: '#111' },
  freccia: { fontSize: 20, color: '#1b6b4a', paddingHorizontal: 2 },
  frecciaSpenta: { color: '#c9d6cf' },
  cancella: { fontSize: 22, color: '#b00020', paddingHorizontal: 2 },
  tondo: {
    position: 'absolute',
    right: 24,
    bottom: 24,
    width: 56,
    height: 56,
    borderRadius: 28,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#1b6b4a',
  },
  piu: { color: '#fff', fontSize: 30, lineHeight: 34 },
  fondale: { flex: 1, justifyContent: 'flex-end', backgroundColor: '#0006' },
  foglio: {
    backgroundColor: '#fff',
    borderTopLeftRadius: 16,
    borderTopRightRadius: 16,
    padding: 20,
    gap: 10,
  },
  titoloFoglio: { fontSize: 18, fontWeight: '600', color: '#111' },
  campo: {
    borderBottomWidth: 1,
    borderBottomColor: '#e2e2e2',
    paddingVertical: 8,
    fontSize: 17,
    color: '#111',
  },
  avviso: { fontSize: 13, color: '#a06a00' },
  intestazione: { fontSize: 13, textTransform: 'uppercase', color: '#777', marginTop: 8 },
  griglia: { maxHeight: 220 },
  azioni: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginTop: 12,
  },
  tastoChiaro: { paddingVertical: 10, paddingHorizontal: 12 },
  tastoChiaroTesto: { fontSize: 16, color: '#1b6b4a' },
  tasto: { backgroundColor: '#1b6b4a', borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24 },
  tastoSpento: { backgroundColor: '#b7c9c0' },
  tastoTesto: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
```

- [ ] **Step 14: Verifica tipi e test**

Comandi:
```bash
npm run typecheck
npm test
```

Atteso: typecheck senza errori — è qui che si vede se una chiamata al
repository o una chiave di traduzione è scritta male; `npm test` PASS, ℹ pass
199, ℹ fail 0 (i 190 lasciati dal Task 20 più i 9 nuovi di
`logica-categorie.test.ts`). In quel file gli ✔ sono 14: i 5 di `vociCategorie`
scritti dal Task 18 più i 9 di questo task.

- [ ] **Step 15: Prova a mano il giro completo**

Comando: `npx expo start --ios` (oppure `--android`).

Atteso, a mano:
1. dalla schermata principale, «Gestisci le categorie» apre una schermata che a
   ricettario nuovo invita a crearne una;
2. il tondo verde con il `+` apre la finestra: scrivendo un nome e scegliendo
   un'icona dalla griglia, «Salva» la fa comparire nell'elenco con la sua icona;
3. creandone una seconda con lo stesso nome, sotto il campo compare l'avviso
   «Esiste già una categoria con questo nome», ma «Salva» resta acceso e
   funziona;
4. toccando il nome di una categoria si riapre la finestra sui suoi valori, e
   cambiando icona la riga si aggiorna;
5. le frecce su e giù spostano la riga subito; uscendo dalla schermata e
   rientrando l'ordine è quello di prima, cioè è finito sul database;
6. le frecce sulla prima e sull'ultima riga sono spente e non fanno niente;
7. toccando la `×` la conferma dice, per esteso, che le ricette non vengono
   cancellate e finiscono in «Senza categoria», col nome della categoria fra
   virgolette e non `{nome}`;
8. confermando, la categoria sparisce dall'elenco e le sue ricette compaiono in
   «Senza categoria» nella schermata principale, tutte, nessuna persa.

- [ ] **Step 16: Commit**

```bash
git add src/ui/componenti/SceltaIcona.tsx src/ui/schermate/GestioneCategorie.tsx
git commit -m "Aggiungi la schermata di gestione delle categorie e la griglia delle icone"
```

---

---

### Task 22: Scrivi e modifica ricetta, con categoria e foto

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta è un'app
iOS e Android che ricalcola le dosi di una ricetta: cambi le porzioni, oppure
dici «ho 250 g di farina invece di 180» e tutti gli altri ingredienti si
adeguano. Il ricettario sta in un SQLite sul dispositivo, senza account e senza
rete.

Questo task scrive la schermata dove una ricetta si crea e si corregge. Quattro
cose ci convivono.

**Gli ingredienti si scrivono incollandoli.** C'è un riquadro grande dove si
incolla un elenco copiato da un sito; il parser (già scritto) lo trasforma in
righe modificabili. Le righe che non ha capito non vengono buttate né inventate:
restano in evidenza finché l'utente non le tocca, così si vedono subito le due
da sistemare invece di scoprire dopo che mancava il burro.

**La categoria, una sola o nessuna.** Le categorie sono raggruppamenti, non
etichette: una ricetta ne ha una, oppure nessuna, e nessuna è uno stato normale.
Si sceglie da una fila di quelle che esistono, e se ne può creare una al volo
senza uscire dalla schermata: chi scrive la prima ricetta non ha ancora nessuna
categoria, e mandarlo fuori a crearla per poi tornare a ritrovare la bozza è il
modo migliore per non fargliene creare nessuna.

**La foto, una sola e facoltativa.** Si scatta o si sceglie dalla libreria, si
vede, si toglie. Il permesso si chiede **al momento del gesto**, mai all'avvio:
a chi ha appena installato l'app e non ha ancora capito a cosa serve, chiedere
la fotocamera significa farsi rispondere di no una volta sola e per sempre. Se
l'utente nega, si mostra il messaggio tradotto, che dice dove si rimedia, e si
smette: non si richiede e non si insiste.

**La cancellazione della ricetta**, con conferma che dice il titolo.

**Files:**
- Create: `src/ui/bozza.ts`
- Create: `src/ui/componenti/RigaIngrediente.tsx`
- Create: `src/ui/componenti/SceltaCategoria.tsx`
- Create: `src/ui/componenti/FotoRicetta.tsx`
- Modify: `src/ui/schermate/Modifica.tsx` (il Task 17 l'ha creata come guscio
  provvisorio; qui se ne sostituisce per intero il contenuto, tenendo la firma)
- Test: `src/ui/bozza.test.ts`

**Interfaces:**

- **Consumes**

  - `src/domain/types.ts` (Task 5):
    ```ts
    export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
    export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }
    export interface Ricetta {
      id: string;
      titolo: string;
      descrizione: string;
      porzioni: number | null;
      categoriaId: string | null;    // null = senza categoria, stato normale
      foto: string | null;           // NOME del file, es. 'r1.jpg', mai un percorso
      gruppi: Gruppo[];
      creataIl: string;              // ISO 8601
      modificataIl: string;          // ISO 8601
      cancellataIl: string | null;   // tombstone, null = viva
    }
    export interface Categoria {
      id: string; nome: string; icona: ChiaveIcona; ordine: number;
      creataIl: string; modificataIl: string; cancellataIl: string | null;
    }
    ```
    `quantita === null` significa «q.b.», `nome === null` su un gruppo significa
    gruppo unico senza intestazione.
  - `src/domain/id.ts` — `export function newId(): string` (uuid v4, id opachi e
    unici). Il Task 17 l'ha riscritta su `expo-crypto` con un `require`
    protetto: resta caricabile da `node --test`.
  - `src/domain/icone.ts` (Task 5):
    ```ts
    /** Niente annotazione: `as const` è quello che tiene le chiavi letterali. */
    export const ICONE = [ /* trenta voci { chiave, nome } */ ] as const;
    /** L'unione delle trenta chiavi, non `string`: fuori catalogo non compila. */
    export type ChiaveIcona = (typeof ICONE)[number]['chiave'];
    export const ICONA_PREDEFINITA: ChiaveIcona;
    ```
  - `src/ui/componenti/Icona.tsx` (Task 18). Traduce la chiave del catalogo nel
    nome del glifo, una volta sola per tutta l'app:
    ```tsx
    export interface PropsIcona { chiave: ChiaveIcona; dimensione: number; colore: string }
    export default function Icona(props: PropsIcona);
    ```
    La usa `SceltaCategoria.tsx`: il cast verso il tipo `name` di
    `MaterialCommunityIcons` sta scritto lì dentro e qui non si ripete.
  - `src/domain/lingua/index.ts` (Task 1):
    ```ts
    export type Lingua = 'it' | 'en';
    export interface Vocabolario { /* unità, numeri a parole, riempitivi, … */ }
    export function vocabolario(lingua: Lingua): Vocabolario;
    ```
  - `src/domain/format.ts` (Task 3) — `export function numero(q: number, voc: Vocabolario): string`
    (separatore decimale secondo la lingua, zeri finali tolti: `numero(0.5, it)`
    è `'0,5'`).
  - `src/domain/units.ts` (Task 2) — `export function normalizzaUnita(raw: string | null | undefined, voc: Vocabolario): string | null`
    (`'gr'` e `'grammi'` diventano `'g'`, `'cucchiaino'` diventa `'cucchiaini'`,
    un'unità sconosciuta resta com'è solo ripulita, la stringa vuota diventa
    `null`).
  - `src/ui/logica-dosatore.ts`, scritto dal Task 20. È un modulo puro come
    `bozza.ts`: importa solo roba di `src/domain/`, quindi `node --test` lo
    carica senza problemi anche di rimbalzo.
    ```ts
    /**
     * Quantità digitata in un campo dove l'unità sta in un campo a parte.
     * Legge quello che legge il parser a inizio stringa (200 | 1,5 | 1.5 | 1/2 |
     * 2 1/2) e pretende che dopo il numero non resti altro: '12 g' -> null.
     * Zero, negativi e testo -> null.
     */
    export function leggiQuantita(testo: string, voc: Vocabolario): number | null;
    ```
    `bozza.ts` la riesporta come `quantitaDaTesto` invece di riscriverla: il
    campo della quantità è lo stesso gesto in tutte e due le schermate, e due
    letture diverse sarebbero due comportamenti diversi. `leggiNumero` di
    `src/domain/parser/numeri.ts` si raggiunge da qui: la bozza non lo chiama
    più direttamente.
  - `src/domain/parser/riga.ts` (Task 7) — qui vive `RigaLetta`, non in
    `blocco.ts`:
    ```ts
    export interface RigaLetta {
      nome: string;
      quantita: number | null;   // null = q.b.
      unita: string | null;
      /** false quando il parser non ha capito: la riga va segnalata all'utente. */
      sicura: boolean;
    }
    ```
    `blocco.ts` la importa con `import type { RigaLetta } from './riga.ts'` e
    **non la ri-esporta**: chi la vuole la prende da `riga.ts`.
  - `src/domain/parser/blocco.ts` (Task 8)
    ```ts
    export interface GruppoLetto { nome: string | null; ingredienti: RigaLetta[] }
    export interface EsitoParser { lingua: Lingua; gruppi: GruppoLetto[]; righeSicure: number; righeTotali: number }
    /** Prova tutti i vocabolari e tiene quello che interpreta più righe. */
    export function leggiBloccoMultilingua(testo: string): EsitoParser;
    ```
    (`sicura: false` = il parser non ha capito la riga, va segnalata all'utente;
    `quantita: null` = q.b.; una parola di stop tipo «Procedimento» ferma la
    lettura e il resto viene scartato.)
  - `src/data/ricette.ts` (Task 11)
    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';
    /**
     * La ricetta con quell'id, null se non c'è proprio. **Restituisce anche le
     * cancellate**, con `cancellataIl` valorizzato: servono all'export e
     * all'import, che devono sapere che una ricetta è stata buttata. Chi la
     * mostra a schermo — questa schermata — deve quindi guardare anche
     * `cancellataIl`, non solo il null.
     */
    export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null>;
    /**
     * Upsert completo, gruppi e ingredienti inclusi; scrive anche categoria_id e
     * foto. Scrive modificataIl come arriva: non timbra.
     */
    export async function salvaRicetta(db: SQLiteDatabase, ricetta: Ricetta): Promise<void>;
    /** Tombstone: marca cancellataIl, azzera foto, non rimuove. Dimentica anche il riscalo. */
    export async function cancellaRicetta(db: SQLiteDatabase, id: string): Promise<void>;
    ```
    Siccome `salvaRicetta` non timbra, è questa schermata a mettere la data:
    `ricettaDaBozza(bozza, lingua, new Date().toISOString())`. Il Task 14 ha già
    fatto in modo che `cancellaRicetta` si porti via anche il file della foto:
    qui non va rifatto.
  - `src/data/categorie.ts` (Task 12)
    ```ts
    /** Solo le vive, per `ordine` crescente e, a parità, per nome all'italiana. */
    export async function elencoCategorie(db: SQLiteDatabase): Promise<Categoria[]>;
    /** Upsert. NON timbra: modificataIl viene scritto così come arriva nell'oggetto. */
    export async function salvaCategoria(db: SQLiteDatabase, categoria: Categoria): Promise<void>;
    ```
  - `src/data/foto.ts` (Task 14). È l'unico posto dell'app che tocca i file
    delle immagini:
    ```ts
    export type OrigineFoto = 'fotocamera' | 'libreria';
    export type EsitoScelta =
      | { tipo: 'scelta'; uri: string }
      | { tipo: 'annullata' }
      | { tipo: 'permessoNegato' };
    /** Chiede il permesso al momento dell'uso e apre fotocamera o libreria. */
    export async function scegliFoto(origine: OrigineFoto): Promise<EsitoScelta>;
    /** Comprime (lato lungo 1280 px, JPEG 0,7, sotto i 300 KB) e salva. Restituisce il NOME del file. */
    export async function salvaFoto(uriOrigine: string, ricettaId: string): Promise<string>;
    /** Percorso assoluto da dare a <Image>, o null se il file non c'è. Non tocca il database. */
    export async function percorsoFoto(nomeFile: string | null): Promise<string | null>;
    /** Cancella il file. Con null, o con un file che non c'è, non fa niente e non lancia. */
    export async function cancellaFoto(nomeFile: string | null): Promise<void>;
    /** Il nome del file per una ricetta: id ripulito + '.jpg'. Lancia se l'id si svuota. */
    export function nomeFoto(ricettaId: string): string;
    ```
    Il nome del file è l'id della ricetta più `.jpg`: rifare la foto sovrascrive
    la precedente senza lasciare orfani. **`cancellaFoto` non lancia mai,
    `nomeFoto` sì**: se l'id, tolto tutto quello che non è lettera minuscola,
    cifra o trattino, resta vuoto, lancia. Gli id delle ricette importate sono
    stringhe qualunque — `validaFile` accetta ogni stringa non vuota — quindi
    ogni chiamata a `nomeFoto` su un id che non abbiamo generato noi va messa al
    riparo.
  - `src/ui/logica-categorie.ts` (Task 21)
    ```ts
    /** Senza nome non si salva. */
    export function siPuoSalvare(nome: string): boolean;
    /** Esiste già una categoria con questo nome? Avvisa, non impedisce. */
    export function nomeDuplicato(nome: string, categorie: Categoria[], idEscluso: string | null): boolean;
    /** Categoria nuova, in fondo all'elenco. */
    export function categoriaNuova(nome: string, icona: ChiaveIcona, categorie: Categoria[], adesso: string): Categoria;
    ```
    Attenzione al nome: `siPuoSalvare` di `logica-categorie.ts` riguarda il nome
    di una **categoria** e prende una stringa; `siPuoSalvare` di `bozza.ts`
    riguarda una **ricetta** e prende una `Bozza`. Sono due funzioni diverse in
    due moduli diversi, e nessun file le importa tutte e due: la schermata usa
    quella della bozza, il componente `SceltaCategoria` quella delle categorie.
  - `src/ui/componenti/SceltaIcona.tsx` (Task 21)
    ```tsx
    export type PropsSceltaIcona = { scelta: ChiaveIcona; onScegli: (chiave: ChiaveIcona) => void };
    export function SceltaIcona(props: PropsSceltaIcona);
    ```
  - `src/i18n/index.ts` (Task 16)
    ```ts
    export type Chiave = 'app.nome' | 'modifica.titolo' | /* … */ | 'errore.db';
    /** Lancia se un segnaposto della chiave resta senza valore. */
    export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;
    ```
    Chiavi usate da questo task, tutte già presenti nell'unione `Chiave`:
    `app.nome`, `modifica.titolo`, `modifica.descrizione`, `modifica.porzioni`,
    `modifica.categoria`, `modifica.categoria.nessuna`, `modifica.ingredienti`,
    `modifica.incolla`, `modifica.salva`, `modifica.annulla`,
    `modifica.elimina`, `modifica.conferma.elimina`,
    `modifica.sezione.aggiungi`, `modifica.riga.incerta`, `foto.aggiungi`,
    `foto.scatta`, `foto.scegli`, `foto.togli`, `foto.permesso.fotocamera`,
    `foto.permesso.libreria`, `categorie.crea`, `categorie.nome`,
    `categorie.nome.duplicato`, `categorie.icona`, `qb`, `errore.db`.
    **`modifica.conferma.elimina` ha il segnaposto `{titolo}`**
    (`'Vuoi eliminare «{titolo}»?'`, in inglese `'Delete “{titolo}”?'`) e va
    sempre chiamata passandolo, altrimenti `t()` lancia. Tutte le altre chiavi
    di questo elenco non hanno segnaposto. **L'unione `Chiave` non va
    allargata**: l'elenco è fissato dal Task 16.
  - `src/ui/navigazione.ts` (Task 17)
    ```ts
    import type { NativeStackScreenProps } from '@react-navigation/native-stack';
    export type ParametriNav = {
      Categorie: undefined;
      Elenco: { filtro: FiltroElenco };
      Dosatore: { ricettaId: string };
      /** ricettaId null significa ricetta nuova. */
      Modifica: { ricettaId: string | null };
      GestioneCategorie: undefined;
    };
    export type PropsSchermata<N extends keyof ParametriNav> = NativeStackScreenProps<ParametriNav, N>;
    ```
  - `src/ui/contesto.ts` (Task 17)
    ```ts
    export interface StatoApp {
      db: SQLiteDatabase;
      lingua: Lingua;
      /** t() già legata alla lingua dell'app. */
      testo: (chiave: Chiave, valori?: Record<string, string>) => string;
    }
    /** Lancia se chiamata fuori dal provider montato in src/ui/App.tsx. */
    export function useApp(): StatoApp;
    ```
  - `src/ui/App.tsx` (Task 17) registra la schermata così, senza passarle
    niente:
    ```tsx
    <Stack.Screen name="Modifica" component={Modifica} options={{ title: '' }} />
    ```
    Si arriva qui in due modi, sempre con il parametro `ricettaId`:
    `navigation.navigate('Modifica', { ricettaId: null })` per una ricetta nuova,
    `navigation.navigate('Modifica', { ricettaId: r.id })` per modificarne una.
  - `@expo/vector-icons` (Task 5). Questo task **non lo importa direttamente**:
    dove disegna un'icona passa da `Icona.tsx`.

- **Produces**
  - `src/ui/bozza.ts` — il modello della ricetta mentre la si scrive:
    ```ts
    export interface RigaBozza { id: string; nome: string; quantita: string; unita: string; incerta: boolean }
    export interface GruppoBozza { id: string; nome: string; righe: RigaBozza[] }
    export interface Bozza {
      id: string; titolo: string; descrizione: string; porzioni: string;
      categoriaId: string | null;
      foto: string | null;
      gruppi: GruppoBozza[]; creataIl: string; esistente: boolean;
    }
    export function bozzaNuova(adesso: string): Bozza;
    export function bozzaDaRicetta(r: Ricetta, lingua: Lingua): Bozza;
    /** Accoda alla bozza gli ingredienti letti dal testo incollato. */
    export function bozzaDaTesto(b: Bozza, testo: string, lingua: Lingua): Bozza;
    export function ricettaDaBozza(b: Bozza, lingua: Lingua, adesso: string): Ricetta;
    /** È `leggiQuantita` di logica-dosatore.ts, riesportata con questo nome. */
    export function quantitaDaTesto(testo: string, voc: Vocabolario): number | null;
    export function cambiaRiga(b: Bozza, rigaId: string, campi: Partial<Pick<RigaBozza, 'nome' | 'quantita' | 'unita'>>): Bozza;
    export function aggiungiRiga(b: Bozza, gruppoId: string): Bozza;
    export function togliRiga(b: Bozza, rigaId: string): Bozza;
    export function aggiungiSezione(b: Bozza): Bozza;
    export function cambiaSezione(b: Bozza, gruppoId: string, nome: string): Bozza;
    export function deveMostrareSezioni(b: Bozza): boolean;
    export function siPuoSalvare(b: Bozza): boolean;
    ```
    `categoriaId` e `foto` sono gli unici campi della bozza che **non** sono
    stringhe di testo: non sono legati a un `TextInput`, sono scelte fatte con
    un tocco, e viaggiano dritti dalla ricetta alla bozza e ritorno.
  - `src/ui/componenti/RigaIngrediente.tsx`
    ```tsx
    export type PropsRigaIngrediente = {
      riga: RigaBozza;
      lingua: Lingua;
      onCambia: (campi: Partial<Pick<RigaBozza, 'nome' | 'quantita' | 'unita'>>) => void;
      onTogli: () => void;
    };
    export function RigaIngrediente(props: PropsRigaIngrediente);
    ```
    È la riga **modificabile**. Il Dosatore rende le sue righe da sé, lì sono di
    sola lettura.
  - `src/ui/componenti/SceltaCategoria.tsx`
    ```tsx
    export type PropsSceltaCategoria = {
      /** id della categoria scelta, null = nessuna. */
      scelta: string | null;
      onScegli: (categoriaId: string | null) => void;
    };
    export function SceltaCategoria(props: PropsSceltaCategoria);
    ```
    Non prende `db` né `lingua`: come le schermate, se li prende da `useApp()`.
  - `src/ui/componenti/FotoRicetta.tsx`
    ```tsx
    export type PropsFotoRicetta = {
      /** NOME del file già salvato, o null. Mai un percorso assoluto. */
      foto: string | null;
      /** Id della ricetta: è anche il nome del file, quindi rifare la foto sovrascrive. */
      ricettaId: string;
      onCambia: (foto: string | null) => void;
    };
    export function FotoRicetta(props: PropsFotoRicetta);
    ```
  - `src/ui/schermate/Modifica.tsx`
    ```tsx
    export default function Modifica({ route, navigation }: PropsSchermata<'Modifica'>);
    ```
    Non esporta nessun tipo di props proprio: la firma è quella comune a tutte
    le schermate, e `db`, `lingua` e `testo` arrivano da `useApp()`.

**Cinque decisioni da tenere a mente mentre si legge il codice**

1. Nella bozza titolo, descrizione, porzioni, quantità e unità sono **stringhe**,
   perché sono legate a dei `TextInput`: mentre l'utente scrive, `"1,"` non è un
   numero. La conversione a numeri avviene una volta sola, in `ricettaDaBozza`,
   al salvataggio.
2. Il testo scritto dall'utente non viene mai interpretato. React Native rende
   il testo dentro `<Text>` e `<TextInput>`, che non conoscono markup: niente
   `WebView`, niente HTML, nessun escape e nessuna ripulitura dei nomi. Un
   ingrediente che si chiama `<b>sale</b>` si salva e si rilegge esattamente
   così, e il test lo verifica.
3. L'incolla **accoda**, non sostituisce, e il riquadro per incollare resta
   sempre a schermo. Altrimenti «gli ingredienti si scrivono incollandoli»
   varrebbe solo la prima volta, e su una ricetta già scritta non ci sarebbe
   modo di aggiungere un secondo blocco. Sotto il riquadro c'è un tasto che lo
   legge: toccare fuori funziona lo stesso, ma è un gesto che nessuno indovina.
   Il `+` che aggiunge una riga a mano c'è anche a ricetta ancora vuota, perché
   l'incolla è il modo comodo, non l'unico.
4. **La bozza non tocca mai il disco.** Togliere la foto è mettere `foto` a
   `null` nella bozza; il file lo cancella la schermata, al salvataggio. Così
   «Annulla» non porta via niente.
5. `bozzaNuova` genera subito l'id della ricetta, e la foto si salva con quel
   nome anche prima che la ricetta esista nel database. È quello che permette di
   scattare la foto mentre si scrive. Il prezzo è un file da meno di 300 KB
   lasciato sul disco se poi si annulla tutto: difetto noto e contenuto, non uno
   da inseguire prima del tempo.

- [ ] **Step 1: Scrivi il test che fallisce**

Crea `src/ui/bozza.test.ts`:

```ts
/**
 * Test del modello della bozza, cioè della ricetta mentre la si scrive.
 * Girano con `npm test`: Node esegue TypeScript nativamente, senza framework.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { bozzaDaRicetta, bozzaNuova, quantitaDaTesto, ricettaDaBozza, siPuoSalvare } from './bozza.ts';
import { vocabolario } from '../domain/lingua/index.ts';
import type { Bozza } from './bozza.ts';
import type { Ricetta } from '../domain/types.ts';

const ADESSO = '2026-08-19T09:00:00.000Z';

/** Biscotti al limone, dall'archivio vero: 40 porzioni, mezzo limone e uno zucchero a velo q.b. */
const biscotti: Ricetta = {
  id: 'r1',
  titolo: 'Biscotti al limone',
  descrizione: '',
  porzioni: 40,
  categoriaId: 'c-dolci',
  foto: 'r1.jpg',
  gruppi: [
    {
      id: 'g1',
      nome: null,
      ingredienti: [
        { id: 'i1', nome: 'Uovo', quantita: 1, unita: 'cad' },
        { id: 'i2', nome: 'Zucchero', quantita: 100, unita: 'g' },
        { id: 'i3', nome: 'Lievito', quantita: 1, unita: 'cucchiaino' },
        { id: 'i4', nome: 'Succo di limone', quantita: 0.5, unita: 'cad' },
        { id: 'i5', nome: 'Zucchero a velo', quantita: null, unita: null },
      ],
    },
  ],
  creataIl: '2026-01-01T10:00:00.000Z',
  modificataIl: '2026-01-01T10:00:00.000Z',
  cancellataIl: null,
};

test('bozza nuova: un gruppo vuoto, e senza titolo non si salva', () => {
  const b = bozzaNuova(ADESSO);
  assert.equal(b.gruppi.length, 1);
  assert.equal(b.gruppi[0].righe.length, 0);
  assert.equal(b.esistente, false);
  assert.equal(b.creataIl, ADESSO);
  assert.equal(siPuoSalvare(b), false);
  assert.equal(siPuoSalvare({ ...b, titolo: '   ' }), false);
  assert.equal(siPuoSalvare({ ...b, titolo: 'Farinata' }), true);
});

test('la ricetta caricata diventa campi di testo nella lingua giusta', () => {
  const b = bozzaDaRicetta(biscotti, 'it');
  assert.equal(b.id, 'r1');
  assert.equal(b.esistente, true);
  assert.equal(b.creataIl, biscotti.creataIl);
  assert.equal(b.porzioni, '40');
  assert.equal(b.gruppi[0].nome, '');           // nome null = campo vuoto
  assert.equal(b.gruppi[0].righe[3].quantita, '0,5');   // separatore italiano
  assert.equal(b.gruppi[0].righe[4].quantita, '');      // q.b. è il campo vuoto
  assert.equal(b.gruppi[0].righe[4].unita, '');
  assert.ok(b.gruppi[0].righe.every((r) => r.incerta === false));
});

test('categoria e foto attraversano la bozza senza cambiare', () => {
  // Una ricetta nuova non sta in nessuna categoria e non ha foto: è lo stato
  // normale di chi scrive prima di essersi fatto le categorie, non un errore.
  const nuova = bozzaNuova(ADESSO);
  assert.equal(nuova.categoriaId, null);
  assert.equal(nuova.foto, null);

  const b = bozzaDaRicetta(biscotti, 'it');
  assert.equal(b.categoriaId, 'c-dolci');
  assert.equal(b.foto, 'r1.jpg');               // il NOME del file, non un percorso

  const uguale = ricettaDaBozza(b, 'it', ADESSO);
  assert.equal(uguale.categoriaId, 'c-dolci');
  assert.equal(uguale.foto, 'r1.jpg');

  // Togliere la foto e la categoria è mettere i due campi a null: il file lo
  // cancella la schermata al salvataggio, la bozza non tocca il disco.
  const senza = ricettaDaBozza({ ...b, categoriaId: null, foto: null }, 'it', ADESSO);
  assert.equal(senza.categoriaId, null);
  assert.equal(senza.foto, null);
});

test('salvando, gli id restano quelli di prima', () => {
  // Se gli id cambiassero, il riscalo in corso (che punta all'id
  // dell'ingrediente) verrebbe buttato via a ogni correzione di refuso.
  const r = ricettaDaBozza(bozzaDaRicetta(biscotti, 'it'), 'it', ADESSO);
  assert.equal(r.id, 'r1');
  assert.equal(r.creataIl, biscotti.creataIl);
  assert.equal(r.modificataIl, ADESSO);
  assert.equal(r.cancellataIl, null);
  assert.equal(r.porzioni, 40);
  assert.equal(r.gruppi.length, 1);
  assert.equal(r.gruppi[0].id, 'g1');
  assert.equal(r.gruppi[0].nome, null);
  assert.deepEqual(
    r.gruppi[0].ingredienti.map((i) => i.id),
    ['i1', 'i2', 'i3', 'i4', 'i5'],
  );
  assert.equal(r.gruppi[0].ingredienti[2].unita, 'cucchiaini');   // normalizzata
  assert.equal(r.gruppi[0].ingredienti[3].quantita, 0.5);
  assert.equal(r.gruppi[0].ingredienti[4].quantita, null);        // resta q.b.
  assert.equal(r.gruppi[0].ingredienti[4].unita, null);
});

test('la quantità scritta a mano accetta virgola, punto e frazione', () => {
  const it = vocabolario('it');
  assert.equal(quantitaDaTesto('200', it), 200);
  assert.equal(quantitaDaTesto('1,5', it), 1.5);
  assert.equal(quantitaDaTesto('1.5', it), 1.5);
  assert.equal(quantitaDaTesto('1/2', it), 0.5);
  assert.equal(quantitaDaTesto('   ', it), null);            // campo vuoto = q.b.
  assert.equal(quantitaDaTesto('quanto basta', it), null);   // testo non numerico = q.b.
  assert.equal(quantitaDaTesto('0', it), null);              // zero non è una dose
  // L'unità ha il suo campo: dopo il numero non deve restare altro. È la stessa
  // regola del Dosatore perché è la stessa funzione, riesportata da bozza.ts.
  assert.equal(quantitaDaTesto('12 g', it), null);
  assert.equal(quantitaDaTesto('200 farina', it), null);
});

test("le righe senza nome spariscono, il testo dell'utente resta letterale", () => {
  const b: Bozza = {
    ...bozzaNuova(ADESSO),
    titolo: '  Prova  ',
    descrizione: '  due righe  ',
    porzioni: '  ',
    gruppi: [
      {
        id: 'g1',
        nome: '',
        righe: [
          { id: 'a', nome: '   ', quantita: '100', unita: 'g', incerta: false },
          { id: 'b', nome: '<b>sale</b> & "pepe"', quantita: '', unita: '', incerta: true },
          { id: 'c', nome: 'Farina 00', quantita: '500', unita: 'gr', incerta: false },
        ],
      },
    ],
  };
  const r = ricettaDaBozza(b, 'it', ADESSO);
  assert.equal(r.titolo, 'Prova');
  assert.equal(r.descrizione, 'due righe');
  assert.equal(r.porzioni, null);
  const ing = r.gruppi[0].ingredienti;
  assert.equal(ing.length, 2);                        // la riga senza nome non si salva
  assert.equal(ing[0].nome, '<b>sale</b> & "pepe"');  // nessuna interpretazione, nessun escape
  assert.equal(ing[0].quantita, null);
  assert.equal(ing[1].nome, 'Farina 00');
  assert.equal(ing[1].quantita, 500);
  assert.equal(ing[1].unita, 'g');                    // 'gr' normalizzato
});
```

- [ ] **Step 2: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `Cannot find module` seguito dal percorso di `src/ui/bozza.ts`
(`ERR_MODULE_NOT_FOUND`, il file non esiste ancora).

- [ ] **Step 3: Implementa il minimo che fa passare il test**

Crea `src/ui/bozza.ts`:

```ts
/**
 * La ricetta mentre la si scrive.
 *
 * Titolo, descrizione, porzioni, quantità e unità sono stringhe perché sono
 * legate ai TextInput: mentre l'utente scrive, "1," non è ancora un numero. La
 * conversione avviene una volta sola, in ricettaDaBozza, al salvataggio.
 *
 * categoriaId e foto sono le due eccezioni: non si digitano, si scelgono con un
 * tocco, e viaggiano dritti dalla ricetta alla bozza e ritorno. `foto` è il NOME
 * del file, mai un percorso assoluto: su iOS la cartella dell'app cambia ad ogni
 * aggiornamento e un percorso salvato ieri oggi punta al nulla.
 *
 * Modulo puro: non importa react-native né expo, così `node --test` lo carica.
 * Vale anche per logica-dosatore.ts, da cui arriva la lettura della quantità.
 */
import { numero } from '../domain/format.ts';
import { newId } from '../domain/id.ts';
import { vocabolario } from '../domain/lingua/index.ts';
import { normalizzaUnita } from '../domain/units.ts';
import { leggiQuantita } from './logica-dosatore.ts';
import type { Lingua } from '../domain/lingua/index.ts';
import type { Gruppo, Ricetta } from '../domain/types.ts';

export interface RigaBozza {
  id: string;
  nome: string;
  /** Testo grezzo: '200', '1,5', '1/2'. Vuoto = q.b. */
  quantita: string;
  unita: string;
  /** true = il parser non ha capito la riga, va evidenziata. */
  incerta: boolean;
}

export interface GruppoBozza {
  id: string;
  /** Vuoto = sezione senza nome. */
  nome: string;
  righe: RigaBozza[];
}

export interface Bozza {
  id: string;
  titolo: string;
  descrizione: string;
  /** Testo grezzo. Vuoto = porzioni non dichiarate. */
  porzioni: string;
  /** id della categoria, null = nessuna. È uno stato normale, non un errore. */
  categoriaId: string | null;
  /** NOME del file dentro la cartella foto dell'app, null = niente foto. */
  foto: string | null;
  gruppi: GruppoBozza[];
  creataIl: string;
  /** true quando la ricetta è già nel database: abilita l'eliminazione. */
  esistente: boolean;
}

/**
 * L'id nasce qui, prima che la ricetta esista nel database: è anche il nome del
 * file della foto, e serve per poterla scattare mentre si scrive.
 */
export function bozzaNuova(adesso: string): Bozza {
  return {
    id: newId(),
    titolo: '',
    descrizione: '',
    porzioni: '',
    categoriaId: null,
    foto: null,
    gruppi: [{ id: newId(), nome: '', righe: [] }],
    creataIl: adesso,
    esistente: false,
  };
}

export function bozzaDaRicetta(r: Ricetta, lingua: Lingua): Bozza {
  const voc = vocabolario(lingua);
  return {
    id: r.id,
    titolo: r.titolo,
    descrizione: r.descrizione,
    porzioni: r.porzioni === null ? '' : numero(r.porzioni, voc),
    categoriaId: r.categoriaId,
    foto: r.foto,
    gruppi: r.gruppi.map((g) => ({
      id: g.id,
      nome: g.nome ?? '',
      righe: g.ingredienti.map((i) => ({
        id: i.id,
        nome: i.nome,
        quantita: i.quantita === null ? '' : numero(i.quantita, voc),
        unita: i.unita ?? '',
        incerta: false,
      })),
    })),
    creataIl: r.creataIl,
    esistente: true,
  };
}

/**
 * '' -> null (q.b.). '1,5' -> 1.5. '1/2' -> 0.5. Testo incomprensibile, zero, o
 * numero seguito da altro testo ('12 g') -> null.
 *
 * Non è una funzione nuova: è `leggiQuantita` del Dosatore, riesportata col nome
 * che usa questa schermata. Si legge lo stesso campo, con l'unità in un campo a
 * parte, quindi vale la stessa regola: dopo il numero non deve restare niente.
 * Scriverne due sarebbe scrivere due comportamenti diversi per lo stesso gesto,
 * e accorgersene richiede di aprire tutti e due i file.
 */
export { leggiQuantita as quantitaDaTesto };

/** Senza titolo non si salva: l'elenco ordina per titolo e una riga senza nome non si ritrova più. */
export function siPuoSalvare(b: Bozza): boolean {
  return b.titolo.trim() !== '';
}

/**
 * Bozza -> Ricetta pronta da salvare. Gli id esistenti si conservano: il
 * riscalo in corso punta all'id dell'ingrediente, rigenerarlo lo butterebbe via.
 *
 * `adesso` finisce dritto in modificataIl: salvaRicetta non timbra da sé.
 */
export function ricettaDaBozza(b: Bozza, lingua: Lingua, adesso: string): Ricetta {
  const voc = vocabolario(lingua);
  const gruppi: Gruppo[] = b.gruppi
    .map((g) => ({
      id: g.id,
      nome: g.nome.trim() === '' ? null : g.nome.trim(),
      ingredienti: g.righe
        .filter((r) => r.nome.trim() !== '')
        .map((r) => ({
          id: r.id,
          nome: r.nome.trim(),
          // Dentro il file si chiama col suo nome: `quantitaDaTesto` è solo il
          // nome con cui esce, e `export {…as…}` non crea un legame locale.
          quantita: leggiQuantita(r.quantita, voc),
          unita: normalizzaUnita(r.unita, voc),
        })),
    }))
    .filter((g) => g.ingredienti.length > 0);

  // Una ricetta ha sempre almeno un gruppo, anche se non ha ancora ingredienti.
  const finali: Gruppo[] =
    gruppi.length > 0 ? gruppi : [{ id: b.gruppi[0]?.id ?? newId(), nome: null, ingredienti: [] }];
  // Una sezione sola non si vede: il suo nome sarebbe un dato invisibile.
  if (finali.length === 1) finali[0].nome = null;

  return {
    id: b.id,
    titolo: b.titolo.trim(),
    descrizione: b.descrizione.trim(),
    porzioni: leggiQuantita(b.porzioni, voc),
    categoriaId: b.categoriaId,
    foto: b.foto,
    gruppi: finali,
    creataIl: b.creataIl,
    modificataIl: adesso,
    cancellataIl: null,
  };
}
```

- [ ] **Step 4: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, 6 test verdi nel file `src/ui/bozza.test.ts`, 0 falliti.

- [ ] **Step 5: Commit**

```bash
git add src/ui/bozza.ts src/ui/bozza.test.ts
git commit -m "Aggiungi il modello della bozza di ricetta, con categoria e foto"
```

- [ ] **Step 6: Scrivi il test che fallisce per l'incolla e le correzioni**

In `src/ui/bozza.test.ts` sostituisci la riga di import da `./bozza.ts` con
questa:

```ts
import {
  aggiungiRiga,
  aggiungiSezione,
  bozzaDaRicetta,
  bozzaDaTesto,
  bozzaNuova,
  cambiaRiga,
  cambiaSezione,
  deveMostrareSezioni,
  quantitaDaTesto,
  ricettaDaBozza,
  siPuoSalvare,
  togliRiga,
} from './bozza.ts';
```

e aggiungi questa riga agli altri import:

```ts
import { leggiBloccoMultilingua } from '../domain/parser/blocco.ts';
```

Poi aggiungi in fondo al file:

```ts
/** Un incolla come capita davvero: pallini, una sezione, un q.b. e la coda del procedimento. */
const INCOLLA = [
  'Ingredienti',
  '- 300 g di farina',
  '2 uova',
  'sale q.b.',
  'Per la crema:',
  '500 ml latte',
  '1 bustina di vanillina',
  'Procedimento',
  'Mescolare tutto e infornare a 180 gradi.',
].join('\n');

test("l'incolla diventa righe modificabili, divise in sezioni", () => {
  const b = bozzaDaTesto(bozzaNuova(ADESSO), INCOLLA, 'it');
  assert.equal(b.gruppi.length, 2);
  assert.equal(b.gruppi[0].nome, '');       // 'Ingredienti' non è una sezione con nome
  assert.notEqual(b.gruppi[1].nome, '');    // 'Per la crema:' sì
  assert.equal(b.gruppi[0].righe.length, 3);
  assert.equal(b.gruppi[1].righe.length, 2);

  const farina = b.gruppi[0].righe[0];
  assert.equal(farina.quantita, '300');
  assert.equal(farina.unita, 'g');
  // La maiuscola la decide il parser: qui interessa che il nome sia arrivato intero.
  assert.equal(farina.nome.toLowerCase(), 'farina');
  assert.equal(b.gruppi[0].righe[2].quantita, '');   // 'sale q.b.' non ha quantità

  const nomi = b.gruppi.flatMap((g) => g.righe.map((r) => r.nome.toLowerCase()));
  assert.ok(!nomi.some((n) => n.includes('mescolare')));   // 'Procedimento' ferma la lettura
  assert.equal(deveMostrareSezioni(b), true);

  // Ogni riga e ogni gruppo hanno un id proprio: servono come chiave in lista.
  const ids = [...b.gruppi.map((g) => g.id), ...b.gruppi.flatMap((g) => g.righe.map((r) => r.id))];
  assert.equal(new Set(ids).size, ids.length);
});

test("un secondo incolla si accoda, non cancella quello che c'era", () => {
  // Su una ricetta già scritta si deve poter incollare un altro blocco senza
  // perdere le righe di prima, altrimenti l'incolla serve solo alla prima stesura.
  const prima = bozzaDaRicetta(biscotti, 'it');
  const dopo = bozzaDaTesto(prima, INCOLLA, 'it');
  assert.equal(dopo.gruppi.length, 3);              // il gruppo di prima più i due letti
  assert.equal(dopo.gruppi[0].id, 'g1');
  assert.deepEqual(
    dopo.gruppi[0].righe.map((r) => r.id),
    ['i1', 'i2', 'i3', 'i4', 'i5'],                 // le righe di prima, con i loro id
  );
  assert.equal(dopo.gruppi[1].righe.length, 3);
  assert.equal(dopo.gruppi[2].righe.length, 2);
  assert.equal(dopo.titolo, prima.titolo);
  assert.equal(dopo.categoriaId, 'c-dolci');        // l'incolla non tocca categoria
  assert.equal(dopo.foto, 'r1.jpg');                // né foto
});

test("l'incerta della bozza è l'insicura del parser, riga per riga", () => {
  const b = bozzaDaTesto(bozzaNuova(ADESSO), INCOLLA, 'it');
  const esito = leggiBloccoMultilingua(INCOLLA);
  const attese = esito.gruppi.flatMap((g) => g.ingredienti.map((r) => !r.sicura));
  const ottenute = b.gruppi.flatMap((g) => g.righe.map((r) => r.incerta));
  assert.deepEqual(ottenute, attese);
});

test("l'incolla vuoto lascia stare la bozza", () => {
  const b = bozzaDaRicetta(biscotti, 'it');
  assert.deepEqual(bozzaDaTesto(b, '   \n  \n', 'it'), b);
});

test('correggere una riga la toglie dalle incerte', () => {
  const b: Bozza = {
    ...bozzaNuova(ADESSO),
    gruppi: [
      {
        id: 'g1',
        nome: '',
        righe: [
          { id: 'x', nome: 'burro morbido a pezzetti', quantita: '', unita: '', incerta: true },
          { id: 'y', nome: 'Farina', quantita: '200', unita: 'g', incerta: false },
        ],
      },
    ],
  };
  const dopo = cambiaRiga(b, 'x', { quantita: '100', unita: 'g' });
  const riga = dopo.gruppi[0].righe[0];
  assert.equal(riga.quantita, '100');
  assert.equal(riga.unita, 'g');
  assert.equal(riga.nome, 'burro morbido a pezzetti');   // i campi non toccati restano
  assert.equal(riga.incerta, false);
  assert.equal(dopo.gruppi[0].righe[1].nome, 'Farina');  // le altre righe non si toccano
  assert.equal(b.gruppi[0].righe[0].incerta, true);      // la bozza di partenza non è mutata
});

test('righe e sezioni si aggiungono e si tolgono', () => {
  let b = bozzaDaTesto(bozzaNuova(ADESSO), INCOLLA, 'it');
  const primo = b.gruppi[0];

  b = aggiungiRiga(b, primo.id);
  assert.equal(b.gruppi[0].righe.length, 4);
  assert.equal(b.gruppi[0].righe[3].nome, '');
  assert.equal(b.gruppi[0].righe[3].incerta, false);
  assert.equal(b.gruppi[1].righe.length, 2);             // l'altro gruppo non si tocca

  b = togliRiga(b, primo.righe[1].id);
  assert.equal(b.gruppi[0].righe.length, 3);
  assert.ok(!b.gruppi[0].righe.some((r) => r.id === primo.righe[1].id));

  const nomeSecondo = b.gruppi[1].nome;
  b = aggiungiSezione(b);
  assert.equal(b.gruppi.length, 3);
  assert.equal(b.gruppi[2].nome, '');
  assert.equal(b.gruppi[2].righe.length, 1);             // una riga pronta da riempire
  b = cambiaSezione(b, b.gruppi[2].id, 'Guarnizione');
  assert.equal(b.gruppi[2].nome, 'Guarnizione');
  assert.equal(b.gruppi[1].nome, nomeSecondo);           // le altre sezioni non si toccano

  const ids = b.gruppi.flatMap((g) => g.righe.map((r) => r.id));
  assert.equal(new Set(ids).size, ids.length);
});

test('una sola sezione non si mostra e non si salva col nome', () => {
  const letto = bozzaDaTesto(bozzaNuova(ADESSO), INCOLLA, 'it');
  const b: Bozza = { ...letto, titolo: 'Crema', gruppi: [letto.gruppi[1]] };
  assert.equal(deveMostrareSezioni(b), false);
  const r = ricettaDaBozza(b, 'it', ADESSO);
  assert.equal(r.gruppi.length, 1);
  assert.equal(r.gruppi[0].nome, null);
});

test('con più sezioni i nomi si salvano', () => {
  const letto = bozzaDaTesto(bozzaNuova(ADESSO), INCOLLA, 'it');
  const b: Bozza = { ...letto, titolo: 'Crostata' };
  const r = ricettaDaBozza(b, 'it', ADESSO);
  assert.equal(r.gruppi.length, 2);
  assert.equal(r.gruppi[0].nome, null);          // la sezione senza nome resta senza
  assert.equal(r.gruppi[1].nome, letto.gruppi[1].nome);
});
```

- [ ] **Step 7: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `does not provide an export named 'bozzaDaTesto'`
(`SyntaxError` al caricamento del modulo: le nuove funzioni non esistono ancora).

- [ ] **Step 8: Implementa l'incolla e le modifiche alle righe**

Aggiungi in cima a `src/ui/bozza.ts`, insieme agli altri import:

```ts
import { leggiBloccoMultilingua } from '../domain/parser/blocco.ts';
```

e aggiungi in fondo al file:

```ts
const rigaVuota = (): RigaBozza => ({ id: newId(), nome: '', quantita: '', unita: '', incerta: false });

/**
 * Accoda alla bozza gli ingredienti letti dal testo incollato.
 *
 * Accoda e non sostituisce: su una ricetta già scritta si deve poter incollare
 * un secondo blocco senza perdere quello che c'è. L'unico gruppo che sparisce è
 * quello rimasto senza righe (la bozza nuova ne ha uno), che altrimenti
 * resterebbe lì come sezione vuota.
 *
 * Il parser prova tutte le lingue e tiene quella che capisce più righe; i numeri
 * però si riscrivono nella lingua dell'interfaccia, perché l'utente li rilegge lì.
 */
export function bozzaDaTesto(b: Bozza, testo: string, lingua: Lingua): Bozza {
  const voc = vocabolario(lingua);
  const esito = leggiBloccoMultilingua(testo);
  const letti: GruppoBozza[] = esito.gruppi
    .map((g) => ({
      id: newId(),
      nome: g.nome ?? '',
      righe: g.ingredienti.map((r) => ({
        id: newId(),
        nome: r.nome,
        quantita: r.quantita === null ? '' : numero(r.quantita, voc),
        unita: r.unita ?? '',
        incerta: !r.sicura,
      })),
    }))
    .filter((g) => g.righe.length > 0);
  if (letti.length === 0) return b;    // niente di leggibile: la bozza resta com'era
  return { ...b, gruppi: [...b.gruppi.filter((g) => g.righe.length > 0), ...letti] };
}

/** Correggere una riga la rende sicura: l'utente l'ha guardata. */
export function cambiaRiga(
  b: Bozza,
  rigaId: string,
  campi: Partial<Pick<RigaBozza, 'nome' | 'quantita' | 'unita'>>,
): Bozza {
  return {
    ...b,
    gruppi: b.gruppi.map((g) => ({
      ...g,
      righe: g.righe.map((r) => (r.id === rigaId ? { ...r, ...campi, incerta: false } : r)),
    })),
  };
}

export function aggiungiRiga(b: Bozza, gruppoId: string): Bozza {
  return {
    ...b,
    gruppi: b.gruppi.map((g) => (g.id === gruppoId ? { ...g, righe: [...g.righe, rigaVuota()] } : g)),
  };
}

export function togliRiga(b: Bozza, rigaId: string): Bozza {
  return {
    ...b,
    gruppi: b.gruppi.map((g) => ({ ...g, righe: g.righe.filter((r) => r.id !== rigaId) })),
  };
}

export function aggiungiSezione(b: Bozza): Bozza {
  return { ...b, gruppi: [...b.gruppi, { id: newId(), nome: '', righe: [rigaVuota()] }] };
}

export function cambiaSezione(b: Bozza, gruppoId: string, nome: string): Bozza {
  return { ...b, gruppi: b.gruppi.map((g) => (g.id === gruppoId ? { ...g, nome } : g)) };
}

/** Le sezioni compaiono solo quando ce n'è più di una. */
export function deveMostrareSezioni(b: Bozza): boolean {
  return b.gruppi.length > 1;
}
```

- [ ] **Step 9: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, 14 test verdi nel file `src/ui/bozza.test.ts` (i 6 dello Step 1
più gli 8 aggiunti allo Step 6), 0 falliti.

- [ ] **Step 10: Commit**

```bash
git add src/ui/bozza.ts src/ui/bozza.test.ts
git commit -m "Leggi l'incolla nella bozza e permetti di correggere righe e sezioni"
```

- [ ] **Step 11: Scrivi la riga di ingrediente modificabile**

Crea `src/ui/componenti/RigaIngrediente.tsx`:

```tsx
/**
 * Una riga di ingrediente in scrittura: quantità, unità, nome, e il tasto per
 * toglierla. Le righe che il parser non ha capito sono in evidenza.
 *
 * Il testo dell'utente non viene mai interpretato: TextInput e Text rendono
 * testo semplice, non markup. Niente WebView, niente HTML.
 */
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';

import { t } from '../../i18n/index.ts';
import type { Lingua } from '../../domain/lingua/index.ts';
import type { RigaBozza } from '../bozza.ts';

export type PropsRigaIngrediente = {
  riga: RigaBozza;
  lingua: Lingua;
  onCambia: (campi: Partial<Pick<RigaBozza, 'nome' | 'quantita' | 'unita'>>) => void;
  onTogli: () => void;
};

export function RigaIngrediente({ riga, lingua, onCambia, onTogli }: PropsRigaIngrediente) {
  return (
    <View style={[stili.riga, riga.incerta ? stili.rigaIncerta : null]}>
      <View style={stili.campi}>
        <TextInput
          style={[stili.campo, stili.quantita]}
          value={riga.quantita}
          onChangeText={(v) => onCambia({ quantita: v })}
          placeholder={t('qb', lingua)}
          placeholderTextColor="#999"
          // Su iOS dà la tastiera coi numeri ma lascia scrivere 1/2 e 1,5;
          // su Android non esiste e si ricade sulla tastiera normale.
          keyboardType="numbers-and-punctuation"
        />
        <TextInput
          style={[stili.campo, stili.unita]}
          value={riga.unita}
          onChangeText={(v) => onCambia({ unita: v })}
          autoCapitalize="none"
          autoCorrect={false}
        />
        <TextInput
          style={[stili.campo, stili.nome]}
          value={riga.nome}
          onChangeText={(v) => onCambia({ nome: v })}
        />
        <Pressable
          onPress={onTogli}
          hitSlop={12}
          accessibilityRole="button"
          accessibilityLabel={`${t('modifica.elimina', lingua)} ${riga.nome}`}
        >
          <Text style={stili.togli}>×</Text>
        </Pressable>
      </View>
      {riga.incerta ? <Text style={stili.avviso}>{t('modifica.riga.incerta', lingua)}</Text> : null}
    </View>
  );
}

const stili = StyleSheet.create({
  riga: { paddingVertical: 4 },
  rigaIncerta: {
    backgroundColor: '#fff6e5',
    borderLeftWidth: 3,
    borderLeftColor: '#f0a500',
    paddingLeft: 6,
    borderRadius: 4,
  },
  campi: { flexDirection: 'row', alignItems: 'center', gap: 6 },
  campo: {
    borderBottomWidth: 1,
    borderBottomColor: '#e2e2e2',
    paddingVertical: 6,
    fontSize: 16,
    color: '#111',
  },
  quantita: { width: 64, textAlign: 'right' },
  unita: { width: 72 },
  nome: { flex: 1 },
  togli: { fontSize: 22, color: '#999', paddingHorizontal: 4 },
  avviso: { fontSize: 12, color: '#a06a00', paddingLeft: 6, paddingTop: 2 },
});
```

Le tre chiavi usate qui (`qb`, `modifica.elimina`, `modifica.riga.incerta`) non
hanno segnaposto, quindi `t()` a due argomenti va bene e non lancia.

- [ ] **Step 12: Scrivi la scelta della categoria**

Crea `src/ui/componenti/SceltaCategoria.tsx`:

```tsx
/**
 * Scelta della categoria di una ricetta: una sola, oppure nessuna.
 *
 * Le categorie sono raggruppamenti, non etichette: la fila si comporta come una
 * scelta singola, e «Nessuna categoria» è la prima voce e uno stato normale, non
 * un ripiego.
 *
 * C'è anche il tasto per crearne una al volo. Chi scrive la prima ricetta non ha
 * ancora nessuna categoria, e mandarlo fuori dalla schermata, in gestione
 * categorie, e poi indietro a ritrovare la bozza è il modo migliore per non
 * fargliene creare nessuna.
 */
import { useEffect, useState } from 'react';
import {
  Alert,
  Modal,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';

import { elencoCategorie, salvaCategoria } from '../../data/categorie.ts';
import { ICONA_PREDEFINITA } from '../../domain/icone.ts';
import Icona from './Icona.tsx';
import { SceltaIcona } from './SceltaIcona.tsx';
import { useApp } from '../contesto.ts';
import { categoriaNuova, nomeDuplicato, siPuoSalvare } from '../logica-categorie.ts';
import type { ChiaveIcona } from '../../domain/icone.ts';
import type { Categoria } from '../../domain/types.ts';

export type PropsSceltaCategoria = {
  /** id della categoria scelta, null = nessuna. */
  scelta: string | null;
  onScegli: (categoriaId: string | null) => void;
};

export function SceltaCategoria({ scelta, onScegli }: PropsSceltaCategoria) {
  const { db, testo } = useApp();
  const [categorie, setCategorie] = useState<Categoria[]>([]);
  const [apri, setApri] = useState(false);
  const [nome, setNome] = useState('');
  const [icona, setIcona] = useState<ChiaveIcona>(ICONA_PREDEFINITA);

  useEffect(() => {
    let vivo = true;
    elencoCategorie(db)
      .then((elenco) => {
        if (vivo) setCategorie(elenco);
      })
      .catch(() => {
        // Le categorie non si leggono: la ricetta si scrive lo stesso, senza.
        if (vivo) setCategorie([]);
      });
    return () => {
      vivo = false;
    };
  }, [db]);

  const crea = async () => {
    if (!siPuoSalvare(nome)) return;
    // salvaCategoria non timbra: la data la mette chi salva.
    const categoria = categoriaNuova(nome, icona, categorie, new Date().toISOString());
    try {
      await salvaCategoria(db, categoria);
      setCategorie([...categorie, categoria]);
      onScegli(categoria.id);            // appena creata è quella scelta: è il motivo per cui l'ha creata
      setApri(false);
      setNome('');
      setIcona(ICONA_PREDEFINITA);
    } catch {
      Alert.alert(testo('app.nome'), testo('errore.db'));
    }
  };

  return (
    <View>
      <ScrollView
        horizontal
        showsHorizontalScrollIndicator={false}
        contentContainerStyle={stili.fila}
      >
        <Pressable
          onPress={() => onScegli(null)}
          style={[stili.pillola, scelta === null ? stili.pillolaScelta : null]}
          accessibilityRole="button"
          accessibilityState={{ selected: scelta === null }}
        >
          <Text style={stili.pillolaTesto}>{testo('modifica.categoria.nessuna')}</Text>
        </Pressable>

        {categorie.map((c) => (
          <Pressable
            key={c.id}
            onPress={() => onScegli(c.id)}
            style={[stili.pillola, scelta === c.id ? stili.pillolaScelta : null]}
            accessibilityRole="button"
            accessibilityState={{ selected: scelta === c.id }}
          >
            <Icona chiave={c.icona} dimensione={16} colore="#444" />
            <Text style={stili.pillolaTesto}>{c.nome}</Text>
          </Pressable>
        ))}

        <Pressable
          onPress={() => setApri(true)}
          style={stili.pillola}
          accessibilityRole="button"
          accessibilityLabel={testo('categorie.crea')}
        >
          <Text style={stili.pillolaTesto}>+</Text>
        </Pressable>
      </ScrollView>

      <Modal visible={apri} animationType="slide" transparent onRequestClose={() => setApri(false)}>
        <View style={stili.fondale}>
          <View style={stili.foglio}>
            <Text style={stili.titoloFoglio}>{testo('categorie.crea')}</Text>

            <TextInput
              style={stili.campo}
              value={nome}
              onChangeText={setNome}
              placeholder={testo('categorie.nome')}
              placeholderTextColor="#999"
              autoFocus
            />
            {/* Avviso, non divieto: l'identità di una categoria è il suo id. */}
            {nomeDuplicato(nome, categorie, null) ? (
              <Text style={stili.avviso}>{testo('categorie.nome.duplicato')}</Text>
            ) : null}

            <Text style={stili.intestazione}>{testo('categorie.icona')}</Text>
            <ScrollView style={stili.griglia}>
              <SceltaIcona scelta={icona} onScegli={setIcona} />
            </ScrollView>

            <View style={stili.azioni}>
              <Pressable
                onPress={() => setApri(false)}
                style={stili.tastoChiaro}
                accessibilityRole="button"
              >
                <Text style={stili.tastoChiaroTesto}>{testo('modifica.annulla')}</Text>
              </Pressable>
              <Pressable
                onPress={crea}
                disabled={!siPuoSalvare(nome)}
                style={[stili.tasto, siPuoSalvare(nome) ? null : stili.tastoSpento]}
                accessibilityRole="button"
              >
                <Text style={stili.tastoTesto}>{testo('modifica.salva')}</Text>
              </Pressable>
            </View>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const stili = StyleSheet.create({
  fila: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
  pillola: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    borderRadius: 16,
    paddingVertical: 8,
    paddingHorizontal: 14,
    backgroundColor: '#f2f2f2',
  },
  pillolaScelta: { backgroundColor: '#dff0e7', borderWidth: 1, borderColor: '#1b6b4a' },
  pillolaTesto: { fontSize: 15, color: '#111' },
  fondale: { flex: 1, justifyContent: 'flex-end', backgroundColor: '#0006' },
  foglio: {
    backgroundColor: '#fff',
    borderTopLeftRadius: 16,
    borderTopRightRadius: 16,
    padding: 20,
    gap: 10,
  },
  titoloFoglio: { fontSize: 18, fontWeight: '600', color: '#111' },
  campo: {
    borderBottomWidth: 1,
    borderBottomColor: '#e2e2e2',
    paddingVertical: 8,
    fontSize: 17,
    color: '#111',
  },
  avviso: { fontSize: 13, color: '#a06a00' },
  intestazione: { fontSize: 13, textTransform: 'uppercase', color: '#777', marginTop: 8 },
  griglia: { maxHeight: 220 },
  azioni: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginTop: 12,
  },
  tastoChiaro: { paddingVertical: 10, paddingHorizontal: 12 },
  tastoChiaroTesto: { fontSize: 16, color: '#1b6b4a' },
  tasto: { backgroundColor: '#1b6b4a', borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24 },
  tastoSpento: { backgroundColor: '#b7c9c0' },
  tastoTesto: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
```

- [ ] **Step 13: Scrivi la foto della ricetta**

Crea `src/ui/componenti/FotoRicetta.tsx`:

```tsx
/**
 * La foto della ricetta: scattala, scegli dalla libreria, toglila.
 *
 * Il permesso si chiede al momento del gesto, mai all'avvio: a chi ha appena
 * installato l'app e non ha ancora capito a cosa serve, chiedere la fotocamera
 * significa farsi rispondere di no una volta sola e per sempre. Se l'utente
 * nega si mostra il messaggio tradotto, che dice dove si rimedia, e si smette:
 * non si richiede e non si insiste.
 *
 * Il file lo scrive salvaFoto, che comprime PRIMA di salvare e restituisce il
 * NOME del file, mai un percorso assoluto. Qui si tiene solo quel nome; il
 * percorso da dare a <Image> lo ricostruisce percorsoFoto ogni volta. Se il file
 * non c'è più, oltre a mostrare la ricetta senza immagine si azzera anche il
 * campo: è l'altra metà del requisito, e senza di lei il nome sbagliato resta
 * nel database per sempre.
 *
 * Togliere la foto è dire onCambia(null): il file lo cancella la schermata al
 * salvataggio, così «Annulla» non porta via niente.
 */
import { useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Image, Pressable, StyleSheet, Text, View } from 'react-native';

import { percorsoFoto, salvaFoto, scegliFoto } from '../../data/foto.ts';
import { useApp } from '../contesto.ts';
import type { OrigineFoto } from '../../data/foto.ts';

export type PropsFotoRicetta = {
  /** NOME del file già salvato, o null. Mai un percorso assoluto. */
  foto: string | null;
  /** Id della ricetta: è anche il nome del file, quindi rifare la foto sovrascrive. */
  ricettaId: string;
  onCambia: (foto: string | null) => void;
};

export function FotoRicetta({ foto, ricettaId, onCambia }: PropsFotoRicetta) {
  const { testo } = useApp();
  const [percorso, setPercorso] = useState<string | null>(null);
  const [inCorso, setInCorso] = useState(false);

  // percorsoFoto restituisce null se il file non c'è, e non tocca il database:
  // una ricetta che dice di avere una foto sparita si mostra senza immagine,
  // senza messaggi di errore.
  //
  // E il campo si azzera: il file non c'è ma il database continua a nominarlo,
  // e finché lo nomina ogni salvataggio riscrive quel nome. Succede davvero, a
  // ogni ricetta importata da un JSON nudo esportato da un telefono che le foto
  // le aveva. onCambia(null) mette la bozza a posto; il disco è già a posto.
  //
  // onCambia non entra fra le dipendenze: chi monta il componente la passa come
  // funzione nuova a ogni render, e metterla lì rifarebbe il giro all'infinito.
  useEffect(() => {
    let vivo = true;
    percorsoFoto(foto)
      .then((p) => {
        if (!vivo) return;
        setPercorso(p);
        if (p === null && foto !== null) onCambia(null);
      })
      .catch(() => {
        if (vivo) setPercorso(null);
      });
    return () => {
      vivo = false;
    };
  }, [foto]);

  const prendi = async (origine: OrigineFoto) => {
    // Il permesso lo chiede scegliFoto, adesso, cioè quando l'utente ha appena
    // toccato il tasto.
    const esito = await scegliFoto(origine);
    if (esito.tipo === 'annullata') return;
    if (esito.tipo === 'permessoNegato') {
      Alert.alert(
        testo('app.nome'),
        testo(origine === 'fotocamera' ? 'foto.permesso.fotocamera' : 'foto.permesso.libreria'),
      );
      return;
    }
    setInCorso(true);
    try {
      onCambia(await salvaFoto(esito.uri, ricettaId));
    } catch (errore) {
      // Nessun testo per questo caso: l'elenco delle chiavi è fissato dal Task
      // 16 e non si allarga da qui. La foto semplicemente non compare, e il
      // resto della ricetta non è toccato.
      console.warn('salvataggio della foto fallito', errore);
    } finally {
      setInCorso(false);
    }
  };

  return (
    <View style={stili.blocco}>
      {percorso === null ? (
        <Text style={stili.intestazione}>{testo('foto.aggiungi')}</Text>
      ) : (
        <Image
          source={{ uri: percorso }}
          style={stili.foto}
          resizeMode="cover"
          accessibilityIgnoresInvertColors
        />
      )}

      <View style={stili.tasti}>
        <Pressable
          onPress={() => void prendi('fotocamera')}
          disabled={inCorso}
          hitSlop={8}
          accessibilityRole="button"
        >
          <Text style={[stili.tasto, inCorso ? stili.spento : null]}>{testo('foto.scatta')}</Text>
        </Pressable>
        <Pressable
          onPress={() => void prendi('libreria')}
          disabled={inCorso}
          hitSlop={8}
          accessibilityRole="button"
        >
          <Text style={[stili.tasto, inCorso ? stili.spento : null]}>{testo('foto.scegli')}</Text>
        </Pressable>
        {foto !== null ? (
          <Pressable
            onPress={() => onCambia(null)}
            disabled={inCorso}
            hitSlop={8}
            accessibilityRole="button"
          >
            <Text style={[stili.togli, inCorso ? stili.spento : null]}>{testo('foto.togli')}</Text>
          </Pressable>
        ) : null}
        {inCorso ? <ActivityIndicator size="small" color="#1b6b4a" /> : null}
      </View>
    </View>
  );
}

const stili = StyleSheet.create({
  blocco: { gap: 8 },
  intestazione: { fontSize: 13, textTransform: 'uppercase', color: '#777' },
  foto: { width: '100%', height: 180, borderRadius: 10, backgroundColor: '#f2f2f2' },
  tasti: { flexDirection: 'row', alignItems: 'center', gap: 16 },
  tasto: { fontSize: 15, color: '#1b6b4a' },
  togli: { fontSize: 15, color: '#b00020' },
  spento: { color: '#b7c9c0' },
});
```

- [ ] **Step 14: Verifica che i tipi tornino**

Comando: `npm run typecheck`

Atteso: nessun errore (nessun output, uscita 0). È qui che si vede se una
chiamata a `src/data/foto.ts` o a `src/data/categorie.ts` è scritta male: quelle
funzioni non hanno test automatici, e il typecheck è la loro prima verifica.

- [ ] **Step 15: Scrivi la schermata di modifica**

Sostituisci per intero il contenuto di `src/ui/schermate/Modifica.tsx` (il Task
17 l'ha lasciata come guscio che dice solo se sta creando o modificando):

```tsx
/**
 * Scrivi e modifica una ricetta.
 *
 * `route.params.ricettaId` null è una ricetta nuova; con un id si modifica
 * quella esistente. db, lingua e testo arrivano da useApp(): la schermata è
 * registrata con `component={Modifica}` e non riceve props oltre a quelle
 * della navigazione.
 *
 * Gli ingredienti si inseriscono incollando un elenco nel riquadro grande, che
 * resta sempre a schermo: il parser lo trasforma in righe modificabili e le
 * accoda a quelle già presenti. Le righe che non ha capito restano in evidenza
 * finché l'utente non le tocca.
 *
 * La foto e la categoria vivono nella bozza come tutto il resto: la bozza non
 * tocca mai il disco, e il file della foto tolta si cancella solo qui, al
 * salvataggio, così «Annulla» non porta via niente.
 */
import { useEffect, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';

import { cancellaFoto, nomeFoto } from '../../data/foto.ts';
import { cancellaRicetta, leggiRicetta, salvaRicetta } from '../../data/ricette.ts';
import { FotoRicetta } from '../componenti/FotoRicetta.tsx';
import { RigaIngrediente } from '../componenti/RigaIngrediente.tsx';
import { SceltaCategoria } from '../componenti/SceltaCategoria.tsx';
import { useApp } from '../contesto.ts';
import {
  aggiungiRiga,
  aggiungiSezione,
  bozzaDaRicetta,
  bozzaDaTesto,
  bozzaNuova,
  cambiaRiga,
  cambiaSezione,
  deveMostrareSezioni,
  ricettaDaBozza,
  siPuoSalvare,
  togliRiga,
} from '../bozza.ts';
import type { Bozza } from '../bozza.ts';
import type { PropsSchermata } from '../navigazione.ts';

export default function Modifica({ route, navigation }: PropsSchermata<'Modifica'>) {
  const { db, lingua, testo } = useApp();
  const { ricettaId } = route.params;
  const [bozza, setBozza] = useState<Bozza | null>(null);
  const [incolla, setIncolla] = useState('');

  useEffect(() => {
    let vivo = true;
    (async () => {
      try {
        const ricetta = ricettaId ? await leggiRicetta(db, ricettaId) : null;
        if (!vivo) return;
        // La ricetta non c'è più: si torna indietro. Il controllo su
        // cancellataIl serve quanto quello sul null, perché leggiRicetta
        // restituisce anche le tombstone — all'export servono — e senza questo
        // ramo una ricetta cancellata da un import si riaprirebbe come viva.
        if (ricettaId && (ricetta === null || ricetta.cancellataIl !== null)) {
          return navigation.goBack();
        }
        setBozza(ricetta ? bozzaDaRicetta(ricetta, lingua) : bozzaNuova(new Date().toISOString()));
      } catch {
        if (vivo) Alert.alert(testo('app.nome'), testo('errore.db'));
      }
    })();
    return () => {
      vivo = false;
    };
    // lingua, testo e navigation non entrano fra le dipendenze: sono fissi per
    // tutta la vita dell'app, e ricaricare qui butterebbe via quello che
    // l'utente ha già scritto.
  }, [db, ricettaId]);

  if (bozza === null) return <View style={stili.schermo} />;

  const righe = bozza.gruppi.reduce((n, g) => n + g.righe.length, 0);

  const leggiIncolla = () => {
    if (incolla.trim() === '') return;
    setBozza(bozzaDaTesto(bozza, incolla, lingua));
    setIncolla('');
  };

  const salva = async () => {
    try {
      // salvaRicetta non timbra: la data di modifica la mette qui chi modifica.
      await salvaRicetta(db, ricettaDaBozza(bozza, lingua, new Date().toISOString()));
      // La foto tolta si cancella solo adesso, e il nome del file si ricalcola
      // dall'id perché è deterministico: copre sia la foto tolta a una ricetta
      // che ce l'aveva, sia quella scattata e poi tolta su una ricetta nuova.
      // Su un file che non c'è, cancellaFoto non fa niente e non lancia.
      //
      // nomeFoto invece lancia, se l'id ripulito resta vuoto: l'id di una
      // ricetta importata è una stringa qualunque, e `validaFile` accetta anche
      // un id fatto di soli caratteri che nomeFoto butta via. In quel caso il
      // file non può esistere — l'ha scritto nomeFoto o non l'ha scritto
      // nessuno — quindi non c'è niente da cancellare e la ricetta resta
      // salvata: senza questo riparo un'immagine che non c'è farebbe fallire
      // il salvataggio con «errore del database», che è pure una bugia.
      if (bozza.foto === null) {
        let daCancellare: string | null = null;
        try {
          daCancellare = nomeFoto(bozza.id);
        } catch {
          daCancellare = null;
        }
        await cancellaFoto(daCancellare);
      }
      navigation.goBack();
    } catch {
      Alert.alert(testo('app.nome'), testo('errore.db'));
    }
  };

  const elimina = () => {
    // Il titolo va passato: 'modifica.conferma.elimina' ha il segnaposto
    // {titolo}, e senza valore t() lancia invece di mostrarlo in chiaro.
    Alert.alert(testo('modifica.elimina'), testo('modifica.conferma.elimina', { titolo: bozza.titolo }), [
      { text: testo('modifica.annulla'), style: 'cancel' },
      {
        text: testo('modifica.elimina'),
        style: 'destructive',
        onPress: async () => {
          try {
            // cancellaRicetta si porta via anche il file della foto: il Task 14
            // ci ha già pensato, qui non si ripete.
            await cancellaRicetta(db, bozza.id);
            navigation.goBack();
          } catch {
            Alert.alert(testo('app.nome'), testo('errore.db'));
          }
        },
      },
    ]);
  };

  return (
    <ScrollView
      style={stili.schermo}
      contentContainerStyle={stili.contenuto}
      keyboardShouldPersistTaps="handled"
    >
      <TextInput
        style={stili.titolo}
        value={bozza.titolo}
        onChangeText={(v) => setBozza({ ...bozza, titolo: v })}
        placeholder={testo('modifica.titolo')}
        placeholderTextColor="#999"
      />

      <FotoRicetta
        foto={bozza.foto}
        ricettaId={bozza.id}
        onCambia={(foto) => setBozza({ ...bozza, foto })}
      />

      <TextInput
        style={stili.campo}
        value={bozza.descrizione}
        onChangeText={(v) => setBozza({ ...bozza, descrizione: v })}
        placeholder={testo('modifica.descrizione')}
        placeholderTextColor="#999"
        multiline
      />
      <TextInput
        style={stili.campo}
        value={bozza.porzioni}
        onChangeText={(v) => setBozza({ ...bozza, porzioni: v })}
        placeholder={testo('modifica.porzioni')}
        placeholderTextColor="#999"
        keyboardType="numeric"
      />

      <Text style={stili.intestazione}>{testo('modifica.categoria')}</Text>
      <SceltaCategoria
        scelta={bozza.categoriaId}
        onScegli={(categoriaId) => setBozza({ ...bozza, categoriaId })}
      />

      <Text style={stili.intestazione}>{testo('modifica.ingredienti')}</Text>

      {/* I gruppi si mostrano sempre, anche quando non c'è ancora nessuna riga:
          una bozza nuova ha già un gruppo vuoto, ed è lì dentro che sta il `+`
          con cui si scrive la prima riga a mano. Nascondendoli finché le righe
          sono zero, su una ricetta nuova non ci sarebbe nessun modo di
          aggiungerne una. */}
      {bozza.gruppi.map((g) => (
        <View key={g.id} style={stili.gruppo}>
          {deveMostrareSezioni(bozza) ? (
            <TextInput
              style={stili.nomeSezione}
              value={g.nome}
              onChangeText={(v) => setBozza(cambiaSezione(bozza, g.id, v))}
              placeholder={testo('modifica.sezione.aggiungi')}
              placeholderTextColor="#999"
            />
          ) : null}
          {g.righe.map((r) => (
            <RigaIngrediente
              key={r.id}
              riga={r}
              lingua={lingua}
              onCambia={(campi) => setBozza(cambiaRiga(bozza, r.id, campi))}
              onTogli={() => setBozza(togliRiga(bozza, r.id))}
            />
          ))}
          <Pressable
            onPress={() => setBozza(aggiungiRiga(bozza, g.id))}
            style={stili.tastoChiaro}
            accessibilityRole="button"
            accessibilityLabel={testo('modifica.ingredienti')}
          >
            <Text style={stili.tastoChiaroTesto}>+</Text>
          </Pressable>
        </View>
      ))}

      {/* Sempre a schermo, anche a ricetta piena: quello che si legge qui si
          accoda alle righe di sopra, non le sostituisce. */}
      <TextInput
        style={stili.incolla}
        value={incolla}
        onChangeText={setIncolla}
        onBlur={leggiIncolla}
        placeholder={testo('modifica.incolla')}
        placeholderTextColor="#999"
        multiline
        textAlignVertical="top"
      />

      {/* Il comando esplicito che legge il riquadro. L'`onBlur` qui sopra fa la
          stessa cosa quando si tocca fuori, ma è un gesto che nessuno indovina:
          alla prima ricetta, senza questo tasto, non c'è niente da toccare che
          dica «adesso leggi quello che ho incollato». Il tasto non fa leggere
          due volte: la ScrollView ha keyboardShouldPersistTaps="handled",
          quindi il tocco arriva qui senza togliere il fuoco al riquadro, e
          quando il fuoco se ne va davvero il riquadro è già vuoto e
          `leggiIncolla` esce subito. */}
      <Pressable
        onPress={leggiIncolla}
        disabled={incolla.trim() === ''}
        style={[stili.tastoChiaro, incolla.trim() === '' ? stili.tastoChiaroSpento : null]}
        accessibilityRole="button"
      >
        {/* Niente accessibilityLabel: il nome accessibile lo dà questo testo, e
            «più ingredienti» è già quello che il tasto fa. Il `+` da solo, come
            sul tasto dentro il gruppo, qui non basterebbe a distinguerli. */}
        <Text style={stili.tastoChiaroTesto}>+ {testo('modifica.ingredienti')}</Text>
      </Pressable>

      {righe > 0 ? (
        <Pressable
          onPress={() => setBozza(aggiungiSezione(bozza))}
          style={stili.tastoChiaro}
          accessibilityRole="button"
        >
          <Text style={stili.tastoChiaroTesto}>{testo('modifica.sezione.aggiungi')}</Text>
        </Pressable>
      ) : null}

      <View style={stili.azioni}>
        <Pressable onPress={() => navigation.goBack()} style={stili.tastoChiaro} accessibilityRole="button">
          <Text style={stili.tastoChiaroTesto}>{testo('modifica.annulla')}</Text>
        </Pressable>
        <Pressable
          onPress={salva}
          disabled={!siPuoSalvare(bozza)}
          style={[stili.tasto, siPuoSalvare(bozza) ? null : stili.tastoSpento]}
          accessibilityRole="button"
        >
          <Text style={stili.tastoTesto}>{testo('modifica.salva')}</Text>
        </Pressable>
      </View>

      {bozza.esistente ? (
        <Pressable onPress={elimina} style={stili.tastoChiaro} accessibilityRole="button">
          <Text style={stili.elimina}>{testo('modifica.elimina')}</Text>
        </Pressable>
      ) : null}
    </ScrollView>
  );
}

const stili = StyleSheet.create({
  schermo: { flex: 1, backgroundColor: '#fff' },
  contenuto: { padding: 16, paddingBottom: 48, gap: 12 },
  titolo: { fontSize: 24, fontWeight: '600', color: '#111', paddingVertical: 6 },
  campo: {
    borderBottomWidth: 1,
    borderBottomColor: '#e2e2e2',
    paddingVertical: 8,
    fontSize: 16,
    color: '#111',
  },
  intestazione: { fontSize: 13, textTransform: 'uppercase', color: '#777', marginTop: 12 },
  incolla: {
    minHeight: 140,
    borderWidth: 1,
    borderColor: '#e2e2e2',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    color: '#111',
  },
  gruppo: { gap: 2 },
  nomeSezione: { fontSize: 16, fontWeight: '600', color: '#111', paddingVertical: 6 },
  tastoChiaro: { alignSelf: 'flex-start', paddingVertical: 10, paddingHorizontal: 12 },
  tastoChiaroTesto: { fontSize: 16, color: '#1b6b4a' },
  tastoChiaroSpento: { opacity: 0.4 },
  azioni: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 16 },
  tasto: { backgroundColor: '#1b6b4a', borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24 },
  tastoSpento: { backgroundColor: '#b7c9c0' },
  tastoTesto: { color: '#fff', fontSize: 16, fontWeight: '600' },
  elimina: { fontSize: 16, color: '#b00020' },
});
```

- [ ] **Step 16: Verifica tipi e test**

Comandi:
```bash
npm run typecheck
npm test
```

Atteso: typecheck senza errori — è qui che si vede se il parametro di rotta è
scritto giusto, perché `route.params.id` non esiste in `ParametriNav`; `npm test`
PASS, ℹ pass 213, ℹ fail 0 (i 199 lasciati dal Task 21 più i 14 nuovi di
`bozza.test.ts`), verdi insieme a tutti quelli dei task precedenti.

- [ ] **Step 17: Prova a mano il giro completo**

Comando: `npx expo start --ios` (oppure `--android`).

Atteso, a mano:
1. dal tondo `+` si apre una schermata vuota con il riquadro dell'incolla e,
   sotto «Ingredienti», un `+` che aggiunge una riga vuota da riempire a mano:
   su una ricetta nuova si può scrivere anche senza incollare niente;
2. incollando un elenco di ingredienti nel riquadro e toccando «+ Ingredienti»
   lì sotto, le righe compaiono modificabili e il riquadro si svuota; quelle non
   capite sono in evidenza con «Da controllare». Toccando fuori dal riquadro
   invece del tasto succede la stessa cosa, e le righe non compaiono due volte;
3. il tasto «+ Ingredienti» è spento finché il riquadro è vuoto;
4. incollando un secondo blocco, le righe di prima restano dove sono e le nuove
   si accodano in fondo;
5. la fila delle categorie mostra «Nessuna categoria» selezionata; toccando il
   `+` si apre la finestra, si scrive un nome, si sceglie un'icona, e la
   categoria appena creata risulta subito selezionata;
6. toccando «Scatta una foto» il telefono chiede il permesso della fotocamera
   **adesso**, non all'avvio dell'app; concedendolo e scattando, l'immagine
   compare sotto il titolo;
7. negando il permesso, compare il messaggio che dice di darlo dalle
   impostazioni del telefono, e non viene richiesto una seconda volta;
8. «Togli la foto» fa sparire l'immagine; toccando «Annulla» invece di «Salva» e
   riaprendo la ricetta, la foto è ancora lì;
9. rifacendo il giro e stavolta salvando, riaprendo la ricetta la foto non c'è
   più;
10. salvando e riaprendo la ricetta dall'elenco, la schermata mostra i suoi
    campi (non una ricetta vuota), la sua categoria e la sua foto, e in fondo
    compare «Elimina ricetta»;
11. toccando «Elimina ricetta», la conferma dice il titolo fra virgolette, non
    `{titolo}`;
12. importando un JSON nudo esportato da un telefono che le foto le aveva, e
    aprendo in modifica una di quelle ricette, compare «Aggiungi una foto» e non
    un errore; salvando e riaprendo, continua a non esserci nessuna foto, perché
    il campo è stato azzerato invece di restare a puntare a un file che non c'è.

- [ ] **Step 18: Commit**

```bash
git add src/ui/componenti/RigaIngrediente.tsx src/ui/componenti/SceltaCategoria.tsx \
        src/ui/componenti/FotoRicetta.tsx src/ui/schermate/Modifica.tsx
git commit -m "Aggiungi la schermata di scrittura e modifica, con categoria e foto"
```

---

---

### Task 23: Export e import nell'interfaccia

**Cosa fa questo task, per chi non conosce il progetto.** QuantoBasta è un'app
iOS e Android che ricalcola le dosi di una ricetta. Il ricettario sta in un
SQLite sul dispositivo: niente account, niente server, niente sincronizzazione.
Finché la sync non arriva, l'unico modo di portare le proprie ricette su un
altro telefono, o di non perderle cambiandolo, è **un file che si esporta e si
importa a mano**. Questo task porta quel gesto dentro l'app.

L'archivio esportato è uno **zip** con dentro `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: serve a riconoscere e
rifiutare i file che non sono nostri, e a gestire i cambi di formato futuri.

L'import accetta **sia lo zip sia un JSON nudo**. I file scritti a mano e quelli
di versione 1, che le categorie e le foto non le avevano, devono continuare a
entrare: un formato che smette di leggere i propri file vecchi è un formato che
ha perso i dati di qualcuno.

Tre cose da non sbagliare.

**L'import è additivo, mai sostitutivo.** A parità di id resta la versione
modificata più di recente. Non svuota niente e non chiede «vuoi sostituire il
ricettario?».

**La copia di sicurezza la fa già `importa`**, prima di toccare qualsiasi cosa.
Qui non va rifatta: rifarla vorrebbe dire due copie, di cui una sbagliata.

**L'esito si dice sempre**, riuscito o rifiutato che sia. Quante ricette sono
entrate, quante aggiornate, quante ignorate, e quante categorie; oppure perché
il file è stato rifiutato. Un import che non dice niente lascia l'utente a
chiedersi se ha funzionato, e la risposta la trova solo cercando una ricetta a
caso.

**Files:**
- Create: `src/ui/scambio.ts`
- Create: `src/ui/componenti/BarraIo.tsx`
- Modify: `src/ui/schermate/Categorie.tsx` (il Task 18 ci ha già messo un
  `headerRight` col tasto «Gestisci»: la barra si aggiunge lì dentro, accanto a
  quello. `src/ui/App.tsx` **non si tocca**, vedi la seconda decisione qui sotto)
- Test: `src/ui/scambio.test.ts`

**Interfaces:**

- **Consumes**

  - `src/io/esporta.ts` (Task 15)
    ```ts
    import type { SQLiteDatabase } from 'expo-sqlite';
    export interface EsitoExport {
      /** Percorso o URI dell'archivio zip appena scritto nella cartella cache. */
      uri: string;
      /**
       * Quante ricette sono finite nell'archivio, **tombstone escluse**: nel
       * file ci vanno anche quelle, servono a non far resuscitare le buttate,
       * ma chi legge il messaggio vuole sapere quante ricette ha in mano.
       */
      ricette: number;
      /** Quanti file foto sono finiti nell'archivio. */
      foto: number;
    }
    /** Scrive l'archivio zip (ricettario.json + foto/) nella cartella cache. */
    export async function esporta(db: SQLiteDatabase): Promise<EsitoExport>;
    ```
  - `src/io/importa.ts` (Task 15)
    ```ts
    /** Si chiama così, non `Conteggi`: quel nome è già preso in src/data/ricette.ts. */
    export interface ContiCategorie { aggiunte: number; aggiornate: number; ignorate: number }
    export type EsitoImport =
      | { ok: true; aggiunte: number; aggiornate: number; ignorate: number; categorie: ContiCategorie }
      /** `motivo` è un identificatore, non una frase: vedi sotto. */
      | { ok: false; motivo: string };
    /**
     * Import di un archivio zip. Additivo, mai sostitutivo: a parità di id vince
     * modificataIl più recente. Fa già la copia di sicurezza del database prima
     * di toccare qualcosa, e rifiuta i file che non sono nostri restituendo
     * { ok: false }.
     */
    export async function importaArchivio(db: SQLiteDatabase, zip: Uint8Array): Promise<EsitoImport>;
    /** Come sopra ma per un JSON nudo, senza foto. Accetta anche la versione 1. */
    export async function importaJson(db: SQLiteDatabase, testo: string): Promise<EsitoImport>;
    ```
    `aggiunte`, `aggiornate` e `ignorate` di primo livello contano le **ricette**;
    `categorie` porta gli stessi tre numeri per le categorie.
    **La copia di sicurezza è già dentro tutte e due: non va rifatta qui.**

    **`motivo` è un identificatore, non un messaggio.** Il Task 15 ne produce due
    e due soltanto: `'file-non-valido'` e `'copia-di-sicurezza-fallita'`. Non
    vanno mostrati così come sono — «File non importato: file-non-valido» è
    codice a schermo, in tutte e due le lingue — e la traduzione tocca a questo
    task, perché `src/io` non sa in che lingua è l'app. La fa `righeImport`, vedi
    i Produces.
  - `src/i18n/index.ts` (Task 16)
    ```ts
    export type Chiave = 'app.nome' | /* … */ | 'modifica.annulla' | 'modifica.categoria'
      | 'io.esporta' | 'io.importa' | 'io.export.pronto' | 'io.import.ok'
      | 'io.import.rifiutato' | 'errore.db';
    /** Lancia se un segnaposto della chiave resta senza valore. */
    export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;
    ```
    `io.export.pronto` contiene i segnaposto `{ricette}` e `{foto}`
    (`'Archivio pronto: {ricette} ricette e {foto} foto.'`); `io.import.ok`
    contiene `{aggiunte}`, `{aggiornate}` e `{ignorate}`
    (`'Aggiunte {aggiunte}, aggiornate {aggiornate}, ignorate {ignorate}.'`);
    `io.import.rifiutato` contiene `{motivo}`. Vanno passati tutti, sempre:
    senza valore `t()` lancia. Le altre chiavi usate qui (`app.nome`,
    `io.esporta`, `io.importa`, `modifica.annulla`, `modifica.categoria`,
    `errore.db`) non hanno segnaposto. **L'unione `Chiave` non va allargata**:
    l'elenco è fissato dal Task 16, e qui non serve nessuna chiave nuova.
  - `src/ui/contesto.ts` (Task 17)
    ```ts
    export interface StatoApp {
      db: SQLiteDatabase;
      lingua: Lingua;
      /** t() già legata alla lingua dell'app. */
      testo: (chiave: Chiave, valori?: Record<string, string>) => string;
    }
    export function useApp(): StatoApp;
    ```
  - `src/ui/schermate/Categorie.tsx` (Task 18). È il file che questo task
    modifica, e la parte che tocca è questa, riportata come sta:
    ```tsx
      useLayoutEffect(() => {
        navigation.setOptions({
          headerRight: () => (
            <Pressable
              onPress={() => navigation.navigate('GestioneCategorie')}
              hitSlop={12}
              accessibilityRole="button"
            >
              <Text style={stili.gestisci}>{testo('categorie.gestisci')}</Text>
            </Pressable>
          ),
        });
      }, [navigation, testo]);
    ```
    La schermata ha già in mano `navigation` e `testo`, importa già `View`,
    `Pressable` e `Text` da `react-native`, e si ricarica da sé al ritorno con
    `useFocusEffect`. Tutto quello che sta dentro `<Stack.Navigator>` è già
    dentro `<ContestoApp.Provider>` (Task 17), quindi anche un componente montato
    nell'intestazione può chiamare `useApp()`.
  - `expo-sharing`, installato dal Task 17:
    ```ts
    function isAvailableAsync(): Promise<boolean>;
    function shareAsync(url: string, options?: { mimeType?: string; UTI?: string; dialogTitle?: string }): Promise<void>;
    ```
    `url` deve essere l'URI locale del file (`file:///…`).
  - `expo-document-picker`, installato dal Task 17:
    ```ts
    function getDocumentAsync(options?: {
      type?: string | string[]; base64?: boolean;
      copyToCacheDirectory?: boolean; multiple?: boolean;
    }): Promise<
      | { canceled: true; assets: null }
      | { canceled: false; assets: { uri: string; name: string; mimeType?: string; size?: number }[] }
    >;
    ```
    `base64` vale `true` per impostazione predefinita: va spento, altrimenti il
    picker carica in memoria l'intero archivio codificato in base64 per poi
    buttarlo via.
  - `expo-file-system`, installato dal Task 17. Serve solo la classe `File`:
    ```ts
    export declare class File {
      constructor(...uris: (string | File | Directory)[]);
      text(): Promise<string>;
      bytes(): Promise<Uint8Array>;
    }
    ```
  - `@react-navigation/native-stack`, installato dal Task 17. Di `navigation`
    serve un solo metodo, quello che rimonta una rotta da capo:
    `replace<N extends keyof ParametriNav>(name: N, params?: ParametriNav[N]): void`.

- **Produces**
  - `src/ui/scambio.ts`
    ```ts
    export interface Avviso {
      chiave: Chiave;
      valori: Record<string, string>;
      /** Etichetta da mettere davanti alla riga, o null. */
      etichetta: Chiave | null;
    }
    /** Zip o JSON nudo? Lo dicono i primi quattro byte. */
    export function sembraZip(byte: Uint8Array): boolean;
    /** expo-sharing vuole un URI; esporta() può restituire un percorso nudo. */
    export function uriDiFile(percorso: string): string;
    /** Che cosa c'è nell'archivio appena scritto. */
    export function avvisoExport(esito: EsitoExport): Avviso;
    /**
     * Le righe del messaggio dopo un import: le ricette, e le categorie se ce
     * n'erano. La lingua serve per tradurre il `motivo` di un rifiuto, che dal
     * Task 15 arriva come identificatore (`'file-non-valido'`).
     */
    export function righeImport(esito: EsitoImport, lingua: Lingua): Avviso[];
    ```
  - `src/ui/componenti/BarraIo.tsx`
    ```tsx
    export type PropsBarraIo = {
      /** Chiamata dopo un import riuscito: la schermata principale si rilegge. */
      onImportato: () => void;
    };
    export function BarraIo(props: PropsBarraIo);
    ```
    Non prende `db` né `lingua`: come le schermate, se li prende da `useApp()`.
  - `src/ui/schermate/Categorie.tsx` mostra la barra nell'intestazione, accanto
    al tasto «Gestisci» che ci ha già messo il Task 18.

**Tre decisioni da tenere a mente mentre si legge il codice**

1. **Il tipo di file si riconosce dai byte, non dal nome.** Su Android il
   gestore file dichiara volentieri qualunque cosa come `application/octet-stream`,
   e un archivio arrivato via chat può chiamarsi in qualunque modo. Un file zip
   comincia sempre con i quattro byte `PK\x03\x04`: si leggono quelli e si decide.
   Per lo stesso motivo il picker si apre su `*/*` e non su un tipo stretto,
   altrimenti su Android il file da importare non compare nemmeno nell'elenco.
2. **L'intestazione della rotta Categorie ha un padrone solo, ed è
   `Categorie.tsx`.** Il Task 18 ci mette già il tasto «Gestisci» con
   `navigation.setOptions`, e in React Navigation le opzioni impostate dalla
   schermata vincono per chiave su quelle dichiarate nella `<Stack.Screen>`:
   montare la barra da `App.tsx` vorrebbe dire non vederla mai. Quindi si
   aggiunge accanto a «Gestisci», dentro lo stesso `setOptions`. Dopo un import
   riuscito la barra chiama `navigation.replace('Categorie')`: la rotta si
   rimonta da capo e la schermata rilegge categorie, conteggi e ricette da sé,
   senza che nessuno debba passarle una funzione di ricarica.
3. **Le categorie si nominano solo se ce n'erano.** Un JSON di versione 1 non ha
   categorie: dire «Categoria: aggiunte 0, aggiornate 0, ignorate 0» a chi ha
   appena importato un file scritto a mano è rumore, e il rumore fa smettere di
   leggere anche le righe che contano.

- [ ] **Step 1: Verifica che le librerie ci siano già**

Il Task 17 ha installato tutto quello che serve qui. Prima di scrivere codice si
controlla che sia vero.

Comando:
```bash
node -p "const d=require('./package.json').dependencies; ['expo-document-picker','expo-file-system','expo-sharing'].map(k=>k+' '+d[k]).join('\n')"
```

Atteso: tre righe, ognuna col nome del pacchetto e una versione, e nessun
`undefined`. Se qualcuna dice `undefined`, il pacchetto manca e va installato con
`npx expo install <nome>`, che sceglie la versione giusta per l'SDK in uso
(`npm install` prenderebbe l'ultima pubblicata, che con Expo 57 può non
funzionare).

- [ ] **Step 2: Scrivi il test che fallisce**

Crea `src/ui/scambio.test.ts`:

```ts
/**
 * Test della colla fra le funzioni di scambio file e l'interfaccia.
 * Girano con `npm test`, senza framework e senza dispositivo.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { avvisoExport, righeImport, sembraZip, uriDiFile } from './scambio.ts';
import type { EsitoImport } from '../io/importa.ts';

/** I primi byte di un archivio zip vero, seguiti da quello che capita. */
const ZIP = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00]);
/** L'inizio di '{"formato":…' in UTF-8: un JSON nudo comincia con la graffa. */
const JSON_NUDO = new Uint8Array([0x7b, 0x22, 0x66, 0x6f]);

test("un archivio si riconosce dai primi quattro byte, non dall'estensione", () => {
  assert.equal(sembraZip(ZIP), true);
  assert.equal(sembraZip(JSON_NUDO), false);
  assert.equal(sembraZip(new Uint8Array([0x50, 0x4b])), false);   // troppo corto
  assert.equal(sembraZip(new Uint8Array()), false);               // file vuoto
});

test("il percorso diventa un URI solo se non lo è già", () => {
  assert.equal(
    uriDiFile('/data/user/0/app/cache/ricettario.zip'),
    'file:///data/user/0/app/cache/ricettario.zip',
  );
  assert.equal(uriDiFile('file:///var/mobile/ricettario.zip'), 'file:///var/mobile/ricettario.zip');
  assert.equal(
    uriDiFile('content://com.android.providers/document/42'),
    'content://com.android.providers/document/42',
  );
});

test("l'archivio pronto dice quante ricette e quante foto", () => {
  assert.deepEqual(avvisoExport({ uri: 'file:///c/ricettario.zip', ricette: 12, foto: 5 }), {
    chiave: 'io.export.pronto',
    valori: { ricette: '12', foto: '5' },
    etichetta: null,
  });
});

test("l'import riuscito dice le ricette e, sotto, le categorie", () => {
  const esito: EsitoImport = {
    ok: true,
    aggiunte: 3,
    aggiornate: 1,
    ignorate: 8,
    categorie: { aggiunte: 2, aggiornate: 0, ignorate: 1 },
  };
  assert.deepEqual(righeImport(esito, 'it'), [
    {
      chiave: 'io.import.ok',
      valori: { aggiunte: '3', aggiornate: '1', ignorate: '8' },
      etichetta: null,
    },
    {
      chiave: 'io.import.ok',
      valori: { aggiunte: '2', aggiornate: '0', ignorate: '1' },
      etichetta: 'modifica.categoria',
    },
  ]);
});

test('gli zeri si dicono, non si nascondono', () => {
  const esito: EsitoImport = {
    ok: true,
    aggiunte: 0,
    aggiornate: 0,
    ignorate: 12,
    categorie: { aggiunte: 0, aggiornate: 0, ignorate: 3 },
  };
  assert.deepEqual(righeImport(esito, 'it')[0].valori, {
    aggiunte: '0',
    aggiornate: '0',
    ignorate: '12',
  });
});

test('un file senza categorie non parla di categorie', () => {
  // È il caso del JSON di versione 1, che le categorie non le aveva proprio.
  const esito: EsitoImport = {
    ok: true,
    aggiunte: 5,
    aggiornate: 0,
    ignorate: 0,
    categorie: { aggiunte: 0, aggiornate: 0, ignorate: 0 },
  };
  assert.equal(righeImport(esito, 'it').length, 1);
});

test('il motivo del rifiuto arriva come sigla e si mostra come frase', () => {
  // src/io produce due identificatori e due soltanto. Mostrarli così come sono
  // vorrebbe dire «File non importato: file-non-valido», cioè codice a schermo.
  const rifiutato: EsitoImport = { ok: false, motivo: 'file-non-valido' };
  assert.deepEqual(righeImport(rifiutato, 'it'), [
    {
      chiave: 'io.import.rifiutato',
      valori: { motivo: 'non è un ricettario di QuantoBasta' },
      etichetta: null,
    },
  ]);
  assert.equal(
    righeImport(rifiutato, 'en')[0].valori.motivo,
    'it is not a QuantoBasta recipe file',
  );

  const fallita: EsitoImport = { ok: false, motivo: 'copia-di-sicurezza-fallita' };
  assert.equal(
    righeImport(fallita, 'it')[0].valori.motivo,
    'non è riuscita la copia di sicurezza, e il ricettario non è stato toccato',
  );

  // Un motivo che non conosciamo si mostra com'è: è sempre meglio di niente, e
  // succede solo se un domani src/io ne aggiunge uno.
  assert.equal(righeImport({ ok: false, motivo: 'boh' }, 'it')[0].valori.motivo, 'boh');
});
```

- [ ] **Step 3: Esegui il test e verifica che fallisca**

Comando: `npm test`

Atteso: FAIL con `Cannot find module` seguito dal percorso di
`src/ui/scambio.ts` (`ERR_MODULE_NOT_FOUND`, il file non esiste ancora).

- [ ] **Step 4: Implementa il minimo che fa passare il test**

Crea `src/ui/scambio.ts`:

```ts
/**
 * Colla fra le funzioni di scambio file (src/io) e l'interfaccia.
 *
 * Sta qui, fuori dai componenti, perché è l'unica parte verificabile con
 * `node --test`: i componenti importano react-native, che fuori dal dispositivo
 * non si carica. Gli import di tipo spariscono a runtime, quindi questo file non
 * tira dentro né expo-sqlite né jszip.
 */
import type { Lingua } from '../domain/lingua/index.ts';
import type { Chiave } from '../i18n/index.ts';
import type { EsitoExport } from '../io/esporta.ts';
import type { EsitoImport } from '../io/importa.ts';

export interface Avviso {
  chiave: Chiave;
  valori: Record<string, string>;
  /** Etichetta da mettere davanti alla riga, o null. */
  etichetta: Chiave | null;
}

/** I quattro byte con cui comincia ogni file zip: 'PK\x03\x04'. */
const FIRMA_ZIP = [0x50, 0x4b, 0x03, 0x04];

/**
 * Zip o JSON nudo?
 *
 * Lo dicono i primi quattro byte, non l'estensione né il tipo MIME: su Android
 * il gestore file dichiara volentieri tutto come octet-stream, e un archivio
 * arrivato via chat può chiamarsi in qualunque modo.
 */
export function sembraZip(byte: Uint8Array): boolean {
  return byte.length >= 4 && FIRMA_ZIP.every((b, i) => byte[i] === b);
}

/**
 * expo-sharing vuole un URI. Se esporta() restituisce un percorso nudo gli si
 * mette davanti lo schema, se restituisce già file:// o content:// si lascia stare.
 */
export function uriDiFile(percorso: string): string {
  return /^[a-z][a-z0-9+.-]*:/i.test(percorso) ? percorso : 'file://' + percorso;
}

/** Che cosa c'è nell'archivio appena scritto. */
export function avvisoExport(esito: EsitoExport): Avviso {
  return {
    chiave: 'io.export.pronto',
    valori: { ricette: String(esito.ricette), foto: String(esito.foto) },
    etichetta: null,
  };
}

/**
 * I due motivi di rifiuto che src/io sa produrre, detti in modo leggibile.
 *
 * Stanno qui e non in src/i18n per due ragioni. `src/io` non conosce la lingua
 * dell'app, e dargliela vorrebbe dire farla passare attraverso tutto lo strato
 * dati per due stringhe. E l'unione `Chiave` del Task 16 è chiusa: queste due
 * frasi non sono testo dell'interfaccia, sono la traduzione di un valore che
 * arriva dallo strato dati, e vivono accanto alla funzione che le usa.
 */
const MOTIVI: Record<Lingua, Record<string, string>> = {
  it: {
    'file-non-valido': 'non è un ricettario di QuantoBasta',
    'copia-di-sicurezza-fallita':
      'non è riuscita la copia di sicurezza, e il ricettario non è stato toccato',
  },
  en: {
    'file-non-valido': 'it is not a QuantoBasta recipe file',
    'copia-di-sicurezza-fallita': 'the safety copy failed, and your recipes were left untouched',
  },
};

/**
 * Le righe del messaggio dopo un import: le ricette, e le categorie se ne sono
 * arrivate.
 *
 * I valori si riempiono sempre tutti, anche gli zeri: le chiavi hanno dei
 * segnaposto e t() lancia se ne trova uno senza valore. La riga delle categorie
 * invece compare solo se il file ne portava: un JSON di versione 1 non ne ha, e
 * «Categoria: aggiunte 0, aggiornate 0, ignorate 0» è rumore che fa smettere di
 * leggere anche la riga che conta.
 *
 * Il `motivo` che arriva da src/io è un identificatore con i trattini: si
 * traduce qui, e se è uno che non conosciamo si mostra com'è invece di lasciare
 * il segnaposto senza valore, che farebbe lanciare t().
 */
export function righeImport(esito: EsitoImport, lingua: Lingua): Avviso[] {
  if (!esito.ok) {
    return [
      {
        chiave: 'io.import.rifiutato',
        valori: { motivo: MOTIVI[lingua][esito.motivo] ?? esito.motivo },
        etichetta: null,
      },
    ];
  }

  const ricette: Avviso = {
    chiave: 'io.import.ok',
    valori: {
      aggiunte: String(esito.aggiunte),
      aggiornate: String(esito.aggiornate),
      ignorate: String(esito.ignorate),
    },
    etichetta: null,
  };

  const c = esito.categorie;
  if (c.aggiunte + c.aggiornate + c.ignorate === 0) return [ricette];

  return [
    ricette,
    {
      chiave: 'io.import.ok',
      valori: {
        aggiunte: String(c.aggiunte),
        aggiornate: String(c.aggiornate),
        ignorate: String(c.ignorate),
      },
      // 'modifica.categoria' è il sostantivo «Categoria» / «Category»: distingue
      // la riga delle categorie da quella delle ricette senza chiedere una
      // chiave nuova, e l'elenco delle chiavi è fissato dal Task 16.
      etichetta: 'modifica.categoria',
    },
  ];
}
```

- [ ] **Step 5: Esegui il test e verifica che passi**

Comando: `npm test`

Atteso: PASS, 7 test verdi nel file `src/ui/scambio.test.ts`, 0 falliti.

- [ ] **Step 6: Commit**

```bash
git add src/ui/scambio.ts src/ui/scambio.test.ts
git commit -m "Aggiungi la colla fra scambio file e interfaccia"
```

- [ ] **Step 7: Scrivi il menu di export e import**

Crea `src/ui/componenti/BarraIo.tsx`:

```tsx
/**
 * Export e import del ricettario, dal menu in cima alla schermata principale.
 *
 * L'export scrive un archivio zip con dentro ricettario.json e la cartella
 * foto/, dice che cosa c'è finito, e lo passa al foglio di condivisione del
 * sistema. L'import lo fa scegliere all'utente e accetta sia lo zip sia un JSON
 * nudo: i file scritti a mano e quelli di versione 1, che le foto non le
 * avevano, devono continuare a entrare.
 *
 * importaArchivio e importaJson sono additivi e fanno già da sé la copia di
 * sicurezza del database: qui non si duplica. L'esito si dice sempre, riuscito o
 * rifiutato che sia.
 *
 * db e testo arrivano da useApp(), come nelle schermate: chi monta la barra le
 * passa solo che cosa fare dopo un import riuscito.
 */
import { useState } from 'react';
import { ActivityIndicator, Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import * as Sharing from 'expo-sharing';
import { File } from 'expo-file-system';

import { esporta } from '../../io/esporta.ts';
import { importaArchivio, importaJson } from '../../io/importa.ts';
import { useApp } from '../contesto.ts';
import { avvisoExport, righeImport, sembraZip, uriDiFile } from '../scambio.ts';

export type PropsBarraIo = {
  /** Chiamata dopo un import riuscito: la schermata principale si rilegge. */
  onImportato: () => void;
};

export function BarraIo({ onImportato }: PropsBarraIo) {
  // `lingua` serve a righeImport, che traduce il motivo di un rifiuto: dallo
  // strato dati arriva come identificatore, non come frase.
  const { db, lingua, testo } = useApp();
  const [inCorso, setInCorso] = useState(false);

  const condividi = async (uri: string) => {
    try {
      if (await Sharing.isAvailableAsync()) {
        await Sharing.shareAsync(uri, {
          mimeType: 'application/zip',
          UTI: 'public.zip-archive',
          dialogTitle: testo('io.esporta'),
        });
      } else {
        // Niente foglio di condivisione: almeno diciamo dov'è finito il file.
        Alert.alert(testo('io.esporta'), uri);
      }
    } catch {
      Alert.alert(testo('app.nome'), testo('errore.db'));
    }
  };

  const esportaRicettario = async () => {
    setInCorso(true);
    try {
      const esito = await esporta(db);
      const avviso = avvisoExport(esito);
      // Il conto si dice prima di condividere: «12 ricette e 5 foto» è il modo
      // più corto per sapere che nell'archivio ci sono davvero anche le foto.
      Alert.alert(testo('io.esporta'), testo(avviso.chiave, avviso.valori), [
        { text: testo('modifica.annulla'), style: 'cancel' },
        { text: testo('io.esporta'), onPress: () => void condividi(uriDiFile(esito.uri)) },
      ]);
    } catch {
      Alert.alert(testo('app.nome'), testo('errore.db'));
    } finally {
      setInCorso(false);
    }
  };

  const importaRicettario = async () => {
    // '*/*' e non 'application/zip': su Android il gestore file dichiara i nostri
    // file come octet-stream e col filtro stretto non si riescono a scegliere.
    // base64 spento perché il file lo leggiamo noi: lasciarlo acceso vorrebbe
    // dire caricare in memoria l'intero archivio codificato, per poi buttarlo.
    const scelta = await DocumentPicker.getDocumentAsync({
      type: '*/*',
      base64: false,
      copyToCacheDirectory: true,
      multiple: false,
    });
    if (scelta.canceled) return;
    setInCorso(true);
    try {
      const file = new File(scelta.assets[0].uri);
      const byte = await file.bytes();
      const esito = sembraZip(byte)
        ? await importaArchivio(db, byte)
        : await importaJson(db, await file.text());
      Alert.alert(
        testo('io.importa'),
        righeImport(esito, lingua)
          .map(
            (r) =>
              (r.etichetta === null ? '' : `${testo(r.etichetta)}: `) + testo(r.chiave, r.valori),
          )
          .join('\n'),
      );
      if (esito.ok) onImportato();
    } catch (errore) {
      // Il file non si è potuto nemmeno leggere: è un rifiuto anche questo, col
      // suo motivo.
      const motivo = errore instanceof Error ? errore.message : String(errore);
      Alert.alert(testo('io.importa'), testo('io.import.rifiutato', { motivo }));
    } finally {
      setInCorso(false);
    }
  };

  // Un tasto solo e un menu, invece di due tasti: «Esporta ricettario e foto»
  // nell'intestazione non ci sta, e accorciarlo vorrebbe dire una chiave nuova
  // per dire la stessa cosa peggio.
  const menu = () => {
    Alert.alert(testo('app.nome'), undefined, [
      { text: testo('io.esporta'), onPress: () => void esportaRicettario() },
      { text: testo('io.importa'), onPress: () => void importaRicettario() },
      { text: testo('modifica.annulla'), style: 'cancel' },
    ]);
  };

  return (
    <View style={stili.barra}>
      {inCorso ? (
        <ActivityIndicator size="small" color="#1b6b4a" />
      ) : (
        <Pressable
          onPress={menu}
          hitSlop={12}
          accessibilityRole="button"
          accessibilityLabel={`${testo('io.esporta')} / ${testo('io.importa')}`}
        >
          <Text style={stili.tasto}>⋯</Text>
        </Pressable>
      )}
    </View>
  );
}

const stili = StyleSheet.create({
  barra: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 4 },
  tasto: { fontSize: 22, color: '#1b6b4a', lineHeight: 26 },
});
```

- [ ] **Step 8: Verifica che i tipi tornino**

Comando: `npm run typecheck`

Atteso: nessun errore (nessun output, uscita 0). È qui che si vedrebbe un
`file.bytes()` scritto male o un campo inventato su `EsitoImport`: questo file
non ha test automatici, e il typecheck contro i tipi delle librerie davvero
installate è la sua verifica.

- [ ] **Step 9: Monta il menu nell'intestazione della schermata principale**

Tre modifiche a `src/ui/schermate/Categorie.tsx`, che il Task 18 ha già scritta.
`src/ui/App.tsx` **non si tocca**: la rotta Categorie ha già un `headerRight`,
messo dalla schermata con `setOptions`, e in React Navigation quello vince su
quanto dichiarato nella `<Stack.Screen>`. Dichiararne un secondo lì vorrebbe dire
non vedere mai la barra.

**(a)** Aggiungi, insieme agli altri import di componenti:

```tsx
import { BarraIo } from '../componenti/BarraIo.tsx';
```

**(b)** Sostituisci il `useLayoutEffect`, cioè

```tsx
  useLayoutEffect(() => {
    navigation.setOptions({
      headerRight: () => (
        <Pressable
          onPress={() => navigation.navigate('GestioneCategorie')}
          hitSlop={12}
          accessibilityRole="button"
        >
          <Text style={stili.gestisci}>{testo('categorie.gestisci')}</Text>
        </Pressable>
      ),
    });
  }, [navigation, testo]);
```

con

```tsx
  // Un solo headerRight per la rotta, con dentro tutti e due i comandi: le
  // opzioni impostate qui vincono su quelle della <Stack.Screen>, quindi due
  // padroni vorrebbero dire un comando invisibile.
  useLayoutEffect(() => {
    navigation.setOptions({
      headerRight: () => (
        <View style={stili.comandi}>
          <Pressable
            onPress={() => navigation.navigate('GestioneCategorie')}
            hitSlop={12}
            accessibilityRole="button"
          >
            <Text style={stili.gestisci}>{testo('categorie.gestisci')}</Text>
          </Pressable>
          {/* Dopo un import riuscito la rotta si rimonta da capo: `replace` la
              rimette con una chiave nuova, e quello che la schermata fa al primo
              montaggio — rileggere categorie, conteggi e ricette — succede di
              nuovo, senza dover estrarre la ricarica dall'useFocusEffect. */}
          <BarraIo onImportato={() => navigation.replace('Categorie')} />
        </View>
      ),
    });
  }, [navigation, testo]);
```

**(c)** Aggiungi lo stile che mette i due comandi in fila, accanto agli altri di
`stili`:

```tsx
  comandi: { flexDirection: 'row', alignItems: 'center', gap: 12 },
```

`View`, `Pressable` e `Text` la schermata li importa già da `react-native`.

- [ ] **Step 10: Verifica tipi e test**

Comandi:
```bash
npm run typecheck
npm test
```

Atteso: typecheck senza errori — è qui che si vede se `navigation.replace` è
scritto con un nome di rotta che non esiste, o se `righeImport` è chiamata senza
la lingua; `npm test` PASS, ℹ pass 220, ℹ fail
0 (i 213 lasciati dal Task 22 più i 7 nuovi di `scambio.test.ts`), verdi insieme
a tutti quelli dei task precedenti. È il totale finale del pezzo A.

- [ ] **Step 11: Prova a mano export e import**

Comando: `npx expo start --ios` (oppure `--android`).

Atteso, a mano:
1. in alto a destra sulla schermata delle categorie compare il tasto `⋯`
   accanto a «Gestisci», e nessuno dei due ha fatto sparire l'altro; il `⋯` apre
   un menu con «Esporta ricettario e foto» e «Importa ricettario»;
2. toccando Esporta, l'avviso dice quante ricette e quante foto ci sono
   nell'archivio, con dei numeri veri e non con `{ricette}`; confermando si apre
   il foglio di condivisione con un file `.zip`;
3. salvando quel file e reimportandolo, l'avviso dice quante ricette sono state
   aggiunte, aggiornate e ignorate, e sotto la stessa cosa per le categorie;
4. rinominando il file `.zip` in `.qualcosa` e reimportandolo, entra lo stesso:
   il tipo lo decidono i byte, non il nome;
5. esportando, aprendo lo zip sul computer, tirandone fuori il solo
   `ricettario.json` e importando quello, l'avviso non dice più niente delle
   foto e riporta solo i numeri delle ricette e delle categorie;
6. scegliendo un file qualsiasi che non sia nostro, l'avviso dice che il file
   non è stato importato e perché, con una frase in italiano («non è un
   ricettario di QuantoBasta») e non con la sigla `file-non-valido`;
7. dopo un import riuscito la schermata delle categorie mostra subito i
   conteggi aggiornati e le categorie nuove, senza dover uscire e rientrare;
8. le ricette importate che avevano una foto la mostrano; quelle importate da un
   JSON nudo compaiono senza immagine e senza messaggi di errore.

- [ ] **Step 12: Commit**

```bash
git add src/ui/componenti/BarraIo.tsx src/ui/schermate/Categorie.tsx
git commit -m "Porta export e import nella schermata principale"
```

---

## Appendice: contratto delle interfacce

Le firme, decise in un posto solo. Dove un task e il contratto divergono, ha
ragione il task: il contratto è la mappa, non il territorio.

Questo documento è vincolante. Ogni task del piano deve usare ESATTAMENTE questi
nomi, firme e tipi. Se ti sembra che una firma sia sbagliata, segnalalo nel tuo
output ma NON cambiarla di tua iniziativa: altri task dipendono da lei.

Tutto il codice è TypeScript. Gli import fra file del progetto usano l'estensione
`.ts` esplicita (serve a Node per eseguire i test nativamente; `tsconfig.json` ha
già `allowImportingTsExtensions: true`).

Lingua del codice: identificatori e commenti in italiano, come il codice già
scritto in `src/domain/`.

---

## Stato attuale del codice

Già scritto e con 17 test verdi in `src/domain/domain.test.ts`:

- `src/domain/types.ts` — Ingrediente, Gruppo, Ricetta, RicettaScalata, isQb, tuttiGliIngredienti
- `src/domain/units.ts` — normalizzaUnita, resaLeggibile, isUnitaDiscreta, isUnitaMetrica, arrotondaAMezzi
- `src/domain/format.ts` — formattaQuantita, numeroItaliano
- `src/domain/scaling.ts` — fattoreDaPorzioni, fattoreDaIngrediente, scala, arrotondaPerCucina
- `src/domain/id.ts` — newId
- `src/domain/migrate.ts` — migraRicetta, migraArchivio
- `scripts/verifica-migrazione.ts` — prova sui dati reali

Questo codice conosce SOLO italiano e SOLO il sistema metrico, e va rifatto
secondo il contratto qui sotto. I test esistenti vanno adeguati, non buttati.

Comandi: `npm test`, `npm run typecheck`, `npm run migra`.

---

## `src/domain/lingua/index.ts`

```ts
export type Lingua = 'it' | 'en';

export type Famiglia = 'precisione' | 'misurino' | 'discreta';

export interface VoceUnita {
  /** Forma canonica salvata nel database, es. 'g', 'cucchiai', 'cups'. */
  canonica: string;
  /** Tutte le scritture che portano a questa unità, minuscole. Include la canonica. */
  alias: string[];
  famiglia: Famiglia;
  /** Resa a schermo con quantità 1, es. 'cubetto'. */
  singolare: string;
  /** Resa a schermo con quantità diversa da 1, es. 'cubetti'. */
  plurale: string;
  /**
   * Solo per la famiglia 'precisione': conversione verso l'unità base della
   * propria famiglia fisica, per la resa leggibile. Es. kg -> { unita: 'g', fattore: 1000 }.
   * Assente = l'unità non si converte.
   */
  base?: { unita: string; fattore: number };
}

export interface Vocabolario {
  lingua: Lingua;
  unita: VoceUnita[];
  /** 'un' -> 1, 'mezzo' -> 0.5, 'three' -> 3. Chiavi minuscole. */
  numeriAParole: Record<string, number>;
  /** Espressioni che significano "quanto basta", minuscole. */
  quantoBasta: string[];
  /** Parole da scartare fra quantità e nome, es. 'di', 'of'. Minuscole. */
  riempitivi: string[];
  /** Parole che fanno smettere di leggere, minuscole. */
  stop: string[];
  /** Intestazioni che NON creano una sezione con nome, es. 'ingredienti'. Minuscole. */
  intestazioneNeutra: string[];
  separatoreDecimale: ',' | '.';
}

export const VOCABOLARI: Record<Lingua, Vocabolario>;
export function vocabolario(lingua: Lingua): Vocabolario;
export const LINGUE: Lingua[];  // ['it', 'en']
```

## `src/domain/lingua/it.ts` e `src/domain/lingua/en.ts`

```ts
export const IT: Vocabolario;   // in it.ts
export const EN: Vocabolario;   // in en.ts
```

Contenuto minimo obbligatorio (dalla spec, sezioni 5 e 6):

- **precisione IT**: g (alias gr, grammo, grammi), kg (chilo, chili, chilogrammo,
  chilogrammi), ml (millilitro, millilitri), cl, dl, l (lt, litro, litri)
- **precisione EN**: g, kg, ml, l, oz (ounce, ounces), lb (lbs, pound, pounds),
  fl oz (floz, fluid ounce, fluid ounces)
- **misurino IT**: cucchiai (cucchiaio), cucchiaini (cucchiaino), tazze (tazza),
  tazzine (tazzina), bicchieri (bicchiere), bicchierini (bicchierino).
  Le canoniche sono sempre al plurale, senza eccezioni.
- **misurino EN**: cups (cup), tbsp (tablespoon, tablespoons), tsp (teaspoon, teaspoons)
- **discreta IT**: cad, pezzi (pezzo), uova (uovo), bustine (bustina), cubetti
  (cubetto), spicchi (spicchio), fette (fetta), foglie (foglia), rametti,
  barattoli, vasetti, confezioni, scatole, stecche, mazzetti, ciuffi, gambi, coste
- **discreta EN**: eggs (egg), sticks (stick), cloves (clove), slices (slice),
  packets (packet), cans (can), jars (jar), sachets (sachet)
- **conversioni base**: kg->g x1000, cl->ml x10, dl->ml x100, l->ml x1000,
  lb->oz x16. (g e ml e oz sono le basi, fattore 1.)
- **numeriAParole IT**: un, uno, una, due, tre, quattro, cinque, sei, sette,
  otto, nove, dieci, undici, dodici, mezzo, mezza (0.5)
- **numeriAParole EN**: a, an, one, two, three, four, five, six, seven, eight,
  nine, ten, eleven, twelve, half (0.5)
- **quantoBasta IT**: qb, q.b., quanto basta, a piacere, a piacimento
- **quantoBasta EN**: to taste, as needed, a pinch of, a pinch
- **riempitivi IT**: di, d', del, della, dello, dei, degli, delle
- **riempitivi EN**: of, the
- **stop IT**: procedimento, preparazione, esecuzione, istruzioni
- **stop EN**: method, directions, instructions, preparation, steps
- **intestazioneNeutra IT**: ingredienti
- **intestazioneNeutra EN**: ingredients
- separatoreDecimale: ',' per IT, '.' per EN

**Attenzione a `fl oz` contro `oz`**: sono unità diverse (volume contro peso) e
l'alias più lungo deve vincere. Il lookup ordina gli alias per lunghezza
decrescente.

---

## `src/domain/types.ts` (da estendere)

```ts
export interface Ingrediente { id: string; nome: string; quantita: number | null; unita: string | null }
export interface Gruppo { id: string; nome: string | null; ingredienti: Ingrediente[] }

export interface Ricetta {
  id: string;
  titolo: string;
  descrizione: string;
  porzioni: number | null;
  gruppi: Gruppo[];
  creataIl: string;       // ISO 8601
  modificataIl: string;   // ISO 8601
  cancellataIl: string | null;   // tombstone, null = viva
}

/** Cosa ha chiesto l'utente. Si salva questa, non il fattore che ne esce. */
export type Richiesta =
  | { tipo: 'porzioni'; porzioni: number }
  | { tipo: 'ingrediente'; ingredienteId: string; quantita: number };

export interface Riscalo { ricettaId: string; richiesta: Richiesta; aggiornatoIl: string }

export interface IngredienteScalato {
  id: string;
  nome: string;
  quantita: number | null;        // null = q.b.
  quantitaEsatta: number | null;  // non arrotondata, per calcoli a catena
  unita: string | null;
}
export interface RicettaScalata {
  fattore: number;
  porzioni: number | null;
  gruppi: { id: string; nome: string | null; ingredienti: IngredienteScalato[] }[];
}

export const isQb: (ing: Ingrediente) => boolean;
export const tuttiGliIngredienti: (r: Ricetta) => Ingrediente[];
```

## `src/domain/units.ts` (da rifare sul vocabolario)

```ts
import type { Famiglia, Vocabolario, VoceUnita } from './lingua/index.ts';

/** Cerca l'unità nel vocabolario. Alias più lungo per primo. */
export function trovaUnita(raw: string | null | undefined, voc: Vocabolario): VoceUnita | null;

/** Forma canonica, oppure la stringa ripulita se sconosciuta, oppure null se vuota. */
export function normalizzaUnita(raw: string | null | undefined, voc: Vocabolario): string | null;

/** 'sconosciuta' per le unità fuori vocabolario: si trattano come 'misurino'. */
export function famigliaDi(unita: string | null, voc: Vocabolario): Famiglia | 'sconosciuta';

/** 1160 g -> 1,16 kg. Solo dentro la stessa famiglia fisica. */
export function resaLeggibile(quantita: number, unita: string | null, voc: Vocabolario):
  { quantita: number; unita: string | null };

/** Al mezzo più vicino, mai sotto 0.5. */
export function arrotondaAMezzi(q: number): number;

/**
 * Converte fra unità della stessa famiglia fisica: g<->kg, ml<->l, oz<->lb.
 * null quando non si può, cioè fra famiglie diverse (g->ml) o verso un'unità
 * da misurino (cup->g), perché quel fattore dipende dall'ingrediente.
 * Esiste come funzione ma in questa versione non ha un gesto nell'interfaccia.
 */
export function converti(quantita: number, da: string, a: string, voc: Vocabolario): number | null;
```

## `src/domain/format.ts` (da rifare bilingue)

```ts
import type { Vocabolario } from './lingua/index.ts';

/** Separatore decimale secondo la lingua, zeri finali tolti. */
export function numero(q: number, voc: Vocabolario): string;

/**
 * Resa completa: quantità + unità accordata al singolare/plurale.
 * - famiglia 'precisione' -> decimali:            "806 g", "1.5 oz"
 * - famiglia 'misurino' o 'discreta' o sconosciuta -> frazioni: "1½ cups", "½ cad"
 * - quantita === null -> la resa di "q.b." nella lingua del vocabolario
 */
export function formattaQuantita(quantita: number | null, unita: string | null, voc: Vocabolario): string;
```

## `src/domain/scaling.ts` (da estendere)

```ts
export const FATTORE_ORIGINALE = 1;
export function arrotondaPerCucina(q: number): number;
export function fattoreDaPorzioni(ricetta: Ricetta, porzioniDesiderate: number): number | null;
export function fattoreDaIngrediente(ricetta: Ricetta, ingredienteId: string, quantitaDisponibile: number): number | null;

/** Risolve una Richiesta salvata contro la ricetta ATTUALE. null = non più calcolabile. */
export function risolviRichiesta(ricetta: Ricetta, richiesta: Richiesta): number | null;

/** Applica il fattore. Il vocabolario serve per famiglie e resa leggibile. */
export function scala(ricetta: Ricetta, fattore: number, voc: Vocabolario): RicettaScalata;
```

## `src/domain/parser/numeri.ts`

```ts
export interface NumeroLetto {
  valore: number;
  /** Caratteri consumati dall'inizio della stringa passata. */
  lunghezza: number;
}
/** Legge un numero a inizio stringa: 200 | 1,5 | 1.5 | 1/2 | 2 1/2 | 2-3 (primo) | un | half */
export function leggiNumero(testo: string, voc: Vocabolario): NumeroLetto | null;
```

## `src/domain/parser/riga.ts`

```ts
export interface RigaLetta {
  nome: string;
  quantita: number | null;   // null = q.b.
  unita: string | null;
  /** false quando il parser non ha capito: la riga va segnalata all'utente. */
  sicura: boolean;
}
export function leggiRiga(riga: string, voc: Vocabolario): RigaLetta;
```

## `src/domain/parser/blocco.ts`

```ts
export interface GruppoLetto { nome: string | null; ingredienti: RigaLetta[] }
export interface EsitoParser {
  lingua: Lingua;
  gruppi: GruppoLetto[];
  righeSicure: number;
  righeTotali: number;
}
export function leggiBlocco(testo: string, voc: Vocabolario): EsitoParser;
/** Prova tutti i vocabolari e tiene quello con più righeSicure. Pareggio: primo di LINGUE. */
export function leggiBloccoMultilingua(testo: string): EsitoParser;
/** Converte l'esito in gruppi pronti da salvare, generando gli id. */
export function esitoInGruppi(esito: EsitoParser): Gruppo[];
```

---

## `src/data/schema.ts`

```ts
import type { SQLiteDatabase } from 'expo-sqlite';
export const VERSIONE_SCHEMA = 1;
export async function applicaMigrazioni(db: SQLiteDatabase): Promise<void>;
```

Tabelle: `ricette`, `gruppi`, `ingredienti`, `riscalo`. Chiavi esterne con
`ON DELETE CASCADE` da ricette a gruppi a ingredienti. `riscalo` ha
`ricetta_id` come chiave primaria e `richiesta` come JSON in TEXT.

## `src/data/db.ts`

```ts
export class ErroreDatabase extends Error {}
export async function apriDb(nome?: string): Promise<SQLiteDatabase>;
/** Copia il file del database accanto all'originale, sovrascrivendo. */
export async function copiaDiSicurezza(): Promise<void>;
```

## `src/data/ricette.ts`

```ts
/** Solo le vive, ordinate per titolo con localeCompare('it'). */
export async function elencoRicette(db: SQLiteDatabase): Promise<Ricetta[]>;
export async function leggiRicetta(db: SQLiteDatabase, id: string): Promise<Ricetta | null>;
/** Upsert completo, gruppi e ingredienti inclusi. Aggiorna modificataIl. */
export async function salvaRicetta(db: SQLiteDatabase, ricetta: Ricetta): Promise<void>;
/** Tombstone: marca cancellataIl, non rimuove. Dimentica anche il riscalo. */
export async function cancellaRicetta(db: SQLiteDatabase, id: string): Promise<void>;
/** Tutte, tombstone incluse: serve all'export e alla sync futura. */
export async function ricetteDaEsportare(db: SQLiteDatabase): Promise<Ricetta[]>;
```

## `src/data/riscalo.ts`

```ts
export async function leggiRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<Riscalo | null>;
export async function salvaRiscalo(db: SQLiteDatabase, ricettaId: string, richiesta: Richiesta): Promise<void>;
export async function dimenticaRiscalo(db: SQLiteDatabase, ricettaId: string): Promise<void>;
/**
 * Riscalo utilizzabile adesso. Ricalcola il fattore dalla richiesta salvata
 * contro la ricetta attuale. Se non è più calcolabile lo dimentica e torna null.
 */
export async function riscaloCorrente(db: SQLiteDatabase, ricetta: Ricetta):
  Promise<{ fattore: number; richiesta: Richiesta } | null>;
```

## `src/io/formato.ts`

```ts
export const FORMATO = 'quantobasta/ricettario';
export const VERSIONE_FILE = 1;
export interface FileRicettario {
  formato: typeof FORMATO;
  versione: number;
  esportatoIl: string;
  ricette: Ricetta[];
}
/** null se il file non è nostro o è malformato. */
export function validaFile(json: unknown): FileRicettario | null;
```

## `src/io/esporta.ts` e `src/io/importa.ts`

```ts
export function creaFile(ricette: Ricetta[], adesso: string): FileRicettario;
/** Scrive il file e restituisce il percorso. */
export async function esporta(db: SQLiteDatabase): Promise<string>;

export type EsitoImport =
  | { ok: true; aggiunte: number; aggiornate: number; ignorate: number }
  | { ok: false; motivo: string };
/** Additivo. A parità di id vince modificataIl più recente. Fa la copia di sicurezza prima. */
export async function importa(db: SQLiteDatabase, json: unknown): Promise<EsitoImport>;
```

## `src/i18n/index.ts`

```ts
export type Chiave =
  | 'app.nome' | 'elenco.cerca' | 'elenco.vuoto.titolo' | 'elenco.vuoto.invito'
  | 'dosatore.porzioni' | 'dosatore.originali' | 'dosatore.fascia.porzioni'
  | 'dosatore.fascia.ingrediente' | 'modifica.titolo' | 'modifica.descrizione'
  | 'modifica.porzioni' | 'modifica.ingredienti' | 'modifica.incolla'
  | 'modifica.salva' | 'modifica.annulla' | 'modifica.elimina'
  | 'modifica.conferma.elimina' | 'modifica.sezione.aggiungi' | 'modifica.riga.incerta'
  | 'qb' | 'io.esporta' | 'io.importa' | 'io.import.ok' | 'io.import.rifiutato'
  | 'errore.db';
export function t(chiave: Chiave, lingua: Lingua, valori?: Record<string, string>): string;
export function linguaDispositivo(): Lingua;
```

---

## Dipendenze da installare

```
expo-sqlite expo-crypto expo-keep-awake expo-file-system expo-sharing
expo-document-picker expo-localization
@react-navigation/native @react-navigation/native-stack
react-native-screens react-native-safe-area-context
```

Navigazione: `@react-navigation/native-stack`, tre schermate.
`src/domain/id.ts` passa a `expo-crypto` mantenendo la firma `newId(): string`.

## Schermate

```
src/ui/App.tsx                        navigazione e apertura del db
src/ui/schermate/Elenco.tsx
src/ui/schermate/Dosatore.tsx
src/ui/schermate/Modifica.tsx
src/ui/componenti/FasciaRiscalo.tsx
src/ui/componenti/RigaIngrediente.tsx
src/ui/componenti/CampoQuantita.tsx
```
