// 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 = `
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 = `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 }); });