From 3422b09214d6fdcdd08fa3c4a89f68f3959e8263 Mon Sep 17 00:00:00 2001 From: cas Date: Tue, 28 Jul 2026 20:10:13 +0200 Subject: [PATCH] =?UTF-8?q?UX=20=E2=80=94=20verwijder=20menu-item=20Tussen?= =?UTF-8?q?doortjes=20uit=20hoofdnavigatie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove navTussendoortjes button HTML, class toggle, and click listener - Remaining nav items (Zoeken, Dagboek, Maaltijden) auto-distribute via flex:1 - Expose window.switchTab for test access (was IIFE-scoped) - Update e2e tests: first test verifies button is gone, rest use page.evaluate(() => switchTab('tussendoortjes')) - Update inspiratie.spec.js helper to use switchTab via evaluate - Sync docker/index.html with index.html Resolves: t_3c830823 --- docker/index.html | 1061 +++++++++++++++++++++++++++++++++++++- index.html | 1061 +++++++++++++++++++++++++++++++++++++- tests/e2e.spec.js | 266 ++++++++-- tests/inspiratie.spec.js | 348 +++++++++++++ 4 files changed, 2674 insertions(+), 62 deletions(-) create mode 100644 tests/inspiratie.spec.js diff --git a/docker/index.html b/docker/index.html index 61c7c4e..99aadf3 100644 --- a/docker/index.html +++ b/docker/index.html @@ -1321,6 +1321,13 @@ body { .save-meal-footer { padding: 4px 14px 10px; + transition: padding 0.3s ease, max-height 0.3s ease; + max-height: 200px; + overflow: hidden; +} +.save-meal-footer.footer-hidden { + padding: 0 14px 0; + max-height: 0; } .save-meal-footer-btn { display: block; @@ -2416,6 +2423,302 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- .portie-stepper-count.count-bump { animation: pulseCount 0.3s ease; } + +/* ===== Tussendoortjes Feature ===== */ +.tussendoortjes-section { + padding: 0 16px 20px; +} +.tussendoortjes-header { + font-family: var(--heading-font); + font-size: var(--font-xl); + font-weight: 700; + color: var(--heading-color); + margin-bottom: 4px; +} +.tussendoortjes-sub { + font-size: var(--font-sm); + color: var(--text-muted); + margin-bottom: 16px; +} +.carb-range-control { + background: var(--white); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow-sm); + margin-bottom: 20px; +} +.carb-range-label { + display: flex; + justify-content: space-between; + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} +.range-inputs { + display: flex; + gap: 12px; + margin-top: 4px; +} +.range-input-group { + flex: 1; +} +.range-input-group label { + display: block; + font-size: var(--font-xs); + color: var(--text-muted); + margin-bottom: 4px; + font-weight: 500; +} +.range-input-group input { + width: 100%; + padding: 10px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius-xs); + font-size: var(--font-md); + font-weight: 700; + color: var(--primary-dark); + text-align: center; + outline: none; + transition: border-color 0.15s; + -webkit-appearance: none; + appearance: none; +} +.range-input-group input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(232,183,48,0.15); +} +.suggestie-card { + background: var(--white); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow-sm); + margin-bottom: 12px; + animation: slideUp 0.35s ease both; + transition: transform 0.15s, box-shadow 0.15s; +} +.suggestie-card:active { + transform: scale(0.98); +} +.suggestie-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 10px; +} +.suggestie-card-naam { + font-weight: 700; + font-size: var(--font-md); + color: var(--heading-color); + flex: 1; +} +.suggestie-card-totaal { + font-weight: 700; + font-size: var(--font-lg); + color: var(--primary-dark); + white-space: nowrap; + margin-left: 8px; +} +.suggestie-ingredienten { + list-style: none; + margin-bottom: 10px; +} +.suggestie-ingredient { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: var(--font-sm); +} +.suggestie-ingredient:last-child { + border-bottom: none; +} +.suggestie-ingredient-naam { + flex: 1; + color: var(--text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.suggestie-ingredient-gram { + color: var(--text-muted); + font-size: var(--font-xs); + margin: 0 8px; + white-space: nowrap; + flex-shrink: 0; +} +.suggestie-ingredient-kh { + font-weight: 600; + color: var(--text-secondary); + font-size: var(--font-sm); + white-space: nowrap; + flex-shrink: 0; +} +.suggestie-totaal { + display: flex; + justify-content: space-between; + padding: 8px 0 6px; + border-top: 2px solid var(--accent); + font-weight: 700; + font-size: var(--font-md); + color: var(--heading-color); + margin-bottom: 8px; +} +.suggestie-totaal-label { + color: var(--text-secondary); + font-weight: 600; +} +.suggestie-totaal-kh { + color: var(--primary-dark); +} +.suggestie-bereiding { + font-size: var(--font-sm); + color: var(--text-muted); + line-height: 1.5; + padding: 8px 10px; + background: var(--primary-lighter); + border-radius: var(--radius-xs); + margin-bottom: 12px; +} +.suggestie-bereiding-icon { + margin-right: 4px; +} +.suggestie-warning { + margin-top: 20px; + padding: 12px 14px; + background: var(--primary-lighter); + border-radius: var(--radius-sm); + border-left: 4px solid var(--primary); + font-size: var(--font-xs); + color: var(--text-secondary); + line-height: 1.5; +} +.suggestie-warning strong { + color: var(--primary-dark); +} + +/* ===== Inspiratie / Vind inspiratie ===== */ +.inspiratie-context { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--primary-light); + border: 1.5px solid var(--primary); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 16px; + animation: slideDown 0.3s ease-out both; +} +.inspiratie-context.hidden { + display: none; +} +.inspiratie-context-label { + font-size: var(--font-sm); + font-weight: 600; + color: var(--primary-dark); +} +.inspiratie-context-close { + background: none; + border: none; + color: var(--primary-dark); + font-size: 1.1rem; + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} +.inspiratie-context-close:active { + background: rgba(0,0,0,0.08); + transform: scale(0.92); +} + +.vind-inspiratie-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--primary); + color: var(--heading-color); + border: none; + border-radius: var(--radius-xs); + padding: 6px 12px; + font-size: var(--font-xs); + font-weight: 700; + cursor: pointer; + min-height: 32px; + transition: background 0.15s, transform 0.12s; + user-select: none; + -webkit-user-select: none; + white-space: nowrap; +} +.vind-inspiratie-btn:active { + background: var(--accent-dark); + transform: scale(0.96); +} +.vind-inspiratie-btn i { + font-size: 0.8rem; +} + +.nieuwe-suggesties-btn-wrap { + text-align: center; + margin: 16px 0 8px; +} +.nieuwe-suggesties-btn { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--primary); + color: var(--heading-color); + border: none; + border-radius: var(--radius); + padding: 12px 24px; + font-size: var(--font-md); + font-weight: 700; + cursor: pointer; + min-height: var(--touch-min); + transition: background 0.15s, transform 0.12s, box-shadow 0.15s; + user-select: none; + -webkit-user-select: none; + box-shadow: 0 2px 8px rgba(232,183,48,0.3); +} +.nieuwe-suggesties-btn:active { + background: var(--accent-dark); + transform: scale(0.97); + box-shadow: 0 1px 4px rgba(232,183,48,0.2); +} +.nieuwe-suggesties-btn i { + font-size: 1rem; + transition: transform 0.3s ease; +} +.nieuwe-suggesties-btn.spinning i { + transform: rotate(180deg); +} + +.inspiratie-empty { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); +} +.inspiratie-empty-icon { + font-size: 2.5rem; + margin-bottom: 12px; +} +.inspiratie-empty-text { + font-size: var(--font-md); +} + +@keyframes cardPopIn { + from { opacity: 0; transform: translateY(20px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.suggestie-card.animate-in { + animation: cardPopIn 0.35s ease both; +} @@ -2536,6 +2839,48 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- + + + @@ -2666,7 +3011,312 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- const CATEGORIES = ['Brood', 'Zuivel', 'Fruit', 'Groente', 'Vlees/Vis', 'Dranken', 'Tussendoortjes', 'Snoep/Koek', 'Overig']; - // Map NEVO categories to app categories for filtering + // ===== Tussendoortjes Recepten ===== + const TUSSENDOORTJES_RECIPES = [ + { + naam: 'Chocolade-Frambozenbowl', + bereiding: 'Schep de kwark in een kom en meng de cacaopoeder er glad door. Was de frambozen en verdeel ze over de kwark. Rasp de pure chocolade erover en serveer direct.', + ingredienten: [ + { n: '305', naam: 'Kwark magere', portie: 175 }, + { n: '161', naam: 'Frambozen', portie: 50 }, + { n: '430', naam: 'Cacaopoeder', portie: 8 }, + { n: '432', naam: 'Pure chocolade', portie: 15 }, + ], + }, + { + naam: 'Aardbei-Chocoladekwark', + bereiding: 'Was de aardbeien en snijd ze in plakjes. Meng de kwark met de cacaopoeder. Schep de aardbeien erdoor. Rasp de pure chocolade erover en geniet.', + ingredienten: [ + { n: '305', naam: 'Kwark magere', portie: 150 }, + { n: '148', naam: 'Aardbeien', portie: 100 }, + { n: '432', naam: 'Pure chocolade', portie: 18 }, + ], + }, + { + naam: 'Warme Appel-Kaneelkwark', + bereiding: 'Schil de appel, verwijder het klokhuis en snijd in kleine blokjes. Doe in een pannetje met een scheutje water en de kaneel. Verwarm 3 minuten op middelhoog vuur. Schep de warme appel door de kwark en serveer lauwwarm.', + ingredienten: [ + { n: '305', naam: 'Kwark magere', portie: 180 }, + { n: '147', naam: 'Appel', portie: 100 }, + { n: '826', naam: 'Kaneel', portie: 2 }, + ], + }, + ]; + + // ===== Inspiratie (Vind inspiratie) state ===== + let _inspiratieMoment = null; // eetmoment id: 'Tussendoor 1/2/3' + let _inspiratieDate = null; // date string from diary + let _lastSuggestieIds = []; // track last set of names to avoid immediate repeats + let _cachedGeneratedSet = null; // current generated set (array of generated recipe objects) + + // ===== Inspiratie ingredient pool (NEVO-based) ===== + const INSPIRATIE_POOL = { + base: [ + { n: '305', naam: 'Kwark magere', kh_per100: 3.8, eiwit_per100: 8.4, minPortie: 125, maxPortie: 200 }, + { n: '306', naam: 'Kwark halfvolle', kh_per100: 4.0, eiwit_per100: 7.5, minPortie: 125, maxPortie: 200 }, + { n: '654', naam: 'Cottage cheese', kh_per100: 2.3, eiwit_per100: 11.2, minPortie: 125, maxPortie: 200 }, + { n: '2503', naam: 'Griekse yoghurt volle', kh_per100: 3.8, eiwit_per100: 3.8, minPortie: 125, maxPortie: 175 }, + ], + fruit: [ + { n: '161', naam: 'Frambozen', kh_per100: 4.5, minPortie: 40, maxPortie: 75 }, + { n: '148', naam: 'Aardbeien', kh_per100: 5.1, minPortie: 50, maxPortie: 100 }, + { n: '152', naam: 'Blauwe bessen', kh_per100: 11, minPortie: 30, maxPortie: 60 }, + { n: '155', naam: 'Bosbessen', kh_per100: 6, minPortie: 40, maxPortie: 75 }, + { n: '157', naam: 'Bramen', kh_per100: 5.1, minPortie: 40, maxPortie: 75 }, + { n: '147', naam: 'Appel', kh_per100: 12, minPortie: 50, maxPortie: 100 }, + ], + topping: [ + { n: '198', naam: 'Amandelen', kh_per100: 7.1, minPortie: 8, maxPortie: 15 }, + { n: '206', naam: 'Walnoten', kh_per100: 5.1, minPortie: 8, maxPortie: 15 }, + { n: '1895', naam: 'Pecannoten', kh_per100: 4.4, minPortie: 8, maxPortie: 15 }, + { n: '5497', naam: 'Kokos geraspt', kh_per100: 8.6, minPortie: 5, maxPortie: 12 }, + { n: '3447', naam: 'Chiazaad', kh_per100: 7.7, minPortie: 5, maxPortie: 10 }, + ], + flavor: [ + { n: '430', naam: 'Cacaopoeder', kh_per100: 10.5, minPortie: 5, maxPortie: 10 }, + { n: '826', naam: 'Kaneel', kh_per100: 56, minPortie: 1, maxPortie: 3 }, + ], + chocolate: [ + { n: '432', naam: 'Pure chocolade', kh_per100: 46.7, minPortie: 8, maxPortie: 18 }, + ], + }; + + // ===== Inspiratie recipe templates (variable slots) ===== + const INSPIRATIE_TEMPLATES = [ + { + naamTpl: function(fruit, topping) { return fruit.naam + '-' + topping.naam + ' bowl'; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'topping', desc: 'topping', varName: 'topping' }, + ], + bereidingTpl: function(base, fruit, topping) { + return 'Schep de ' + base.naam.toLowerCase() + ' in een kom. Was de ' + fruit.naam.toLowerCase() + ' en verdeel ze erover. Bestrooi met ' + topping.naam.toLowerCase() + ' en serveer.'; + }, + }, + { + naamTpl: function(base, fruit, flavor) { return fruit.naam + ' ' + base.naam + ' met ' + flavor.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'flavor', desc: 'smaak', varName: 'flavor' }, + ], + bereidingTpl: function(base, fruit, flavor) { + return 'Meng de ' + base.naam.toLowerCase() + ' met ' + flavor.naam.toLowerCase() + '. Was de ' + fruit.naam.toLowerCase() + ' en schep ze erdoor. Serveer gekoeld.'; + }, + }, + { + naamTpl: function(base, fruit, topping) { return 'Romige ' + fruit.naam + '-' + base.naam + ' met ' + topping.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'topping', desc: 'topping', varName: 'topping' }, + ], + bereidingTpl: function(base, fruit, topping) { + return 'Doe de ' + base.naam.toLowerCase() + ' in een kom. Snijd de ' + fruit.naam.toLowerCase() + ' en roer erdoor. Bestrooi met ' + topping.naam.toLowerCase() + ' en geniet.'; + }, + }, + { + naamTpl: function(base, fruit, chocolate) { return fruit.naam + '-chocolade ' + base.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'chocolate', desc: 'chocolade', varName: 'chocolate' }, + ], + bereidingTpl: function(base, fruit, chocolate) { + return 'Schep de ' + base.naam.toLowerCase() + ' in een kom. Was de ' + fruit.naam.toLowerCase() + ' en verdeel ze erover. Rasp de ' + chocolate.naam.toLowerCase() + ' erboven en serveer direct.'; + }, + }, + { + naamTpl: function(base, fruit, topping) { return 'Frisse ' + fruit.naam + ' ' + base.naam + ' met ' + topping.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'topping', desc: 'topping', varName: 'topping' }, + ], + bereidingTpl: function(base, fruit, topping) { + return 'Meng de ' + base.naam.toLowerCase() + ' met de ' + fruit.naam.toLowerCase() + '. Hak de ' + topping.naam.toLowerCase() + ' grof en strooi erover. Direct serveren.'; + }, + }, + { + naamTpl: function(base, fruit, flavor) { return 'Warme ' + fruit.naam + ' met ' + base.naam + ' en ' + flavor.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'flavor', desc: 'smaak', varName: 'flavor' }, + ], + bereidingTpl: function(base, fruit, flavor) { + return 'Verwarm de ' + fruit.naam.toLowerCase() + ' kort in een pannetje. Schep de ' + base.naam.toLowerCase() + ' in een kom, voeg de warme ' + fruit.naam.toLowerCase() + ' toe en bestrooi met ' + flavor.naam.toLowerCase() + '. Lauwwarm serveren.'; + }, + }, + ]; + + /** + * Pick a random element from an array. + */ + function pickRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)]; + } + + /** + * Shuffle an array in place (Fisher-Yates). + */ + function shuffleArray(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + } + + /** + * Calculate the kh for a given ingredient and portion. + */ + function calcIngKh(khPer100, portie) { + return Math.round((khPer100 / 100) * portie * 10) / 10; + } + + /** + * Generate one recipe suggestion from a random template + random ingredient choices, + * aiming to stay within [minVal, maxVal] carbs. + * Returns null if no valid combination found after maxAttempts. + */ + function generateSingleSuggestion(minVal, maxVal, existingNames) { + const MAX_ATTEMPTS = 30; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const tpl = pickRandom(INSPIRATIE_TEMPLATES); + const chosen = {}; + let valid = true; + let anyFromDb = false; // track whether at least one ingredient was resolved from DB + + for (const slot of tpl.slots) { + const pool = INSPIRATIE_POOL[slot.pool]; + if (!pool || pool.length === 0) { valid = false; break; } + // Shuffle and pick first that avoids name repetition within this suggestion + const shuffled = shuffleArray([...pool]); + let picked = null; + for (const candidate of shuffled) { + // Resolve each candidate via the actual NEVO database + const dbItem = findFoodByN(candidate.n); + if (!dbItem) continue; // skip IDs not found in the database + const nameFromDb = displayNaam(dbItem.naam); + const nameLower = nameFromDb.toLowerCase(); + const alreadyUsed = Object.values(chosen).some(v => v && v.dbNaam && v.dbNaam.toLowerCase() === nameLower); + if (!alreadyUsed) { + // Build resolved ingredient: kh from DB, portions from pool metadata. + // Use candidate.naam (friendly name) for recipe titles and preparation text; + // store dbNaam separately for the ingredient list display. + picked = { + n: candidate.n, + naam: candidate.naam, // friendly name for templates (title + bereiding) + dbNaam: nameFromDb, // DB display name for ingredient list + khPer100: parseKh(dbItem.kh), + minPortie: candidate.minPortie, + maxPortie: candidate.maxPortie, + }; + anyFromDb = true; + break; + } + } + if (!picked) { valid = false; break; } + chosen[slot.varName] = picked; + } + + if (!valid) continue; + + // Assign portions (using DB-backed khPer100) + const ingredients = []; + let totaalKh = 0; + for (const slot of tpl.slots) { + const ing = chosen[slot.varName]; + const portie = ing.minPortie + Math.floor(Math.random() * (ing.maxPortie - ing.minPortie + 1)); + const kh = calcIngKh(ing.khPer100, portie); + ingredients.push({ n: ing.n, naam: ing.dbNaam, portie: portie, kh: kh, khPer100: ing.khPer100 }); + totaalKh += kh; + } + totaalKh = Math.round(totaalKh * 10) / 10; + + // Check range + if (totaalKh < minVal || totaalKh > maxVal) continue; + + // Check not identical to recently shown + const suggestionName = tpl.naamTpl(...Object.values(chosen)); + if (existingNames && existingNames.includes(suggestionName)) continue; + + // Build ingredient details (all values already from DB) + const ingredientDetails = ingredients.map(ing => ({ + n: ing.n, + displayNaam: ing.naam, + portie: ing.portie, + kh: ing.kh, + khPer100: ing.khPer100, + })); + + // Build bereiding text + const bereiding = tpl.bereidingTpl(...Object.values(chosen)); + + return { + naam: suggestionName, + ingredientDetails: ingredientDetails, + totaalKh: totaalKh, + bereiding: bereiding, + anyFromDb: anyFromDb, + }; + } + return null; // could not generate within range + } + + /** + * Generate a set of up to 3 suggestions within the given carb range. + * Returns an array of suggestion objects (may be empty). + */ + function generateInspiratieSet(minVal, maxVal) { + const set = []; + const usedNames = new Set(); + + // Include existing recipes as part of the pool β€” at most 1 per set, + // so generated suggestions get a chance to appear. + const existingInRange = TUSSENDOORTJES_RECIPES.filter(r => { + let totaal = 0; + r.ingredienten.forEach(ing => { + const item = findFoodByN(ing.n); + if (item) totaal += (parseKh(item.kh) / 100) * ing.portie; + }); + totaal = Math.round(totaal * 10) / 10; + return totaal >= minVal && totaal <= maxVal; + }); + shuffleArray(existingInRange); + + // Pick at most 1 existing recipe + if (existingInRange.length > 0) { + const r = existingInRange[0]; + let totaalKh = 0; + const ingredientDetails = r.ingredienten.map(ing => { + const item = findFoodByN(ing.n); + const khPer100 = item ? parseKh(item.kh) : 0; + const khPortie = Math.round((khPer100 / 100) * ing.portie * 10) / 10; + totaalKh += khPortie; + return { n: ing.n, displayNaam: item ? displayNaam(item.naam) : ing.naam, portie: ing.portie, kh: khPortie, khPer100: khPer100 }; + }); + totaalKh = Math.round(totaalKh * 10) / 10; + if (totaalKh >= minVal && totaalKh <= maxVal) { + usedNames.add(r.naam); + set.push({ naam: r.naam, ingredientDetails, totaalKh, bereiding: r.bereiding, fromPool: true }); + } + } + + // Generate remaining suggestions + const MAX_GEN_ATTEMPTS = 50; + for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS && set.length < 3; attempt++) { + const suggestion = generateSingleSuggestion(minVal, maxVal, Array.from(usedNames)); + if (suggestion) { + usedNames.add(suggestion.naam); + set.push({ ...suggestion, fromPool: false }); + } + } + + return set; + } const CATEGORY_MAP = { 'Brood': 'Brood', 'Melk en melkproducten': 'Zuivel', @@ -3700,10 +4350,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- return `
  • -
    +
    🍽 ${escapeHtml(entry.mealNaam)} ${formatKh(mealKh)} g - +
    ${mealItemsHtml}
    @@ -3770,8 +4420,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    + Voedingsmiddel toevoegen
    + ${moment.id.startsWith('Tussendoor') ? ` +
    + +
    ` : ''} ${items.length > 1 ? ` - +
    + +
    + +
    + +
    + +
    + Belangrijk: De getoonde voedingswaarden zijn gebaseerd op de NEVO-database (RIVM) en kunnen afwijken van merkproducten. Controleer altijd het etiket van jouw eigen producten. Deze suggesties zijn geen medisch advies en vervangen geen begeleiding van een diΓ«tist of arts. +
    +
    + @@ -2666,7 +3011,312 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- const CATEGORIES = ['Brood', 'Zuivel', 'Fruit', 'Groente', 'Vlees/Vis', 'Dranken', 'Tussendoortjes', 'Snoep/Koek', 'Overig']; - // Map NEVO categories to app categories for filtering + // ===== Tussendoortjes Recepten ===== + const TUSSENDOORTJES_RECIPES = [ + { + naam: 'Chocolade-Frambozenbowl', + bereiding: 'Schep de kwark in een kom en meng de cacaopoeder er glad door. Was de frambozen en verdeel ze over de kwark. Rasp de pure chocolade erover en serveer direct.', + ingredienten: [ + { n: '305', naam: 'Kwark magere', portie: 175 }, + { n: '161', naam: 'Frambozen', portie: 50 }, + { n: '430', naam: 'Cacaopoeder', portie: 8 }, + { n: '432', naam: 'Pure chocolade', portie: 15 }, + ], + }, + { + naam: 'Aardbei-Chocoladekwark', + bereiding: 'Was de aardbeien en snijd ze in plakjes. Meng de kwark met de cacaopoeder. Schep de aardbeien erdoor. Rasp de pure chocolade erover en geniet.', + ingredienten: [ + { n: '305', naam: 'Kwark magere', portie: 150 }, + { n: '148', naam: 'Aardbeien', portie: 100 }, + { n: '432', naam: 'Pure chocolade', portie: 18 }, + ], + }, + { + naam: 'Warme Appel-Kaneelkwark', + bereiding: 'Schil de appel, verwijder het klokhuis en snijd in kleine blokjes. Doe in een pannetje met een scheutje water en de kaneel. Verwarm 3 minuten op middelhoog vuur. Schep de warme appel door de kwark en serveer lauwwarm.', + ingredienten: [ + { n: '305', naam: 'Kwark magere', portie: 180 }, + { n: '147', naam: 'Appel', portie: 100 }, + { n: '826', naam: 'Kaneel', portie: 2 }, + ], + }, + ]; + + // ===== Inspiratie (Vind inspiratie) state ===== + let _inspiratieMoment = null; // eetmoment id: 'Tussendoor 1/2/3' + let _inspiratieDate = null; // date string from diary + let _lastSuggestieIds = []; // track last set of names to avoid immediate repeats + let _cachedGeneratedSet = null; // current generated set (array of generated recipe objects) + + // ===== Inspiratie ingredient pool (NEVO-based) ===== + const INSPIRATIE_POOL = { + base: [ + { n: '305', naam: 'Kwark magere', kh_per100: 3.8, eiwit_per100: 8.4, minPortie: 125, maxPortie: 200 }, + { n: '306', naam: 'Kwark halfvolle', kh_per100: 4.0, eiwit_per100: 7.5, minPortie: 125, maxPortie: 200 }, + { n: '654', naam: 'Cottage cheese', kh_per100: 2.3, eiwit_per100: 11.2, minPortie: 125, maxPortie: 200 }, + { n: '2503', naam: 'Griekse yoghurt volle', kh_per100: 3.8, eiwit_per100: 3.8, minPortie: 125, maxPortie: 175 }, + ], + fruit: [ + { n: '161', naam: 'Frambozen', kh_per100: 4.5, minPortie: 40, maxPortie: 75 }, + { n: '148', naam: 'Aardbeien', kh_per100: 5.1, minPortie: 50, maxPortie: 100 }, + { n: '152', naam: 'Blauwe bessen', kh_per100: 11, minPortie: 30, maxPortie: 60 }, + { n: '155', naam: 'Bosbessen', kh_per100: 6, minPortie: 40, maxPortie: 75 }, + { n: '157', naam: 'Bramen', kh_per100: 5.1, minPortie: 40, maxPortie: 75 }, + { n: '147', naam: 'Appel', kh_per100: 12, minPortie: 50, maxPortie: 100 }, + ], + topping: [ + { n: '198', naam: 'Amandelen', kh_per100: 7.1, minPortie: 8, maxPortie: 15 }, + { n: '206', naam: 'Walnoten', kh_per100: 5.1, minPortie: 8, maxPortie: 15 }, + { n: '1895', naam: 'Pecannoten', kh_per100: 4.4, minPortie: 8, maxPortie: 15 }, + { n: '5497', naam: 'Kokos geraspt', kh_per100: 8.6, minPortie: 5, maxPortie: 12 }, + { n: '3447', naam: 'Chiazaad', kh_per100: 7.7, minPortie: 5, maxPortie: 10 }, + ], + flavor: [ + { n: '430', naam: 'Cacaopoeder', kh_per100: 10.5, minPortie: 5, maxPortie: 10 }, + { n: '826', naam: 'Kaneel', kh_per100: 56, minPortie: 1, maxPortie: 3 }, + ], + chocolate: [ + { n: '432', naam: 'Pure chocolade', kh_per100: 46.7, minPortie: 8, maxPortie: 18 }, + ], + }; + + // ===== Inspiratie recipe templates (variable slots) ===== + const INSPIRATIE_TEMPLATES = [ + { + naamTpl: function(fruit, topping) { return fruit.naam + '-' + topping.naam + ' bowl'; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'topping', desc: 'topping', varName: 'topping' }, + ], + bereidingTpl: function(base, fruit, topping) { + return 'Schep de ' + base.naam.toLowerCase() + ' in een kom. Was de ' + fruit.naam.toLowerCase() + ' en verdeel ze erover. Bestrooi met ' + topping.naam.toLowerCase() + ' en serveer.'; + }, + }, + { + naamTpl: function(base, fruit, flavor) { return fruit.naam + ' ' + base.naam + ' met ' + flavor.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'flavor', desc: 'smaak', varName: 'flavor' }, + ], + bereidingTpl: function(base, fruit, flavor) { + return 'Meng de ' + base.naam.toLowerCase() + ' met ' + flavor.naam.toLowerCase() + '. Was de ' + fruit.naam.toLowerCase() + ' en schep ze erdoor. Serveer gekoeld.'; + }, + }, + { + naamTpl: function(base, fruit, topping) { return 'Romige ' + fruit.naam + '-' + base.naam + ' met ' + topping.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'topping', desc: 'topping', varName: 'topping' }, + ], + bereidingTpl: function(base, fruit, topping) { + return 'Doe de ' + base.naam.toLowerCase() + ' in een kom. Snijd de ' + fruit.naam.toLowerCase() + ' en roer erdoor. Bestrooi met ' + topping.naam.toLowerCase() + ' en geniet.'; + }, + }, + { + naamTpl: function(base, fruit, chocolate) { return fruit.naam + '-chocolade ' + base.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'chocolate', desc: 'chocolade', varName: 'chocolate' }, + ], + bereidingTpl: function(base, fruit, chocolate) { + return 'Schep de ' + base.naam.toLowerCase() + ' in een kom. Was de ' + fruit.naam.toLowerCase() + ' en verdeel ze erover. Rasp de ' + chocolate.naam.toLowerCase() + ' erboven en serveer direct.'; + }, + }, + { + naamTpl: function(base, fruit, topping) { return 'Frisse ' + fruit.naam + ' ' + base.naam + ' met ' + topping.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'topping', desc: 'topping', varName: 'topping' }, + ], + bereidingTpl: function(base, fruit, topping) { + return 'Meng de ' + base.naam.toLowerCase() + ' met de ' + fruit.naam.toLowerCase() + '. Hak de ' + topping.naam.toLowerCase() + ' grof en strooi erover. Direct serveren.'; + }, + }, + { + naamTpl: function(base, fruit, flavor) { return 'Warme ' + fruit.naam + ' met ' + base.naam + ' en ' + flavor.naam; }, + slots: [ + { pool: 'base', desc: 'basis', varName: 'base' }, + { pool: 'fruit', desc: 'fruit', varName: 'fruit' }, + { pool: 'flavor', desc: 'smaak', varName: 'flavor' }, + ], + bereidingTpl: function(base, fruit, flavor) { + return 'Verwarm de ' + fruit.naam.toLowerCase() + ' kort in een pannetje. Schep de ' + base.naam.toLowerCase() + ' in een kom, voeg de warme ' + fruit.naam.toLowerCase() + ' toe en bestrooi met ' + flavor.naam.toLowerCase() + '. Lauwwarm serveren.'; + }, + }, + ]; + + /** + * Pick a random element from an array. + */ + function pickRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)]; + } + + /** + * Shuffle an array in place (Fisher-Yates). + */ + function shuffleArray(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + } + + /** + * Calculate the kh for a given ingredient and portion. + */ + function calcIngKh(khPer100, portie) { + return Math.round((khPer100 / 100) * portie * 10) / 10; + } + + /** + * Generate one recipe suggestion from a random template + random ingredient choices, + * aiming to stay within [minVal, maxVal] carbs. + * Returns null if no valid combination found after maxAttempts. + */ + function generateSingleSuggestion(minVal, maxVal, existingNames) { + const MAX_ATTEMPTS = 30; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const tpl = pickRandom(INSPIRATIE_TEMPLATES); + const chosen = {}; + let valid = true; + let anyFromDb = false; // track whether at least one ingredient was resolved from DB + + for (const slot of tpl.slots) { + const pool = INSPIRATIE_POOL[slot.pool]; + if (!pool || pool.length === 0) { valid = false; break; } + // Shuffle and pick first that avoids name repetition within this suggestion + const shuffled = shuffleArray([...pool]); + let picked = null; + for (const candidate of shuffled) { + // Resolve each candidate via the actual NEVO database + const dbItem = findFoodByN(candidate.n); + if (!dbItem) continue; // skip IDs not found in the database + const nameFromDb = displayNaam(dbItem.naam); + const nameLower = nameFromDb.toLowerCase(); + const alreadyUsed = Object.values(chosen).some(v => v && v.dbNaam && v.dbNaam.toLowerCase() === nameLower); + if (!alreadyUsed) { + // Build resolved ingredient: kh from DB, portions from pool metadata. + // Use candidate.naam (friendly name) for recipe titles and preparation text; + // store dbNaam separately for the ingredient list display. + picked = { + n: candidate.n, + naam: candidate.naam, // friendly name for templates (title + bereiding) + dbNaam: nameFromDb, // DB display name for ingredient list + khPer100: parseKh(dbItem.kh), + minPortie: candidate.minPortie, + maxPortie: candidate.maxPortie, + }; + anyFromDb = true; + break; + } + } + if (!picked) { valid = false; break; } + chosen[slot.varName] = picked; + } + + if (!valid) continue; + + // Assign portions (using DB-backed khPer100) + const ingredients = []; + let totaalKh = 0; + for (const slot of tpl.slots) { + const ing = chosen[slot.varName]; + const portie = ing.minPortie + Math.floor(Math.random() * (ing.maxPortie - ing.minPortie + 1)); + const kh = calcIngKh(ing.khPer100, portie); + ingredients.push({ n: ing.n, naam: ing.dbNaam, portie: portie, kh: kh, khPer100: ing.khPer100 }); + totaalKh += kh; + } + totaalKh = Math.round(totaalKh * 10) / 10; + + // Check range + if (totaalKh < minVal || totaalKh > maxVal) continue; + + // Check not identical to recently shown + const suggestionName = tpl.naamTpl(...Object.values(chosen)); + if (existingNames && existingNames.includes(suggestionName)) continue; + + // Build ingredient details (all values already from DB) + const ingredientDetails = ingredients.map(ing => ({ + n: ing.n, + displayNaam: ing.naam, + portie: ing.portie, + kh: ing.kh, + khPer100: ing.khPer100, + })); + + // Build bereiding text + const bereiding = tpl.bereidingTpl(...Object.values(chosen)); + + return { + naam: suggestionName, + ingredientDetails: ingredientDetails, + totaalKh: totaalKh, + bereiding: bereiding, + anyFromDb: anyFromDb, + }; + } + return null; // could not generate within range + } + + /** + * Generate a set of up to 3 suggestions within the given carb range. + * Returns an array of suggestion objects (may be empty). + */ + function generateInspiratieSet(minVal, maxVal) { + const set = []; + const usedNames = new Set(); + + // Include existing recipes as part of the pool β€” at most 1 per set, + // so generated suggestions get a chance to appear. + const existingInRange = TUSSENDOORTJES_RECIPES.filter(r => { + let totaal = 0; + r.ingredienten.forEach(ing => { + const item = findFoodByN(ing.n); + if (item) totaal += (parseKh(item.kh) / 100) * ing.portie; + }); + totaal = Math.round(totaal * 10) / 10; + return totaal >= minVal && totaal <= maxVal; + }); + shuffleArray(existingInRange); + + // Pick at most 1 existing recipe + if (existingInRange.length > 0) { + const r = existingInRange[0]; + let totaalKh = 0; + const ingredientDetails = r.ingredienten.map(ing => { + const item = findFoodByN(ing.n); + const khPer100 = item ? parseKh(item.kh) : 0; + const khPortie = Math.round((khPer100 / 100) * ing.portie * 10) / 10; + totaalKh += khPortie; + return { n: ing.n, displayNaam: item ? displayNaam(item.naam) : ing.naam, portie: ing.portie, kh: khPortie, khPer100: khPer100 }; + }); + totaalKh = Math.round(totaalKh * 10) / 10; + if (totaalKh >= minVal && totaalKh <= maxVal) { + usedNames.add(r.naam); + set.push({ naam: r.naam, ingredientDetails, totaalKh, bereiding: r.bereiding, fromPool: true }); + } + } + + // Generate remaining suggestions + const MAX_GEN_ATTEMPTS = 50; + for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS && set.length < 3; attempt++) { + const suggestion = generateSingleSuggestion(minVal, maxVal, Array.from(usedNames)); + if (suggestion) { + usedNames.add(suggestion.naam); + set.push({ ...suggestion, fromPool: false }); + } + } + + return set; + } const CATEGORY_MAP = { 'Brood': 'Brood', 'Melk en melkproducten': 'Zuivel', @@ -3700,10 +4350,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- return `
  • -
    +
    🍽 ${escapeHtml(entry.mealNaam)} ${formatKh(mealKh)} g - +
    ${mealItemsHtml}
    @@ -3770,8 +4420,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    + Voedingsmiddel toevoegen
    + ${moment.id.startsWith('Tussendoor') ? ` +
    + +
    ` : ''} ${items.length > 1 ? ` -