Modalità cucina: timer multipli persistenti con etichetta, riprendi dal passo salvato, progress dots, pulsante Ricomincia; priorità ricette basata su scadenze con ingredienti obbligatori
This commit is contained in:
+77
-24
@@ -1359,8 +1359,37 @@ function generateRecipe(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build ingredient list with expiry info
|
||||
$ingredientLines = [];
|
||||
// Helper to compute priority group for an item:
|
||||
// 1=scaduto, 2=scadenza imminente ≤3gg, 3=scadenza ravvicinata ≤7gg, 4=scadenza lontana, 5=confezione aperta, 6=chiuso
|
||||
$getItemPriority = function($item) {
|
||||
$daysLeft = floatval($item['days_left']);
|
||||
$isOpen = (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf');
|
||||
if (!empty($item['expiry_date']) && $daysLeft < 0) return 1;
|
||||
if (!empty($item['expiry_date']) && $daysLeft <= 3) return 2;
|
||||
if (!empty($item['expiry_date']) && $daysLeft <= 7) return 3;
|
||||
if (!empty($item['expiry_date'])) return 4;
|
||||
if ($isOpen) return 5;
|
||||
return 6;
|
||||
};
|
||||
|
||||
// Sort by priority group, then by days_left within each group
|
||||
usort($items, function($a, $b) use ($getItemPriority) {
|
||||
$pa = $getItemPriority($a);
|
||||
$pb = $getItemPriority($b);
|
||||
if ($pa !== $pb) return $pa - $pb;
|
||||
return floatval($a['days_left']) - floatval($b['days_left']);
|
||||
});
|
||||
|
||||
// Build ingredient list grouped by priority
|
||||
$priorityHeaders = [
|
||||
1 => '⚠️ PRODOTTI SCADUTI (PRIORITÀ MASSIMA - USA SUBITO SE ANCORA COMMESTIBILI)',
|
||||
2 => '🔴 SCADENZA IMMINENTE (entro 3 giorni - USA PER PRIMI)',
|
||||
3 => '🟠 SCADENZA RAVVICINATA (entro 7 giorni)',
|
||||
4 => '🟡 ALTRI PRODOTTI CON SCADENZA',
|
||||
5 => '📦 CONFEZIONI APERTE (da consumare prima dei chiusi)',
|
||||
6 => '🟢 ALTRI PRODOTTI',
|
||||
];
|
||||
$priorityGroups = [];
|
||||
foreach ($items as $item) {
|
||||
$line = "- {$item['name']}";
|
||||
if ($item['brand']) $line .= " ({$item['brand']})";
|
||||
@@ -1371,27 +1400,44 @@ function generateRecipe(PDO $db): void {
|
||||
if ($item['expiry_date']) {
|
||||
$daysLeft = intval($item['days_left']);
|
||||
if ($daysLeft < 0) {
|
||||
$line .= " [SCADUTO da " . abs($daysLeft) . " giorni!]";
|
||||
} elseif ($daysLeft <= 3) {
|
||||
$line .= " [SCADE TRA $daysLeft GIORNI - PRIORITÀ ALTA!]";
|
||||
} elseif ($daysLeft <= 7) {
|
||||
$line .= " [scade tra $daysLeft giorni - priorità media]";
|
||||
$line .= " [SCADUTO da " . abs($daysLeft) . " giorni]";
|
||||
} else {
|
||||
$line .= " [scade tra $daysLeft giorni]";
|
||||
}
|
||||
}
|
||||
// Flag fridge items for priority
|
||||
if (strtolower($item['location']) === 'frigo') {
|
||||
$line .= " [IN FRIGO - PRIORITÀ]";
|
||||
$line .= " [FRIGO]";
|
||||
}
|
||||
// Flag opened packages (fractional quantity = already opened)
|
||||
$qty = floatval($item['quantity']);
|
||||
if ($qty > 0 && $qty < 1 && $item['unit'] === 'conf') {
|
||||
$line .= " [CONFEZIONE APERTA - USA PRIMA]";
|
||||
$line .= " [APERTO]";
|
||||
}
|
||||
$line .= " (in {$item['location']})";
|
||||
$ingredientLines[] = $line;
|
||||
|
||||
$group = $getItemPriority($item);
|
||||
$priorityGroups[$group][] = $line;
|
||||
}
|
||||
|
||||
$ingredientsText = implode("\n", $ingredientLines);
|
||||
$ingredientSections = [];
|
||||
foreach ($priorityHeaders as $g => $header) {
|
||||
if (!empty($priorityGroups[$g])) {
|
||||
$ingredientSections[] = "=== {$header} ===\n" . implode("\n", $priorityGroups[$g]);
|
||||
}
|
||||
}
|
||||
$ingredientsText = implode("\n\n", $ingredientSections);
|
||||
|
||||
// Build a mandatory-use list from the most urgent items (groups 1 + 2)
|
||||
$urgentItems = [];
|
||||
foreach ($items as $item) {
|
||||
$g = $getItemPriority($item);
|
||||
if ($g <= 2) {
|
||||
$urgentItems[] = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}";
|
||||
}
|
||||
}
|
||||
$mustUseText = '';
|
||||
if (!empty($urgentItems)) {
|
||||
$mustUseText = "\n\n⚠️⚠️⚠️ INGREDIENTI OBBLIGATORI (SCADUTI O IN SCADENZA IMMINENTE) ⚠️⚠️⚠️\nLa ricetta DEVE usare ALMENO uno (meglio se tutti) di questi ingredienti come ingrediente PRINCIPALE della ricetta. Non sono opzionali!\n" . implode("\n", array_map(fn($n) => "→ $n", $urgentItems));
|
||||
}
|
||||
|
||||
$mealLabels = [
|
||||
'colazione' => 'colazione (mattina)',
|
||||
@@ -1409,7 +1455,7 @@ function generateRecipe(PDO $db): void {
|
||||
'pocafame' => 'L\'utente ha POCA FAME: proponi una porzione leggera, magari uno snack, un\'insalata o qualcosa di semplice e poco abbondante.',
|
||||
'scadenze' => 'PRIORITÀ SCADENZE: usa ASSOLUTAMENTE per primi gli ingredienti più vicini alla scadenza o già scaduti (se ancora commestibili).',
|
||||
'salutare' => 'Ricetta EXTRA SALUTARE: prediligi ingredienti integrali, tante verdure, pochi grassi, cotture leggere.',
|
||||
'opened' => 'PRIORITÀ COSE APERTE: dai la MASSIMA PRIORITÀ ai prodotti con confezione aperta (contrassegnati [CONFEZIONE APERTA]) e a quelli in FRIGO (contrassegnati [IN FRIGO]). Questi prodotti si deteriorano più in fretta e DEVONO essere usati per primi. Costruisci la ricetta attorno a questi ingredienti.',
|
||||
'opened' => 'PRIORITÀ COSE APERTE: dai la MASSIMA PRIORITÀ ai prodotti con confezione aperta (contrassegnati [APERTO]) e a quelli in FRIGO (contrassegnati [FRIGO]). Questi prodotti si deteriorano più in fretta e DEVONO essere usati per primi. Costruisci la ricetta attorno a questi ingredienti.',
|
||||
'zerowaste' => 'ZERO SPRECHI: cerca di usare quanti più ingredienti in scadenza possibile, combina anche ingredienti insoliti pur di non sprecare nulla.'
|
||||
];
|
||||
foreach ($options as $opt) {
|
||||
@@ -1474,18 +1520,25 @@ function generateRecipe(PDO $db): void {
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando PRINCIPALMENTE gli ingredienti disponibili nella dispensa dell'utente.
|
||||
{$extraRulesText}{$appliancesText}{$dietaryText}{$varietyText}
|
||||
{$extraRulesText}{$appliancesText}{$dietaryText}{$varietyText}{$mustUseText}
|
||||
|
||||
REGOLE IMPORTANTI:
|
||||
1. PRIORITÀ ASSOLUTA: usa prima gli ingredienti in scadenza o già scaduti (se ancora utilizzabili)
|
||||
2. SUGGERIMENTO (non obbligatorio): quando possibile, preferisci ingredienti in FRIGO (contrassegnati [IN FRIGO]) e quelli con CONFEZIONE APERTA (contrassegnati [CONFEZIONE APERTA]) perché si deteriorano più in fretta. Ma se la ricetta migliore usa altri ingredienti, va benissimo.
|
||||
3. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE
|
||||
4. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili
|
||||
5. Adatta le quantità per $persons persona/e
|
||||
6. Se non ci sono abbastanza ingredienti per una ricetta completa, suggerisci la migliore combinazione possibile
|
||||
7. La ricetta deve essere adatta al pasto: $mealLabel
|
||||
8. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Le unità ammesse sono SOLO: g (grammi), ml (millilitri), pz (pezzi), conf (confezioni). NON usare mai kg o litri. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2000 g" e servono 300g, qty_number = 300. Per ingredienti non dalla dispensa, qty_number = 0.
|
||||
9. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?"
|
||||
1. ORDINE DI PRIORITÀ INGREDIENTI (dal più urgente al meno urgente) — gli ingredienti nella lista sono già ordinati per priorità:
|
||||
a) PRODOTTI SCADUTI: se ancora commestibili, usali SUBITO — hanno la priorità massima assoluta
|
||||
b) PRODOTTI IN SCADENZA IMMINENTE (≤3 giorni): usa questi per primi dopo gli scaduti
|
||||
c) PRODOTTI IN SCADENZA RAVVICINATA (≤7 giorni): poi questi
|
||||
d) PRODOTTI CON SCADENZA PIÙ LONTANA: poi questi
|
||||
e) CONFEZIONI APERTE (contrassegnate [APERTO]): preferiscile rispetto a quelle chiuse
|
||||
f) PRODOTTI CHIUSI SENZA SCADENZA: usa per ultimi
|
||||
Costruisci la ricetta partendo dagli ingredienti delle categorie più urgenti! Usa il maggior numero possibile di ingredienti ad alta priorità.
|
||||
*** OBBLIGO: se nella sezione "INGREDIENTI OBBLIGATORI" sopra ci sono prodotti, la ricetta DEVE contenere ALMENO UNO di quei prodotti come ingrediente principale. Se li ignori, la ricetta è SBAGLIATA. ***
|
||||
2. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE
|
||||
3. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili
|
||||
4. Adatta le quantità per $persons persona/e
|
||||
5. Se non ci sono abbastanza ingredienti per una ricetta completa, suggerisci la migliore combinazione possibile
|
||||
6. La ricetta deve essere adatta al pasto: $mealLabel
|
||||
7. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Le unità ammesse sono SOLO: g (grammi), ml (millilitri), pz (pezzi), conf (confezioni). NON usare mai kg o litri. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2000 g" e servono 300g, qty_number = 300. Per ingredienti non dalla dispensa, qty_number = 0.
|
||||
8. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?"
|
||||
|
||||
INGREDIENTI DISPONIBILI IN DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
+141
-43
@@ -3029,6 +3029,15 @@ body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cooking-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cooking-step-num {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
@@ -3038,6 +3047,51 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cooking-restart-btn {
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
color: rgba(255,255,255,0.45);
|
||||
border-radius: 14px;
|
||||
padding: 4px 11px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cooking-restart-btn:active {
|
||||
background: rgba(255,255,255,0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Progress dots */
|
||||
.cooking-progress-dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
}
|
||||
.cprog-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.18);
|
||||
border: 1.5px solid rgba(255,255,255,0.18);
|
||||
transition: background 0.25s, border-color 0.25s, transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cprog-dot.visited {
|
||||
background: rgba(255,255,255,0.50);
|
||||
border-color: rgba(255,255,255,0.50);
|
||||
}
|
||||
.cprog-dot.current {
|
||||
background: #f59e0b;
|
||||
border-color: #fbbf24;
|
||||
transform: scale(1.35);
|
||||
}
|
||||
|
||||
.cooking-step-text {
|
||||
font-size: clamp(1.4rem, 5vw, 2.2rem);
|
||||
line-height: 1.5;
|
||||
@@ -3068,30 +3122,55 @@ body {
|
||||
}
|
||||
|
||||
.cooking-timer-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin-top: 4px;
|
||||
display: none; /* legacy - replaced by cooking-timers-bar */
|
||||
}
|
||||
|
||||
.cooking-timer-display {
|
||||
font-size: clamp(2.4rem, 10vw, 4rem);
|
||||
/* ===== Persistent timers bar (fixed below header, survives step changes) ===== */
|
||||
.cooking-timers-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
overflow-x: auto;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.10);
|
||||
flex-shrink: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.cooking-timers-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.cooking-timer-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,0.09);
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
border-radius: 14px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctimer-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.60);
|
||||
max-width: 85px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ctimer-display {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #fff;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.03em;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
|
||||
.cooking-timer-display.timer-warning {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.cooking-timer-display.timer-done {
|
||||
.ctimer-display.ctimer-warning { color: #f97316; }
|
||||
.ctimer-display.ctimer-done {
|
||||
color: #ef4444;
|
||||
animation: timerPulse 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@@ -3101,41 +3180,60 @@ body {
|
||||
to { opacity: 0.5; transform: scale(1.06); }
|
||||
}
|
||||
|
||||
.cooking-timer-actions {
|
||||
.ctimer-btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.ctimer-btn {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.80rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctimer-btn:active { background: rgba(255,255,255,0.25); }
|
||||
.ctimer-toggle.running {
|
||||
background: rgba(239,68,68,0.28);
|
||||
border-color: rgba(239,68,68,0.55);
|
||||
}
|
||||
.ctimer-remove {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-color: rgba(255,255,255,0.10);
|
||||
color: rgba(255,255,255,0.45);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.ctimer-remove:active { background: rgba(239,68,68,0.25); }
|
||||
|
||||
.cooking-timer-btn {
|
||||
/* ===== Step timer suggestion button ===== */
|
||||
.cooking-timer-suggest {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.cooking-timer-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.10);
|
||||
border: 1px solid rgba(255,255,255,0.20);
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 8px 20px;
|
||||
font-size: 1rem;
|
||||
background: rgba(251,191,36,0.12);
|
||||
border: 1.5px dashed rgba(251,191,36,0.45);
|
||||
color: #fbbf24;
|
||||
border-radius: 22px;
|
||||
padding: 10px 22px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cooking-timer-btn:active {
|
||||
background: rgba(255,255,255,0.22);
|
||||
}
|
||||
|
||||
.cooking-timer-btn.timer-running {
|
||||
background: rgba(239,68,68,0.25);
|
||||
border-color: rgba(239,68,68,0.5);
|
||||
}
|
||||
|
||||
.cooking-timer-reset {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cooking-timer-add-btn:active { background: rgba(251,191,36,0.26); }
|
||||
|
||||
.cooking-step-ings {
|
||||
width: 100%;
|
||||
|
||||
+171
-94
@@ -6095,6 +6095,7 @@ function renderRecipe(r) {
|
||||
let _cookingRecipe = null;
|
||||
let _cookingStep = 0;
|
||||
let _cookingTTS = true;
|
||||
let _cookingVisited = new Set(); // indices of steps already seen
|
||||
|
||||
function startCookingMode() {
|
||||
const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null;
|
||||
@@ -6102,8 +6103,14 @@ function startCookingMode() {
|
||||
showToast('Nessun procedimento disponibile', 'info');
|
||||
return;
|
||||
}
|
||||
_cookingRecipe = JSON.parse(JSON.stringify(recipe)); // deep copy so we can track .used
|
||||
// Resume if same recipe; otherwise start fresh
|
||||
const isSame = _cookingRecipe && _cookingRecipe.title === recipe.title;
|
||||
if (!isSame) {
|
||||
_cookingRecipe = JSON.parse(JSON.stringify(recipe));
|
||||
_cookingStep = 0;
|
||||
_cookingVisited = new Set();
|
||||
clearAllCookingTimers();
|
||||
}
|
||||
_cookingTTS = true;
|
||||
document.getElementById('cooking-title').textContent = _cookingRecipe.title || '';
|
||||
document.getElementById('cooking-tts-btn').textContent = '🔊';
|
||||
@@ -6138,10 +6145,18 @@ function closeCookingMode() {
|
||||
document.getElementById('cooking-overlay').style.display = 'none';
|
||||
document.body.classList.remove('cooking-mode-active');
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
clearCookingTimer();
|
||||
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
|
||||
// so the user can resume from the same step when they reopen
|
||||
try { screen.orientation?.unlock(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function restartCookingMode() {
|
||||
_cookingStep = 0;
|
||||
_cookingVisited = new Set();
|
||||
clearAllCookingTimers();
|
||||
renderCookingStep();
|
||||
}
|
||||
|
||||
function renderCookingStep() {
|
||||
if (!_cookingRecipe) return;
|
||||
const steps = _cookingRecipe.steps || [];
|
||||
@@ -6149,9 +6164,23 @@ function renderCookingStep() {
|
||||
const cleanStep = step.replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
|
||||
const total = steps.length;
|
||||
|
||||
// Mark current step as visited
|
||||
_cookingVisited.add(_cookingStep);
|
||||
|
||||
document.getElementById('cooking-step-num').textContent = `${_cookingStep + 1} / ${total}`;
|
||||
document.getElementById('cooking-step-text').textContent = cleanStep;
|
||||
|
||||
// Progress dots
|
||||
const dotsEl = document.getElementById('cooking-progress-dots');
|
||||
if (dotsEl) {
|
||||
dotsEl.innerHTML = Array.from({ length: total }, (_, i) => {
|
||||
let cls = 'cprog-dot';
|
||||
if (i === _cookingStep) cls += ' current';
|
||||
else if (_cookingVisited.has(i)) cls += ' visited';
|
||||
return `<span class="${cls}"></span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Find pantry ingredients that appear in this step's text and haven't been used yet
|
||||
const stepLower = cleanStep.toLowerCase();
|
||||
const ings = (_cookingRecipe.ingredients || [])
|
||||
@@ -6184,8 +6213,8 @@ function renderCookingStep() {
|
||||
prevBtn.disabled = _cookingStep === 0;
|
||||
nextBtn.textContent = _cookingStep === total - 1 ? '✅ Fine' : 'Successivo ▶';
|
||||
|
||||
// Timer: detect durations in step text
|
||||
setupCookingTimer(cleanStep);
|
||||
// Timer: detect duration in step text and show suggestion
|
||||
setupCookingTimerSuggestion(cleanStep);
|
||||
|
||||
// Speak step
|
||||
if (_cookingTTS) speakCookingStep(cleanStep);
|
||||
@@ -6230,139 +6259,185 @@ function replayCookingTTS() {
|
||||
if (text) speakCookingStep(text);
|
||||
}
|
||||
|
||||
// ===== COOKING TIMER =====
|
||||
let _cookingTimerInterval = null;
|
||||
let _cookingTimerSeconds = 0;
|
||||
let _cookingTimerRunning = false;
|
||||
let _cookingTimerTotal = 0; // original total seconds
|
||||
// ===== COOKING TIMER SYSTEM =====
|
||||
let _cookingTimers = []; // { id, label, total, seconds, running, interval }
|
||||
let _cookingTimerIdCounter = 0;
|
||||
let _cookingSuggestedSeconds = 0;
|
||||
let _cookingSuggestedLabel = '';
|
||||
|
||||
/**
|
||||
* Parse time durations from step text.
|
||||
* Returns total seconds or 0 if no time found.
|
||||
* Handles: "10 minuti", "1 ora", "1 ora e 30 minuti", "30 secondi",
|
||||
* "un'ora", "mezz'ora", "un paio di minuti", "qualche minuto"
|
||||
*/
|
||||
function _parseStepTimer(text) {
|
||||
const t = text.toLowerCase();
|
||||
let totalSec = 0;
|
||||
|
||||
// "mezz'ora" / "mezzora"
|
||||
if (/mezz['']?\s*ora/i.test(t)) totalSec += 30 * 60;
|
||||
// "un quarto d'ora"
|
||||
if (/un\s+quarto\s+d['']?\s*ora/i.test(t)) totalSec += 15 * 60;
|
||||
// "un'ora" (without other numbers)
|
||||
if (/un['']?\s*ora(?!\s*e)/i.test(t) && !/\d\s*or[ae]/i.test(t)) totalSec += 60 * 60;
|
||||
|
||||
if (totalSec > 0) return totalSec;
|
||||
|
||||
// Numeric patterns: "N ore/ora [e M minuti]", "N minuti/min", "N secondi/sec"
|
||||
const reOre = /(\d+(?:[.,]\d+)?)\s*or[ae]/gi;
|
||||
const reMin = /(\d+(?:[.,]\d+)?)\s*min(?:ut[oi])?/gi;
|
||||
const reSec = /(\d+(?:[.,]\d+)?)\s*second[oi]/gi;
|
||||
|
||||
let m;
|
||||
while ((m = reOre.exec(t)) !== null) totalSec += parseFloat(m[1].replace(',', '.')) * 3600;
|
||||
while ((m = reMin.exec(t)) !== null) totalSec += parseFloat(m[1].replace(',', '.')) * 60;
|
||||
while ((m = reSec.exec(t)) !== null) totalSec += parseFloat(m[1].replace(',', '.'));
|
||||
|
||||
// "un paio di minuti" / "qualche minuto" / "pochi minuti"
|
||||
if (totalSec === 0 && /(?:un\s+paio\s+di|qualche|pochi)\s+minut/i.test(t)) totalSec = 2 * 60;
|
||||
// "qualche secondo"
|
||||
if (totalSec === 0 && /qualche\s+second/i.test(t)) totalSec = 15;
|
||||
|
||||
return Math.round(totalSec);
|
||||
}
|
||||
|
||||
function _formatTimerDisplay(sec) {
|
||||
const m = Math.floor(Math.abs(sec) / 60);
|
||||
const s = Math.abs(sec) % 60;
|
||||
const sign = sec < 0 ? '-' : '';
|
||||
const abs = Math.abs(sec);
|
||||
const m = Math.floor(abs / 60);
|
||||
const s = abs % 60;
|
||||
const sign = sec < 0 ? '+' : '';
|
||||
return `${sign}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function setupCookingTimer(stepText) {
|
||||
clearCookingTimer();
|
||||
/** Extract a short 2-3 word label from the step text for the timer. */
|
||||
function _extractTimerLabel(text, stepNum) {
|
||||
const fillers = new Set(['il','la','lo','le','gli','i','dell','della','dello','delle','degli','dei',
|
||||
'un','una','uno','del','al','alla','allo','alle','agli','ai','nel','nella','nello','nelle',
|
||||
'negli','nei','per','con','che','poi','e','o','non','se','in','di','a','da','fino','mentre',
|
||||
'quando','dopo','prima','circa','bene','ancora','subito','su','ad','ed','più','meno','tutto','tutta']);
|
||||
const timePatterns = [/mezz['']?\s*ora/i, /\bor[ae]\b/i, /\bmin(?:ut[oi])?\b/i, /\bsecond[oi]\b/i, /\bquarto\s+d['']?\s*ora/i];
|
||||
let timeIdx = text.length;
|
||||
for (const p of timePatterns) { const r = p.exec(text); if (r && r.index < timeIdx) timeIdx = r.index; }
|
||||
const beforeTime = (text.slice(0, timeIdx).trim() || text);
|
||||
const words = beforeTime.replace(/[.,!?;:'"()\[\]]/g, '').split(/\s+/).filter(w => w.length > 2 && !/^\d+$/.test(w));
|
||||
const meaningful = words.filter(w => !fillers.has(w.toLowerCase()));
|
||||
if (meaningful.length >= 1) return meaningful.slice(0, 3).join(' ');
|
||||
return `Passo ${stepNum + 1}`;
|
||||
}
|
||||
|
||||
function setupCookingTimerSuggestion(stepText) {
|
||||
const seconds = _parseStepTimer(stepText);
|
||||
const wrap = document.getElementById('cooking-timer-wrap');
|
||||
const suggestEl = document.getElementById('cooking-timer-suggest');
|
||||
if (seconds <= 0) {
|
||||
wrap.style.display = 'none';
|
||||
suggestEl.style.display = 'none';
|
||||
_cookingSuggestedSeconds = 0;
|
||||
_cookingSuggestedLabel = '';
|
||||
return;
|
||||
}
|
||||
_cookingTimerTotal = seconds;
|
||||
_cookingTimerSeconds = seconds;
|
||||
_cookingTimerRunning = false;
|
||||
wrap.style.display = 'flex';
|
||||
document.getElementById('cooking-timer-display').textContent = _formatTimerDisplay(seconds);
|
||||
document.getElementById('cooking-timer-display').className = 'cooking-timer-display';
|
||||
document.getElementById('cooking-timer-start').textContent = '⏱️ Avvia timer';
|
||||
document.getElementById('cooking-timer-start').classList.remove('timer-running');
|
||||
document.getElementById('cooking-timer-reset').style.display = 'none';
|
||||
_cookingSuggestedSeconds = seconds;
|
||||
_cookingSuggestedLabel = _extractTimerLabel(stepText, _cookingStep);
|
||||
document.getElementById('cooking-timer-suggest-text').textContent =
|
||||
`⏱️ ${_formatTimerDisplay(seconds)} · ${_cookingSuggestedLabel}`;
|
||||
suggestEl.style.display = 'flex';
|
||||
}
|
||||
|
||||
function toggleCookingTimer() {
|
||||
if (_cookingTimerRunning) {
|
||||
// Pause
|
||||
clearInterval(_cookingTimerInterval);
|
||||
_cookingTimerInterval = null;
|
||||
_cookingTimerRunning = false;
|
||||
document.getElementById('cooking-timer-start').textContent = '▶️ Riprendi';
|
||||
document.getElementById('cooking-timer-start').classList.remove('timer-running');
|
||||
document.getElementById('cooking-timer-reset').style.display = 'inline-flex';
|
||||
} else {
|
||||
// Start / Resume
|
||||
_cookingTimerRunning = true;
|
||||
document.getElementById('cooking-timer-start').textContent = '⏸️ Pausa';
|
||||
document.getElementById('cooking-timer-start').classList.add('timer-running');
|
||||
document.getElementById('cooking-timer-reset').style.display = 'inline-flex';
|
||||
_cookingTimerInterval = setInterval(() => {
|
||||
_cookingTimerSeconds--;
|
||||
const display = document.getElementById('cooking-timer-display');
|
||||
display.textContent = _formatTimerDisplay(Math.abs(_cookingTimerSeconds));
|
||||
function addSuggestedCookingTimer() {
|
||||
if (_cookingSuggestedSeconds <= 0) return;
|
||||
addCookingTimer(_cookingSuggestedSeconds, _cookingSuggestedLabel);
|
||||
document.getElementById('cooking-timer-suggest').style.display = 'none';
|
||||
_cookingSuggestedSeconds = 0;
|
||||
}
|
||||
|
||||
if (_cookingTimerSeconds <= 0 && _cookingTimerSeconds > -1) {
|
||||
// Timer just finished
|
||||
display.classList.add('timer-done');
|
||||
display.classList.remove('timer-warning');
|
||||
_cookingTimerDone();
|
||||
} else if (_cookingTimerSeconds > 0 && _cookingTimerSeconds <= 30) {
|
||||
display.classList.add('timer-warning');
|
||||
}
|
||||
if (_cookingTimerSeconds < 0) {
|
||||
// Counting overtime (negative)
|
||||
display.textContent = '+' + _formatTimerDisplay(Math.abs(_cookingTimerSeconds));
|
||||
}
|
||||
function addCookingTimer(seconds, label) {
|
||||
const id = ++_cookingTimerIdCounter;
|
||||
_cookingTimers.push({ id, label, total: seconds, seconds, running: false, interval: null });
|
||||
renderTimersBar();
|
||||
toggleCookingTimerById(id); // auto-start
|
||||
}
|
||||
|
||||
function removeCookingTimer(id) {
|
||||
const t = _cookingTimers.find(t => t.id === id);
|
||||
if (t && t.interval) clearInterval(t.interval);
|
||||
_cookingTimers = _cookingTimers.filter(t => t.id !== id);
|
||||
renderTimersBar();
|
||||
}
|
||||
|
||||
function toggleCookingTimerById(id) {
|
||||
const t = _cookingTimers.find(t => t.id === id);
|
||||
if (!t) return;
|
||||
if (t.running) {
|
||||
clearInterval(t.interval);
|
||||
t.interval = null;
|
||||
t.running = false;
|
||||
} else {
|
||||
t.running = true;
|
||||
t.interval = setInterval(() => {
|
||||
t.seconds--;
|
||||
if (t.seconds === 0) _cookingTimerDoneById(id);
|
||||
_updateTimerCard(id);
|
||||
}, 1000);
|
||||
}
|
||||
_updateTimerCard(id);
|
||||
}
|
||||
|
||||
function resetCookingTimer() {
|
||||
clearInterval(_cookingTimerInterval);
|
||||
_cookingTimerInterval = null;
|
||||
_cookingTimerRunning = false;
|
||||
_cookingTimerSeconds = _cookingTimerTotal;
|
||||
document.getElementById('cooking-timer-display').textContent = _formatTimerDisplay(_cookingTimerTotal);
|
||||
document.getElementById('cooking-timer-display').className = 'cooking-timer-display';
|
||||
document.getElementById('cooking-timer-start').textContent = '⏱️ Avvia timer';
|
||||
document.getElementById('cooking-timer-start').classList.remove('timer-running');
|
||||
document.getElementById('cooking-timer-reset').style.display = 'none';
|
||||
function resetCookingTimerById(id) {
|
||||
const t = _cookingTimers.find(t => t.id === id);
|
||||
if (!t) return;
|
||||
clearInterval(t.interval);
|
||||
t.interval = null;
|
||||
t.running = false;
|
||||
t.seconds = t.total;
|
||||
_updateTimerCard(id);
|
||||
}
|
||||
|
||||
function clearCookingTimer() {
|
||||
if (_cookingTimerInterval) clearInterval(_cookingTimerInterval);
|
||||
_cookingTimerInterval = null;
|
||||
_cookingTimerRunning = false;
|
||||
_cookingTimerSeconds = 0;
|
||||
_cookingTimerTotal = 0;
|
||||
}
|
||||
|
||||
function _cookingTimerDone() {
|
||||
// Vibrate if supported
|
||||
function _cookingTimerDoneById(id) {
|
||||
if (navigator.vibrate) navigator.vibrate([300, 100, 300, 100, 300]);
|
||||
// TTS announcement
|
||||
if (_cookingTTS) speakCookingStep('Tempo scaduto!');
|
||||
// Visual alert already handled by .timer-done CSS animation
|
||||
const t = _cookingTimers.find(t => t.id === id);
|
||||
if (_cookingTTS && t) speakCookingStep(`Timer ${t.label} scaduto!`);
|
||||
}
|
||||
// ===== END COOKING TIMER =====
|
||||
|
||||
function _updateTimerCard(id) {
|
||||
const t = _cookingTimers.find(t => t.id === id);
|
||||
if (!t) return;
|
||||
const card = document.getElementById(`ctimer-${id}`);
|
||||
if (!card) { renderTimersBar(); return; }
|
||||
const sec = t.seconds;
|
||||
const dispEl = card.querySelector('.ctimer-display');
|
||||
const toggleBtn = card.querySelector('.ctimer-toggle');
|
||||
dispEl.textContent = _formatTimerDisplay(sec);
|
||||
if (sec <= 0) {
|
||||
dispEl.className = 'ctimer-display ctimer-done';
|
||||
} else if (sec <= 30) {
|
||||
dispEl.className = 'ctimer-display ctimer-warning';
|
||||
} else {
|
||||
dispEl.className = 'ctimer-display';
|
||||
}
|
||||
toggleBtn.textContent = t.running ? '⏸' : '▶';
|
||||
toggleBtn.classList.toggle('running', t.running);
|
||||
}
|
||||
|
||||
function renderTimersBar() {
|
||||
const bar = document.getElementById('cooking-timers-bar');
|
||||
if (!bar) return;
|
||||
if (_cookingTimers.length === 0) {
|
||||
bar.style.display = 'none';
|
||||
bar.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'flex';
|
||||
bar.innerHTML = _cookingTimers.map(t => {
|
||||
const sec = t.seconds;
|
||||
const doneClass = sec <= 0 ? ' ctimer-done' : sec <= 30 ? ' ctimer-warning' : '';
|
||||
const runClass = t.running ? ' running' : '';
|
||||
return `<div class="cooking-timer-card" id="ctimer-${t.id}">
|
||||
<span class="ctimer-label">${escapeHtml(t.label)}</span>
|
||||
<span class="ctimer-display${doneClass}">${_formatTimerDisplay(sec)}</span>
|
||||
<div class="ctimer-btns">
|
||||
<button class="ctimer-btn ctimer-toggle${runClass}" onclick="toggleCookingTimerById(${t.id})">${t.running ? '⏸' : '▶'}</button>
|
||||
<button class="ctimer-btn ctimer-reset" onclick="resetCookingTimerById(${t.id})">↩</button>
|
||||
<button class="ctimer-btn ctimer-remove" onclick="removeCookingTimer(${t.id})">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function clearAllCookingTimers() {
|
||||
_cookingTimers.forEach(t => { if (t.interval) clearInterval(t.interval); });
|
||||
_cookingTimers = [];
|
||||
_cookingTimerIdCounter = 0;
|
||||
_cookingSuggestedSeconds = 0;
|
||||
_cookingSuggestedLabel = '';
|
||||
const bar = document.getElementById('cooking-timers-bar');
|
||||
if (bar) { bar.style.display = 'none'; bar.innerHTML = ''; }
|
||||
}
|
||||
// ===== END COOKING TIMER SYSTEM =====
|
||||
|
||||
function toggleCookingTTS() {
|
||||
_cookingTTS = !_cookingTTS;
|
||||
@@ -6383,6 +6458,8 @@ function navigateCookingStep(delta) {
|
||||
const next = _cookingStep + delta;
|
||||
if (next < 0) return;
|
||||
if (next >= total) {
|
||||
// All steps done: mark all visited, close overlay
|
||||
for (let i = 0; i < total; i++) _cookingVisited.add(i);
|
||||
closeCookingMode();
|
||||
return;
|
||||
}
|
||||
|
||||
+9
-6
@@ -970,16 +970,19 @@
|
||||
<span class="cooking-title" id="cooking-title"></span>
|
||||
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
|
||||
</div>
|
||||
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
|
||||
<div class="cooking-body">
|
||||
<div class="cooking-step-header">
|
||||
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
|
||||
<button class="cooking-restart-btn" onclick="restartCookingMode()" title="Ricomincia dall'inizio">↺ Ricomincia</button>
|
||||
</div>
|
||||
<div class="cooking-progress-dots" id="cooking-progress-dots"></div>
|
||||
<div class="cooking-step-text" id="cooking-step-text"></div>
|
||||
<button class="cooking-replay-btn" id="cooking-replay" onclick="replayCookingTTS()" title="Rileggi questo passo">🔊 Rileggi</button>
|
||||
<div class="cooking-timer-wrap" id="cooking-timer-wrap" style="display:none">
|
||||
<div class="cooking-timer-display" id="cooking-timer-display">00:00</div>
|
||||
<div class="cooking-timer-actions">
|
||||
<button class="cooking-timer-btn" id="cooking-timer-start" onclick="toggleCookingTimer()">⏱️ Avvia timer</button>
|
||||
<button class="cooking-timer-btn cooking-timer-reset" id="cooking-timer-reset" onclick="resetCookingTimer()" style="display:none">↩ Reset</button>
|
||||
</div>
|
||||
<div class="cooking-timer-suggest" id="cooking-timer-suggest" style="display:none">
|
||||
<button class="cooking-timer-add-btn" onclick="addSuggestedCookingTimer()">
|
||||
<span id="cooking-timer-suggest-text">⏱️ 00:00 · Timer</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user