Compare commits

15 Commits

Author SHA1 Message Date
d79ce06278 feat: condivisione piano pasti via QR code
Merge branch 'qrcode'
2026-03-31 10:44:54 +02:00
55257f849d fix: aggiungi permesso CAMERA all'AndroidManifest.xml generato da Capacitor 2026-03-31 10:44:09 +02:00
7dd1bce80f chore: merge main nel branch qrcode (fix vulnerabilità npm)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:20:03 +02:00
9b099dac83 fix: risolvi 6 vulnerabilità npm aggiornando vite e vitest
- Upgrade vite 5 → 6 (fix GHSA-67mh-4wv8-2f99: esbuild CORS dev server)
- Upgrade vitest 2 → 3 e @vitest/coverage-v8 2 → 3
- Rinomina vite.config.js → vite.config.mjs (richiesto da vite 6 ESM)
- Aggiorna requisito Node.js in README: 18.x → >= 20.x LTS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:15:55 +02:00
8685ea0aca feat: condivisione piano pasti via QR code
Aggiunge due pulsanti in fondo alla schermata Piano Pasti:
- "Condividi": genera un QR code con i dati della settimana
- "Ricevi": apre la fotocamera per scansionare il QR e importare il piano

Dipendenze aggiunte: qrcode, jsqr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:13:21 +02:00
7a63706af5 chore: aggiorna versione a 1.0.0 e CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:16:45 +01:00
5df7956069 fix: crea dist/ prima del docker run per evitare ownership root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:57:33 +01:00
76cf672d80 fix: APK con --head ora copiato in dist/ invece di andare perso nella tmpdir
Con --head la DIST_DIR puntava alla directory temporanea che veniva cancellata
dal trap EXIT prima che l'utente potesse recuperare l'APK. L'APK viene ora
copiato esplicitamente in PROJECT_ROOT/dist/ dopo il docker run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:50:55 +01:00
96f75df6f2 feat: supporto build APK release firmato nella pipeline Docker
- Dockerfile: CMD condizionale su BUILD_TYPE (debug default, release con
  assembleRelease + zipalign + apksigner)
- build.sh: flag --release, richiesta password interattiva, mount keystore
  come volume read-only (mai nell'immagine)
- .gitignore: aggiunto *.jks
- docker/README.md: documentazione build debug/release, keytool, flag combinabili

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:10:43 +01:00
0c2a227be2 chore: aggiorna versione a 0.9.1 e CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:47:57 +01:00
e10bb2df4c test: aggiungi suite completa unit, integration ed e2e
- Unit (12+9): conversion.js (rawToCooked/cookedToRaw, edge case, inversa)
  e storage.js (save/load, round-trip, default fallback)
- Integration (17+12+14): Converter (ricerca, selezione, calcolo, swap, reset),
  MealPlanner (rendering, add/remove, generateShopping, deduplicazione),
  ShoppingList (add, toggle, remove, clearAll, contatore)
- E2E Playwright (6+6+7+10): navigation, meal-planner, converter, shopping-list
- Configurazione: vitest.config.js + playwright.config.js + tests/setup.js
- Script: test, test:coverage, test:e2e, test:e2e:ui

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:45:31 +01:00
09c9706938 docs: aggiungi requisiti di sviluppo al README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:35:11 +01:00
ecbae836b3 feat: guida utente in-app e fix grafici Convertitore
- DocsPanel: pannello documentazione slide-from-right con nav a pill,
  IntersectionObserver per sezione attiva, card con step numerati e callout tip
- InfoPanel: bottone "Guida" emette open-docs invece di aprire link esterno
- App: integra DocsPanel con v-model showDocs
- Converter: fix capitalize food names (capFirst invece di text-transform),
  simmetria visiva input/output (stesso underline e font-size 1.6rem),
  rimosso CSS morto (doppio align-items, doppio background, visibility hack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:32:36 +01:00
f24e591ca0 docs: riscrivi guida utente allineata allo stato attuale dell'app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:10:00 +01:00
eef38a74cc Merge branch 'new-logo' 2026-03-27 14:07:32 +01:00
27 changed files with 4717 additions and 222 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.DS_Store .DS_Store
*.jks

View File

@@ -7,6 +7,55 @@ il progetto aderisce al [Semantic Versioning](https://semver.org/lang/it/).
--- ---
## [1.0.0] — 2026-03-28
### Aggiunto
- **Build APK release firmato** — pipeline Docker aggiornata con supporto
`assembleRelease` + `zipalign` + `apksigner`; flag `--release` in `build.sh`
per attivare la firma; keystore montato come volume read-only (mai nell'immagine)
- **`build.sh --release`** — chiede interattivamente le password del keystore;
supporta override via `KEYSTORE_PASS`, `KEY_PASS`, `KEYSTORE_PATH`
- **Flag combinabili** — `--head` e `--release` ora combinabili in qualsiasi ordine
### Fix
- `build.sh --head` — APK ora copiato in `dist/` del progetto invece di andare
perso nella directory temporanea cancellata dal trap EXIT
- `build.sh``dist/` creata con `mkdir -p` prima del `docker run` per evitare
ownership root che causava "Permission denied" nelle build successive
---
## [0.9.1] — 2026-03-27
### Aggiunto
- **Guida utente in-app** — pannello documentazione integrato (DocsPanel): slide-from-right, navigazione a pill per sezione (Pasti, Converti, Spesa), IntersectionObserver per pill attiva durante lo scroll, card con step numerati e callout tip
- **Suite di test** — 64 test automatici con Vitest + Playwright:
- Unit: `conversion.js` e `storage.js` con edge case
- Integration: Converter, MealPlanner, ShoppingList con Vue Test Utils
- E2E: navigazione, piano pasti, convertitore, lista della spesa con Playwright
- **Script npm**: `test`, `test:coverage`, `test:e2e`, `test:e2e:ui`
### Modificato
- **InfoPanel** — il link esterno alla documentazione è sostituito dal pulsante "Guida" che apre DocsPanel in-app
- **Convertitore** — nomi degli alimenti in sentence case (`capFirst`) anche nella lista risultati e nell'header della card (non solo i metodi)
- **Convertitore** — simmetria visiva input/output: stesso underline `border-bottom` e stessa dimensione `1.6rem` per entrambe le colonne
- **README** — aggiunta sezione "Requisiti per lo sviluppo" con versioni minime di Node.js, npm, Git e browser
- **Icona app** — favicon e icona Android (round launcher) aggiornate a forma circolare con sfondo trasparente
### Fix
- **Convertitore** — rimosso CSS morto: doppio `align-items` in `.calc-output`, doppio `background` in `.btn-reset`, hack visibility globale su `.calc-unit`
### Documentazione
- **docs/guida-utente.md** — riscritta e allineata allo stato attuale dell'app (tab corrette, funzionalità aggiornate, friggitrice ad aria inclusa)
---
## [0.9.0] — 2026-03-27 ## [0.9.0] — 2026-03-27
### Aggiunto ### Aggiunto

View File

@@ -33,6 +33,17 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
| Mobile | Capacitor Android | | Mobile | Capacitor Android |
| Build APK | Docker | | Build APK | Docker |
## Requisiti per lo sviluppo
| Strumento | Versione minima | Note |
|---|---|---|
| Node.js | >= 20.x LTS | testato con v24 |
| npm | 9.x | incluso con Node.js |
| Git | 2.x | |
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |
Per la build APK Android sono necessari requisiti aggiuntivi — vedi [docker/README.md](docker/README.md).
## Avvio in sviluppo ## Avvio in sviluppo
```bash ```bash

View File

@@ -52,6 +52,10 @@ RUN npm install @capacitor/core @capacitor/cli @capacitor/android --save
RUN npx cap add android RUN npx cap add android
# ── Permessi Android ──────────────────────────────────────────────────────────
RUN sed -i 's|</manifest>| <uses-permission android:name="android.permission.CAMERA" />\n</manifest>|' \
android/app/src/main/AndroidManifest.xml
# ── Versione da package.json → android/app/build.gradle ────────────────────── # ── Versione da package.json → android/app/build.gradle ──────────────────────
RUN node -e "\ RUN node -e "\
const v = require('./package.json').version; \ const v = require('./package.json').version; \
@@ -76,11 +80,29 @@ RUN rm -rf android/app/src/main/res/mipmap-anydpi-v26 && \
# Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8) # Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8)
RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle
# ── Runtime: dist/ viene montato come volume dall'host ──────────────────────── # ── Runtime: dist/ e (opzionale) keystore montati come volumi dall'host ───────
# build.sh esegue: docker run -v ./dist:/app/dist ... # build.sh esegue: docker run -v ./dist:/app/dist ...
# Qui cap sync copia dist/ in android/assets, poi Gradle builda l'APK # Con --release: aggiunge -e BUILD_TYPE=release -v biteplan.jks:/app/biteplan.jks:ro
# BUILD_TYPE=release → assembleRelease + zipalign + apksigner
# BUILD_TYPE vuoto → assembleDebug (default)
CMD npx cap sync android && \ CMD npx cap sync android && \
cd android && chmod +x gradlew && ./gradlew assembleDebug --no-daemon && \ cd android && chmod +x gradlew && \
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \ if [ "$BUILD_TYPE" = "release" ]; then \
echo "APK generato: /app/dist/biteplan.apk" ./gradlew assembleRelease --no-daemon && \
$ANDROID_HOME/build-tools/34.0.0/zipalign -v 4 \
app/build/outputs/apk/release/app-release-unsigned.apk \
/tmp/aligned.apk && \
$ANDROID_HOME/build-tools/34.0.0/apksigner sign \
--ks /app/biteplan.jks \
--ks-key-alias biteplan \
--ks-pass pass:$KEYSTORE_PASS \
--key-pass pass:$KEY_PASS \
--out /app/dist/biteplan.apk \
/tmp/aligned.apk && \
echo "APK release firmato: /app/dist/biteplan.apk"; \
else \
./gradlew assembleDebug --no-daemon && \
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \
echo "APK debug: /app/dist/biteplan.apk"; \
fi

View File

@@ -1,6 +1,6 @@
# Build APK — Docker # Build APK — Docker
Genera un APK Android debug senza installare nulla sull'host oltre a Docker. Genera un APK Android senza installare nulla sull'host oltre a Docker.
## Requisiti host ## Requisiti host
@@ -11,21 +11,75 @@ Genera un APK Android debug senza installare nulla sull'host oltre a Docker.
| **Docker** | Installato e avviato | | **Docker** | Installato e avviato |
| **Node.js** | v18+ (per il build Vite locale) | | **Node.js** | v18+ (per il build Vite locale) |
> Gli Android build-tools (`aapt2`, `zipalign`, ecc.) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU. > Gli Android build-tools (`aapt2`, `zipalign`, `apksigner`) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU.
## Utilizzo ---
Dalla root del progetto: ## Build debug (default)
APK firmato con il debug keystore di Android. Adatto per test su dispositivo.
```bash ```bash
# Build dalla working directory (file attuali)
bash docker/build.sh bash docker/build.sh
# Build da HEAD (ignora modifiche non committate)
bash docker/build.sh --head
``` ```
L'APK viene generato in `dist/biteplan.apk`. ---
## Build release (distribuzione)
APK firmato con il tuo keystore personale. Necessario per distribuire l'app.
### 1. Genera il keystore (una volta sola)
```bash
keytool -genkey -v \
-keystore docker/biteplan.jks \
-alias biteplan \
-keyalg RSA \
-keysize 2048 \
-validity 10000
```
> Il file `docker/biteplan.jks` è già in `.gitignore` — non verrà mai committato.
> Conservalo in un posto sicuro: senza di esso non puoi aggiornare l'app.
### 2. Esegui la build release
```bash
bash docker/build.sh --release
# chiede interattivamente: Password keystore / Password chiave
```
Oppure passando le password come variabili d'ambiente (utile in CI):
```bash
KEYSTORE_PASS=tuapassword KEY_PASS=tuapassword bash docker/build.sh --release
```
Per usare un keystore in un percorso diverso da `docker/biteplan.jks`:
```bash
KEYSTORE_PATH=/percorso/biteplan.jks bash docker/build.sh --release
```
### 3. Verifica la firma
```bash
$ANDROID_HOME/build-tools/34.0.0/apksigner verify --verbose dist/biteplan.apk
```
---
## Flag combinabili
| Comando | Risultato |
|---------|-----------|
| `bash docker/build.sh` | APK debug dalla working directory |
| `bash docker/build.sh --head` | APK debug dall'ultimo commit git |
| `bash docker/build.sh --release` | APK release firmato dalla working directory |
| `bash docker/build.sh --head --release` | APK release firmato dall'ultimo commit git |
---
## Prima build ## Prima build
@@ -34,8 +88,6 @@ Le build successive usano la cache Docker e sono molto più rapide.
## Installazione su dispositivo ## Installazione su dispositivo
Con ADB:
```bash ```bash
adb install dist/biteplan.apk adb install dist/biteplan.apk
``` ```
@@ -43,14 +95,15 @@ adb install dist/biteplan.apk
## Pipeline ## Pipeline
``` ```
[host] npm run build → dist/ (montato come volume in sola lettura) [host] npm run build → dist/
[docker] cap sync → copia dist/ in android/assets/ [docker] cap sync → copia dist/ in android/assets/
[docker] gradlew assembleDebug [docker] gradlew assembleDebug/Release
[host] output/biteplan.apk [docker] zipalign + apksigner → solo in modalità release
[host] dist/biteplan.apk
``` ```
## Note ## Note
- APK di tipo **debug**, non firmato per la produzione
- App ID: `com.davide.biteplan` - App ID: `com.davide.biteplan`
- Android target: API 34 - Android target: API 34
- Il keystore non viene mai copiato nell'immagine Docker (montato come volume read-only)

View File

@@ -6,10 +6,31 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DIST_DIR="$PROJECT_ROOT/dist" DIST_DIR="$PROJECT_ROOT/dist"
FROM_HEAD=false FROM_HEAD=false
RELEASE=false
for arg in "$@"; do for arg in "$@"; do
[[ "$arg" == "--head" ]] && FROM_HEAD=true [[ "$arg" == "--head" ]] && FROM_HEAD=true
[[ "$arg" == "--release" ]] && RELEASE=true
done done
# ── Keystore (solo in modalità release) ───────────────────────────────────────
KEYSTORE_PATH="${KEYSTORE_PATH:-$SCRIPT_DIR/biteplan.jks}"
if $RELEASE; then
if [[ ! -f "$KEYSTORE_PATH" ]]; then
echo "Errore: keystore non trovato in $KEYSTORE_PATH"
echo "Generalo con:"
echo " keytool -genkey -v -keystore docker/biteplan.jks -alias biteplan -keyalg RSA -keysize 2048 -validity 10000"
exit 1
fi
if [[ -z "${KEYSTORE_PASS:-}" ]]; then
read -rsp "Password keystore: " KEYSTORE_PASS; echo
fi
if [[ -z "${KEY_PASS:-}" ]]; then
read -rsp "Password chiave: " KEY_PASS; echo
fi
fi
# ── Build Vite ──────────────────────────────────────────────────────────────── # ── Build Vite ────────────────────────────────────────────────────────────────
if $FROM_HEAD; then if $FROM_HEAD; then
COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD) COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD)
@@ -36,11 +57,31 @@ docker build \
-t biteplan-builder \ -t biteplan-builder \
"$PROJECT_ROOT" "$PROJECT_ROOT"
# ── Generazione APK (dist/ montato come volume) ─────────────────────────────── # ── Generazione APK ───────────────────────────────────────────────────────────
echo "==> Generazione APK..." echo "==> Generazione APK${RELEASE:+ release}..."
docker run --rm \ mkdir -p "$PROJECT_ROOT/dist"
-v "$DIST_DIR:/app/dist" \
biteplan-builder DOCKER_ARGS=(
--rm
-v "$DIST_DIR:/app/dist"
)
if $RELEASE; then
DOCKER_ARGS+=(
-e BUILD_TYPE=release
-e KEYSTORE_PASS="$KEYSTORE_PASS"
-e KEY_PASS="$KEY_PASS"
-v "$KEYSTORE_PATH:/app/biteplan.jks:ro"
)
fi
docker run "${DOCKER_ARGS[@]}" biteplan-builder
# Con --head l'APK è nella dir temporanea — copiarlo in dist/ del progetto
if $FROM_HEAD; then
mkdir -p "$PROJECT_ROOT/dist"
cp "$DIST_DIR/biteplan.apk" "$PROJECT_ROOT/dist/biteplan.apk"
fi
echo "" echo ""
echo "APK pronto in: $DIST_DIR/biteplan.apk" echo "APK pronto in: $PROJECT_ROOT/dist/biteplan.apk"

View File

@@ -4,21 +4,21 @@
BitePlan è organizzata in tre sezioni accessibili dalla barra in basso: BitePlan è organizzata in tre sezioni accessibili dalla barra in basso:
| Icona | Sezione | Funzione | | Icona | Tab | Funzione |
|---|---|---| |---|---|---|
| Calendario | Piano Pasti | Pianifica i pasti della settimana | | Calendario | Pasti | Pianifica i pasti della settimana |
| Bilancia | Convertitore | Converti peso crudo/cotto | | Frecce | Converti | Converti peso crudo/cotto |
| Lista | Lista della spesa | Gestisci la spesa | | Carrello | Spesa | Gestisci la lista della spesa |
--- ---
## Piano Pasti ## Pasti
La sezione mostra i sette giorni della settimana. Il giorno corrente è aperto di default. La sezione mostra i sette giorni della settimana. Il giorno corrente è espanso di default.
### Aggiungere un alimento ### Aggiungere un alimento
1. Tocca il giorno per espandere la card 1. Tocca il giorno per espandere la card (se non è già aperta)
2. Vai al pasto desiderato (Colazione, Pranzo o Cena) 2. Vai al pasto desiderato (Colazione, Pranzo o Cena)
3. Scrivi il nome dell'alimento nel campo di testo 3. Scrivi il nome dell'alimento nel campo di testo
4. Premi **+** o il tasto Invio per aggiungerlo 4. Premi **+** o il tasto Invio per aggiungerlo
@@ -27,43 +27,60 @@ La sezione mostra i sette giorni della settimana. Il giorno corrente è aperto d
Tocca il pulsante **×** a destra dell'elemento. Tocca il pulsante **×** a destra dell'elemento.
### Generare la lista della spesa
Premi il pulsante **Genera lista della spesa** in fondo alla pagina. Tutti gli alimenti pianificati vengono aggiunti alla lista della spesa, senza duplicati rispetto a quelli già presenti. L'app passa automaticamente alla tab Spesa.
### Persistenza ### Persistenza
I dati vengono salvati automaticamente sul dispositivo (LocalStorage). Non è necessario premere nessun pulsante di salvataggio. I dati vengono salvati automaticamente sul dispositivo. Non è necessario premere nessun pulsante di salvataggio.
--- ---
## Convertitore crudo/cotto ## Converti
Calcola il peso cotto a partire dal crudo, o viceversa, in base ai coefficienti di resa di ogni alimento. Calcola il peso cotto a partire dal crudo, o viceversa, in base ai coefficienti di resa di ogni alimento e metodo di cottura.
### Utilizzo ### Utilizzo
1. Cerca l'alimento nel campo di ricerca (es. `pollo`, `riso`) 1. Cerca l'alimento nel campo di ricerca (es. `pollo`, `riso`, `zucchine`)
2. Seleziona il metodo di cottura dall'elenco risultati 2. Seleziona dall'elenco la combinazione alimento + metodo di cottura
3. Scegli la direzione: **Crudo → Cotto** o **Cotto → Crudo** (pulsante swap) 3. Inserisci il peso in grammi
4. Inserisci il peso in grammi 4. Il risultato appare in tempo reale
5. Il risultato appare in tempo reale 5. Per invertire la direzione (cotto → crudo), premi il pulsante **⇄** al centro
### Alimenti disponibili ### Alimenti e metodi disponibili
Proteine: pollo, manzo, maiale, salmone, tonno, uova Il database copre oltre 50 voci suddivise per categoria:
Carboidrati: riso, pasta, lenticchie
Verdure: zucchine, carote, patate, spinaci, broccoli
Per l'elenco completo con metodi di cottura e coefficienti, vedi [conversioni.md](conversioni.md). | Categoria | Esempi |
|---|---|
| Cereali e pasta | Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous |
| Legumi secchi | Ceci, fagioli, lenticchie |
| Verdure | Carote, zucchine, patate, spinaci, broccoli, cavolfiore, asparagi, carciofi, finocchi e altri |
| Carni | Pollo petto, tacchino fesa, hamburger, vitello |
| Pesce | Tonno, merluzzo, spigola, sogliola |
| Uova | Uovo al tegamino, frittata |
I metodi di cottura disponibili variano per alimento: **bollitura**, **padella**, **forno**, **friggitrice ad aria**.
Per l'elenco completo con coefficienti e fonti, vedi [conversioni.md](conversioni.md).
--- ---
## Lista della spesa ## Spesa
### Aggiungere un elemento ### Aggiungere un elemento
Scrivi il nome nel campo in alto e premi **+** o Invio. Scrivi il nome nel campo in alto e premi **+** o Invio.
### Importare dal piano pasti
Vai alla tab **Pasti** e premi **Genera lista della spesa** — gli alimenti pianificati vengono aggiunti automaticamente.
### Spuntare un elemento ### Spuntare un elemento
Tocca la casella a sinistra dell'elemento. Gli elementi completati vengono spostati in fondo con testo barrato. Tocca la casella a sinistra dell'elemento. Gli elementi completati vengono spostati in una sezione separata con testo barrato.
### Rimuovere un elemento ### Rimuovere un elemento

2714
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,29 @@
{ {
"name": "biteplan", "name": "biteplan",
"version": "0.9.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0" "vue": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@playwright/test": "^1.58.2",
"vite": "^5.0.0" "@vitejs/plugin-vue": "^5.2.0",
"@vitest/coverage-v8": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.8.9",
"jsdom": "^29.0.1",
"vite": "^6.0.0",
"vitest": "^3.0.0"
} }
} }

19
playwright.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
use: {
baseURL: 'http://localhost:5173',
// Simula iPhone 14 Pro — dimensioni target dell'app
viewport: { width: 393, height: 852 },
locale: 'it-IT',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
},
projects: [
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
})

View File

@@ -14,7 +14,8 @@
</svg> </svg>
</button> </button>
<InfoPanel v-model="showInfo" /> <InfoPanel v-model="showInfo" @open-docs="showDocs = true" />
<DocsPanel v-model="showDocs" />
</div> </div>
</template> </template>
@@ -22,12 +23,14 @@
import { ref } from 'vue' import { ref } from 'vue'
import BottomNav from './components/BottomNav.vue' import BottomNav from './components/BottomNav.vue'
import InfoPanel from './components/InfoPanel.vue' import InfoPanel from './components/InfoPanel.vue'
import DocsPanel from './components/DocsPanel.vue'
import MealPlanner from './pages/MealPlanner.vue' import MealPlanner from './pages/MealPlanner.vue'
import Converter from './pages/Converter.vue' import Converter from './pages/Converter.vue'
import ShoppingList from './pages/ShoppingList.vue' import ShoppingList from './pages/ShoppingList.vue'
const page = ref('meal') const page = ref('meal')
const showInfo = ref(false) const showInfo = ref(false)
const showDocs = ref(false)
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,505 @@
<template>
<Transition name="slide-right">
<div v-if="modelValue" class="docs-panel" role="dialog" aria-label="Guida utente">
<!-- Header sticky: back arrow + titolo centrato + spacer bilanciante -->
<div class="docs-header">
<button class="btn-back" @click="$emit('update:modelValue', false)" aria-label="Torna indietro">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="docs-title">Guida</span>
<!-- spacer identico al btn-back per centrare il titolo -->
<div aria-hidden="true" class="header-spacer" />
</div>
<!-- Pill nav scrollabile IntersectionObserver aggiorna la pill attiva -->
<div class="section-nav" role="tablist" aria-label="Sezioni">
<button
v-for="s in sections"
:key="s.id"
:class="['nav-pill', { active: activeSection === s.id }]"
role="tab"
:aria-selected="activeSection === s.id"
@click="scrollToSection(s.id)"
>{{ s.label }}</button>
</div>
<!-- Contenuto scorrevole -->
<div class="docs-scroll" ref="scrollEl">
<!-- Pasti -->
<section data-section="pasti">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</span>
<h2 class="section-heading">Pasti</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Aggiungere un alimento</h3>
<ol class="steps">
<li>Tocca il giorno per espandere la card</li>
<li>Scegli il pasto: <strong>Colazione</strong>, <strong>Pranzo</strong> o <strong>Cena</strong></li>
<li>Scrivi il nome nel campo di testo</li>
<li>Premi <kbd>+</kbd> o Invio per aggiungerlo</li>
</ol>
</div>
<div class="doc-card tip">
<p class="tip-label" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Suggerimento
</p>
<p>Premi <strong>Genera lista della spesa</strong> in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Rimuovere un alimento</h3>
<p>Tocca il pulsante <strong>×</strong> a destra dell'elemento.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Salvataggio automatico</h3>
<p>I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.</p>
</div>
</section>
<!-- ── Converti ───────────────────────────── -->
<section data-section="converti">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/>
<polyline points="7 23 3 19 7 15"/>
<path d="M21 13v2a4 4 0 01-4 4H3"/>
</svg>
</span>
<h2 class="section-heading">Converti</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Come usarlo</h3>
<ol class="steps">
<li>Cerca l'alimento nel campo (es. <em>pollo</em>, <em>riso</em>)</li>
<li>Seleziona il metodo di cottura dall'elenco</li>
<li>Inserisci il peso in grammi</li>
<li>Il risultato appare in tempo reale</li>
<li>Premi <strong>⇄</strong> per invertire crudo ↔ cotto</li>
</ol>
</div>
<div class="doc-card">
<h3 class="card-title">Alimenti disponibili</h3>
<div class="category-table" role="list">
<div class="cat-row" v-for="cat in categories" :key="cat.label" role="listitem">
<span class="cat-label">{{ cat.label }}</span>
<span class="cat-items">{{ cat.items }}</span>
</div>
</div>
<div class="methods-row" aria-label="Metodi di cottura disponibili">
<span class="method-chip" v-for="m in cookingMethods" :key="m">{{ m }}</span>
</div>
</div>
</section>
<!-- ── Spesa ──────────────────────────────── -->
<section data-section="spesa">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
</span>
<h2 class="section-heading">Spesa</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Aggiungere un elemento</h3>
<p>Scrivi il nome nel campo in alto e premi <kbd>+</kbd> o Invio.</p>
</div>
<div class="doc-card tip">
<p class="tip-label" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Suggerimento
</p>
<p>Vai alla tab <strong>Pasti</strong> e premi <strong>Genera lista della spesa</strong> per importare automaticamente gli alimenti pianificati.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Spuntare un elemento</h3>
<p>Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Rimuovere / svuotare</h3>
<p>Tocca <strong>×</strong> per rimuovere un elemento singolo, oppure <strong>Svuota lista</strong> in fondo per eliminare tutto (richiede conferma).</p>
</div>
</section>
<div class="docs-footer">BitePlan · v{{ version }} · Davide Grilli</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue'
import pkg from '../../package.json'
const props = defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue'])
const version = pkg.version
const scrollEl = ref(null)
const activeSection = ref('pasti')
let observer = null
const sections = [
{ id: 'pasti', label: 'Pasti' },
{ id: 'converti', label: 'Converti' },
{ id: 'spesa', label: 'Spesa' },
]
const categories = [
{ label: 'Cereali e pasta', items: 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous' },
{ label: 'Legumi secchi', items: 'Ceci, fagioli, lenticchie' },
{ label: 'Verdure', items: 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri' },
{ label: 'Carni', items: 'Pollo petto, tacchino fesa, hamburger, vitello' },
{ label: 'Pesce', items: 'Tonno, merluzzo, spigola, sogliola' },
{ label: 'Uova', items: 'Uovo al tegamino, frittata' },
]
const cookingMethods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria']
watch(() => props.modelValue, async (open) => {
if (open) {
await nextTick()
setupObserver()
activeSection.value = 'pasti'
scrollEl.value?.scrollTo({ top: 0 })
} else {
observer?.disconnect()
observer = null
}
})
function setupObserver() {
if (!scrollEl.value) return
observer?.disconnect()
// rootMargin: entra in zona attiva quando la sezione raggiunge il 20% dall'alto del container
observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter(e => e.isIntersecting)
if (visible.length) activeSection.value = visible[0].target.dataset.section
},
{ root: scrollEl.value, rootMargin: '0px 0px -65% 0px', threshold: 0 }
)
scrollEl.value.querySelectorAll('[data-section]').forEach(el => observer.observe(el))
}
function scrollToSection(id) {
const el = scrollEl.value?.querySelector(`[data-section="${id}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onUnmounted(() => observer?.disconnect())
</script>
<style scoped>
/* ── Panel container ─────────────────────────── */
.docs-panel {
position: fixed;
inset: 0;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg);
z-index: 300;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Animazione slide da destra ──────────────── */
/* enter ease-out: risposta immediata, decelera all'arrivo (feel nativo) */
.slide-right-enter-active { transition: transform 320ms cubic-bezier(0.25, 1, 0.5, 1); }
/* leave ease-in: parte lentamente, accelera — chiusura decisa */
.slide-right-leave-active { transition: transform 240ms cubic-bezier(0.55, 0, 1, 0.45); }
.slide-right-enter-from,
.slide-right-leave-to { transform: translateX(100%); }
/* ── Header ──────────────────────────────────── */
.docs-header {
display: flex;
align-items: center;
justify-content: space-between;
/* safe-area-inset-top: evita sovrapposizione con status bar su iOS */
padding: calc(env(safe-area-inset-top, 0px) + 10px) 8px 10px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.btn-back {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
background: none;
color: var(--color-primary);
border-radius: var(--radius-sm);
padding: 0;
transition: background var(--transition);
}
.btn-back:active {
background: var(--color-primary-muted);
opacity: 1;
}
.docs-title {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
/* spacer uguale al btn-back per centrare il titolo otticamente */
.header-spacer { width: 44px; }
/* ── Navigazione sezioni (pill) ──────────────── */
.section-nav {
display: flex;
gap: 6px;
padding: 10px 16px;
overflow-x: auto;
scrollbar-width: none;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.section-nav::-webkit-scrollbar { display: none; }
.nav-pill {
flex-shrink: 0;
font-size: 0.85rem;
font-weight: 600;
padding: 0 14px;
min-height: 32px;
border-radius: var(--radius-full);
background: var(--color-bg);
color: var(--color-muted);
border: 1px solid var(--color-border);
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.nav-pill.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
/* ── Scroll container ────────────────────────── */
.docs-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px);
}
/* ── Section header ──────────────────────────── */
section { padding-top: 4px; }
section + section { margin-top: 8px; }
.section-head {
display: flex;
align-items: center;
gap: 10px;
padding: 24px 16px 10px;
}
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
/* quadratino con sfondo primario muted — identifica visivamente la sezione */
background: var(--color-primary-muted);
border-radius: var(--radius-sm);
color: var(--color-primary);
flex-shrink: 0;
}
.section-heading {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-text);
}
/* ── Card contenuto ──────────────────────────── */
.doc-card {
margin: 0 16px 8px;
background: var(--color-surface);
border-radius: var(--radius);
padding: 14px 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
font-size: 0.9rem;
line-height: 1.55;
color: var(--color-text);
}
.card-title {
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-bottom: 8px;
}
/* ── Card tip / suggerimento ─────────────────── */
/* border-left accent: segnala contenuto accessorio senza competere con le card */
.doc-card.tip {
background: var(--color-primary-muted);
border-color: transparent;
border-left: 3px solid var(--color-primary);
box-shadow: none;
}
.tip-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-primary);
margin-bottom: 6px;
}
/* ── Lista numerata con badge ────────────────── */
/* counter CSS invece di <span> inline: mantiene semantica <ol> accessibile */
.steps {
list-style: none;
counter-reset: steps;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 8px;
}
.steps li {
counter-increment: steps;
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 0.9rem;
line-height: 1.5;
}
.steps li::before {
content: counter(steps);
display: flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
background: var(--color-primary);
color: #fff;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
margin-top: 2px;
}
/* ── Kbd inline ──────────────────────────────── */
kbd {
display: inline-flex;
align-items: center;
padding: 1px 7px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 5px;
font-family: inherit;
font-size: 0.85em;
font-weight: 600;
color: var(--color-text);
}
/* ── Tabella categorie alimenti ──────────────── */
.category-table {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
margin-top: 10px;
}
.cat-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 0.86rem;
}
.cat-row:last-child { border-bottom: none; }
.cat-label {
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
min-width: 110px;
}
.cat-items { color: var(--color-muted); font-size: 0.82rem; }
/* ── Chip metodi cottura ─────────────────────── */
.methods-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.method-chip {
font-size: 0.78rem;
font-weight: 600;
padding: 3px 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
}
/* ── Footer ──────────────────────────────────── */
.docs-footer {
margin: 24px 16px 0;
text-align: center;
font-size: 0.78rem;
color: var(--color-muted);
opacity: 0.6;
}
</style>

View File

@@ -32,16 +32,15 @@
<span class="info-label">Licenza</span> <span class="info-label">Licenza</span>
<span class="info-value">EUPL v1.2</span> <span class="info-value">EUPL v1.2</span>
</div> </div>
<div class="info-row"> <button class="info-row info-row--btn" @click="$emit('open-docs')" aria-label="Apri guida">
<span class="info-label">Documentazione</span> <span class="info-label">Guida</span>
<a class="info-link" href="https://docs.biteplan.example.com" target="_blank" rel="noopener"> <span class="info-link">
Apri Apri
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/> <polyline points="9 18 15 12 9 6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg> </svg>
</a> </span>
</div> </button>
</div> </div>
</div> </div>
@@ -52,7 +51,7 @@
import pkg from '../../package.json' import pkg from '../../package.json'
import appIcon from '../../assets/icon-only.png' import appIcon from '../../assets/icon-only.png'
defineProps({ modelValue: Boolean }) defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue']) defineEmits(['update:modelValue', 'open-docs'])
const version = pkg.version const version = pkg.version
</script> </script>
@@ -150,6 +149,16 @@ const version = pkg.version
.info-row:last-child { border-bottom: none; } .info-row:last-child { border-bottom: none; }
/* riga cliccabile: reset button, mantiene layout identico alle righe statiche */
.info-row--btn {
width: 100%;
min-height: unset;
border-radius: 0;
background: var(--color-surface);
text-align: left;
}
.info-row--btn:active { background: var(--color-bg); opacity: 1; }
.info-label { .info-label {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text); color: var(--color-text);

View File

@@ -27,7 +27,7 @@
class="result-item" class="result-item"
@click="selectItem(r)" @click="selectItem(r)"
> >
<span class="result-food">{{ r.food }}</span> <span class="result-food">{{ capFirst(r.food) }}</span>
<span class="result-method">{{ capFirst(r.method) }}</span> <span class="result-method">{{ capFirst(r.method) }}</span>
</li> </li>
</ul> </ul>
@@ -37,7 +37,7 @@
<div v-if="selected" class="converter-card"> <div v-if="selected" class="converter-card">
<div class="card-top"> <div class="card-top">
<div class="food-info"> <div class="food-info">
<span class="food-name">{{ selected.food }}</span> <span class="food-name">{{ capFirst(selected.food) }}</span>
<span class="food-sep">·</span> <span class="food-sep">·</span>
<span class="food-method">{{ capFirst(selected.method) }}</span> <span class="food-method">{{ capFirst(selected.method) }}</span>
</div> </div>
@@ -64,7 +64,7 @@
</div> </div>
</div> </div>
<button class="btn-swap" @click="swapDirection" :title="direction === 'rawToCooked' ? 'Inverti direzione' : 'Inverti direzione'"> <button class="btn-swap" @click="swapDirection" aria-label="Inverti direzione">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/> <polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/> <path d="M3 11V9a4 4 0 014-4h14"/>
@@ -73,12 +73,12 @@
</svg> </svg>
</button> </button>
<div class="calc-side output-side" :class="{ 'has-result': result !== null }"> <div class="calc-side output-side">
<div class="calc-label">{{ direction === 'rawToCooked' ? 'cotto' : 'crudo' }}</div> <div class="calc-label">{{ direction === 'rawToCooked' ? 'cotto' : 'crudo' }}</div>
<div class="calc-output"> <div class="calc-output">
<span v-if="result !== null" class="output-value">{{ result }}</span> <span v-if="result !== null" class="output-value">{{ result }}</span>
<span v-else class="output-placeholder"></span> <span v-else class="output-placeholder"></span>
<span class="calc-unit" :class="{ visible: result !== null }">g</span> <span v-if="result !== null" class="calc-unit">g</span>
</div> </div>
</div> </div>
</div> </div>
@@ -206,7 +206,7 @@ function swapDirection() {
.result-item:last-child { border-bottom: none; } .result-item:last-child { border-bottom: none; }
.result-item:active { background: var(--color-bg); } .result-item:active { background: var(--color-bg); }
.result-food { font-weight: 600; text-transform: capitalize; } .result-food { font-weight: 600; }
.result-method { font-size: 0.85rem; color: var(--color-muted); } .result-method { font-size: 0.85rem; color: var(--color-muted); }
/* ── converter card ───────────────────────────────── */ /* ── converter card ───────────────────────────────── */
@@ -231,12 +231,11 @@ function swapDirection() {
gap: 6px; gap: 6px;
} }
.food-name { font-weight: 700; font-size: 1rem; text-transform: capitalize; } .food-name { font-weight: 700; font-size: 1rem; }
.food-sep { color: var(--color-border); font-size: 1.1rem; } .food-sep { color: var(--color-border); font-size: 1.1rem; }
.food-method { font-size: 0.9rem; color: var(--color-muted); } .food-method { font-size: 0.9rem; color: var(--color-muted); }
.btn-reset { .btn-reset {
background: none;
color: var(--color-primary); color: var(--color-primary);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
@@ -284,7 +283,7 @@ function swapDirection() {
/* override globale per input dentro la card */ /* override globale per input dentro la card */
.calc-input { .calc-input {
font-size: 1.5rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
border: none; border: none;
@@ -308,6 +307,8 @@ function swapDirection() {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--color-muted); color: var(--color-muted);
align-self: flex-end;
padding-bottom: 6px;
} }
/* bottone swap centrale */ /* bottone swap centrale */
@@ -332,33 +333,31 @@ function swapDirection() {
opacity: 1; opacity: 1;
} }
/* colonna output */ /* colonna output: underline visivo per simmetria con il lato input */
.output-side .calc-output { .calc-output {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 4px; gap: 4px;
min-height: 44px; min-height: 44px;
align-items: center; border-bottom: 2px solid var(--color-border);
padding-bottom: 4px;
} }
.output-value { .output-value {
font-size: 2rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
letter-spacing: -0.03em; letter-spacing: -0.03em;
color: var(--color-primary); color: var(--color-primary);
line-height: 1;
} }
.output-placeholder { .output-placeholder {
font-size: 2rem; font-size: 1.6rem;
font-weight: 300; font-weight: 300;
color: var(--color-border); color: var(--color-border);
line-height: 1;
} }
/* nasconde "g" finché non c'è un risultato */
.calc-unit { visibility: hidden; }
.calc-input-wrap .calc-unit,
.has-result .calc-unit { visibility: visible; }
/* ── footer card ─────────────────────────────────── */ /* ── footer card ─────────────────────────────────── */
.card-footer { .card-footer {
padding: 10px 16px 14px; padding: 10px 16px 14px;

View File

@@ -21,13 +21,89 @@
</svg> </svg>
Genera lista della spesa Genera lista della spesa
</button> </button>
<div class="btn-share-row">
<button class="btn-share" @click="openShare">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="3" height="3" rx="0.5"/>
<rect x="19" y="14" width="2" height="2" rx="0.5"/>
<rect x="14" y="19" width="2" height="2" rx="0.5"/>
<rect x="18" y="19" width="3" height="2" rx="0.5"/>
</svg>
Condividi
</button>
<button class="btn-share btn-share--receive" @click="openScan">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
Ricevi
</button>
</div>
</div> </div>
<!-- Modal: Condividi -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showShareModal" class="qr-overlay" @click.self="closeShare">
<div class="qr-sheet" role="dialog" aria-label="Condividi piano pasti" aria-modal="true">
<div class="sheet-handle" />
<div class="qr-header">
<span class="qr-title">Condividi piano</span>
<button class="btn-x" @click="closeShare" aria-label="Chiudi">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="qr-hint">Fai scansionare questo codice dall'altro dispositivo</p>
<div class="qr-img-wrap">
<img v-if="qrDataUrl" :src="qrDataUrl" alt="QR code piano pasti" class="qr-img" />
<p v-else-if="qrError" class="qr-error">{{ qrError }}</p>
<div v-else class="qr-loading">Generazione in corso…</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Modal: Ricevi -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showScanModal" class="qr-overlay" @click.self="closeAndStopScan">
<div class="qr-sheet" role="dialog" aria-label="Ricevi piano pasti" aria-modal="true">
<div class="sheet-handle" />
<div class="qr-header">
<span class="qr-title">Scansiona QR</span>
<button class="btn-x" @click="closeAndStopScan" aria-label="Chiudi">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="qr-hint">Inquadra il codice QR dell'altro dispositivo</p>
<div class="scan-wrap">
<video ref="videoEl" class="scan-video" autoplay playsinline muted />
<canvas ref="canvasEl" class="scan-canvas" aria-hidden="true" />
<div class="scan-frame" aria-hidden="true" />
</div>
<p v-if="scanError" class="qr-error">{{ scanError }}</p>
<p v-if="scanSuccess" class="qr-success">Piano ricevuto!</p>
</div>
</div>
</Transition>
</Teleport>
</template> </template>
<script setup> <script setup>
import { reactive, watch } from 'vue' import { reactive, watch, ref, onUnmounted, nextTick } from 'vue'
import MealCard from '../components/MealCard.vue' import MealCard from '../components/MealCard.vue'
import { save, load } from '../utils/storage.js' import { save, load } from '../utils/storage.js'
import QRCode from 'qrcode'
import jsQR from 'jsqr'
const emit = defineEmits(['go-shop']) const emit = defineEmits(['go-shop'])
@@ -79,6 +155,144 @@ function generateShopping() {
save('shopping', [...existing, ...toAdd]) save('shopping', [...existing, ...toAdd])
emit('go-shop') emit('go-shop')
} }
// ── Share (QR generation) ─────────────────────────────────────────────────
const showShareModal = ref(false)
const qrDataUrl = ref('')
const qrError = ref('')
async function openShare() {
qrDataUrl.value = ''
qrError.value = ''
showShareModal.value = true
const payload = JSON.stringify({ v: 1, meals })
if (payload.length > 2953) {
qrError.value = 'Dati troppo grandi per un QR code. Riduci il numero di alimenti inseriti.'
return
}
try {
qrDataUrl.value = await QRCode.toDataURL(payload, {
errorCorrectionLevel: 'L',
margin: 1,
width: 260,
color: { dark: '#1a1a1a', light: '#ffffff' },
})
} catch {
qrError.value = 'Impossibile generare il QR code.'
}
}
function closeShare() {
showShareModal.value = false
qrDataUrl.value = ''
qrError.value = ''
}
// ── Receive (camera scan) ─────────────────────────────────────────────────
const showScanModal = ref(false)
const videoEl = ref(null)
const canvasEl = ref(null)
const scanError = ref('')
const scanSuccess = ref(false)
let stream = null
let rafId = null
async function openScan() {
scanError.value = ''
scanSuccess.value = false
showScanModal.value = true
await nextTick()
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
})
videoEl.value.srcObject = stream
videoEl.value.play()
rafId = requestAnimationFrame(scanFrame)
} catch (err) {
if (err.name === 'NotAllowedError') {
scanError.value = 'Accesso fotocamera negato. Abilita il permesso nelle impostazioni del dispositivo.'
} else {
scanError.value = 'Impossibile accedere alla fotocamera.'
}
}
}
function scanFrame() {
const video = videoEl.value
const canvas = canvasEl.value
if (!video || !canvas || video.readyState < 2) {
rafId = requestAnimationFrame(scanFrame)
return
}
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, canvas.width, canvas.height, {
inversionAttempts: 'dontInvert',
})
if (code) {
handleScannedData(code.data)
return
}
rafId = requestAnimationFrame(scanFrame)
}
function handleScannedData(raw) {
stopCamera()
try {
const parsed = JSON.parse(raw)
if (parsed?.v !== 1 || typeof parsed.meals !== 'object') {
scanError.value = 'QR non valido: dati non riconosciuti.'
return
}
const expectedDays = ['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica']
const expectedSlots = ['colazione', 'pranzo', 'cena']
for (const day of expectedDays) {
for (const slot of expectedSlots) {
if (!Array.isArray(parsed.meals[day]?.[slot])) {
scanError.value = 'QR non valido: struttura dati errata.'
return
}
}
}
for (const day of expectedDays) {
for (const slot of expectedSlots) {
meals[day][slot] = parsed.meals[day][slot]
}
}
scanSuccess.value = true
setTimeout(() => closeAndStopScan(), 1200)
} catch {
scanError.value = 'QR non valido: impossibile leggere i dati.'
}
}
function stopCamera() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
}
function closeAndStopScan() {
stopCamera()
showScanModal.value = false
scanError.value = ''
scanSuccess.value = false
}
onUnmounted(stopCamera)
</script> </script>
<style scoped> <style scoped>
@@ -100,4 +314,182 @@ function generateShopping() {
.btn-generate:active { .btn-generate:active {
opacity: 0.85; opacity: 0.85;
} }
/* ── Riga Condividi / Ricevi ─────────────────────── */
.btn-share-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-share {
flex: 1;
background: var(--color-primary-muted);
color: var(--color-primary);
font-weight: 600;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 44px;
border-radius: var(--radius);
border: 1.5px solid transparent;
}
.btn-share:active {
background: var(--color-primary);
color: #fff;
opacity: 1;
}
.btn-share--receive {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text);
}
.btn-share--receive:active {
background: var(--color-bg);
opacity: 1;
}
/* ── Overlay ─────────────────────────────────────── */
.qr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 400;
display: flex;
align-items: flex-end;
justify-content: center;
}
/* ── Bottom sheet ────────────────────────────────── */
.qr-sheet {
width: 100%;
max-width: 480px;
background: var(--color-surface);
border-radius: var(--radius) var(--radius) 0 0;
padding: 12px 20px 48px;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
}
.sheet-handle {
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: var(--radius-full);
margin: 0 auto 20px;
}
.qr-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.qr-title {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.btn-x {
background: var(--color-bg);
color: var(--color-muted);
min-height: 36px;
min-width: 36px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
}
.qr-hint {
font-size: 0.85rem;
color: var(--color-muted);
margin-bottom: 20px;
}
/* ── QR image ────────────────────────────────────── */
.qr-img-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 180px;
}
.qr-img {
width: 240px;
height: 240px;
border-radius: var(--radius-sm);
image-rendering: pixelated;
}
.qr-loading {
font-size: 0.9rem;
color: var(--color-muted);
}
/* ── Scanner ─────────────────────────────────────── */
.scan-wrap {
position: relative;
width: 100%;
max-width: 320px;
margin: 0 auto 16px;
border-radius: var(--radius);
overflow: hidden;
background: #000;
aspect-ratio: 1;
}
.scan-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.scan-canvas {
display: none;
}
.scan-frame {
position: absolute;
inset: 24px;
border: 2.5px solid var(--color-primary-light);
border-radius: var(--radius-sm);
pointer-events: none;
}
/* ── Feedback ────────────────────────────────────── */
.qr-error {
font-size: 0.85rem;
color: var(--color-danger);
background: var(--color-danger-muted);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-top: 4px;
}
.qr-success {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-primary);
background: var(--color-primary-muted);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-top: 4px;
text-align: center;
}
/* ── Transizioni ─────────────────────────────────── */
.fade-enter-active,
.fade-leave-active { transition: opacity 200ms ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style> </style>

View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
test.describe('Convertitore', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
})
test('mostra il messaggio iniziale prima di cercare', async ({ page }) => {
await expect(page.locator('.hint-state')).toBeVisible()
await expect(page.locator('.converter-card')).not.toBeVisible()
})
test('cerca un alimento e mostra i risultati', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso')
await expect(page.locator('.result-item').first()).toBeVisible()
})
test('i nomi nella lista hanno solo l\'iniziale maiuscola', async ({ page }) => {
await page.locator('input[type="text"]').fill('pollo')
const firstFood = await page.locator('.result-food').first().textContent()
// "Pollo petto" → solo la prima lettera maiuscola
expect(firstFood[0]).toBe(firstFood[0].toUpperCase())
if (firstFood.includes(' ')) {
const secondWord = firstFood.split(' ')[1]
expect(secondWord[0]).toBe(secondWord[0].toLowerCase())
}
})
test('seleziona un alimento e mostra la converter card', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso basmati')
await page.locator('.result-item').first().click()
await expect(page.locator('.converter-card')).toBeVisible()
await expect(page.locator('.result-item')).toHaveCount(0)
})
test('calcola il peso cotto inserendo i grammi', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso basmati')
await page.locator('.result-item').first().click()
await page.locator('.calc-input').fill('100')
// Riso basmati ha fattore 3.0 → 300g cotto
await expect(page.locator('.output-value')).toBeVisible()
const result = await page.locator('.output-value').textContent()
expect(parseFloat(result)).toBeCloseTo(300, 0)
})
test('il pulsante ⇄ inverte la direzione crudo↔cotto', async ({ page }) => {
await page.locator('input[type="text"]').fill('pasta')
await page.locator('.result-item').first().click()
const labelBefore = await page.locator('.calc-label').first().textContent()
await page.locator('.btn-swap').click()
const labelAfter = await page.locator('.calc-label').first().textContent()
expect(labelBefore).not.toBe(labelAfter)
})
test('il pulsante Cambia torna alla ricerca', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso')
await page.locator('.result-item').first().click()
await page.locator('.btn-reset').click()
await expect(page.locator('.converter-card')).not.toBeVisible()
await expect(page.locator('input[type="text"]')).not.toBeDisabled()
})
test('mostra il footer con fattore di resa quando c\'è un risultato', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso')
await page.locator('.result-item').first().click()
await page.locator('.calc-input').fill('100')
await expect(page.locator('.card-footer')).toContainText('fattore di resa')
})
})

View File

@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test'
test.describe('Piano Pasti', () => {
test.beforeEach(async ({ page }) => {
// Pulisce localStorage per partire da uno stato noto
await page.goto('/')
await page.evaluate(() => localStorage.clear())
await page.reload()
})
test('mostra 7 card giornaliere', async ({ page }) => {
const cards = page.locator('.meal-card')
await expect(cards).toHaveCount(7)
})
test('il giorno corrente è espanso di default', async ({ page }) => {
// Almeno una card deve essere aperta (class "open")
await expect(page.locator('.meal-card.open')).toHaveCount(1)
})
test('si può espandere e chiudere una card con tap', async ({ page }) => {
const firstHeader = page.locator('.card-header').first()
const firstCard = page.locator('.meal-card').first()
// Se la prima card è già aperta, chiudila prima
const isOpen = await firstCard.evaluate(el => el.classList.contains('open'))
await firstHeader.click()
if (isOpen) {
await expect(firstCard).not.toHaveClass(/open/)
} else {
await expect(firstCard).toHaveClass(/open/)
}
})
test('aggiunge un alimento al pranzo del giorno corrente', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
await pranzoInput.fill('pasta al pomodoro')
await pranzoInput.press('Enter')
await expect(openCard.locator('.item-text', { hasText: 'pasta al pomodoro' })).toBeVisible()
})
test('rimuove un alimento con il pulsante ×', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
await pranzoInput.fill('riso')
await pranzoInput.press('Enter')
const itemRow = openCard.locator('.item-row', { hasText: 'riso' })
await expect(itemRow).toBeVisible()
await itemRow.locator('.btn-remove').click()
await expect(itemRow).not.toBeVisible()
})
test('genera la lista della spesa e passa alla tab Spesa', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const cenahInput = openCard.locator('.meal-slot').nth(2).locator('input[type="text"]')
await cenahInput.fill('pollo')
await cenahInput.press('Enter')
await page.locator('.btn-generate').click()
// Deve essere passato alla tab Spesa
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
await expect(page.locator('.item-name', { hasText: 'pollo' })).toBeVisible()
})
test('i dati persistono dopo il reload', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const colazioneInput = openCard.locator('.meal-slot').first().locator('input[type="text"]')
await colazioneInput.fill('caffè')
await colazioneInput.press('Enter')
await page.reload()
await expect(page.locator('.meal-card.open .item-text', { hasText: 'caffè' })).toBeVisible()
})
})

View File

@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test'
test.describe('Navigazione tra tab', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('la tab Pasti è attiva al caricamento', async ({ page }) => {
await expect(page.locator('.nav-btn.active')).toContainText('Pasti')
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
})
test('la tab Converti mostra il convertitore', async ({ page }) => {
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
await expect(page.locator('.page-title')).toContainText('Convertitore')
await expect(page.locator('.nav-btn.active')).toContainText('Converti')
})
test('la tab Spesa mostra la lista della spesa', async ({ page }) => {
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
await expect(page.locator('.nav-btn.active')).toContainText('Spesa')
})
test('si può tornare a Pasti da un\'altra tab', async ({ page }) => {
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
await page.locator('.nav-btn', { hasText: 'Pasti' }).click()
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
})
test('il pulsante info apre il pannello informazioni', async ({ page }) => {
await page.locator('.btn-info').click()
await expect(page.locator('.sheet')).toBeVisible()
await expect(page.locator('.app-name')).toContainText('BitePlan')
})
test('il pannello info si chiude con la X', async ({ page }) => {
await page.locator('.btn-info').click()
await page.locator('.btn-x').click()
await expect(page.locator('.sheet')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test'
test.describe('Lista della spesa', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.evaluate(() => localStorage.clear())
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
})
test('mostra stato vuoto con lista vuota', async ({ page }) => {
await expect(page.locator('.empty-state')).toBeVisible()
})
test('aggiunge un elemento tramite il pulsante +', async ({ page }) => {
await page.locator('input[type="text"]').fill('latte')
await page.locator('.btn-add').click()
await expect(page.locator('.item-name', { hasText: 'latte' })).toBeVisible()
})
test('aggiunge un elemento con il tasto Invio', async ({ page }) => {
await page.locator('input[type="text"]').fill('burro')
await page.locator('input[type="text"]').press('Enter')
await expect(page.locator('.item-name', { hasText: 'burro' })).toBeVisible()
})
test('svuota il campo dopo l\'aggiunta', async ({ page }) => {
await page.locator('input[type="text"]').fill('olio')
await page.locator('.btn-add').click()
await expect(page.locator('input[type="text"]')).toHaveValue('')
})
test('spunta un elemento e lo sposta nei completati', async ({ page }) => {
await page.locator('input[type="text"]').fill('pasta')
await page.locator('.btn-add').click()
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
await expect(page.locator('.section-divider')).toBeVisible()
await expect(page.locator('.muted .item-name', { hasText: 'pasta' })).toBeVisible()
})
test('rimuove un singolo elemento con ×', async ({ page }) => {
await page.locator('input[type="text"]').fill('farina')
await page.locator('.btn-add').click()
await page.locator('.checkbox-item').first().locator('.btn-remove').click()
await expect(page.locator('.item-name', { hasText: 'farina' })).not.toBeVisible()
await expect(page.locator('.empty-state')).toBeVisible()
})
test('svuota lista con conferma del dialog', async ({ page }) => {
await page.locator('input[type="text"]').fill('test')
await page.locator('.btn-add').click()
page.once('dialog', dialog => dialog.accept())
await page.locator('.btn-clear').click()
await expect(page.locator('.empty-state')).toBeVisible()
})
test('non svuota lista se si annulla il dialog', async ({ page }) => {
await page.locator('input[type="text"]').fill('test')
await page.locator('.btn-add').click()
page.once('dialog', dialog => dialog.dismiss())
await page.locator('.btn-clear').click()
await expect(page.locator('.item-name', { hasText: 'test' })).toBeVisible()
})
test('il contatore mostra elementi completati / totale', async ({ page }) => {
await page.locator('input[type="text"]').fill('a')
await page.locator('.btn-add').click()
await page.locator('input[type="text"]').fill('b')
await page.locator('.btn-add').click()
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
const subtitle = await page.locator('.page-subtitle').textContent()
expect(subtitle).toMatch(/1/)
expect(subtitle).toMatch(/2/)
})
test('i dati persistono dopo il reload', async ({ page }) => {
await page.locator('input[type="text"]').fill('yogurt')
await page.locator('.btn-add').click()
await page.reload()
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
await expect(page.locator('.item-name', { hasText: 'yogurt' })).toBeVisible()
})
})

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Converter from '../../src/pages/Converter.vue'
function mountConverter() {
return mount(Converter, { attachTo: document.body })
}
describe('Converter — stato iniziale', () => {
it('mostra il messaggio hint quando non c\'è nessuna ricerca', () => {
const w = mountConverter()
expect(w.find('.hint-state').exists()).toBe(true)
expect(w.find('.converter-card').exists()).toBe(false)
})
it('non mostra risultati senza input', () => {
const w = mountConverter()
expect(w.findAll('.result-item')).toHaveLength(0)
})
})
describe('Converter — ricerca', () => {
it('mostra risultati corrispondenti alla query', async () => {
const w = mountConverter()
const input = w.find('input[type="text"]')
await input.setValue('riso')
await input.trigger('input')
expect(w.findAll('.result-item').length).toBeGreaterThan(0)
})
it('ogni risultato ha nome alimento e metodo di cottura', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pollo')
await w.find('input[type="text"]').trigger('input')
const first = w.find('.result-item')
expect(first.find('.result-food').text()).toBeTruthy()
expect(first.find('.result-method').text()).toBeTruthy()
})
it('nasconde l\'hint state durante la ricerca', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pasta')
await w.find('input[type="text"]').trigger('input')
expect(w.find('.hint-state').exists()).toBe(false)
})
it('svuota i risultati per query vuota', async () => {
const w = mountConverter()
const input = w.find('input[type="text"]')
await input.setValue('riso')
await input.trigger('input')
await input.setValue('')
await input.trigger('input')
expect(w.findAll('.result-item')).toHaveLength(0)
expect(w.find('.hint-state').exists()).toBe(true)
})
it('i nomi degli alimenti hanno solo l\'iniziale maiuscola', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('riso')
await w.find('input[type="text"]').trigger('input')
const foods = w.findAll('.result-food').map(el => el.text())
// Nessun testo deve avere lettere maiuscole dopo la prima (test multi-word)
foods.forEach(f => {
if (f.includes(' ')) {
// "Riso basmati" → la seconda parola non deve essere capitalizzata
const words = f.split(' ')
words.slice(1).forEach(w => expect(w[0]).toBe(w[0].toLowerCase()))
}
})
})
})
describe('Converter — selezione alimento', () => {
async function selectFirstResult(w, query = 'riso') {
await w.find('input[type="text"]').setValue(query)
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
}
it('mostra la converter card dopo la selezione', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.find('.converter-card').exists()).toBe(true)
})
it('nasconde i risultati dopo la selezione', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.findAll('.result-item')).toHaveLength(0)
})
it('mostra nome alimento e metodo nella card', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.find('.food-name').text()).toBeTruthy()
expect(w.find('.food-method').text()).toBeTruthy()
})
it('disabilita il campo di ricerca dopo la selezione', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.find('input[type="text"]').attributes('disabled')).toBeDefined()
})
})
describe('Converter — calcolo', () => {
async function mountWithSelection(query = 'riso basmati') {
const w = mountConverter()
await w.find('input[type="text"]').setValue(query)
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
return w
}
it('mostra il risultato dopo aver inserito i grammi', async () => {
const w = await mountWithSelection()
await w.find('.calc-input').setValue('100')
await w.find('.calc-input').trigger('input')
expect(w.find('.output-value').exists()).toBe(true)
})
it('non mostra risultato per input 0', async () => {
const w = await mountWithSelection()
await w.find('.calc-input').setValue('0')
await w.find('.calc-input').trigger('input')
expect(w.find('.output-value').exists()).toBe(false)
})
it('mostra il footer con fattore di resa quando c\'è un risultato', async () => {
const w = await mountWithSelection()
await w.find('.calc-input').setValue('100')
await w.find('.calc-input').trigger('input')
expect(w.find('.card-footer').exists()).toBe(true)
expect(w.find('.card-footer').text()).toContain('fattore di resa')
})
})
describe('Converter — swap direzione', () => {
it('il pulsante swap inverte le label crudo/cotto', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pasta')
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
expect(w.find('.calc-label').text()).toMatch(/crudo/i)
await w.find('.btn-swap').trigger('click')
expect(w.find('.calc-label').text()).toMatch(/cotto/i)
})
it('swap azzera il campo grammi', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pasta')
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
await w.find('.calc-input').setValue('200')
await w.find('.btn-swap').trigger('click')
// Dopo lo swap il campo deve essere vuoto (grams = null)
expect(w.find('.calc-input').element.value).toBe('')
})
})
describe('Converter — reset', () => {
it('il pulsante Cambia riporta allo stato di ricerca', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('riso')
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
await w.find('.btn-reset').trigger('click')
expect(w.find('.converter-card').exists()).toBe(false)
expect(w.find('input[type="text"]').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import MealPlanner from '../../src/pages/MealPlanner.vue'
import { load } from '../../src/utils/storage.js'
// Stub di MealCard: espone props e può emettere add/remove
const MealCardStub = {
name: 'MealCard',
template: '<div class="meal-card-stub" />',
props: ['dayName', 'meals', 'defaultOpen'],
emits: ['add', 'remove'],
}
function mountPlanner() {
return mount(MealPlanner, {
global: { stubs: { MealCard: MealCardStub } },
})
}
// Pre-popola il localStorage con un piano pasti completo
function seedMeals(overrides = {}) {
const base = Object.fromEntries(
['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica'].map(
d => [d, { colazione: [], pranzo: [], cena: [] }]
)
)
const merged = { ...base, ...overrides }
localStorage.setItem('meals', JSON.stringify(merged))
return merged
}
describe('MealPlanner — rendering', () => {
it('rende 7 card giornaliere', () => {
const w = mountPlanner()
expect(w.findAll('.meal-card-stub')).toHaveLength(7)
})
it('mostra il pulsante "Genera lista della spesa"', () => {
const w = mountPlanner()
expect(w.find('.btn-generate').exists()).toBe(true)
})
it('mostra la data corrente nel sottotitolo', () => {
const w = mountPlanner()
expect(w.find('.page-subtitle').text()).toMatch(/oggi/i)
})
})
describe('MealPlanner — aggiunta e rimozione voci', () => {
it('aggiunge un alimento al pranzo di lunedì', async () => {
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
// lunedì è il primo giorno (indice 0)
await cards[0].vm.$emit('add', 'pranzo', 'pasta')
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.pranzo).toContain('pasta')
})
it('non aggiunge stringhe vuote', async () => {
// Seed necessario: il watcher non scatta se nulla cambia,
// quindi localStorage rimarrebbe vuoto e load() returnerebbe {}
seedMeals()
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
await cards[0].vm.$emit('add', 'pranzo', ' ')
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.pranzo).toHaveLength(0)
})
it('rimuove un alimento tramite indice', async () => {
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta', 'insalata'], cena: [] } })
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
await cards[0].vm.$emit('remove', 'pranzo', 0) // rimuovi "pasta"
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.pranzo).toEqual(['insalata'])
})
it('persiste le modifiche in localStorage', async () => {
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
await cards[1].vm.$emit('add', 'cena', 'pollo')
await nextTick()
expect(load('meals', {}).martedi.cena).toContain('pollo')
})
})
describe('MealPlanner — genera lista della spesa', () => {
it('emette go-shop al click del pulsante', async () => {
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
expect(w.emitted('go-shop')).toBeTruthy()
})
it('salva gli alimenti del piano in localStorage come spesa', async () => {
seedMeals({
lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: ['pollo'] },
martedi: { colazione: [], pranzo: ['riso'], cena: [] },
})
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
const shopping = load('shopping', [])
const names = shopping.map(i => i.name)
expect(names).toContain('caffè')
expect(names).toContain('pasta')
expect(names).toContain('pollo')
expect(names).toContain('riso')
})
it('non aggiunge duplicati rispetto agli elementi già in lista', async () => {
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta'], cena: [] } })
// Pasta già presente nella lista della spesa
localStorage.setItem('shopping', JSON.stringify([
{ id: 1, name: 'pasta', checked: false },
]))
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
const shopping = load('shopping', [])
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
expect(pastaCount).toBe(1)
})
it('non aggiunge duplicati tra i giorni del piano', async () => {
seedMeals({
lunedi: { colazione: [], pranzo: ['pasta'], cena: [] },
martedi: { colazione: [], pranzo: ['pasta'], cena: [] }, // stesso alimento
})
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
const shopping = load('shopping', [])
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
expect(pastaCount).toBe(1)
})
it('non aggiunge nulla se il piano è vuoto', async () => {
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
expect(load('shopping', [])).toHaveLength(0)
})
})

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ShoppingList from '../../src/pages/ShoppingList.vue'
import { load } from '../../src/utils/storage.js'
const CheckboxItemStub = {
name: 'CheckboxItem',
template: '<li class="checkbox-stub">{{ item.name }}</li>',
props: ['item'],
emits: ['toggle', 'remove'],
}
function seedShopping(items) {
localStorage.setItem('shopping', JSON.stringify(items))
}
function mountShoppingList() {
return mount(ShoppingList, {
global: { stubs: { CheckboxItem: CheckboxItemStub } },
})
}
describe('ShoppingList — stato iniziale', () => {
it('mostra lo stato vuoto se non ci sono elementi', () => {
const w = mountShoppingList()
expect(w.find('.empty-state').exists()).toBe(true)
})
it('non mostra il pulsante svuota lista se la lista è vuota', () => {
const w = mountShoppingList()
expect(w.find('.btn-clear').exists()).toBe(false)
})
it('carica gli elementi dal localStorage all\'avvio', () => {
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
expect(w.find('.empty-state').exists()).toBe(false)
expect(w.findAll('.checkbox-stub')).toHaveLength(1)
})
})
describe('ShoppingList — aggiunta elementi', () => {
it('aggiunge un elemento tramite click su +', async () => {
const w = mountShoppingList()
await w.find('input[type="text"]').setValue('latte')
await w.find('.btn-add').trigger('click')
const items = load('shopping', [])
expect(items).toHaveLength(1)
expect(items[0].name).toBe('latte')
expect(items[0].checked).toBe(false)
})
it('aggiunge un elemento tramite tasto Invio', async () => {
const w = mountShoppingList()
const input = w.find('input[type="text"]')
await input.setValue('burro')
await input.trigger('keyup.enter')
expect(load('shopping', []).map(i => i.name)).toContain('burro')
})
it('svuota il campo input dopo l\'aggiunta', async () => {
const w = mountShoppingList()
await w.find('input[type="text"]').setValue('olio')
await w.find('.btn-add').trigger('click')
expect(w.find('input[type="text"]').element.value).toBe('')
})
it('non aggiunge stringhe vuote', async () => {
const w = mountShoppingList()
await w.find('input[type="text"]').setValue(' ')
await w.find('.btn-add').trigger('click')
expect(load('shopping', [])).toHaveLength(0)
expect(w.find('.empty-state').exists()).toBe(true)
})
})
describe('ShoppingList — toggle e rimozione', () => {
it('sposta un elemento nei completati dopo toggle', async () => {
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
await nextTick()
const items = load('shopping', [])
expect(items[0].checked).toBe(true)
})
it('ripristina un elemento già spuntato dopo un secondo toggle', async () => {
seedShopping([{ id: 1, name: 'pasta', checked: true }])
const w = mountShoppingList()
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
await nextTick()
expect(load('shopping', [])[0].checked).toBe(false)
})
it('rimuove un singolo elemento', async () => {
seedShopping([
{ id: 1, name: 'pasta', checked: false },
{ id: 2, name: 'riso', checked: false },
])
const w = mountShoppingList()
await w.findAllComponents(CheckboxItemStub)[0].vm.$emit('remove')
await nextTick()
const items = load('shopping', [])
expect(items).toHaveLength(1)
expect(items[0].name).toBe('riso')
})
})
describe('ShoppingList — svuota lista', () => {
it('svuota la lista dopo conferma', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
await w.find('.btn-clear').trigger('click')
await nextTick()
expect(w.find('.empty-state').exists()).toBe(true)
expect(load('shopping', [])).toHaveLength(0)
})
it('non svuota se l\'utente annulla', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
await w.find('.btn-clear').trigger('click')
await nextTick()
expect(load('shopping', [])).toHaveLength(1)
})
})
describe('ShoppingList — contatore completati', () => {
it('mostra N/totale nel sottotitolo', () => {
seedShopping([
{ id: 1, name: 'pasta', checked: true },
{ id: 2, name: 'riso', checked: false },
{ id: 3, name: 'patate', checked: false },
])
const w = mountShoppingList()
const subtitle = w.find('.page-subtitle').text()
expect(subtitle).toMatch(/1/)
expect(subtitle).toMatch(/3/)
})
it('non mostra il sottotitolo se la lista è vuota', () => {
const w = mountShoppingList()
expect(w.find('.page-subtitle').exists()).toBe(false)
})
})

5
tests/setup.js Normal file
View File

@@ -0,0 +1,5 @@
import { beforeEach, afterEach } from 'vitest'
// Svuota localStorage prima e dopo ogni test per garantire isolamento
beforeEach(() => localStorage.clear())
afterEach(() => localStorage.clear())

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { rawToCooked, cookedToRaw } from '../../src/utils/conversion.js'
// DB minimale — isola i test dalle modifiche al JSON reale
const db = {
'riso basmati': { bollitura: { yield: 3.00 } },
'pasta semola': { bollitura: { yield: 1.88 } },
'pollo petto': { padella: { yield: 0.75 }, forno: { yield: 0.70 } },
'zucchine': { bollitura: { yield: 0.90 }, padella: { yield: 0.82 } },
'ceci secchi': { bollitura: { yield: 2.90 } },
}
describe('rawToCooked', () => {
it('calcola il peso cotto con fattore > 1 (cereali)', () => {
expect(rawToCooked('riso basmati', 'bollitura', 100, db)).toBe(300)
})
it('calcola il peso cotto con fattore non intero', () => {
expect(rawToCooked('pasta semola', 'bollitura', 100, db)).toBeCloseTo(188)
})
it('calcola il peso cotto con fattore < 1 (verdure)', () => {
expect(rawToCooked('zucchine', 'bollitura', 200, db)).toBeCloseTo(180)
})
it('restituisce 0 per peso 0', () => {
expect(rawToCooked('riso basmati', 'bollitura', 0, db)).toBe(0)
})
it('scala linearmente con la quantità', () => {
const single = rawToCooked('riso basmati', 'bollitura', 100, db)
const double = rawToCooked('riso basmati', 'bollitura', 200, db)
expect(double).toBeCloseTo(single * 2)
})
it('usa il metodo di cottura corretto', () => {
const padella = rawToCooked('pollo petto', 'padella', 100, db)
const forno = rawToCooked('pollo petto', 'forno', 100, db)
expect(padella).not.toBe(forno)
expect(padella).toBe(75)
expect(forno).toBe(70)
})
it('funziona con legumi secchi (fattore molto > 1)', () => {
expect(rawToCooked('ceci secchi', 'bollitura', 100, db)).toBeCloseTo(290)
})
})
describe('cookedToRaw', () => {
it('calcola il peso crudo da un peso cotto', () => {
expect(cookedToRaw('riso basmati', 'bollitura', 300, db)).toBeCloseTo(100)
})
it('è l\'inverso esatto di rawToCooked', () => {
const rawIn = 150
const cooked = rawToCooked('pollo petto', 'padella', rawIn, db)
const rawOut = cookedToRaw('pollo petto', 'padella', cooked, db)
expect(rawOut).toBeCloseTo(rawIn)
})
it('funziona con verdure (fattore < 1 → crudo > cotto)', () => {
const crudo = cookedToRaw('zucchine', 'padella', 82, db)
expect(crudo).toBeCloseTo(100)
})
it('restituisce 0 per peso cotto 0', () => {
expect(cookedToRaw('riso basmati', 'bollitura', 0, db)).toBe(0)
})
it('scala linearmente', () => {
const base = cookedToRaw('riso basmati', 'bollitura', 300, db)
const doppio = cookedToRaw('riso basmati', 'bollitura', 600, db)
expect(doppio).toBeCloseTo(base * 2)
})
})

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { save, load } from '../../src/utils/storage.js'
// localStorage viene svuotato in tests/setup.js — qui garantiamo isolamento locale
beforeEach(() => localStorage.clear())
describe('save', () => {
it('serializza un oggetto in JSON', () => {
save('test', { a: 1, b: 'due' })
expect(localStorage.getItem('test')).toBe('{"a":1,"b":"due"}')
})
it('serializza un array', () => {
save('list', [1, 2, 3])
expect(localStorage.getItem('list')).toBe('[1,2,3]')
})
it('sovrascrive un valore esistente', () => {
save('key', 'primo')
save('key', 'secondo')
expect(localStorage.getItem('key')).toBe('"secondo"')
})
it('gestisce valori primitivi (numero, stringa, booleano)', () => {
save('n', 42)
save('s', 'ciao')
save('b', false)
expect(JSON.parse(localStorage.getItem('n'))).toBe(42)
expect(JSON.parse(localStorage.getItem('s'))).toBe('ciao')
expect(JSON.parse(localStorage.getItem('b'))).toBe(false)
})
})
describe('load', () => {
it('restituisce il valore parsato se la chiave esiste', () => {
localStorage.setItem('obj', '{"a":1}')
expect(load('obj', null)).toEqual({ a: 1 })
})
it('restituisce il default se la chiave non esiste', () => {
expect(load('nonexistent', 'fallback')).toBe('fallback')
})
it('restituisce il default anche per array vuoto come fallback', () => {
expect(load('missing', [])).toEqual([])
})
it('round-trip: save → load restituisce il valore originale', () => {
const data = [{ id: 1, name: 'pasta', checked: false }]
save('shopping', data)
expect(load('shopping', [])).toEqual(data)
})
it('round-trip su struttura pasti annidata', () => {
const meals = { lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } }
save('meals', meals)
expect(load('meals', {})).toEqual(meals)
})
})

17
vitest.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
setupFiles: ['./tests/setup.js'],
include: ['tests/unit/**/*.test.js', 'tests/integration/**/*.test.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/utils/**', 'src/pages/**', 'src/components/**'],
exclude: ['src/data/**'],
},
},
})