// wwwroot/js/ingredients.js // ====================== // Globals & Helpers // ====================== let OWNED_SET = new Set(); let ALL_AGG_INGS = []; let AI_CTRL = null; const AI_DELAY = 300; const SEARCH_STATE = { query: "", mode: null }; const els = { list: null, search: null }; // --- Exclusions par recette --- const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... } const EXCLUDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés) const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes"; const GET_MY_OWNED_URL = "/HelloFresh/GetRecipesOwned?isUserImportant=true"; const CORE_USERS = Array.isArray(window.CORE_USERS) ? window.CORE_USERS : ["Mae", "Byakuya"]; // État “Ingrédients à avoir” let LAST_ING_TO_HAD = null; let LAST_PORTIONS = 1; let lastNeededSignature = ""; let prevNotShippedCount = -1; const IS_CORE_USER = (window.IS_CORE_USER === true || window.IS_CORE_USER === 'true'); const ACCENT_RX = /[\u0300-\u036f]/g; const normalizeName = (s) => (s || "").trim().toLowerCase(); const normalizeText = (s) => { let t = (s || "").toLowerCase(); try { t = t.normalize("NFD").replace(ACCENT_RX, ""); } catch { } return t.replace(/\s/g, " ").trim(); }; // Fractions unicode → décimaux const VULGAR_FRAC = { "½": "0.5", "¼": "0.25", "¾": "0.75", "⅓": "0.3333", "⅔": "0.6667", "⅛": "0.125", "⅜": "0.375", "⅝": "0.625", "⅞": "0.875" }; // Rang unités const UNIT_RANK = { "pièce": 6, "piece": 6, "pcs": 6, "pc": 6, "cs": 5, "tbsp": 5, "cc": 4, "tsp": 4, "g": 3, "kg": 3, "mg": 3, "ml": 3, "cl": 3, "l": 3, "": 1 }; const nameKey = (s) => (s || "").trim().toLowerCase(); // --- Exclusions par recette (ingrédients "principaux" ET "à avoir") --- const EXCLUDED_NEEDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés) function excludedUnionFrom(map) { const out = new Set(); for (const set of map.values()) for (const n of set) out.add(n); return out; } function applyNeededExclusions(items) { if (EXCLUDED_NEEDED_BY_RECIPE.size === 0) return items; const ex = excludedUnionFrom(EXCLUDED_NEEDED_BY_RECIPE); return (items || []).filter(it => !ex.has(nameKey(it?.Name))); } // Récupère les noms "à avoir" d’une recette (à partir de l’objet recette du strip) function getNeededNamesForRecipeObj(recipe, portions) { if (!recipe) return []; const det = detectIngredientsToHadProp(recipe); const arr = det ? (det.parsed?.[String(portions)] || []) : []; return (Array.isArray(arr) ? arr : []) .map(el => (el?.Name ?? el?.name ?? "").toString().trim()) .filter(Boolean); } (function addExcludeStyle() { const id = "hf-exclude-style"; if (document.getElementById(id)) return; const st = document.createElement("style"); st.id = id; st.textContent = ` #recipeStrip .card.card--excluded{ opacity:.45; outline:2px dashed #888; } #recipeStrip .card.card--excluded:hover{ opacity:.7; } `; document.head.appendChild(st); })(); // Retourne les noms d'ingrédients (par portion 1..4) pour une recette async function getIngredientNamesForRecipe(recipeId, portions = 1) { const id = String(recipeId); if (!RECIPE_ING_CACHE.has(id)) { try { const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(id)}`); if (!res.ok) throw new Error("HTTP " + res.status); const arr = await res.json(); const one = Array.isArray(arr) ? arr.find(x => String(x.id) === id) : null; const map = {}; if (one && one.ingredientsJson) { try { const obj = JSON.parse(one.ingredientsJson); for (const k of ["1", "2", "3", "4"]) { const list = Array.isArray(obj[k]) ? obj[k] : []; map[k] = list.map(el => (el?.Name ?? el?.name ?? "").toString().trim()).filter(Boolean); } } catch { /* ignore */ } } RECIPE_ING_CACHE.set(id, map); } catch (e) { console.warn("[ingredients] GetRecipesDetails fail for", id, e); RECIPE_ING_CACHE.set(id, {}); // évite re-fetch en boucle } } const cache = RECIPE_ING_CACHE.get(id) || {}; return (cache[String(portions)] || []).slice(); } // Union des exclusions (Set global) function excludedUnion() { const out = new Set(); for (const set of EXCLUDED_BY_RECIPE.values()) { for (const n of set) out.add(n); } return out; } // Filtre une liste d’items en appliquant les exclusions function applyExclusionsTo(items) { if (EXCLUDED_BY_RECIPE.size === 0) return items; const ex = excludedUnion(); return (items || []).filter(it => !ex.has(nameKey(it?.Name))); } // ========== Normalisation quantités ========== function normalizeQuantityText(q) { if (!q) return ""; let s = String(q).toLowerCase(); s = s.replace(/[½¼¾⅓⅔⅛⅜⅝⅞]/g, m => VULGAR_FRAC[m] || m); s = s.replace(/\b(\d+)\s*\/\s*(\d+)\b/g, (_, a, b) => { const num = parseFloat(a), den = parseFloat(b); return (!den || isNaN(num) || isNaN(den)) ? `${a}/${b}` : (num / den).toString(); }); s = s.replace(/,/g, ".").replace(/\s+/g, " ").trim(); s = s .replace(/\b(pieces?)\b/g, "pièce") .replace(/\b(pcs?)\b/g, "pièce") .replace(/\b(gr|grammes?|grams?)\b/g, "g") .replace(/\b(millilitres?|milliliters?|mls?)\b/g, "ml") .replace(/\b(litres?|liters?)\b/g, "l"); s = s .replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*s(?:oupe)?s?\.?)\b/gi, "cs") .replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*caf[eé]s?s?\.?)\b/gi, "cc"); s = s.replace(/[\/]+/g, " ").trim(); return s; } function parseQuantity(q) { const s = normalizeQuantityText(q); const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*([a-zéû]+)?$/i); if (!m) return { value: NaN, unit: "", raw: s }; return { value: parseFloat(m[1]), unit: (m[2] || "").trim(), raw: s }; } function quantityScore(q) { const { value, unit, raw } = parseQuantity(q); const isNum = !Number.isNaN(value); const unitRank = UNIT_RANK[unit] ?? (unit ? 2 : 1); return { isNum, unitRank, value: isNum ? value : -Infinity, raw }; } function pickBestQuantity(qtys) { const arr = Array.from(qtys || []); if (arr.length === 0) return ""; const dedup = Array.from(new Map(arr.map(q => [normalizeQuantityText(q), q])).values()); dedup.sort((a, b) => { const sa = quantityScore(a), sb = quantityScore(b); if (sa.isNum !== sb.isNum) return sa.isNum ? -1 : 1; if (sa.unitRank !== sb.unitRank) return sb.unitRank - sa.unitRank; if (sa.value !== sb.value) return sb.value - sa.value; return sa.raw.localeCompare(sb.raw, "fr"); }); return normalizeQuantityText(dedup[0]); } // ========== Ingrédients utils ========== function normalizeItem(it) { if (!it || typeof it !== "object") return { Name: "", Quantity: "", Image: "", Owned: false }; return { Name: it.Name ?? it.name ?? "", Quantity: it.Quantity ?? it.quantity ?? "", Image: it.Image ?? it.image ?? "", Owned: Boolean(it.Owned ?? it.owned ?? false), }; } function isOwned(name) { return OWNED_SET.has(normalizeName(name)); } function mergeDuplicates(items) { const map = new Map(); for (const raw of items) { const it = normalizeItem(raw); if (!it.Name) continue; const key = normalizeName(it.Name); const prev = map.get(key); if (!prev) { map.set(key, { ...it, _qty: new Set(it.Quantity ? [it.Quantity] : []) }); } else { if (!prev.Image && it.Image) prev.Image = it.Image; if (it.Quantity) prev._qty.add(it.Quantity); prev.Owned = prev.Owned || it.Owned; } } return Array.from(map.values()).map(x => ({ ...x, Quantity: pickBestQuantity(x._qty) })); } function sortIngredientsForDisplay(items) { return items.map(normalizeItem).filter(x => x.Name).sort((a, b) => { const ao = isOwned(a.Name) ? 1 : 0, bo = isOwned(b.Name) ? 1 : 0; if (ao !== bo) return ao - bo; return (a.Name || "").localeCompare((b.Name || ""), "fr", { sensitivity: "base" }); }); } // ========== UI cartes ingrédients ========== function renderIngredientCard(ingRaw) { const ing = normalizeItem(ingRaw); const div = document.createElement("div"); div.className = "card"; div.dataset.name = ing.Name; if (isOwned(ing.Name)) div.classList.add("checked"); div.innerHTML = `
${ing.Name}

${ing.Name || "(ingrédient)"}

${ing.Quantity || ""}
`; let busy = false; div.addEventListener("click", async () => { if (busy) return; busy = true; const name = div.dataset.name, key = normalizeName(name), was = div.classList.contains("checked"); div.classList.toggle("checked"); try { const res = await fetch("/HelloFresh/ToggleOwnedIngredient", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(name), }); if (!res.ok) throw new Error("HTTP " + res.status); const { status } = await res.json(); if (status === "added") OWNED_SET.add(key); else if (status === "removed") OWNED_SET.delete(key); else div.classList.toggle("checked", was); const idx = ALL_AGG_INGS.findIndex(x => normalizeName(x.Name) === key); if (idx !== -1) ALL_AGG_INGS[idx].Owned = (status === "added"); } catch (e) { console.error("Erreur toggle ingrédient", e); div.classList.toggle("checked", was); } finally { busy = false; reapplyCurrentSearch(); } }); return div; } function renderItems(items) { if (!els.list) return; els.list.innerHTML = ""; items.forEach(ing => els.list.appendChild(renderIngredientCard(ing))); } // ========== Recherche ========== async function applyAISearch(q) { SEARCH_STATE.query = q; SEARCH_STATE.mode = q ? "ai" : null; if (!q?.trim()) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; } if (els.list) els.list.innerHTML = "Recherche IA…"; try { if (AI_CTRL) AI_CTRL.abort(); AI_CTRL = new AbortController(); const res = await fetch("/HelloFresh/SmartSearch?debug=true", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q }), signal: AI_CTRL.signal }); const raw = await res.clone().text(); if (!res.ok) { console.warn("AI SmartSearch HTTP", res.status, raw); applyLiveFilter(q); return; } const data = JSON.parse(raw); const items = Array.isArray(data?.items) ? data.items : []; if (!items.length) { applyLiveFilter(q); return; } renderItems(sortIngredientsForDisplay(mergeDuplicates(items))); } catch (err) { if (err.name === "AbortError") console.log("[AI] annulée"); else { console.error("AI SmartSearch error:", err); applyLiveFilter(q); } } finally { AI_CTRL = null; } } function applyLiveFilter(q) { SEARCH_STATE.query = String(q ?? ""); SEARCH_STATE.mode = q ? "live" : null; const nq = normalizeText(q); if (!nq) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; } const filtered = ALL_AGG_INGS.filter(x => normalizeText(x.Name).includes(nq)); const list = sortIngredientsForDisplay(mergeDuplicates(applyExclusionsTo(filtered))); renderItems(list); } function reapplyCurrentSearch() { const q = SEARCH_STATE.query || ""; if (!q) { const base = sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS])); renderItems(applyExclusionsTo(base)); return; } if (SEARCH_STATE.mode === "ai") applyAISearch(q); else applyLiveFilter(q); } // ========== Chargement liste principale ========== async function loadAndRenderIngredients() { if (!els.list) return; els.list.innerHTML = "Chargement…"; try { if (IS_CORE_USER) { // 🔒 Vue partagée: ingrédients = uniquement Mae/Byakuya const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`; const res = await fetch(url, { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const rows = await res.json(); window.allRecipes = Array.isArray(rows) ? rows : []; const items = await buildAggregatedIngredientsFromRows(window.allRecipes); ALL_AGG_INGS = items; const list = sortIngredientsForDisplay(applyExclusionsTo(items)); els.list.innerHTML = ""; renderItems(list.length ? list : []); if (!list.length) els.list.innerHTML = "Aucun ingrédient"; return; } // Utilisateur "tiers" : ingrédients = SES recettes (déjà en place) const mineAgg = await buildMyAggregatedIngredients(); ALL_AGG_INGS = mineAgg; const list = sortIngredientsForDisplay(applyExclusionsTo(mineAgg)); els.list.innerHTML = ""; renderItems(list.length ? list : []); if (!list.length) els.list.innerHTML = "Aucun ingrédient"; } catch (e) { console.error("[ingredients] loadAndRenderIngredients exception", e); els.list.innerHTML = "Erreur chargement ingrédients"; } } // ========== Not shipped (DOM) + BDD ========== function getNotShippedIngredientsFromDOM(root = document) { const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped")); return nodes.map(n => { const name = (n.querySelector('[data-test="ingredient-name"], .ingredient-name, .name, h3, span')?.textContent || n.getAttribute("data-name") || n.querySelector("img")?.alt || "").trim(); const qty = (n.querySelector('[data-test="ingredient-quantity"], .ingredient-amount, .quantity, .qty')?.textContent || n.getAttribute("data-quantity") || "").trim(); const img = (n.querySelector("img")?.src || n.getAttribute("data-img") || "").trim(); return normalizeItem({ Name: name, Quantity: qty, Image: img, Owned: isOwned(name) }); }).filter(x => x.Name); } // Détecte la propriété "ingredients à avoir" dans un objet recette, // même si le nom varie (ingredientsToHad / IngredientsToHad / pantry / etc.) function detectIngredientsToHadProp(recipe) { if (!recipe || typeof recipe !== "object") return null; // Candidats fréquents (essayés d'abord) const CANDIDATES = [ "ingredientsToHad", "IngredientsToHad", "ingredients_to_had", "ingredientsToHaveAtHome", "ingredientsToHave", "toHaveAtHome", "pantry", "pantryItems", "pantry_ingredients", "ingredientsPantry" ]; for (const key of CANDIDATES) { if (key in recipe && recipe[key]) { try { const val = recipe[key]; const obj = (typeof val === "string") ? JSON.parse(val) : val; if (obj && typeof obj === "object" && (Array.isArray(obj["1"]) || Array.isArray(obj["2"]) || Array.isArray(obj["3"]) || Array.isArray(obj["4"]))) { return { prop: key, parsed: obj }; } } catch { } } } // Heuristique : scanne toutes les props à la recherche d’un objet 1..4 -> array d’objets Name/Quantity for (const [key, val] of Object.entries(recipe)) { if (!val) continue; try { const obj = (typeof val === "string") ? JSON.parse(val) : val; if (obj && typeof obj === "object") { const arr = obj["1"] || obj["2"] || obj["3"] || obj["4"]; if (Array.isArray(arr) && arr.length) { const it = arr[0] || {}; if (typeof it === "object" && ("Name" in it || "name" in it)) { return { prop: key, parsed: obj }; } } } } catch { } } return null; } // Lit une recette en tolérant la casse / formats function getIngredientsToHadFromRecipe(r) { const det = detectIngredientsToHadProp(r); if (!det) return []; const pRaw = r?.portions ?? r?.Portions ?? 0; const portions = clampQty(pRaw) + 1; // 1..4 const list = Array.isArray(det.parsed[portions]) ? det.parsed[portions] : []; return list.map(normalizeItem); } function collectIngredientsToHadFromAllRecipes() { const res = []; const all = Array.isArray(window.allRecipes) ? window.allRecipes : []; for (const r of all) res.push(...getIngredientsToHadFromRecipe(r)); return res; } function computeSignature(items) { const merged = mergeDuplicates(items || []); return JSON.stringify(merged.map(i => ({ n: normalizeName(i.Name), q: normalizeQuantityText(i.Quantity), img: i.Image || "" }))); } function renderNeededList(itemsArr) { const container = document.getElementById("neededIngredients"); if (!container) return; const merged = mergeDuplicates(itemsArr || []); const sig = computeSignature(merged); if (sig === lastNeededSignature) return; lastNeededSignature = sig; container.innerHTML = ""; for (const ing of merged) { const li = document.createElement("li"); li.className = "card needed"; li.innerHTML = `
${ing.Name}

${ing.Name || "(ingrédient)"}

`; container.appendChild(li); } } function updateNeededList(trigger = "manual") { const container = document.getElementById("neededIngredients"); if (!container) { console.debug("[needed]", trigger, "→ container #neededIngredients introuvable (on réessaiera)"); return; } console.debug("[needed]", trigger, "→ container OK"); // 1) DOM (non livrés) const domItems = getNotShippedIngredientsFromDOM(); if (domItems.length) { const filtered = applyNeededExclusions(domItems); renderNeededList(filtered); return; } // 2) Agrégation de TOUTES les recettes const aggregated = collectIngredientsToHadFromAllRecipes(); if (aggregated.length) { const filtered = applyNeededExclusions(aggregated); renderNeededList(filtered); return; } // 3) Dernière recette vue (fallback) if (LAST_ING_TO_HAD) { try { const parsed = JSON.parse(LAST_ING_TO_HAD || "{}"); const list = Array.isArray(parsed[LAST_PORTIONS]) ? parsed[LAST_PORTIONS] : []; const filtered = applyNeededExclusions(list); if (filtered.length) { renderNeededList(filtered); return; } } catch (e) { /* ... */ } } // 4) Rien const emptySig = "EMPTY"; if (lastNeededSignature !== emptySig) { lastNeededSignature = emptySig; container.innerHTML = "

Aucun ingrédient à afficher.

"; } console.warn("[needed]", trigger, "→ aucun item"); } // Scan DOM non-livrés (anti-boucle) function scanNotShippedAndUpdate() { const count = document.querySelectorAll(".ingredient-item-not-shipped").length; if (count !== prevNotShippedCount) { prevNotShippedCount = count; updateNeededList(); } } // ========== Recettes (strip) ========== const ownedMap = new Map(); const MAX_PORTIONS = 14; const clampQty = n => Math.max(0, Math.min(3, Number(n) || 0)); const truncate = (s, m) => (s && s.length > m ? s.slice(0, m) + "…" : (s ?? "")); function setSelectedUI(card, selected) { const slot = card?.querySelector?.(".selection-slot"); if (!slot) return; if (selected) { slot.classList.add("active"); if (!slot.querySelector(".selection-indicator")) { slot.innerHTML = `
Sélectionné
`; } } else { slot.classList.remove("active"); slot.innerHTML = ""; } } function applySelectionUIById(id) { const card = document.getElementById(String(id)); const selected = (ownedMap.get(String(id)) || 0) > 0; setSelectedUI(card, selected); } function buildLabels(tagsStr) { const wrap = document.createElement("div"); wrap.className = "label-container"; const tags = (tagsStr || "").split(/[,•]/).map(t => t.trim()).filter(Boolean).filter(t => t !== "•" && t !== "\u2022"); tags.forEach(tag => { const span = document.createElement("span"); span.className = "label-badge"; const c = (window.labelColors || {})[tag]; span.style.backgroundColor = c?.bg ?? "#eee"; span.style.color = c?.color ?? "#333"; span.textContent = tag; wrap.appendChild(span); }); return wrap; } function buildRecipeCardList(recipe) { const card = document.createElement("div"); card.className = "card"; card.id = recipe.id ?? recipe.Id; card.innerHTML = `
${truncate(recipe.name, 10)}

${recipe.name}

${recipe.tempsDePreparation}min
`.replace(/\${/g, "${"); card.addEventListener("click", async (e) => { if (e.ctrlKey) return; // laisse le Ctrl+clic tranquille const id = recipe.id ?? recipe.Id; const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1; // 1..4 if (EXCLUDED_BY_RECIPE.has(String(id))) { // ré-afficher EXCLUDED_BY_RECIPE.delete(String(id)); EXCLUDED_NEEDED_BY_RECIPE.delete(String(id)); // 👈 aussi pour "à avoir" card.classList.remove("card--excluded"); } else { // cacher const namesMain = await getIngredientNamesForRecipe(id, portions); // déjà en place chez toi EXCLUDED_BY_RECIPE.set(String(id), new Set(namesMain.map(nameKey))); const namesNeeded = getNeededNamesForRecipeObj(recipe, portions); // 👈 "à avoir" depuis l’objet recette EXCLUDED_NEEDED_BY_RECIPE.set(String(id), new Set(namesNeeded.map(nameKey))); card.classList.add("card--excluded"); } // Re-rendre les deux panneaux reapplyCurrentSearch(); // liste principale updateNeededList("exclude-toggle"); // “à avoir” }); if (IS_CORE_USER) { const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.user ?? []; const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean); if (usersArr.length) { const img = card.querySelector(".image-container"); const wrap = document.createElement("div"); wrap.className = "label-container"; usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); }); img?.appendChild(wrap); } } // Mémorise la dernière recette pour un fallback, via détection auto const det = detectIngredientsToHadProp(recipe); const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1; LAST_PORTIONS = portions; LAST_ING_TO_HAD = det ? JSON.stringify(det.parsed) : null; console.debug("[needed] buildRecipe detected prop:", det?.prop || "(none)", "portions:", portions); updateNeededList("buildRecipe"); return card; } async function loadAllRecipesHorizontal() { const strip = document.getElementById("recipeStrip"); if (!strip) return; // === Vue "partagée" (Mae/Byakuya) : ne montrer QUE leurs recettes === if (IS_CORE_USER) { try { const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`; const res = await fetch(url, { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const onlyCore = await res.json(); window.allRecipes = Array.isArray(onlyCore) ? onlyCore : []; strip.innerHTML = ""; if (!window.allRecipes.length) { strip.innerHTML = '

Aucune recette (Mae/Byakuya).

'; updateNeededList(); return; } window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" })); window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r))); updateNeededList("afterCoreRecipes"); } catch (e) { console.error("loadAllRecipesHorizontal (core) error", e); strip.innerHTML = '

Erreur de chargement.

'; updateNeededList(); } return; } // === Utilisateur "tiers" : uniquement SES recettes === try { const res = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const mine = await res.json(); window.allRecipes = Array.isArray(mine) ? mine : []; strip.innerHTML = ""; if (!window.allRecipes.length) { strip.innerHTML = '

Vous n’avez pas encore de recettes sélectionnées.

'; updateNeededList(); return; } window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" })); window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r))); updateNeededList("afterGetMyRecipes"); } catch (e) { console.error("loadAllRecipesHorizontal (mine) error", e); strip.innerHTML = '

Erreur de chargement.

'; updateNeededList(); } } async function refreshOwnedSet() { try { const r = await fetch("/HelloFresh/GetOwnedIngredients"); if (r.ok) { const names = await r.json(); OWNED_SET = new Set((names || []).map(normalizeName)); } } catch { /* ignore */ } } async function buildAggregatedIngredientsFromRows(rows) { const ids = (rows || []).map(r => r.id); const details = await fetchDetailsBatched(ids); const byId = new Map(details.map(d => [String(d.id), d])); await refreshOwnedSet(); const rawItems = []; for (const r of (rows || [])) { const det = byId.get(String(r.id)); if (!det || !det.ingredientsJson) continue; let portions = Number(r.portions || 1); if (!Number.isFinite(portions) || portions < 1) portions = 1; const base = Math.min(4, Math.max(1, portions)); const scale = (portions <= 4) ? 1 : (portions / 4); let obj; try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; } const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : []; for (const el of arr) { const it = normalizeItem(el); if (!it.Name) continue; const { value, unit } = parseQuantity(it.Quantity); if (!Number.isNaN(value) && scale !== 1) { const scaled = value * scale; it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled); } it.Owned = isOwned(it.Name); rawItems.push(it); } } return aggregateRawItems(rawItems); } function fmtNumber(n) { const i = Math.round(n); return (Math.abs(n - i) < 1e-9) ? String(i) : String(Number(n.toFixed(2))).replace(/\.?0+$/, ''); } function aggregateRawItems(rawItems) { // Somme numérique par (name, unit) + “meilleure” quantité textuelle sinon const numMap = new Map(); // key: name##unit -> {sum, unit, item} const textMap = new Map(); // key: name -> {set:Set(qty), item} for (const raw of rawItems) { const it = normalizeItem(raw); if (!it.Name) continue; const keyName = normalizeName(it.Name); const q = it.Quantity || ""; const { value, unit } = parseQuantity(q); if (!Number.isNaN(value)) { const k = `${keyName}##${unit || ""}`; const prev = numMap.get(k); if (!prev) { numMap.set(k, { sum: value, unit: unit || "", item: { ...it } }); } else { prev.sum += value; // garder une image si absente if (!prev.item.Image && it.Image) prev.item.Image = it.Image; prev.item.Owned = prev.item.Owned || it.Owned; } } else { const p = textMap.get(keyName); if (!p) { const s = new Set(); if (q) s.add(q); textMap.set(keyName, { set: s, item: { ...it } }); } else { if (q) p.set.add(q); if (!p.item.Image && it.Image) p.item.Image = it.Image; p.item.Owned = p.item.Owned || it.Owned; } } } const out = []; // numérique → format “val unit” for (const { sum, unit, item } of numMap.values()) { const qty = unit ? `${fmtNumber(sum)} ${unit}` : fmtNumber(sum); out.push({ ...item, Quantity: qty }); } // textuel → pickBestQuantity for (const { set, item } of textMap.values()) { const qty = pickBestQuantity(Array.from(set)); out.push({ ...item, Quantity: qty }); } return out; } async function fetchDetailsBatched(ids, batchSize = 120) { const all = []; for (let i = 0; i < ids.length; i += batchSize) { const part = ids.slice(i, i + batchSize); const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(part.join(','))}`); if (res.ok) all.push(...await res.json()); } return all; } async function buildMyAggregatedIngredients() { // 1) mes recettes + portions const r = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const mine = await r.json(); // [{id, portions, ...}] if (!Array.isArray(mine) || mine.length === 0) return []; // 2) détails (ingrédients par 1..4) const ids = mine.map(x => x.id); const details = await fetchDetailsBatched(ids); const byId = new Map(details.map(d => [String(d.id), d])); // 3) Owned flags (facultatif, pour tri visuel) await refreshOwnedSet(); // 4) construire la liste brute + mise à l’échelle des quantités (>4 portions) const rawItems = []; for (const rcp of mine) { const id = String(rcp.id); const det = byId.get(id); if (!det || !det.ingredientsJson) continue; let portions = Number(rcp.portions || 1); if (!Number.isFinite(portions) || portions < 1) portions = 1; const base = Math.min(4, Math.max(1, portions)); const scale = (portions <= 4) ? 1 : (portions / 4); let obj; try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; } const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : []; for (const el of arr) { const it = normalizeItem(el); if (!it.Name) continue; // scale quantité si numérique const { value, unit } = parseQuantity(it.Quantity); if (!Number.isNaN(value) && scale !== 1) { const scaled = value * scale; it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled); } it.Owned = isOwned(it.Name); rawItems.push(it); } } return aggregateRawItems(rawItems); } // ====================== // Init // ====================== document.addEventListener("DOMContentLoaded", () => { els.list = document.getElementById("ingredientsList") || null; els.search = document.getElementById("hfSearch") || null; document.querySelectorAll(".dropdown-toggle").forEach(el => el.addEventListener("click", (e) => e.preventDefault())); if (els.search) { let aiTimer; els.search.addEventListener("input", (e) => { clearTimeout(aiTimer); const val = e.target.value || ""; aiTimer = setTimeout(() => applyAISearch(val), AI_DELAY); }); els.search.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); clearTimeout(aiTimer); applyAISearch(els.search.value || ""); } }); } else { console.error("[ingredients] #hfSearch introuvable dans le DOM"); } loadAndRenderIngredients(); loadAllRecipesHorizontal(); // Premier rendu (même si le UL n'est pas encore dans le DOM, on loggue) updateNeededList("dom-ready"); // Petits réessaies pour les pages qui injectent tardivement le UL / les cartes setTimeout(() => updateNeededList("post-300ms"), 300); setTimeout(() => updateNeededList("post-1200ms"), 1200); const mo = new MutationObserver(() => { // Throttle : on regroupe les mutations if (typeof window !== "undefined") { if (mo._scheduled) return; mo._scheduled = true; setTimeout(() => { mo._scheduled = false; updateNeededList("mutation"); }, 120); } else { updateNeededList("mutation"); } }); mo.observe(document.body, { childList: true, subtree: true }); });