Files
administration/wwwroot/js/HelloFresh/ingredients.js
2025-09-16 22:20:21 +02:00

852 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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" dune recette (à partir de lobjet 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 ditems 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 = `
<div class="image-container">
<img src="${ing.Image || ""}" alt="${ing.Name}">
</div>
<div class="card-content">
<h3>${ing.Name || "(ingrédient)"}</h3>
<div class="footerCard">
<div class="selection-slot"></div>
<div class="recipe-info">
<span class="prep-time">${ing.Quantity || ""}</span>
</div>
</div>
</div>
`;
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 = "<em>Recherche IA…</em>";
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 = "<em>Aucun ingrédient</em>";
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 = "<em>Aucun ingrédient</em>";
} 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 dun objet 1..4 -> array dobjets 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 = `
<div class="image-container"><img src="${ing.Image || ""}" alt="${ing.Name}"></div>
<div class="card-content">
<h3>${ing.Name || "(ingrédient)"}</h3>
<div class="footerCard"><div class="recipe-info"></div></div>
</div>`;
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 = "<p>Aucun ingrédient à afficher.</p>";
}
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 = `<div class="selection-indicator"><span class="selection-text">Sélectionné</span></div>`;
}
} 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 = `
<div class="image-container"><img src="${recipe.image}" alt="${truncate(recipe.name, 10)}"></div>
<div class="card-content">
<h3>${recipe.name}</h3>
<div class="footerCard"><div class="recipe-info"><span class="prep-time">${recipe.tempsDePreparation}min</span></div></div>
</div>
`.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 lobjet 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 = '<p class="placeholder">Aucune recette (Mae/Byakuya).</p>';
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 = '<p class="placeholder">Erreur de chargement.</p>';
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 = '<p class="placeholder">Vous navez pas encore de recettes sélectionnées.</p>';
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 = '<p class="placeholder">Erreur de chargement.</p>';
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 });
});