chore: rimuovi sorgente Vue 3, Vite e Playwright
Il progetto viene riscritto in Flutter. Eliminati tutti i file Vue 3 (src/, tests/, public/), le configurazioni Vite/Vitest e i test Playwright che non saranno più mantenuti. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
-13
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>BitePlan</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-3572
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "biteplan",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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'] } },
|
||||
],
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 MiB |
-113
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div id="app-root">
|
||||
<MealPlanner v-if="page === 'meal'" @go-shop="page = 'shop'" />
|
||||
<Converter v-else-if="page === 'convert'" />
|
||||
<ShoppingList v-else-if="page === 'shop'" />
|
||||
<BottomNav v-model="page" />
|
||||
|
||||
<!-- bottone info fisso in alto a destra -->
|
||||
<button class="btn-info" @click="showInfo = true" aria-label="Informazioni app">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<InfoPanel v-model="showInfo" @open-docs="showDocs = true" />
|
||||
<DocsPanel v-model="showDocs" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import BottomNav from './components/BottomNav.vue'
|
||||
import InfoPanel from './components/InfoPanel.vue'
|
||||
import DocsPanel from './components/DocsPanel.vue'
|
||||
import MealPlanner from './pages/MealPlanner.vue'
|
||||
import Converter from './pages/Converter.vue'
|
||||
import ShoppingList from './pages/ShoppingList.vue'
|
||||
|
||||
const page = ref('meal')
|
||||
const showInfo = ref(false)
|
||||
const showDocs = ref(false)
|
||||
|
||||
function fixOrientation() {
|
||||
const isLandscape = window.innerWidth > window.innerHeight
|
||||
// screen.orientation.angle potrebbe non essere ancora aggiornato o non disponibile:
|
||||
// fallback basato su dimensioni finestra
|
||||
const angle = screen.orientation?.angle ?? (isLandscape ? 90 : 0)
|
||||
const root = document.getElementById('app-root')
|
||||
if (!root) return
|
||||
if (isLandscape) {
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
root.style.cssText = `
|
||||
position: fixed;
|
||||
width: ${h}px;
|
||||
height: ${w}px;
|
||||
top: ${(h - w) / 2}px;
|
||||
left: ${(w - h) / 2}px;
|
||||
transform: rotate(${angle === 90 ? -90 : 90}deg);
|
||||
transform-origin: center center;
|
||||
`
|
||||
} else {
|
||||
root.style.cssText = ''
|
||||
}
|
||||
}
|
||||
|
||||
// orientationchange si triggera prima che angle/dimensioni siano aggiornati:
|
||||
// aspettiamo un tick
|
||||
function onOrientationChange() {
|
||||
setTimeout(fixOrientation, 50)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fixOrientation()
|
||||
window.addEventListener('orientationchange', onOrientationChange)
|
||||
window.addEventListener('resize', fixOrientation)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('orientationchange', onOrientationChange)
|
||||
window.removeEventListener('resize', fixOrientation)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app-root {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
position: fixed;
|
||||
top: calc(14px + env(safe-area-inset-top));
|
||||
right: 16px;
|
||||
/* centra rispetto al max-width del layout */
|
||||
max-width: calc(480px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-muted);
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
z-index: 50;
|
||||
transition: color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.btn-info:active {
|
||||
color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<nav class="bottom-nav">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['nav-btn', { active: modelValue === tab.id }]"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
:aria-label="tab.label"
|
||||
>
|
||||
<span class="nav-icon" v-html="tab.icon" />
|
||||
<span class="nav-label">{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ modelValue: String })
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'meal',
|
||||
label: 'Pasti',
|
||||
icon: `<svg width="22" height="22" 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>`,
|
||||
},
|
||||
{
|
||||
id: 'convert',
|
||||
label: 'Converti',
|
||||
icon: `<svg width="22" height="22" 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>`,
|
||||
},
|
||||
{
|
||||
id: 'shop',
|
||||
label: 'Spesa',
|
||||
icon: `<svg width="22" height="22" 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>`,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: var(--nav-height);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
color: var(--color-muted);
|
||||
min-height: unset;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* indicatore attivo: pill sopra l'icona — segnala la tab senza aggressività */
|
||||
.nav-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<li class="checkbox-item" :class="{ checked: item.checked }">
|
||||
<label class="item-label">
|
||||
<input type="checkbox" :checked="item.checked" @change="$emit('toggle')" />
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
</label>
|
||||
<button class="btn-remove" @click="$emit('remove')" :aria-label="`Rimuovi ${item.name}`">
|
||||
<svg width="14" height="14" 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>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ item: Object })
|
||||
defineEmits(['toggle', 'remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 4px 4px 14px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.checkbox-item.checked {
|
||||
background: var(--color-bg);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 1rem;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.checked .item-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* touch target pieno 44×44px */
|
||||
.btn-remove {
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-remove:active { color: var(--color-danger); }
|
||||
</style>
|
||||
@@ -1,523 +0,0 @@
|
||||
<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">Condividere il piano</h3>
|
||||
<ol class="steps">
|
||||
<li>Premi <strong>Condividi</strong> in fondo alla pagina</li>
|
||||
<li>Viene generato un QR code con l'intero piano settimanale</li>
|
||||
<li>Fai scansionare il codice dall'altro dispositivo</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Ricevere un piano</h3>
|
||||
<ol class="steps">
|
||||
<li>Premi <strong>Scansiona QR</strong> in fondo alla pagina</li>
|
||||
<li>Consenti l'accesso alla fotocamera quando richiesto</li>
|
||||
<li>Inquadra il QR code — il piano viene importato automaticamente</li>
|
||||
</ol>
|
||||
</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>
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="overlay" @click="$emit('update:modelValue', false)" />
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div v-if="modelValue" class="sheet" role="dialog" aria-label="Informazioni app">
|
||||
|
||||
<div class="sheet-handle" />
|
||||
|
||||
<div class="sheet-top">
|
||||
<div class="app-identity">
|
||||
<img class="app-icon" :src="appIcon" alt="BitePlan" />
|
||||
<div>
|
||||
<div class="app-name">BitePlan</div>
|
||||
<div class="app-version">Versione {{ version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-x" @click="$emit('update:modelValue', false)" 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>
|
||||
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Autore</span>
|
||||
<span class="info-value">Davide Grilli</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Licenza</span>
|
||||
<span class="info-value">EUPL v1.2</span>
|
||||
</div>
|
||||
<button class="info-row info-row--btn" @click="$emit('open-docs')" aria-label="Apri guida">
|
||||
<span class="info-label">Guida</span>
|
||||
<span class="info-link">
|
||||
Apri
|
||||
<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">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import pkg from '../../package.json'
|
||||
import appIcon from '../../assets/icon-only.png'
|
||||
defineProps({ modelValue: Boolean })
|
||||
defineEmits(['update:modelValue', 'open-docs'])
|
||||
const version = pkg.version
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 12px 20px 48px;
|
||||
z-index: 201;
|
||||
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 24px;
|
||||
}
|
||||
|
||||
/* header: icona app + nome/versione + X */
|
||||
.sheet-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* lista righe info */
|
||||
.info-list {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.info-link {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* transizioni */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 200ms ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active { transition: transform 250ms ease; }
|
||||
.slide-up-enter-from, .slide-up-leave-to { transform: translateX(-50%) translateY(100%); }
|
||||
</style>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<div class="meal-card" :class="{ open: isOpen }">
|
||||
<button class="card-header" @click="isOpen = !isOpen" :aria-expanded="isOpen">
|
||||
<span class="day-name">{{ dayName }}</span>
|
||||
<span class="day-summary" v-if="!isOpen && totalItems > 0">{{ totalItems }} {{ totalItems === 1 ? 'voce' : 'voci' }}</span>
|
||||
<span class="chevron" :class="{ rotated: isOpen }">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="card-body" v-show="isOpen">
|
||||
<div v-for="slot in slots" :key="slot.id" class="meal-slot">
|
||||
<div class="slot-header">
|
||||
<span class="slot-icon">{{ slot.icon }}</span>
|
||||
<h3 class="slot-label">{{ slot.label }}</h3>
|
||||
</div>
|
||||
|
||||
<ul v-if="meals[slot.id].length" class="item-list">
|
||||
<li v-for="(item, idx) in meals[slot.id]" :key="idx" class="item-row">
|
||||
<span class="item-text">{{ item }}</span>
|
||||
<button class="btn-remove" @click="$emit('remove', slot.id, idx)" :aria-label="`Rimuovi ${item}`">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="input-row">
|
||||
<input
|
||||
v-model="inputs[slot.id]"
|
||||
type="text"
|
||||
:placeholder="`Aggiungi a ${slot.label.toLowerCase()}...`"
|
||||
@keyup.enter="submit(slot.id)"
|
||||
/>
|
||||
<button class="btn-add" @click="submit(slot.id)" aria-label="Aggiungi">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
dayName: String,
|
||||
meals: Object,
|
||||
defaultOpen: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['add', 'remove'])
|
||||
|
||||
const isOpen = ref(props.defaultOpen)
|
||||
|
||||
const slots = [
|
||||
{ id: 'colazione', label: 'Colazione', icon: '☀️' },
|
||||
{ id: 'pranzo', label: 'Pranzo', icon: '🍽️' },
|
||||
{ id: 'cena', label: 'Cena', icon: '🌙' },
|
||||
]
|
||||
|
||||
const inputs = reactive({ colazione: '', pranzo: '', cena: '' })
|
||||
|
||||
const totalItems = computed(() =>
|
||||
slots.reduce((sum, s) => sum + props.meals[s.id].length, 0)
|
||||
)
|
||||
|
||||
function submit(slotId) {
|
||||
const t = inputs[slotId].trim()
|
||||
if (!t) return
|
||||
emit('add', slotId, t)
|
||||
inputs[slotId] = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meal-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
min-height: unset;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* numero voci mostrato quando la card è chiusa */
|
||||
.day-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-primary-muted);
|
||||
color: var(--color-primary);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--color-muted);
|
||||
display: flex;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.chevron.rotated { transform: rotate(180deg); }
|
||||
|
||||
.card-body {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meal-slot { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.slot-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slot-icon { font-size: 0.9rem; }
|
||||
|
||||
.slot-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.item-text { font-size: 0.9rem; flex: 1; }
|
||||
|
||||
/* touch target 44px garantito con padding */
|
||||
.btn-remove {
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-remove:active { color: var(--color-danger); }
|
||||
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"cous cous precotto": { "bollitura": { "yield": 2.25 } },
|
||||
"farro perlato": { "bollitura": { "yield": 2.28 } },
|
||||
"orzo perlato": { "bollitura": { "yield": 2.67 } },
|
||||
"quinoa": { "bollitura": { "yield": 3.12 } },
|
||||
"riso basmati": { "bollitura": { "yield": 3.00 } },
|
||||
"riso brillato": { "bollitura": { "yield": 2.60 } },
|
||||
"riso parboiled": { "bollitura": { "yield": 2.36 } },
|
||||
"riso venere": { "bollitura": { "yield": 2.10 } },
|
||||
"pasta all'uovo fresca": { "bollitura": { "yield": 1.36 } },
|
||||
"pasta all'uovo secca": { "bollitura": { "yield": 2.99 } },
|
||||
"pasta semola corta": { "bollitura": { "yield": 1.88 } },
|
||||
"pasta semola lunga": { "bollitura": { "yield": 2.10 } },
|
||||
"gnocchi di patate": { "bollitura": { "yield": 1.06 } },
|
||||
|
||||
"ceci secchi": { "bollitura": { "yield": 2.90 } },
|
||||
"fagioli secchi": { "bollitura": { "yield": 2.30 } },
|
||||
"lenticchie secche": { "bollitura": { "yield": 2.47 } },
|
||||
|
||||
"carote": { "bollitura": { "yield": 0.87 }, "forno": { "yield": 0.80 }, "friggitrice ad aria": { "yield": 0.74 } },
|
||||
"carote (fettine)": { "padella": { "yield": 0.38 }, "forno": { "yield": 0.72 }, "friggitrice ad aria": { "yield": 0.66 } },
|
||||
"cipolle": { "bollitura": { "yield": 0.73 }, "forno": { "yield": 0.65 }, "friggitrice ad aria": { "yield": 0.60 } },
|
||||
"cipolle (cubetti)": { "padella": { "yield": 0.44 }, "forno": { "yield": 0.50 }, "friggitrice ad aria": { "yield": 0.46 } },
|
||||
"melanzane": { "forno": { "yield": 0.42 }, "friggitrice ad aria": { "yield": 0.37 } },
|
||||
"melanzane (cubetti)": { "padella": { "yield": 0.80 }, "forno": { "yield": 0.75 }, "friggitrice ad aria": { "yield": 0.69 } },
|
||||
"patate": { "bollitura": { "yield": 0.94 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.90 } },
|
||||
"patate (spicchi)": { "padella": { "yield": 0.64 }, "forno": { "yield": 0.63 }, "friggitrice ad aria": { "yield": 0.60 } },
|
||||
"peperoni": { "padella": { "yield": 0.60 }, "forno": { "yield": 0.96 }, "friggitrice ad aria": { "yield": 0.88 } },
|
||||
"verdure miste": { "padella": { "yield": 0.75 }, "forno": { "yield": 0.78 }, "friggitrice ad aria": { "yield": 0.60 } },
|
||||
"zucchine": { "bollitura": { "yield": 0.90 }, "padella": { "yield": 0.82 }, "forno": { "yield": 0.86 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||
"zucchine (fettine)": { "padella": { "yield": 0.76 }, "forno": { "yield": 0.72 }, "friggitrice ad aria": { "yield": 0.66 } },
|
||||
"spinaci": { "bollitura": { "yield": 0.85 }, "padella": { "yield": 0.85 } },
|
||||
"bieta": { "bollitura": { "yield": 0.83 }, "padella": { "yield": 0.80 } },
|
||||
"broccoli": { "bollitura": { "yield": 0.97 }, "padella": { "yield": 0.89 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||
"cavolfiore": { "bollitura": { "yield": 0.97 }, "padella": { "yield": 0.89 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||
"fagiolini": { "bollitura": { "yield": 0.95 }, "padella": { "yield": 0.86 }, "forno": { "yield": 0.88 }, "friggitrice ad aria": { "yield": 0.81 } },
|
||||
"asparagi": { "bollitura": { "yield": 0.97 }, "padella": { "yield": 0.88 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||
"carciofi": { "bollitura": { "yield": 0.75 }, "padella": { "yield": 0.66 }, "forno": { "yield": 0.68 }, "friggitrice ad aria": { "yield": 0.63 } },
|
||||
"finocchi": { "bollitura": { "yield": 0.88 }, "padella": { "yield": 0.75 }, "forno": { "yield": 0.78 }, "friggitrice ad aria": { "yield": 0.72 } },
|
||||
"porri": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.80 }, "forno": { "yield": 0.82 } },
|
||||
"verza": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.88 }, "forno": { "yield": 0.90 } },
|
||||
"cavolo cappuccio": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.92 }, "forno": { "yield": 0.88 } },
|
||||
"cicoria coltivata": { "bollitura": { "yield": 0.80 }, "padella": { "yield": 0.75 } },
|
||||
"cicoria di campo": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.88 } },
|
||||
"cavolini di bruxelles": { "bollitura": { "yield": 0.89 }, "padella": { "yield": 0.82 }, "forno": { "yield": 0.82 }, "friggitrice ad aria": { "yield": 0.75 } },
|
||||
"rape": { "bollitura": { "yield": 0.92 }, "padella": { "yield": 0.83 }, "forno": { "yield": 0.85 }, "friggitrice ad aria": { "yield": 0.78 } },
|
||||
"agretti": { "bollitura": { "yield": 0.88 }, "padella": { "yield": 0.80 } },
|
||||
|
||||
"hamburger": { "padella": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.85 } },
|
||||
"pollo petto": { "bollitura": { "yield": 0.90 }, "padella": { "yield": 0.83 }, "forno": { "yield": 0.67 }, "friggitrice ad aria": { "yield": 0.63 } },
|
||||
"tacchino fesa": { "bollitura": { "yield": 0.94 }, "padella": { "yield": 0.85 }, "forno": { "yield": 0.69 }, "friggitrice ad aria": { "yield": 0.65 } },
|
||||
"vitello magro": { "friggitrice ad aria": { "yield": 0.51 } },
|
||||
"frittata semplice": { "padella": { "yield": 0.87 } },
|
||||
"uovo al tegamino": { "padella": { "yield": 0.90 } },
|
||||
|
||||
"merluzzo": { "bollitura": { "yield": 0.86 }, "forno": { "yield": 0.70 }, "friggitrice ad aria": { "yield": 0.66 } },
|
||||
"sogliola": { "friggitrice ad aria": { "yield": 0.66 } },
|
||||
"spigola": { "bollitura": { "yield": 0.86 }, "forno": { "yield": 0.75 }, "friggitrice ad aria": { "yield": 0.71 } },
|
||||
"tonno": { "padella": { "yield": 0.78 }, "forno": { "yield": 0.74 }, "friggitrice ad aria": { "yield": 0.70 } }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -1,381 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Convertitore</h1>
|
||||
<p class="page-subtitle">Calcola il peso cotto dal crudo e viceversa</p>
|
||||
</div>
|
||||
|
||||
<!-- step 1: ricerca -->
|
||||
<div class="search-wrapper">
|
||||
<div class="search-field">
|
||||
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Cerca alimento…"
|
||||
@input="onSearch"
|
||||
:disabled="!!selected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul v-if="results.length && !selected" class="results-list">
|
||||
<li
|
||||
v-for="r in results"
|
||||
:key="r.key"
|
||||
class="result-item"
|
||||
@click="selectItem(r)"
|
||||
>
|
||||
<span class="result-food">{{ capFirst(r.food) }}</span>
|
||||
<span class="result-method">{{ capFirst(r.method) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- step 2: converter card -->
|
||||
<div v-if="selected" class="converter-card">
|
||||
<div class="card-top">
|
||||
<div class="food-info">
|
||||
<span class="food-name">{{ capFirst(selected.food) }}</span>
|
||||
<span class="food-sep">·</span>
|
||||
<span class="food-method">{{ capFirst(selected.method) }}</span>
|
||||
</div>
|
||||
<button class="btn-reset" @click="reset" aria-label="Cambia alimento">
|
||||
Cambia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-divider" />
|
||||
|
||||
<!-- riga input → swap → output -->
|
||||
<div class="calc-row">
|
||||
<div class="calc-side input-side">
|
||||
<div class="calc-label">{{ direction === 'rawToCooked' ? 'crudo' : 'cotto' }}</div>
|
||||
<div class="calc-input-wrap">
|
||||
<input
|
||||
v-model.number="grams"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
class="calc-input"
|
||||
/>
|
||||
<span class="calc-unit">g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<div class="calc-side output-side">
|
||||
<div class="calc-label">{{ direction === 'rawToCooked' ? 'cotto' : 'crudo' }}</div>
|
||||
<div class="calc-output">
|
||||
<span v-if="result !== null" class="output-value">{{ result }}</span>
|
||||
<span v-else class="output-placeholder">—</span>
|
||||
<span v-if="result !== null" class="calc-unit">g</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result !== null" class="card-footer">
|
||||
fattore di resa {{ yieldValue }} · {{ direction === 'rawToCooked' ? 'da mangiare cotti' : 'peso crudo equivalente' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- stato iniziale -->
|
||||
<div v-if="!selected && !results.length && !query" class="hint-state">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<p>Cerca un alimento per iniziare</p>
|
||||
<p class="hint-sub">es. pollo, riso, zucchine</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import db from '../data/conversions.json'
|
||||
import { rawToCooked, cookedToRaw } from '../utils/conversion.js'
|
||||
|
||||
const query = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const direction = ref('rawToCooked')
|
||||
const grams = ref(null)
|
||||
|
||||
const yieldValue = computed(() => {
|
||||
if (!selected.value) return ''
|
||||
return db[selected.value.food][selected.value.method].yield
|
||||
})
|
||||
|
||||
const result = computed(() => {
|
||||
if (!selected.value || !grams.value || grams.value <= 0) return null
|
||||
const { food, method } = selected.value
|
||||
const val = direction.value === 'rawToCooked'
|
||||
? rawToCooked(food, method, grams.value, db)
|
||||
: cookedToRaw(food, method, grams.value, db)
|
||||
return Math.round(val * 10) / 10
|
||||
})
|
||||
|
||||
function onSearch() {
|
||||
const q = query.value.toLowerCase().trim()
|
||||
if (!q) { results.value = []; return }
|
||||
const matches = []
|
||||
for (const food of Object.keys(db)) {
|
||||
if (food.includes(q)) {
|
||||
for (const method of Object.keys(db[food])) {
|
||||
matches.push({ key: `${food}-${method}`, food, method })
|
||||
}
|
||||
}
|
||||
}
|
||||
results.value = matches
|
||||
}
|
||||
|
||||
function selectItem(r) {
|
||||
selected.value = r
|
||||
query.value = ''
|
||||
results.value = []
|
||||
grams.value = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selected.value = null
|
||||
query.value = ''
|
||||
results.value = []
|
||||
grams.value = null
|
||||
direction.value = 'rawToCooked'
|
||||
}
|
||||
|
||||
function capFirst(s) {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
|
||||
}
|
||||
|
||||
function swapDirection() {
|
||||
direction.value = direction.value === 'rawToCooked' ? 'cookedToRaw' : 'rawToCooked'
|
||||
grams.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── ricerca ───────────────────────────────────────── */
|
||||
.search-wrapper { margin-bottom: 16px; }
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--color-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.result-item:last-child { border-bottom: none; }
|
||||
.result-item:active { background: var(--color-bg); }
|
||||
.result-food { font-weight: 600; }
|
||||
.result-method { font-size: 0.85rem; color: var(--color-muted); }
|
||||
|
||||
/* ── converter card ───────────────────────────────── */
|
||||
.converter-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.food-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.food-name { font-weight: 700; font-size: 1rem; }
|
||||
.food-sep { color: var(--color-border); font-size: 1.1rem; }
|
||||
.food-method { font-size: 0.9rem; color: var(--color-muted); }
|
||||
|
||||
.btn-reset {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
min-height: unset;
|
||||
padding: 4px 10px;
|
||||
border: 1.5px solid var(--color-primary-muted);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
/* ── riga calcolatrice ───────────────────────────── */
|
||||
.calc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calc-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calc-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.calc-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* override globale per input dentro la card */
|
||||
.calc-input {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
padding: 4px 0;
|
||||
min-height: unset;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.calc-input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.calc-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
align-self: flex-end;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
/* bottone swap centrale */
|
||||
.btn-swap {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-primary);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid var(--color-border);
|
||||
transition: background var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.btn-swap:active {
|
||||
background: var(--color-primary-muted);
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* colonna output: underline visivo per simmetria con il lato input */
|
||||
.calc-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 44px;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.output-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.output-placeholder {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
color: var(--color-border);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── footer card ─────────────────────────────────── */
|
||||
.card-footer {
|
||||
padding: 10px 16px 14px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── stato iniziale ──────────────────────────────── */
|
||||
.hint-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.hint-sub { font-size: 0.85rem; opacity: 0.6; }
|
||||
</style>
|
||||
@@ -1,505 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Piano Pasti</h1>
|
||||
<p class="page-subtitle">{{ todayDate }}</p>
|
||||
</div>
|
||||
<MealCard
|
||||
v-for="day in days"
|
||||
:key="day.id"
|
||||
:day-name="day.label"
|
||||
:meals="meals[day.id]"
|
||||
:default-open="day.id === todayId"
|
||||
@add="(slot, text) => addItem(day.id, slot, text)"
|
||||
@remove="(slot, idx) => removeItem(day.id, slot, idx)"
|
||||
/>
|
||||
<button class="btn-generate" @click="generateShopping">
|
||||
<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>
|
||||
Genera lista della spesa
|
||||
</button>
|
||||
|
||||
<button v-if="hasMeals" class="btn-clear" @click="clearAll">Svuota piano</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, ref, computed, onUnmounted, nextTick } from 'vue'
|
||||
import MealCard from '../components/MealCard.vue'
|
||||
import { save, load } from '../utils/storage.js'
|
||||
import QRCode from 'qrcode'
|
||||
import jsQR from 'jsqr'
|
||||
|
||||
const emit = defineEmits(['go-shop'])
|
||||
|
||||
const days = [
|
||||
{ id: 'lunedi', label: 'Lunedì' },
|
||||
{ id: 'martedi', label: 'Martedì' },
|
||||
{ id: 'mercoledi', label: 'Mercoledì' },
|
||||
{ id: 'giovedi', label: 'Giovedì' },
|
||||
{ id: 'venerdi', label: 'Venerdì' },
|
||||
{ id: 'sabato', label: 'Sabato' },
|
||||
{ id: 'domenica', label: 'Domenica' },
|
||||
]
|
||||
|
||||
const todayMap = ['domenica', 'lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato']
|
||||
const todayId = todayMap[new Date().getDay()]
|
||||
const todayDate = 'Oggi, ' + new Date().toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
const defaultMeals = () =>
|
||||
Object.fromEntries(days.map(d => [d.id, { colazione: [], pranzo: [], cena: [] }]))
|
||||
|
||||
const meals = reactive(load('meals', defaultMeals()))
|
||||
|
||||
watch(meals, () => save('meals', meals), { deep: true })
|
||||
|
||||
function addItem(day, slot, text) {
|
||||
const t = text.trim()
|
||||
if (t) meals[day][slot].push(t)
|
||||
}
|
||||
|
||||
function removeItem(day, slot, idx) {
|
||||
meals[day][slot].splice(idx, 1)
|
||||
}
|
||||
|
||||
const hasMeals = computed(() =>
|
||||
days.some(d => ['colazione', 'pranzo', 'cena'].some(s => meals[d.id][s].length > 0))
|
||||
)
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Svuotare tutto il piano settimanale?')) Object.assign(meals, defaultMeals())
|
||||
}
|
||||
|
||||
function generateShopping() {
|
||||
const allItems = days.flatMap(d =>
|
||||
['colazione', 'pranzo', 'cena'].flatMap(slot => meals[d.id][slot])
|
||||
)
|
||||
const existing = load('shopping', [])
|
||||
const existingNames = new Set(existing.map(i => i.name.toLowerCase()))
|
||||
const seen = new Set()
|
||||
const toAdd = []
|
||||
for (const name of allItems) {
|
||||
const key = name.toLowerCase()
|
||||
if (!existingNames.has(key) && !seen.has(key)) {
|
||||
seen.add(key)
|
||||
toAdd.push({ id: Date.now() + Math.random(), name, checked: false })
|
||||
}
|
||||
}
|
||||
save('shopping', [...existing, ...toAdd])
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.btn-generate {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.btn-generate:active {
|
||||
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>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Lista della spesa</h1>
|
||||
<p class="page-subtitle" v-if="items.length">
|
||||
{{ checkedCount }} / {{ items.length }} completat{{ checkedCount === 1 ? 'o' : 'i' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="newItem"
|
||||
type="text"
|
||||
placeholder="Aggiungi elemento..."
|
||||
@keyup.enter="add"
|
||||
/>
|
||||
<button class="btn-add" @click="add" aria-label="Aggiungi">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="items.length">
|
||||
<ul class="shop-list">
|
||||
<CheckboxItem
|
||||
v-for="item in pendingItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle="toggle(item.id)"
|
||||
@remove="remove(item.id)"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<template v-if="checkedCount > 0">
|
||||
<div class="section-divider">
|
||||
<span>Completati ({{ checkedCount }})</span>
|
||||
</div>
|
||||
<ul class="shop-list muted">
|
||||
<CheckboxItem
|
||||
v-for="item in checkedItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle="toggle(item.id)"
|
||||
@remove="remove(item.id)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<button class="btn-clear" @click="clearAll">Svuota lista</button>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--color-border)">
|
||||
<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>
|
||||
<p>Lista vuota</p>
|
||||
<p class="empty-hint">Aggiungi qualcosa con il campo qui sopra.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import CheckboxItem from '../components/CheckboxItem.vue'
|
||||
import { save, load } from '../utils/storage.js'
|
||||
|
||||
const items = ref(load('shopping', []))
|
||||
const newItem = ref('')
|
||||
|
||||
const pendingItems = computed(() => items.value.filter(i => !i.checked))
|
||||
const checkedItems = computed(() => items.value.filter(i => i.checked))
|
||||
const checkedCount = computed(() => checkedItems.value.length)
|
||||
|
||||
watch(items, () => save('shopping', items.value), { deep: true })
|
||||
|
||||
function add() {
|
||||
const t = newItem.value.trim()
|
||||
if (!t) return
|
||||
items.value.push({ id: Date.now(), name: t, checked: false })
|
||||
newItem.value = ''
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const item = items.value.find(i => i.id === id)
|
||||
if (item) item.checked = !item.checked
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Svuotare tutta la lista?')) items.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.shop-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.section-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #f0f4f1;
|
||||
--color-surface: #ffffff;
|
||||
--color-primary: #2d6a4f;
|
||||
--color-primary-light: #52b788;
|
||||
--color-primary-muted: #e8f5ee;
|
||||
--color-text: #1a1a1a;
|
||||
--color-muted: #6b7280;
|
||||
--color-border: #e2e8e4;
|
||||
--color-danger: #dc2626;
|
||||
--color-danger-muted: #fef2f2;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-full: 999px;
|
||||
--nav-height: 64px;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 16px;
|
||||
padding-bottom: calc(var(--nav-height) + 20px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
transition: opacity var(--transition), background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
button:active { opacity: 0.75; }
|
||||
|
||||
/* bottone distruttivo in ghost style — meno aggressivo del solid rosso */
|
||||
.btn-clear {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
border: 1.5px solid var(--color-danger);
|
||||
margin-top: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-clear:active {
|
||||
background: var(--color-danger-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
font-size: 1rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #b0bab4;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield
|
||||
export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield
|
||||
@@ -1,5 +0,0 @@
|
||||
export const save = (key, val) => localStorage.setItem(key, JSON.stringify(val))
|
||||
export const load = (key, def) => {
|
||||
const v = localStorage.getItem(key)
|
||||
return v ? JSON.parse(v) : def
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
import { describe, it, expect, vi } 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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MealPlanner — svuota piano', () => {
|
||||
it('non mostra il pulsante se il piano è vuoto', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.btn-clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('mostra il pulsante se c\'è almeno un pasto', () => {
|
||||
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.btn-clear').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('svuota il piano dopo conferma', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.colazione).toHaveLength(0)
|
||||
expect(saved.lunedi.pranzo).toHaveLength(0)
|
||||
expect(w.find('.btn-clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('non svuota se l\'utente annulla', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(load('meals', {}).lunedi.colazione).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Svuota localStorage prima e dopo ogni test per garantire isolamento
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => localStorage.clear())
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
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/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user