⚠️ Avant d'installer la mise à jour ! Exporte tes patients — sous peine de perte définitive de tes données.
générée.
const safeJson = (v) => JSON.stringify(v).replace(/<\//g, '<\\/');
const dataJs = `
var NT_PAR_REPAS = ${safeJson(parRepas)};
var NT_NB_PERS = ${nbPers};
var NT_ENSEIGNES = ${safeJson(enseignesData || [])};
var NT_VILLE = ${safeJson(ville || '')};
var NT_CAT_ORDER = ${safeJson(['Fruits et légumes','Viandes et poissons','Épicerie','Produits laitiers et alternatives','Boulangerie','Surgelés','Divers'])};
`;
const logicJs = String.raw`
function ntEsc(s){
return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
function ntCleanForSearch(raw){
return String(raw||'')
.split(':')[0]
.replace(/\([^)]*\)/g,'')
.replace(/\b\d+([.,]\d+)?\s*(g|gr|kg|ml|cl|l|pc|pi[èe]ces?|tranches?|tasses?|c[àa]s|c[àa]c)\b/gi,'')
.replace(/\bx\s*\d+/gi,'')
.replace(/\s+/g,' ')
.trim();
}
function ntBuildEnseigneUrl(d, raw){
if((d.name === 'Leclerc' || d.name === 'Intermarché') && NT_VILLE){
return 'https://www.google.com/search?q=' + encodeURIComponent(d.name + ' drive ' + NT_VILLE + ' ' + raw);
}
return d.url + encodeURIComponent(raw);
}
function ntFormatQty(q, u){
var uu = String(u||'').toLowerCase();
var isWhole = (uu === 'g' || uu === 'gr' || uu === 'ml');
var r = isWhole ? Math.round(q) : Math.round(q*10)/10;
return r + (u ? ' ' + u : '');
}
function ntUpdateTotals(n){
var el = document.getElementById('totalArticlesCount');
if(el) el.textContent = n;
}
function ntRenderSections(byCat){
var container = document.getElementById('courseSections');
if(!container) return;
var cats = Object.keys(byCat);
if(!cats.length){
container.innerHTML = '
Aucun repas sélectionné pour l\'instant.
';
ntUpdateTotals(0);
return;
}
cats.sort(function(a,b){
var ia = NT_CAT_ORDER.indexOf(a); if(ia<0) ia = 999;
var ib = NT_CAT_ORDER.indexOf(b); if(ib<0) ib = 999;
return ia - ib || a.localeCompare(b);
});
var total = 0;
var html = cats.map(function(cat){
var items = byCat[cat];
total += items.length;
var lis = items.map(function(it){
var cleaned = ntCleanForSearch(it);
var buttons = NT_ENSEIGNES.map(function(d){
return '' + ntEsc(d.name) + '';
}).join('');
return '
' + buttons + '
';
}).join('');
return '
' + ntEsc(cat) + '
' + lis + '
';
}).join('');
container.innerHTML = html;
ntUpdateTotals(total);
}
function ntRecompute(){
if(!NT_PAR_REPAS) return;
var checks = document.querySelectorAll('.meal-chk');
var agg = {};
checks.forEach(function(chk){
if(!chk.checked) return;
var jour = chk.dataset.jour, repas = chk.dataset.repas;
var items = (NT_PAR_REPAS[jour] && NT_PAR_REPAS[jour][repas]) || [];
items.forEach(function(it){
if(!it || !it.n || typeof it.q !== 'number' || !isFinite(it.q) || it.q <= 0) return;
var key = it.n.toLowerCase().trim() + '|' + (it.u||'');
if(!agg[key]) agg[key] = { n: it.n, u: it.u||'', c: it.c||'Divers', q: 0 };
agg[key].q += it.q * NT_NB_PERS;
});
});
var byCat = {};
Object.keys(agg).forEach(function(k){
var it = agg[k];
if(!byCat[it.c]) byCat[it.c] = [];
byCat[it.c].push(it.n + ' : ' + ntFormatQty(it.q, it.u));
});
ntRenderSections(byCat);
}
// FIX 2026-10-07 — sélecteur « nombre de personnes » côté patiente (plainte :
// impossible de changer le nombre de personnes dans la liste de courses).
// NT_NB_PERS (fiche cabinet) reste la valeur de départ ; la patiente ajuste
// de 1 à 10, la liste se recalcule dans son navigateur (0 appel IA), l'en-tête
// suit, et le choix est mémorisé dans ce navigateur pour cette page.
// Texte neutre en voix (partagé entre les 2 générateurs, comme ntRecompute).
var NT_NB_PERS_KEY = 'nt_nb_pers:' + (location.pathname || '');
function ntUpdateNbPersLabel(){
var lab = document.getElementById('ntNbPersLabel');
if(lab) lab.textContent = '\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67 ' + NT_NB_PERS + ' personne(s)';
var val = document.getElementById('ntNbPersValue');
if(val) val.textContent = NT_NB_PERS;
var minus = document.getElementById('ntNbPersMinus'), plus = document.getElementById('ntNbPersPlus');
if(minus) minus.disabled = NT_NB_PERS <= 1;
if(plus) plus.disabled = NT_NB_PERS >= 10;
}
function ntSetNbPers(n){
n = parseInt(n, 10);
if(!isFinite(n)) return;
if(n < 1) n = 1;
if(n > 10) n = 10;
NT_NB_PERS = n;
try { localStorage.setItem(NT_NB_PERS_KEY, String(n)); } catch(_){}
ntUpdateNbPersLabel();
ntRecompute();
}
function ntInitNbPers(){
if(document.getElementById('ntNbPersBox')) return;
var anchor = document.querySelector('.meal-picker') || document.getElementById('courseSections');
if(!anchor || !anchor.parentNode) return;
try {
var saved = parseInt(localStorage.getItem(NT_NB_PERS_KEY), 10);
if(isFinite(saved) && saved >= 1 && saved <= 10) NT_NB_PERS = saved;
} catch(_){}
var btn = 'width:44px;height:44px;border-radius:50%;border:1.5px solid #107ACA;background:#fff;color:#107ACA;font-size:22px;font-weight:700;line-height:1;cursor:pointer;';
var box = document.createElement('div');
box.id = 'ntNbPersBox';
box.className = 'meal-picker';
box.innerHTML =
'
\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67 Pour combien de personnes ?
' +
'
Les quantit\u00E9s de la liste se recalculent automatiquement (de 1 \u00E0 10 personnes).
'}
`).join('');
return `Recettes du cabinet — ${escH(p.prenom||'')}
📚 Recettes du cabinet
Recettes sélectionnées par votre diététicien pour ${escH(p.prenom||'vous')}${allergies.length?` — filtrées pour exclure : ${escH(allergies.join(', '))}`:''}.
${items || '
Aucune recette du cabinet disponible. La diététicienne peut importer ses recettes via 📖 Mes recettes dans la sidebar NutriDiet.
'}
Cabinet de Diététique · ${escH(today())}
`;
}
function switchTab(tab,pid){
// FIX 2026-05-12 bis — remap des anciens noms d'onglets vers les nouvelles étapes wizard
// Évite que d'anciennes refs hardcodées dans le code (renderImpedance, courses, etc.)
// pointent vers un état mort qui ne match aucune étape du wizard et perdrait le user.
if(tab === 'nutritrack'){ window.ntSuiviSub = 'nutritrack'; tab = 'suivi'; }
else if(tab === 'impedance'){ window.ntSuiviSub = 'impedance'; tab = 'suivi'; }
else if(tab === 'courses'){ tab = 'envoi'; }
currentTab=tab;
if(currentChart){currentChart.destroy();currentChart=null;}
if(window.ntChart){window.ntChart.destroy();window.ntChart=null;}
if(window.ntWeightChart){window.ntWeightChart.destroy();window.ntWeightChart=null;}
if(window.impedanceCharts){ window.impedanceCharts.forEach(c=>c.destroy()); window.impedanceCharts = []; }
const p=patients.find(x=>x.id===pid);
// FIX 2026-05-12 — wizard 5 étapes (anciens onglets remappés)
// (le re-render complet de showPatient mettra à jour l'état actif de la barre)
showPatient(p);
}
function renderTab(p){
if(currentTab==='info')renderInfo(p);
else if(currentTab==='plan')renderPlan(p);
else if(currentTab==='menus')renderMenus(p);
else if(currentTab==='envoi')renderEnvoi(p); // FIX 2026-05-12 — nouvelle étape 4 du wizard
else if(currentTab==='suivi')renderSuivi(p); // FIX 2026-05-12 — fusion ⚖️ Impédance + 📊 NutriTrack
else if(currentTab==='cr')renderCompteRendu(p); // 2026-06-17 — Compte-rendu / ordonnance diététique (IA)
// Legacy (compat ancien state) :
else if(currentTab==='courses')renderCourses(p);
else if(currentTab==='impedance')renderImpedance(p);
else if(currentTab==='nutritrack')renderNutriTrack(p);
}
// FIX 2026-05-12 — Étape 4 du wizard : Envoi à la patiente
// Hub central qui regroupe les 3 bundles d'envoi + l'accès rapide à la liste de courses.
function renderEnvoi(p){
get('tabContent').innerHTML = `
📤 Envoi à ${esc(p.prenom)}
${p.email ? '📧 '+esc(p.email) : '⚠ Aucun email — saisir dans la fiche'}
Choisis le bundle à envoyer ou à imprimer. Chaque option ouvre directement le document généré, prêt à transmettre.
📖
Menu détaillé seul
Tableau jour par jour, avec ingrédients pesés et préparation. Version IA de la semaine.
📖🛒
Menu détaillé + liste de courses
1 mail avec 2 liens cliquables : menu détaillé + liste de courses (FIX 2026-05-17).
📖🛒📚
Bundle complet
Menu + courses + recettes préférées du cabinet (que tu as importées depuis 📖 Mes recettes en .docx).
💡 Une fois le document généré, tu peux le télécharger ou ouvrir le mail pré-rempli — la patiente reçoit un lien direct, pas de pièce jointe lourde.
`;
}
// FIX 2026-05-12 v2 — Étape 5 du wizard : Suivi (fusion ⚖️ Impédance + 📊 NutriTrack)
// Le toggle bascule est inséré dans tabContent en haut, puis re-injecté via setTimeout
// pour survivre aux innerHTML async de renderNutriTrack/renderImpedance qui écrasent
// le container plusieurs fois pendant le chargement.
function renderSuivi(p){
if(!window.ntSuiviSub) window.ntSuiviSub = 'nutritrack';
const sub = window.ntSuiviSub;
// 1) On appelle le bon renderer qui écrit dans tabContent
if(sub === 'coach'){ renderCoachReview(p); }
else if(sub === 'impedance'){ if(typeof renderImpedance === 'function') renderImpedance(p); }
else { if(typeof renderNutriTrack === 'function') renderNutriTrack(p); }
// 2) Helper qui (re)injecte le toggle en haut du tabContent
const injectToggle = () => {
const tc = get('tabContent');
if(!tc) return;
if(tc.querySelector('[data-suivi-toggle="1"]')) return; // déjà présent
const toggle = document.createElement('div');
toggle.setAttribute('data-suivi-toggle', '1');
toggle.style.cssText = 'display:flex;gap:6px;margin-bottom:14px;background:var(--warm);border:1px solid var(--border);border-radius:10px;padding:4px;width:fit-content;';
toggle.innerHTML = `
`;
tc.insertBefore(toggle, tc.firstChild);
};
// 3) Injecter immédiatement (synchrone) + ré-injecter plusieurs fois pour
// survivre aux innerHTML async de renderNutriTrack (spinner → résultats).
injectToggle();
setTimeout(injectToggle, 50);
setTimeout(injectToggle, 300);
setTimeout(injectToggle, 1000);
setTimeout(injectToggle, 2500);
// Sécurité ultime : un MutationObserver qui ré-injecte si le tabContent
// est réécrit complètement (max 30s puis on s'arrête pour ne pas leak).
try {
const tc = get('tabContent');
if(tc && !tc._suiviObserver){
const obs = new MutationObserver(() => injectToggle());
obs.observe(tc, { childList: true, subtree: false });
tc._suiviObserver = obs;
setTimeout(() => { if(tc._suiviObserver){ tc._suiviObserver.disconnect(); delete tc._suiviObserver; } }, 30000);
}
} catch(_) {}
}
// ═══════════════════════════════════════════════════════════════
// 💬 COACH IA — Vue côté diet (13 mai 2026)
// ═══════════════════════════════════════════════════════════════
// Affiche les conversations Coach IA de la patiente sélectionnée.
// Le diet peut : (1) supprimer un message IA, (2) corriger une réponse IA,
// (3) répondre lui-même en tant que "Guillaume" (apparaît côté patiente
// avec un visuel spécifique).
async function renderCoachReview(p){
const tc = get('tabContent');
if(!tc) return;
tc.innerHTML = `
Chargement de la conversation…
`;
// Charge les messages depuis Supabase via la session diet
const link = JSON.parse(localStorage.getItem('nutritrack_link_v1') || '{}');
if(!link.url || !link.jwt){
tc.innerHTML = `
⚠ Connexion Supabase requise pour voir les conversations coach. Va sur l'étape « Suivi NutriTrack » et identifie-toi.
`;
return;
}
if(!p.email){
tc.innerHTML = `
⚠ Cette fiche n'a pas d'email — impossible de lier la patiente à ses messages coach.
`;
return;
}
let messages = [];
try {
// FIX 2026-05-18 — Charge aussi edited_by_dietitian/edited_at/original_content/edited_by_email
// pour afficher le badge "✓ Validé par votre diététicien" sur les réponses IA corrigées.
const r = await fetch(`${link.url}/rest/v1/coach_messages?select=id,role,content,ai_model,created_at,flagged_for_review,edited_by_dietitian,edited_at,original_content,edited_by_email&user_email=eq.${encodeURIComponent(p.email)}&order=created_at.asc&limit=500`, {
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt }
});
if(r.status === 404 || r.status === 400){
tc.innerHTML = `
📋 La table coach_messages n'existe pas encore dans Supabase. Exécute le SQL SQL-CREER-COACH-MESSAGES-2026-05-13.sql dans Supabase Studio pour activer le coach IA.
${esc(p.prenom)} n'a pas encore utilisé le Coach IA depuis son app NutriTrack. Les conversations apparaîtront ici dès qu'elle écrira un premier message.
`;
} else {
// FIX 2026-05-18 — Phase 2 : badge "✓ Validé par votre diététicien" si edited_by_dietitian.
// Le message reste role='ai' (donc dans le bon ordre + bonne couleur),
// mais on signale visuellement la validation côté diét ET côté patiente.
const validated = !!m.edited_by_dietitian;
const validatedBadge = validated
? `
${nbPatient} questions${nbAI} réponses IA
${nbDiet>0 ? `${nbDiet} tes interventions` : ''}
${nbFlag>0 ? `${nbFlag} ⚠ à revoir` : ''}
Dernier : ${lastFmt}
${msgsHTML}
👨⚕️ Intervenir dans la conversation
`;
}
// Actions du diet sur les messages IA : delete / edit / reply
async function ndCoachAction(action, msgId, pid, patientEmail){
const link = JSON.parse(localStorage.getItem('nutritrack_link_v1') || '{}');
if(!link.url || !link.jwt){ showToast('⚠ Session Supabase expirée'); return; }
if(action === 'delete'){
if(!confirm('Supprimer ce message du coach IA ? Cette action est irréversible et la patiente ne le verra plus.')) return;
try {
const r = await fetch(`${link.url}/rest/v1/coach_messages?id=eq.${msgId}`, {
method: 'DELETE',
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt }
});
if(!r.ok) throw new Error('HTTP ' + r.status);
showToast('🗑 Message supprimé');
const p = patients.find(x => x.id === pid);
if(p) renderCoachReview(p);
} catch(e){ showToast('⚠ Erreur : ' + e.message); }
} else if(action === 'edit'){
const newContent = prompt('Corriger la réponse IA — texte qui sera visible côté patiente :');
if(newContent == null || !newContent.trim()) return;
try {
// FIX 2026-05-14 v25 — récupère d'abord la question patiente précédente
// + la réponse IA originale, pour stocker l'exemple dans coach_corrections.
// FIX 2026-05-18 — Phase 2 : ajout original_content/edited_by_dietitian au SELECT
// pour savoir si c'est la 1ère édition (et préserver le texte IA originel).
const beforeR = await fetch(`${link.url}/rest/v1/coach_messages?select=id,content,role,user_email,created_at,original_content,edited_by_dietitian&id=eq.${msgId}`, {
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt }
});
const beforeData = beforeR.ok ? await beforeR.json() : [];
const aiMsg = beforeData[0];
let originalQuestion = '';
if(aiMsg){
// Trouve la question patiente juste avant ce message AI
const qR = await fetch(`${link.url}/rest/v1/coach_messages?select=content,role,created_at&user_email=eq.${encodeURIComponent(aiMsg.user_email)}&role=eq.patient&created_at=lt.${aiMsg.created_at}&order=created_at.desc&limit=1`, {
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt }
});
const qData = qR.ok ? await qR.json() : [];
originalQuestion = qData[0]?.content || '';
}
// FIX 2026-05-18 — Phase 2 : on garde role='ai' et on marque la validation diét.
// original_content : on préserve la 1re version IA (pas réécrasée si re-édition).
// Avant : on changeait role='ai' → 'dietitian', ce qui faisait disparaître la trace
// de la validation côté patiente (qui voyait une bulle diet à la place du badge).
const c0 = ntGetConfig();
const dietEmail0 = c0.dietitianEmail || (state?.user?.email) || link.dietitianEmail || '';
const patchBody = {
content: newContent.trim(),
edited_by_dietitian: true,
edited_at: new Date().toISOString(),
edited_by_email: dietEmail0 || null,
};
// Si c'est la première édition, on stocke le texte IA d'origine
if(aiMsg && !aiMsg.edited_by_dietitian && !aiMsg.original_content){
patchBody.original_content = aiMsg.content;
}
const r = await fetch(`${link.url}/rest/v1/coach_messages?id=eq.${msgId}`, {
method: 'PATCH',
headers: {
apikey: link.anonKey,
Authorization: 'Bearer ' + link.jwt,
'Content-Type': 'application/json',
Prefer: 'return=representation'
},
body: JSON.stringify(patchBody)
});
if(!r.ok){
const errText = await r.text().catch(()=>'');
if(r.status === 403 || /policy|rls|row-level/i.test(errText)){
throw new Error('Permission Supabase refusée (RLS) — il manque une policy UPDATE sur coach_messages pour le diet. Détail : ' + errText.slice(0,120));
}
throw new Error('HTTP ' + r.status + ' — ' + errText.slice(0,120));
}
// FIX 2026-05-18 — Vérif que l'UPDATE a vraiment marché (sinon erreur silencieuse RLS)
const after = await r.json().catch(()=>null);
if(Array.isArray(after) && after.length === 0){
throw new Error('PATCH n\'a modifié 0 ligne (msgId=' + msgId + ' introuvable ou RLS muet — vérifier policy UPDATE coach_messages)');
}
if(Array.isArray(after) && after[0] && !after[0].edited_by_dietitian){
throw new Error('PATCH accepté mais edited_by_dietitian reste false. Vérifie la policy UPDATE WITH CHECK sur coach_messages.');
}
// FIX 2026-05-14 v25 — Enregistre la correction comme exemple d'apprentissage
// que le coach IA pourra réutiliser dans ses futures réponses.
const c = ntGetConfig();
const dietEmail = c.dietitianEmail || (state?.user?.email) || link.dietitianEmail || '';
if(dietEmail && aiMsg){
try {
await fetch(`${link.url}/rest/v1/coach_corrections`, {
method: 'POST',
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt, 'Content-Type': 'application/json' },
body: JSON.stringify({
dietitian_email: dietEmail,
patient_email: aiMsg.user_email,
question: originalQuestion,
ai_original: aiMsg.content,
diet_correction: newContent.trim(),
correction_type: 'edit',
topic_keywords: (originalQuestion || '').toLowerCase().split(/\s+/).filter(w => w.length > 4).slice(0, 5).join(' '),
})
});
} catch(_){ /* l'enregistrement non-critique n'empêche pas la correction */ }
}
showToast('✏ Message corrigé — l\'IA s\'en souviendra pour les prochaines réponses');
const p = patients.find(x => x.id === pid);
if(p) renderCoachReview(p);
} catch(e){ showToast('⚠ Erreur : ' + e.message); }
}
}
// Envoie un message en tant que diet (Guillaume)
async function ndCoachReply(pid, patientEmail){
const textarea = document.getElementById('diet-reply-' + pid);
if(!textarea) return;
const text = textarea.value.trim();
if(!text){ showToast('⚠ Tape un message d\'abord'); return; }
const link = JSON.parse(localStorage.getItem('nutritrack_link_v1') || '{}');
if(!link.url || !link.jwt){ showToast('⚠ Session Supabase expirée'); return; }
try {
// FIX 2026-05-14 v25 — récupère la dernière question patiente non-résolue
// pour la lier à la réponse Guillaume (apprentissage IA)
let lastQuestion = '';
try {
const qR = await fetch(`${link.url}/rest/v1/coach_messages?select=content,role,created_at&user_email=eq.${encodeURIComponent(patientEmail)}&role=eq.patient&order=created_at.desc&limit=1`, {
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt }
});
const qData = qR.ok ? await qR.json() : [];
lastQuestion = qData[0]?.content || '';
} catch(_){}
const r = await fetch(`${link.url}/rest/v1/coach_messages`, {
method: 'POST',
headers: {
apikey: link.anonKey,
Authorization: 'Bearer ' + link.jwt,
'Content-Type': 'application/json',
Prefer: 'return=representation'
},
body: JSON.stringify({
user_email: patientEmail,
dietitian_email: link.dietitianEmail || null,
role: 'dietitian',
content: text
})
});
if(!r.ok) throw new Error('HTTP ' + r.status);
// FIX 2026-05-14 v25 — Enregistre la réponse Guillaume comme exemple d'apprentissage
const cfg = ntGetConfig();
const dietEmail = cfg.dietitianEmail || link.dietitianEmail || '';
if(dietEmail && lastQuestion){
try {
await fetch(`${link.url}/rest/v1/coach_corrections`, {
method: 'POST',
headers: { apikey: link.anonKey, Authorization: 'Bearer ' + link.jwt, 'Content-Type': 'application/json' },
body: JSON.stringify({
dietitian_email: dietEmail,
patient_email: patientEmail,
question: lastQuestion,
ai_original: null,
diet_correction: text,
correction_type: 'reply',
topic_keywords: lastQuestion.toLowerCase().split(/\s+/).filter(w => w.length > 4).slice(0, 5).join(' '),
})
});
} catch(_){}
}
showToast('📤 Message envoyé — l\'IA apprend de votre formulation');
textarea.value = '';
const p = patients.find(x => x.id === pid);
if(p) renderCoachReview(p);
} catch(e){ showToast('⚠ Erreur envoi : ' + e.message); }
}
// ═════════════════════════════════════════════════════════════
// 📋 ONGLET INFORMATIONS
// ═════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// ✏ ÉDITION INLINE — Auto-save sur blur (13 mai 2026)
// ═══════════════════════════════════════════════════════════════
// Chaque champ de la fiche patient devient cliquable. Au clic, le span
// est remplacé par un input. Au blur (perte de focus) ou Enter, la
// valeur est sauvegardée et le span re-rendu. Esc annule.
//
// Usage dans le HTML :
// 72 kg
//
// data-type : "text" | "number" | "date" | "select" | "textarea"
// data-options : "Option 1|Option 2|Option 3" (pour select uniquement)
// data-suffix : suffixe affiché APRÈS la valeur (ex: " kg", " cm")
// data-placeholder : texte affiché si valeur vide
function ndInlineStart(el){
if(el.querySelector('input,select,textarea')) return; // déjà en édition
const type = el.dataset.type || 'text';
const pid = el.dataset.pid;
const field= el.dataset.field;
const suffix = el.dataset.suffix || '';
// Récupère la valeur actuelle depuis l'objet patient (source de vérité)
const patient = patients.find(x=>x.id===pid);
if(!patient){ console.warn('ndInlineStart: patient introuvable', pid); return; }
const currentVal = patient[field] != null ? String(patient[field]) : '';
let input;
if(type === 'select'){
input = document.createElement('select');
input.className = 'nd-edit-select';
const opts = (el.dataset.options || '').split('|').filter(Boolean);
// Option vide
const optEmpty = document.createElement('option');
optEmpty.value=''; optEmpty.textContent='—';
input.appendChild(optEmpty);
opts.forEach(o => {
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
if(o === currentVal) opt.selected = true;
input.appendChild(opt);
});
} else if(type === 'textarea'){
input = document.createElement('textarea');
input.className = 'nd-edit-textarea';
input.value = currentVal;
} else {
input = document.createElement('input');
input.className = 'nd-edit-input';
input.type = (type === 'date') ? 'date' : (type === 'number') ? 'number' : 'text';
if(type === 'number'){
const step = el.dataset.step; if(step) input.step = step;
const min = el.dataset.min; if(min !== undefined) input.min = min;
}
input.value = currentVal;
}
// Mémorise le HTML original pour rollback si annulation
el._origHTML = el.innerHTML;
el.innerHTML = '';
el.appendChild(input);
input.focus();
if(input.select) input.select();
let committed = false;
const commit = () => {
if(committed) return; committed = true;
const newVal = (type === 'select') ? input.value : input.value.trim();
ndInlineCommit(el, newVal);
};
const cancel = () => {
if(committed) return; committed = true;
el.innerHTML = el._origHTML;
delete el._origHTML;
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if(e.key === 'Enter' && type !== 'textarea'){ e.preventDefault(); input.blur(); }
else if(e.key === 'Escape'){ e.preventDefault(); cancel(); }
else if(e.key === 'Enter' && type === 'textarea' && (e.metaKey || e.ctrlKey)){
e.preventDefault(); input.blur();
}
});
}
async function ndInlineCommit(el, newVal){
const pid = el.dataset.pid;
const field = el.dataset.field;
const suffix = el.dataset.suffix || '';
const placeholder = el.dataset.placeholder || '—';
const patient = patients.find(x=>x.id===pid);
if(!patient){ el.innerHTML = el._origHTML || newVal; return; }
const oldVal = patient[field] != null ? String(patient[field]) : '';
// Aucun changement → on rétablit l'affichage d'origine
if(newVal === oldVal){
el.innerHTML = el._origHTML || ndInlineDisplay(newVal, suffix, placeholder);
delete el._origHTML;
return;
}
patient[field] = newVal;
el.innerHTML = ndInlineDisplay(newVal, suffix, placeholder);
el.classList.toggle('nd-edit-empty', !newVal);
// Flash visuel de confirmation
el.classList.add('nd-edit-saved');
setTimeout(()=>el.classList.remove('nd-edit-saved'), 800);
try{ await autosave(); }
catch(e){ console.error('ndInlineCommit autosave failed', e); showToast && showToast('⚠ Sauvegarde échouée — recharge la page'); }
// Effets dérivés : si on touche au poids ou à la taille, on rafraîchit l'IMC affiché
if(field === 'poids' || field === 'taille'){
const imcSpan = document.querySelector(`[data-pid="${pid}"][data-field="_imc"]`);
if(imcSpan){
const imc = calcIMC(patient.poids, patient.taille);
imcSpan.innerHTML = imc || '—';
}
}
}
function ndInlineDisplay(value, suffix, placeholder){
if(!value) return esc(placeholder || '—');
return esc(value + (suffix || ''));
}
// Génère un span éditable. `type` détermine le widget. Pour les selects,
// passe les options sous forme de tableau dans `options`.
function ndEditSpan(pid, field, value, opts){
opts = opts || {};
const type = opts.type || 'text';
const suffix = opts.suffix || '';
const placeholder = opts.placeholder || '—';
const optionsAttr = (opts.options && opts.options.length) ? ` data-options="${esc(opts.options.join('|'))}"` : '';
const stepAttr = (opts.step) ? ` data-step="${esc(opts.step)}"` : '';
const minAttr = (opts.min != null) ? ` data-min="${esc(opts.min)}"` : '';
const display = value ? esc(value + suffix) : esc(placeholder);
const emptyCls = value ? '' : ' nd-edit-empty';
return `${display}`;
}
// Variante du drows() de renderInfo : chaque valeur devient éditable.
// `rows` = tableau de [label, currentValue, field, opts] (field optionnel pour valeurs calculées)
function drowsEdit(pid, rows){
return rows.map(([label, val, field, opts]) => {
let content;
if(field === '_imc' || field === '_readonly'){
// Valeur calculée non-éditable (ex: IMC, infos dérivées)
content = `${esc(val || '—')}`;
} else if(field){
content = `${ndEditSpan(pid, field, val, opts || {})}`;
} else {
// Pas de field défini → affichage simple (rare)
content = `${esc(val || '—')}`;
}
return `
${esc(label)}${content}
`;
}).join('');
}
// ═══════════════════════════════════════════════════════════════
// 📋 RENDERINFO — Tout déplié, tout éditable directement (13 mai 2026)
// ═══════════════════════════════════════════════════════════════
// Pas de clic-pour-éditer. Tous les champs sont des inputs/selects/textareas
// dès l'ouverture de la fiche. Auto-save sur blur (perte de focus) — pas de
// bouton Enregistrer.
function renderInfo(p){
const r = p.recueil || {};
const selOpt = (opts, cur) => opts.map(o => ``).join('');
const pillsHTML = (arr, sel, group, cls) => arr.map(v => {
const on = (sel||[]).includes(v);
return `${esc(v)}`;
}).join('');
// Handlers de save factorisés (blur)
const sb = (f) => `onblur="ndInfoSaveField('${p.id}','${f}',this.value)"`;
const sbr = (mealKey, subKey) => `onblur="ndInfoSaveRecueil('${p.id}','${mealKey}','${subKey}',this.value)"`;
const sbN = (sub) => `onblur="ndInfoSaveNote('${p.id}','${sub}',this.value)"`;
const imc = calcIMC(p.poids, p.taille);
// 4 cartes repas (always visible — éditable direct)
const mealForms = MEAL_CONFIG.map(({key,icon,label,color}) => {
const d = r[key] || {};
return `
${icon} ${label}
`;
}).join('');
get('tabContent').innerHTML = `
✏Tous les champs sont modifiables directement — clique dedans, modifie, et sors du champ (Tab ou clic ailleurs) pour sauvegarder. Pas besoin du bouton Modifier.
${items.map((it, idx)=>{
// FIX 2026-05-12 — Clarification automatique selon allergies actives
// L'item original est conservé pour la sauvegarde mais on AFFICHE la version
// enrichie pour le diet (ex: « céréales » → « céréales sans gluten (sarrasin/millet/riz) »)
const itDisplay = ndClarifyItemForAllergies(it, allergies);
const problem = detectProblem(itDisplay, allergies);
if(problem){
totalProblems++;
return renderItemWithAlternatives(itDisplay, problem, `plan|${p.id}|${key}|${idx}`);
}
const wasClarified = itDisplay !== it;
return `
🚫 Intolérances/allergies actives : ${allergies.map(a=>esc(a)).join(' · ')}
${totalProblems > 0
? ` 💡 ${totalProblems} aliment${totalProblems>1?'s':''} à adapter — clique dessus pour choisir un remplacement.`
: ` ✓ Aucune incompatibilité dans le plan actuel.`}
` : '';
get('tabContent').innerHTML=`
Base alimentaire
${/* FIX 2026-05-19 — Même rangée des bases dans la vue PLAN (pas seulement l'éditeur).
Quand un plan est importé (.docx), on désactive l'état "active" des bases bleues
et on ajoute un 3e bouton vert "📄 Plan Word importé" À DROITE. */ ''}
${(()=>{
const hasImport = !!plan._lastImportSource;
const importDate = hasImport && plan._lastImportAt
? new Date(plan._lastImportAt).toLocaleDateString('fr-FR', {day:'numeric',month:'short'})
: '';
const blueActive = (key) => (!hasImport && baseKey === key) ? 'active' : '';
return `
${hasImport ? `
` : ''}
⚙ pour éditer la base · clic sur un bouton pour appliquer au plan
`;
})()}
${filterBlock}
${noteH?`
${esc(noteH)}
`:''}
${cardsHTML}
${noteG?`
Notes & équivalences
${esc(noteG)}
`:''}`;
}
// Applique une base directement depuis la vue Plan alimentaire (pas l'éditeur)
// Demande confirmation si le plan a déjà été personnalisé OU importé depuis le dossier
async function applyBaseFromPlanView(pid, baseKey){
const p = patients.find(x => x.id === pid);
if(!p) return;
const base = BASES[baseKey];
if(!base){ alert('Base inconnue : ' + baseKey); return; }
const plan = p.plan || {};
const hasImport = !!plan._lastImportSource;
const hasCustom = plan.baseKey && plan.baseKey !== baseKey && (
(plan.petitDejeuner && plan.petitDejeuner.length) ||
(plan.dejeuner && plan.dejeuner.length) ||
(plan.collation && plan.collation.length) ||
(plan.diner && plan.diner.length)
);
if(hasImport){
if(!confirm(`Ce plan a été importé depuis « ${plan._lastImportSource} ».\n\nAppliquer la base « ${base.label} » va remplacer le contenu importé par celui de cette base. Le lien au fichier est conservé mais le plan sera écrasé.\n\nContinuer ?`)) return;
} else if(hasCustom){
if(!confirm(`Le plan actuel a été personnalisé.\n\nAppliquer la base « ${base.label} » va remplacer tous les aliments par ceux de cette base. Les modifications manuelles seront perdues.\n\nContinuer ?`)) return;
}
// FIX 14 mai 2026 — préserve le lien au fichier d'origine pour que le badge reste visible
const savedImportSource = plan._lastImportSource || null;
const savedImportAt = plan._lastImportAt || null;
// Applique la base
p.plan = {
baseKey,
petitDejeuner: [...(base.petitDejeuner||[])],
dejeuner: [...(base.dejeuner||[])],
collation: [...(base.collation||[])],
diner: [...(base.diner||[])],
noteHuile: base.note || '',
noteGeneral: base.notes?.general || '',
};
if(savedImportSource){ p.plan._lastImportSource = savedImportSource; p.plan._lastImportAt = savedImportAt; }
await autosave();
// FIX 2026-05-19 — Push immédiat vers NutriTrack si email présent (cf bug Camille).
// Avant : "Appliquer la base" écrivait juste en localStorage NutriDiet et le plan
// n'arrivait jamais côté patient.
if(p?.email && p?.plan){
ntPushPlanToNutriTrack(p).catch(e => console.warn('Push plan après applyBase:', e.message));
}
if(typeof showToast === 'function') showToast(`✅ Base « ${base.label} » appliquée${p?.email ? ' + push NutriTrack' : ''}`);
// Rafraîchit l'affichage
renderTab(p);
}
// Rendu d'un item avec menu déroulant d'alternatives
// context = "plan|pid|key|idx" ou "menu|pid|dayIdx|type" ou "course|pid|cat|idx"
function renderItemWithAlternatives(item, problem, context){
const id = 'alt_' + context.replace(/\|/g, '_').replace(/[^a-z0-9_]/gi, '_');
const options = problem.alternatives.map(a =>
`
${esc(a)}
`
).join('');
return `
●
${esc(item)}${esc(problem.allergy)}
Remplacer par — adapté ${esc(problem.allergy)}
${options}
✏️ Saisir un autre aliment…
Garder tel quel (ignorer)
`;
}
// Ferme tous les menus déroulants ouverts.
// FIX 2026-06-24 — on remet aussi chaque menu « porté » (portal) à sa place d'origine
// dans son .alt-wrap (voir toggleAltMenu) pour éviter tout orphelin / id dupliqué.
function closeAllAltMenus(except){
document.querySelectorAll('.alt-menu.open').forEach(m=>{
if(m.id !== except){
m.classList.remove('open');
if(m._altHome && m.parentElement === document.body){ try{ m._altHome.appendChild(m); }catch(e){} }
}
});
document.querySelectorAll('.alt-custom-form.open').forEach(f=>f.classList.remove('open'));
}
// Positionne le dropdown en position:fixed à proximité du trigger
// (évite le clipping par les conteneurs avec overflow:hidden — .plan-card, .menu-day…)
function positionAltMenu(menu){
const wrap = menu.closest('.alt-wrap');
// Si le menu a été porté dans (portal), .alt-wrap n'est plus un ancêtre →
// on retombe sur le trigger mémorisé par toggleAltMenu.
const trigger = (wrap ? wrap.querySelector('.alt-trigger') : null) || menu._altTrigger;
if(!trigger) return;
const rect = trigger.getBoundingClientRect();
// Mesure : le menu doit être visible pour mesurer son height
menu.style.visibility = 'hidden';
menu.classList.add('open');
const menuH = menu.offsetHeight;
const menuW = menu.offsetWidth;
menu.classList.remove('open');
menu.style.visibility = '';
const vw = window.innerWidth, vh = window.innerHeight;
const margin = 8;
// Largeur : au moins celle du trigger, sans dépasser la viewport
const desiredW = Math.max(rect.width, menuW);
const finalW = Math.min(desiredW, vw - 2*margin);
// Position horizontale : aligner à gauche du trigger, mais rabattre si déborde
let left = rect.left;
if(left + finalW > vw - margin) left = vw - finalW - margin;
if(left < margin) left = margin;
// Position verticale : sous le trigger si possible, sinon au-dessus
const spaceBelow = vh - rect.bottom - margin;
const spaceAbove = rect.top - margin;
let top;
if(spaceBelow >= menuH || spaceBelow >= spaceAbove){
top = rect.bottom + 4;
// Limite la hauteur si manque de place
const maxH = Math.max(180, vh - top - margin);
menu.style.maxHeight = `min(70vh, 460px, ${maxH}px)`;
} else {
const maxH = Math.max(180, spaceAbove);
menu.style.maxHeight = `min(70vh, 460px, ${maxH}px)`;
top = Math.max(margin, rect.top - Math.min(menuH, maxH) - 4);
}
menu.style.top = top + 'px';
menu.style.left = left + 'px';
menu.style.width = finalW + 'px';
// FIX 2026-06-24 — Si un ancêtre porte un transform (ex. l'animation d'onglet
// nd-fadeIn avec animation-fill-mode:both laisse un transform identité résiduel),
// position:fixed se résout par rapport à CET ancêtre et non au viewport → le menu
// (ex. ⋯ « Imprimer la semaine ») partait hors écran et restait invisible.
// On mesure l'écart réel de positionnement et on le compense.
menu.style.visibility = 'hidden';
menu.classList.add('open');
const got = menu.getBoundingClientRect();
menu.classList.remove('open');
menu.style.visibility = '';
const dx = left - got.left, dy = top - got.top;
if(Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5){
menu.style.left = (left + dx) + 'px';
menu.style.top = (top + dy) + 'px';
}
}
function toggleAltMenu(id, event){
if(event){ event.stopPropagation(); }
const menu = get(id);
if(!menu) return;
const wasOpen = menu.classList.contains('open');
closeAllAltMenus();
if(!wasOpen){
// FIX 2026-06-24 — PORTAL : on déplace le menu directement dans avant de
// l'ouvrir. Raison : à l'intérieur du contenu d'onglet, n'importe quel ancêtre qui
// crée un « stacking context » (transform résiduel d'animation, opacité < 1 pendant
// un fondu, carte .menu-day, etc.) peut recouvrir ou enfermer ce menu position:fixed.
// Dans , plus aucun ancêtre ne peut le masquer → z-index 9999 toujours au-dessus.
// closeAllAltMenus() le remet à sa place d'origine à la fermeture.
const wrap = menu.closest('.alt-wrap');
const trigger = wrap ? wrap.querySelector('.alt-trigger') : null;
if(trigger){ menu._altTrigger = trigger; menu._altHome = menu.parentElement; }
if(menu.parentElement !== document.body){ document.body.appendChild(menu); }
positionAltMenu(menu);
menu.classList.add('open');
}
}
// Fermer les menus quand on scroll ou redimensionne (positionnement fixe → décalage sinon)
window.addEventListener('scroll', ()=>closeAllAltMenus(), true);
window.addEventListener('resize', ()=>closeAllAltMenus());
function toggleCustomInput(id, event){
if(event){ event.stopPropagation(); }
const form = get(id+'_custom');
if(!form) return;
form.classList.toggle('open');
if(form.classList.contains('open')) setTimeout(()=>get(id+'_custom_input')?.focus(), 50);
}
function submitCustom(id, context){
const input = get(id+'_custom_input');
if(!input) return;
const val = input.value.trim();
if(!val) return;
applyAlternative(null, context, val);
}
// Applique un remplacement. context = "type|pid|key|idx"
async function applyAlternative(event, context, newValue){
if(event) event.stopPropagation();
closeAllAltMenus();
const parts = context.split('|');
const type = parts[0];
const pid = parts[1];
const p = patients.find(x=>x.id===pid);
if(!p) return;
if(type === 'plan'){
const mealKey = parts[2];
const idx = parseInt(parts[3]);
// S'assurer que le plan existe avec les valeurs de base
if(!p.plan) p.plan = {};
if(!p.plan[mealKey]){
const baseKey = p.plan.baseKey || detectBase(p.motifs) || 'colopathie';
p.plan[mealKey] = [...(BASES[baseKey][mealKey] || [])];
}
if(newValue !== null){
p.plan[mealKey][idx] = newValue;
}
// Si newValue est null → on garde tel quel (pas de changement)
await autosave();
renderPlan(p);
if(newValue !== null) showToast('✅ Aliment remplacé');
}
else if(type === 'menu'){
const dayIdx = parseInt(parts[2]);
const mealType = parts[3]; // 'dejeuner' ou 'diner'
if(newValue !== null && p.menus && p.menus[dayIdx]){
p.menus[dayIdx][mealType] = newValue;
await autosave();
renderMenus(p);
showToast('✅ Plat remplacé');
}
}
else if(type === 'course'){
const cat = decodeURIComponent(parts[2]);
const idx = parseInt(parts[3]);
if(newValue !== null && p.courses && p.courses[cat]){
p.courses[cat][idx] = newValue;
await autosave();
renderCourses(p);
showToast('✅ Article remplacé');
}
}
}
// Fermer les menus quand on clique ailleurs
document.addEventListener('click', (e)=>{
if(!e.target.closest('.alt-wrap')) closeAllAltMenus();
});
// ═════════════════════════════════════════════════════════════
// ⌨️ RACCOURCIS CLAVIER GLOBAUX (FIX 2026-05-12)
// ═════════════════════════════════════════════════════════════
// / → focus la barre de recherche patients
// Esc → ferme le modal ouvert OU retour à la liste patient si en édition
// n → nouveau patient (uniquement hors champ texte)
// Cmd+S → sauve la fiche si on est dans showForm
// ? → affiche les raccourcis (cheat sheet)
//
// Tous les raccourcis sont DÉSACTIVÉS quand l'utilisateur tape dans un input/textarea
// (sauf Esc et Cmd+S qui doivent toujours marcher).
document.addEventListener('keydown', (e) => {
const tag = (e.target.tagName || '').toUpperCase();
const isTyping = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target.isContentEditable);
const cmd = e.metaKey || e.ctrlKey;
// Cmd+S : sauvegarde formulaire patient si on est dans showForm
if(cmd && e.key.toLowerCase() === 's'){
const saveBtn = document.querySelector('.btn-primary[onclick*="saveForm"], .btn-save-plan[onclick*="savePlan"]');
if(saveBtn){
e.preventDefault();
saveBtn.click();
return;
}
}
// Esc : ferme modal en priorité, sinon retour navigation
if(e.key === 'Escape'){
const modal = document.querySelector('.modal-overlay');
if(modal){
const closeBtn = modal.querySelector('button[onclick*="remove"], [onclick*="closest(\'.modal-overlay\').remove"]');
if(closeBtn) closeBtn.click();
else modal.remove();
e.preventDefault();
return;
}
// Sinon : si on est dans un formulaire patient, cliquer le bouton Retour
const retourBtn = document.querySelector('.page-hdr .btn-edit[onclick*="selectPatient"], .page-hdr .btn-edit[onclick*="cancelNewPatient"], .page-hdr .btn-edit[onclick*="showDashboard"]');
if(retourBtn){
retourBtn.click();
e.preventDefault();
return;
}
}
if(isTyping) return; // bloque les raccourcis 1-touche dans les inputs
// / : focus la barre de recherche
if(e.key === '/'){
const search = document.getElementById('searchInput');
if(search){ e.preventDefault(); search.focus(); search.select(); }
return;
}
// n : nouveau patient
if(e.key === 'n' || e.key === 'N'){
if(typeof showForm === 'function'){ e.preventDefault(); showForm(null); }
return;
}
// ? : afficher les raccourcis
if(e.key === '?'){
e.preventDefault();
showKeyboardShortcuts();
return;
}
});
function showKeyboardShortcuts(){
if(document.getElementById('shortcuts-modal')) return;
const div = document.createElement('div');
div.id = 'shortcuts-modal';
div.className = 'modal-overlay';
div.style.cssText = 'position:fixed;inset:0;background:rgba(40,30,20,0.55);backdrop-filter:blur(4px);z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px;';
div.innerHTML = `
⌨️ Raccourcis clavier
/
Recherche patient
n
Nouveau patient
Esc
Fermer modal / Retour
⌘S
Enregistrer la fiche
?
Afficher cet écran
Astuce : appuie sur « ? » à n'importe quel moment pour revoir cette liste.
`;
document.body.appendChild(div);
}
// ═════════════════════════════════════════════════════════════
// 🗓 ONGLET MENUS
// ═════════════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════════
// ⚙ OPTIONS DE GÉNÉRATION DES MENUS IA — FIX 2026-05-27 v2.2
// Permet au diét de contrôler ce qui est envoyé au LLM. ON forcés :
// plan, aliments à éviter, allergies, régime (sécurité). OFF par défaut :
// motif, aimes, nbPersonnes, cuisine, activite. Stockage localStorage par patient.
// ═════════════════════════════════════════════════════════════
// ── Stockage options/consigne de génération : IndexedDB + cache mémoire ──────
// FIX 2026-07-23 : le localStorage était SATURÉ (miroir base ~5 Mo) → les
// écritures brutes (localStorage.setItem) échouaient EN SILENCE, donc la
// consigne « que des salades » et les cases d'options ne se sauvaient jamais.
// On passe par IndexedDB (source de vérité, comme la base patientes) + un cache
// mémoire pour la lecture SYNCHRONE (les getters sont appelés dans le rendu et
// la construction du prompt) + miroir localStorage best-effort. Une seule clé
// IDB = map { patientId: { ...options, styleConsigne } }.
const NDMG_KEY = 'nutridiet_v3_menu_gen_toggles_MAP';
let ndMgCache = null; // {id:{...}} — null tant que non amorcé
function ndMgEnsureCache(){
if(ndMgCache) return ndMgCache;
ndMgCache = {};
// Reprise best-effort des anciennes clés localStorage par patiente (héritage).
try {
for(let i=0;i
Oriente le style des menus IA. Le plan alimentaire reste toujours prioritaire : quantités et groupes d'aliments ne bougent pas. Ex. : ${esc(ex)}
`;
}
// Rend le panel `` avec les checkboxes des options de génération.
// Insérer avant les boutons Générer/Regénérer. État pré-rempli depuis localStorage.
function renderMenuGenOptionsPanel(p){
if(!p || !p.id) return '';
const opts = getMenuGenOptions(p);
const checkbox = (key, label, hint) => `
`;
return `
${renderMenuStyleField(p)}
⚙ Options de génération · données patient envoyées à l'IA
L'IA reçoit toujours le plan alimentaire et les vetos médicaux. Tu peux ajouter d'autres infos patient via les cases ci-dessous (OFF par défaut pour ne pas surcharger le contexte).
Toujours actifs (non désactivables)
✓ Plan alimentaire · ✓ Aliments à éviter · ✓ Allergies/intolérances · ✓ Régime · ✓ Identité patient (prénom, sexe, âge)
Optionnels — coche pour activer
${checkbox('motif', 'Motif principal', 'perte de poids, colopathie/SII, diabète…')}
${checkbox('aimes', 'Aliments appréciés', 'le texte libre saisi dans la fiche')}
${checkbox('nbPersonnes', 'Personnes au foyer', 'utile pour les quantités')}
${checkbox('cuisine', 'Cuisine à domicile', 'utile pour la complexité des recettes')}
${checkbox('activite', 'Activité physique', 'utile pour les apports énergétiques')}
`;
}
// ═════════════════════════════════════════════════════════════
// 🛡️ VALIDATEUR DE MENU — FIX 2026-05-18 (filet de sécurité après IA)
// Détecte les exclusions implicites du plan (sans lactose, sans gluten,
// sans FODMAP, sans légumineuses) et scanne les 14 plats pour signaler
// les violations. Indépendant du prompt IA — c'est une garantie.
// ═════════════════════════════════════════════════════════════
function ndValidateMenuAgainstPlan(p){
if(!p || !p.menus || !p.menus.length) return { rules: [], violations: [] };
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const base = BASES[baseKey] || {};
// Texte du plan complet pour détecter les mots-clés d'exclusion implicite
const planText = [
...(p.plan?.petitDejeuner || base.petitDejeuner || []),
...(p.plan?.dejeuner || base.dejeuner || []),
...(p.plan?.collation || base.collation || []),
...(p.plan?.diner || base.diner || []),
].join(' | ').toLowerCase();
const motifsText = ((p.motifs || []).join(' ') + ' ' + baseKey).toLowerCase();
const rules = [];
// === SANS LACTOSE ===
const lactoseTriggers = [
/sans\s*lactose/, /\b0%\s*lactose/, /lactose[- ]?free/,
/\balpro\b/, /skyr\s*alpro/, /yaourt\s+(?:de\s+)?(?:soja|amande|coco)/,
/lait\s+v[ée]g[ée]tal/, /lait\s+(?:d['']?\s*)?(?:amande|soja|coco|riz|avoine)/,
/saint[- ]?hubert/, /cr[èe]me\s+(?:de\s+)?(?:soja|amande|coco)/,
/faisselle\s+de\s+soja/, /\bsoja\b/
];
if(lactoseTriggers.some(rx => rx.test(planText))) rules.push('SANS LACTOSE');
// === SANS GLUTEN ===
const glutenTriggers = [
/sans\s*gluten/, /farine\s+(?:de\s+)?(?:riz|ch[âa]taigne|schar)/,
/pain\s+(?:sans\s+gluten|aux?\s+fleurs|de\s+fleurs)/,
/flocons?\s+(?:de\s+)?(?:quinoa|riz|sarrasin)/,
/\bschar\b/, /\bquinoa\b/, /\bsarrasin\b/, /\bmillet\b/
];
// Mais si le plan mentionne explicitement du blé/seigle/pâtes blé → pas sans gluten
const glutenAllowed = /\b(?:bl[ée]|seigle|orge|[ée]peautre|kamut|p[âa]tes\s+(?:blanc|bl[ée]|complet))\b/.test(planText);
if(glutenTriggers.some(rx => rx.test(planText)) && !glutenAllowed) rules.push('SANS GLUTEN');
// === SANS FODMAP ===
const fodmapTriggers = [
/cf\s*fodmap/, /sans\s*fodmap/, /low\s*fodmap/, /pauvre\s*en\s*fodmap/,
/\bsii\b/, /\bcolopathie\b/, /irritable/
];
// FIX 2026-05-28 — Ne plus déclencher FODMAP sur les motifs (Colopathie/SII en tag de base
// est juste un template, c'est le plan visible qui prime — cf hiérarchie v2.2)
if(fodmapTriggers.some(rx => rx.test(planText))) rules.push('SANS FODMAP');
// === SANS LÉGUMINEUSES (si AUCUNE mention dans le plan) ===
const legumesMentioned = /\b(?:l[ée]gumineuses?|lentilles?|pois\s+chiches?|haricots?\s+(?:rouges?|blancs?|secs?|noirs?|mungo)|f[èe]ves|edamame|tofu|tempeh)\b/.test(planText);
// FIX 2026-05-28 — Idem : ne plus déclencher SANS LÉGUMINEUSES sur les motifs
if(!legumesMentioned && fodmapTriggers.some(rx => rx.test(planText))) {
rules.push('SANS LÉGUMINEUSES');
}
// === VPO INTERDIT par repas (FIX 2026-05-27 — Lola FABRE) ===
// Pour chaque repas, on regarde si une protéine animale est explicitement listée.
// Si NON → le menu de ce repas ne doit pas contenir de viande/poisson/œuf.
const vpoTriggerRx = /\bvpo\b|\bviande[s]?\b|\bpoisson[s]?\b|(?:^|\W)(?:oeuf|œuf)s?(?=\W|$)|\bjambon[s]?\b|\b(?:poulet|dinde|b[oeœ]uf|veau|porc|agneau|canard|magret|lapin|pintade|coquelet|caille|gibier|filet\s+mignon|roastbeef|grisons|gésiers?|bavette|aiguillettes?)\b|\b(?:cabillaud|saumon|thon|dorade|sole|lieu|merlan|sardine[s]?|maquereau|hareng|truite|rouget|lotte|raie|espadon|turbot|mulet|colin|limande|loup|fl[ée]tan)\b|\b(?:crevette[s]?|gambas|langoustines?|moule[s]?|hu[iî]tre[s]?|(?:st\.?|saint)\s*jacques|calamar|seiche|poulpe|crabe|tourteau|bulot[s]?|coque[s]?|oursin[s]?)\b/i;
const planByMeal = {
petitDejeuner: (p.plan?.petitDejeuner || base.petitDejeuner || []).join(' | '),
dejeuner: (p.plan?.dejeuner || base.dejeuner || []).join(' | '),
collation: (p.plan?.collation || base.collation || []).join(' | '),
diner: (p.plan?.diner || base.diner || []).join(' | '),
};
const vpoAuthorized = {
petitDejeuner: vpoTriggerRx.test(planByMeal.petitDejeuner),
dejeuner: vpoTriggerRx.test(planByMeal.dejeuner),
collation: vpoTriggerRx.test(planByMeal.collation),
diner: vpoTriggerRx.test(planByMeal.diner),
};
if(!vpoAuthorized.dejeuner) rules.push('VPO INTERDIT (déjeuner)');
if(!vpoAuthorized.diner) rules.push('VPO INTERDIT (dîner)');
// === VPO MANQUANTE par repas (FIX 2026-09-07 — Hélène JACHT) ===
// Symétrique de « VPO INTERDIT » : quand le plan EXIGE une VPO au déjeuner/dîner, chaque
// plat de ce repas doit en contenir une. Avant ce fix, un « curry de lentilles corail »
// au déjeuner (plan : VPO 200-250g) passait sans alerte — le prompt savait interdire la VPO,
// pas l'exiger, et le validateur non plus. Sauf si le plan la rend facultative
// (« VPO +/- », « facultatif », « optionnel », « si faim »).
// Trois garde-fous (contre-vérification 07/09 12h50) : (a) VPO facultative dans la ligne du plan,
// (b) alternative végétale explicite sur la même ligne (« VPO ou tofu », « VPO / légumineuses »),
// (c) régime végétarien / végétalien / vegan déclaré sur la fiche — on n'ajoute JAMAIS de viande
// à une patiente végétarienne même si le plan garde le mot « VPO » d'un template.
const vpoOptionalRx = /\b(?:vpo|viandes?|poissons?|(?:oeuf|œuf)s?)\b[^|]{0,30}(?:\+\s*\/\s*[-−]|facultati|optionn|si\s+faim|selon\s+(?:la\s+)?faim)/i;
const vpoVegAltRx = /\b(?:vpo|viandes?|poissons?|(?:oeuf|œuf)s?)\b[^|]{0,40}(?:\bou\b|\/)\s*(?:tofu|tempeh|seitan|l[ée]gumineuses?|lentilles?|pois\s+chiches?|soja|prot[ée]ines?\s+v[ée]g[ée]tales?)/i;
const regimeVegetal = /v[ée]g[ée]tarien|v[ée]g[ée]talien|vegan|v[ée]gane/i.test(p.regime || '');
const vpoRequired = {
dejeuner: vpoAuthorized.dejeuner && !regimeVegetal && !vpoOptionalRx.test(planByMeal.dejeuner) && !vpoVegAltRx.test(planByMeal.dejeuner),
diner: vpoAuthorized.diner && !regimeVegetal && !vpoOptionalRx.test(planByMeal.diner) && !vpoVegAltRx.test(planByMeal.diner),
};
// Détection de PRÉSENCE d'une VPO dans un plat — volontairement plus LARGE que vpoViolators
// (qui sert à interdire, donc calibrée pour la précision). Ici l'erreur coûteuse est le faux
// « manquant » (un plat correct réécrit pour rien) : tolère pluriels, accents, ligatures,
// termes génériques (volaille, omelette), poissons et pièces non listés côté interdit.
// Bornes accent-safe (JS \b est ASCII : « haché » / « Saint-Jacques » / « boeuf » y échappent).
const vpoPresenceRx = new RegExp('(?:^|[^a-zà-ÿœ])(?:' + [
'vpo', 'viandes?', 'poissons?', 'volailles?', 'jambons?', 'charcuteries?', 'fruits\\s+de\\s+mer', 'crustac[ée]s?', 'coquillages?', 'prot[ée]ines?\\s+animales?',
'(?:oeuf|œuf)s?', 'omelettes?', 'frittatas?', 'tortillas?',
'poulets?', 'dindes?', 'dindonneaux?', 'b(?:oe|œ|eu)ufs?', 'beef', 'veaux?', 'porcs?', 'agneaux?', 'canards?', 'magrets?', 'lapins?', 'pintades?', 'coquelets?', 'cailles?', 'gibiers?',
'roastbeef', 'rosbif', 'grisons', 'g[ée]siers?', 'bavettes?', 'aiguillettes?', 'filets?\\s+mignons?', 'steaks?(?!\\s+(?:de\\s+soja|v[ée]g[ée]tal|de\\s+l[ée]gumineuses?|de\\s+lentilles|de\\s+tofu))', 'tournedos', 'rumstecks?', 'onglets?', 'paupiettes?', 'escalopes?', 'supr[êe]mes?',
'cuisses?', 'pilons?', 'foies?', 'rognons?', 'boudins?', 'keftas?', 'kebabs?', 'chicken', 'bulgogi', 'osso\\s*bucco', '(? ((txt || '').split(' | ').find(l => vpoTriggerRx.test(l)) || '').trim();
// === ALIMENTS À ÉVITER (FIX 2026-05-27 v2.2) — veto strict du diét ===
// Le champ p.eviter (texte libre séparé par virgules dans NutriDiet) est un veto strict
// distinct des allergies. Si présent, on scanne pour chaque terme + variante plurielle.
const eviterTerms = (p.eviter||'')
.toLowerCase()
.split(/[,;]+/)
.map(s => s.trim())
.filter(s => s.length >= 3); // ignore les mots trop courts ("à", "ou", "de"…)
if(eviterTerms.length > 0) rules.push('ALIMENTS À ÉVITER');
// === SCAN DES VIOLATIONS DANS LES MENUS ===
const violations = [];
const addV = (day, meal, content, rule, term) => violations.push({ day, meal, content, rule, term });
// === FRÉQUENCES HEBDOMADAIRES (FIX 2026-05-28) — féculent/légumineuse au dîner ===
// Parse les lignes du plan dîner pour trouver une mention de fréquence ("1 à 2 fois par semaine",
// "max 3 fois/sem", etc.) associée à féculents/légumineuses/pomme de terre. Si trouvée, compte
// les dîners contenant un féculent et flag les occurrences excédentaires. Les nouilles de
// konjac/shirataki sont volontairement EXCLUES du compteur — ce sont des légumes.
const dinerLines = (p.plan?.diner || base.diner || []);
const freqRx = /(\d+)(?:\s*à\s*(\d+))?\s*fois\s*(?:par|\/)\s*semaine/i;
const feculentCatRx = /c[ée]r[ée]ales?|f[ée]culent|l[ée]gumineuse|pomme\s+de\s+terre|patate\s+douce|riz\b|quinoa|sarrasin|boulgour|lentilles?|pois\s+chiches?|haricots?\s+secs?|f[èe]ves|polenta|semoule|millet|[ée]peautre|avoine|sachet\s+bjorg/i;
let maxFeculentDiner = null;
dinerLines.forEach(line => {
const m = (line||'').match(freqRx);
if(!m) return;
if(!feculentCatRx.test(line)) return;
const high = parseInt(m[2] || m[1], 10);
if(!isNaN(high) && (maxFeculentDiner === null || high < maxFeculentDiner)) {
maxFeculentDiner = high;
}
});
// Regex pour détecter un féculent/légumineuse mentionné dans le titre d'un plat.
// Note : on n'utilise PAS `\b` à cause des accents en JS regex non-Unicode (édamame
// ne matche pas avec `\b`). À la place : début de chaîne ou séparateur (espace/virgule/etc.).
const feculentInDishRx = /(?:^|[\s,(.;:'])(?:riz(?!\s+sauvage)|quinoa|sarrasin|boulgour|lentilles?|pois\s+chiches?|haricots?\s+(?:rouges?|blancs?|secs?|noirs?)|f[èe]ves|[eé]damame|polenta|semoule|couscous|millet|[ée]peautre|avoine(?!\s+aux)|p[âa]tes(?!\s+de\s+konjac)|patate\s+douce|pomme\s+de\s+terre|gnocchi|ch[âa]taignes?|topinambour)/i;
if(maxFeculentDiner !== null && p.menus && p.menus.length) {
let counter = 0;
p.menus.forEach((j, i) => {
const day = JOURS[i] || ('Jour ' + (i+1));
const din = (j.diner||'').toString();
if(feculentInDishRx.test(din)) {
counter++;
if(counter > maxFeculentDiner) {
const m = din.match(feculentInDishRx);
const matched = m ? m[0].replace(/^[\s,(.;:']+/, '').trim() : '';
const ruleName = 'FRÉQUENCE FÉCULENT DÎNER (max ' + maxFeculentDiner + '/sem)';
if(!rules.includes(ruleName)) rules.push(ruleName);
addV(day, 'Dîner', din, ruleName, 'féculent excédentaire (« ' + matched + ' », occurrence n°' + counter + ' sur la semaine)');
}
}
});
}
const lactoseViolators = [
{ rx: /(? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const eviterViolators = eviterTerms.map(t => ({
rx: new RegExp('\\b' + escapeRx(t) + 's?\\b', 'i'),
term: t
}));
const scan = (text, validators, rule, day, meal) => {
for(const v of validators){
const m = text.match(v.rx);
if(m) addV(day, meal, text, rule, v.term + ' (« ' + m[0] + ' »)');
}
};
// === LIBELLÉ NON FRANÇAIS (FIX 2026-09-18 — cas REPETTI « cuisine chilienne ») ===
// Symptôme : la consigne de style fait rédiger les plats en espagnol (« Cabillada 120g »,
// « Pollo al jugo », « porotos negros y maíz ») et TOUS les filets deviennent aveugles —
// ils lisent des libellés français. Mesuré le 18/09 : « Cabillaud » → VPO vue ; « Cabillada »,
// « Pollo », « Merluza » → VPO NON vue (6 fausses violations « VPO manquante » alors que les
// dîners contenaient bien un poisson) ; « maíz », « porotos », « papas », « arroz » → invisibles
// pour la blacklist amidon ET pour le garde de portion. La liste de courses partait en espagnol.
// Règle retenue par Guillaume : le TITRE peut garder le nom local, les ALIMENTS sont en français.
// Seuil à 2 marqueurs : un titre typé seul (« Pastel de choclo ») n'en contient qu'un et ne
// déclenche rien ; une composition rédigée en langue étrangère en contient toujours plusieurs.
const langueEtrangereRx = new RegExp('(?:^|[^a-zà-ÿœ])(' + [
// espagnol — protéines & féculents
'pollo','pescados?','carne','res','cerdo','pavo','merluza','huevos?','atun','camarones','mariscos',
'arroz','papas','patatas','ma[ií]z','choclos?','porotos','frijoles','lentejas','garbanzos','fideos','harina',
// espagnol — légumes, fruits, laitages, divers
'verduras','ensalada','sopa','guiso','cazuela','zapallo','acelga','cebolla','cebollino','palta','aceituna',
'queso','yogur','leche','manzana','pl[áa]tano','naranja','frutilla','durazno','aceite','az[úu]car',
'cocid[oa]s?','saltead[oa]s?','asad[oa]s?','al\\s+horno','cdta','cda','taza',
// anglais / italien
'chicken','beef','pork','rice','salad','cheese','yogurt','bread','eggs?','tbsp','tsp',
'riso','formaggio','insalata','zuppa'
].join('|') + ')(?=$|[^a-zà-ÿœ])', 'gi');
const marqueursEtrangers = (text) => {
const t = String(text || '');
langueEtrangereRx.lastIndex = 0;
const found = [];
let m;
while((m = langueEtrangereRx.exec(t)) !== null){
const mot = m[1];
if(found.indexOf(mot.toLowerCase()) === -1) found.push(mot.toLowerCase());
if(found.length > 5) break;
}
return found;
};
p.menus.forEach((j, i) => {
const day = JOURS[i] || ('Jour ' + (i+1));
const dej = (j.dejeuner || j.lunch || '').toString();
const din = (j.diner || j.dinner || '').toString();
if(rules.includes('SANS LACTOSE')){
scan(dej, lactoseViolators, 'SANS LACTOSE', day, 'Déjeuner');
scan(din, lactoseViolators, 'SANS LACTOSE', day, 'Dîner');
}
if(rules.includes('SANS GLUTEN')){
scan(dej, glutenViolators, 'SANS GLUTEN', day, 'Déjeuner');
scan(din, glutenViolators, 'SANS GLUTEN', day, 'Dîner');
}
if(rules.includes('SANS FODMAP')){
scan(dej, fodmapViolators, 'SANS FODMAP', day, 'Déjeuner');
scan(din, fodmapViolators, 'SANS FODMAP', day, 'Dîner');
}
if(rules.includes('SANS LÉGUMINEUSES')){
scan(dej, legumesViolators, 'SANS LÉGUMINEUSES', day, 'Déjeuner');
scan(din, legumesViolators, 'SANS LÉGUMINEUSES', day, 'Dîner');
}
// VPO interdit : on scanne UNIQUEMENT le repas concerné (FIX 2026-05-27)
if(rules.includes('VPO INTERDIT (déjeuner)')){
scan(dej, vpoViolators, 'VPO INTERDIT (déjeuner)', day, 'Déjeuner');
}
if(rules.includes('VPO INTERDIT (dîner)')){
scan(din, vpoViolators, 'VPO INTERDIT (dîner)', day, 'Dîner');
}
// VPO manquante (FIX 2026-09-07) : le plan exige une VPO et AUCUN terme animal n'est dans le plat.
// Présence = vpoViolators (listes de l'interdit) OU vpoPresenceRx (liste large, voir plus haut).
// Les règles INTERDIT et MANQUANTE sont exclusives par construction (vpoAuthorized).
const hasVPO = (text) => vpoViolators.some(v => v.rx.test(text)) || vpoPresenceRx.test(text);
if(rules.includes('VPO MANQUANTE (déjeuner)') && dej.trim() && !hasVPO(dej)){
addV(day, 'Déjeuner', dej, 'VPO MANQUANTE (déjeuner)', 'aucune protéine animale — le plan exige « ' + (vpoPlanLine(planByMeal.dejeuner) || 'VPO') + ' »');
}
if(rules.includes('VPO MANQUANTE (dîner)') && din.trim() && !hasVPO(din)){
addV(day, 'Dîner', din, 'VPO MANQUANTE (dîner)', 'aucune protéine animale — le plan exige « ' + (vpoPlanLine(planByMeal.diner) || 'VPO') + ' »');
}
// Aliments à éviter : scan déj + dîner pour chaque terme du veto (FIX 2026-05-27 v2.2)
if(rules.includes('ALIMENTS À ÉVITER')){
scan(dej, eviterViolators, 'ALIMENTS À ÉVITER', day, 'Déjeuner');
scan(din, eviterViolators, 'ALIMENTS À ÉVITER', day, 'Dîner');
}
// Libellé non français (FIX 2026-09-18) : 2 marqueurs ou plus = la composition est
// rédigée en langue étrangère, donc illisible pour la patiente ET pour les filets.
[[dej, 'Déjeuner'], [din, 'Dîner']].forEach(function(pair){
if(!pair[0] || !pair[0].trim()) return;
const mots = marqueursEtrangers(pair[0]);
if(mots.length >= 2){
addV(day, pair[1], pair[0], 'LIBELLÉ NON FRANÇAIS',
'aliments écrits en langue étrangère (« ' + mots.slice(0,4).join(' », « ') + ' ») — invisibles pour les contrôles allergies/portions et illisibles pour la patiente');
}
});
});
if(violations.some(v => v.rule === 'LIBELLÉ NON FRANÇAIS')) rules.push('LIBELLÉ NON FRANÇAIS');
// === MONOTONIE PROTÉINE (FIX 2026-09-08 — kevin SECK) ===
// Symptôme : consigne de style « oriental, méditerranéen » → les 7 déjeuners en poisson
// (dorade, lieu, rouget, cabillaud…), sans la moindre alerte. Rien ne pouvait l'attraper :
// la règle « pas la même protéine plus de 2 fois » du prompt compte les protéines UNE PAR
// UNE (4 poissons différents = 0 répétition) et l'auto-vérification finale ne regardait pas
// les FAMILLES. Le fix VPO du 07/09 a aggravé l'effet en supprimant la soupape légumineuse.
// Ici on compte par famille sur les 7 jours d'un même repas.
//
// Garde-fous — l'erreur coûteuse serait de faire réécrire un plat conforme :
// (a) uniquement sur un repas où le plan EXIGE une VPO (vpoRequired) ;
// (b) désactivé dès qu'une consigne du diététicien restreint les familles (aliments à
// éviter, régime, allergies, consigne de style) — « que du poisson », « pas de viande »,
// « pescétarien », allergie aux fruits de mer… ne doivent JAMAIS déclencher d'alerte ;
// (c) un plat où plusieurs familles cohabitent (surf & turf) ou aucune n'est reconnue
// n'est compté nulle part — sous-compter est sans danger, sur-compter ne l'est pas ;
// (d) seuil volontairement haut (plus de 4 plats sur 7) : on vise la monotonie franche,
// pas l'équilibre parfait, qui reste le métier du diététicien.
const MAX_FAMILLE_SEM = 4;
// Listes propres à cette règle, dérivées de vpoPresenceRx (liste LARGE) et découpées par
// famille. On ne réutilise PAS vpoViolators : calibré pour l'interdiction, il rate des
// pièces courantes à cause des bornes \b ASCII (« steak haché » : le \b après « é »
// n'existe pas en JS non-Unicode — même piège que « bœuf » et « Saint-Jacques »).
// D'où les bornes accent-safe ci-dessous, identiques à celles de vpoPresenceRx.
const famRx = (mots) => new RegExp('(?:^|[^a-zà-ÿœ])(?:' + mots.join('|') + ')(?=$|[^a-zà-ÿœ])', 'i');
const famillesVPO = [
{ nom: 'poisson ou fruits de mer', rx: [famRx([
'poissons?', 'fruits\\s+de\\s+mer', 'crustac[ée]s?', 'coquillages?', 'bouillabaisses?',
'cabillauds?', 'saumons?', 'thons?', 'dorades?', 'daurades?', 'soles?', 'lieu(?!\\s+de)s?', 'merlans?', 'sardines?', 'maquereaux?', 'harengs?', 'truites?', 'rougets?', 'lottes?', 'raies?', 'espadons?', 'turbots?', 'mulets?', 'colins?', 'limandes?', 'loups?', 'fl[ée]tans?', 'bars?', 'merlus?', '[ée]glefins?', 'haddocks?', 'tilapias?', 'anchois', 'morues?', 'm[ée]rous?', 'saint-?pierres?', 'bonites?', 'rascasses?', 'grondins?', 'carrelets?', 'brochets?', 'empereurs?', 'sabres?', 'vivaneaux?', 'plies?', 'perches?', 'pagres?', 'pageots?', 'dentis?', 'sars?', 'congres?', 'chinchards?', 'tacauds?', 'barbues?', 'flets?', 'capelans?', '[ée]perlans?', 'pangasius', 'f[ée]ras?', 'lingues?', 's[ée]rioles?', 'mahi[\\s-]*mahi', 'thazards?', 'saumonettes?', 'roussettes?', 'baudroies?', 'aiglefins?', 'anguilles?', 'aloses?', 'silures?', 'carpes?', 'tanches?', 'mostelles?', 'cerniers?', 'surimis?', 'bigorneaux?', 'tellines?', 'ormeaux?', 'supions?', 'chipirons?', 'sandres?', 'ombles?',
'crevettes?', 'gambas', 'langoustines?', 'langoustes?', 'homards?', 'moules?', 'hu[iî]tres?', '(?:st\\.?|saint)[\\s-]*jacques', 'calamars?', 'calmars?', 'encornets?', 'seiches?', 'poulpes?', 'crabes?', 'tourteaux?', 'bulots?', 'coques?', 'oursins?', 'palourdes?', 'praires?', '[ée]crevisses?',
])] },
{ nom: 'viande ou volaille', rx: [famRx([
'viandes?', 'volailles?', 'jambons?', 'charcuteries?',
'poulets?', 'dindes?', 'dindonneaux?', 'b(?:oe|œ|eu)ufs?', 'veaux?', 'porcs?', 'agneaux?', 'canards?', 'magrets?', 'lapins?', 'pintades?', 'coquelets?', 'cailles?', 'gibiers?',
'roastbeef', 'rosbif', 'grisons', 'g[ée]siers?', 'bavettes?', 'aiguillettes?', 'filets?\\s+mignons?', 'steaks?(?!\\s+(?:de\\s+soja|v[ée]g[ée]tal|de\\s+l[ée]gumineuses?|de\\s+lentilles|de\\s+tofu))', 'tournedos', 'rumstecks?', 'onglets?', 'paupiettes?', 'escalopes?', 'supr[êe]mes?',
'cuisses?', 'pilons?', 'keftas?', 'kebabs?', 'bulgogi', 'osso\\s*bucco', 'hachis', 'blanquettes?', 'tandooris?',
'bacon', 'lardons?', 'saucisses?', 'saucissons?', 'mortadelle', 'chorizo', 'rillettes?', 'merguez', 'andouilles?',
])] },
{ nom: 'œufs', rx: [famRx(['(?:oeuf|œuf)s?', 'omelettes?', 'frittatas?'])] },
];
const consigneStyleTxt = (typeof getMenuStyleConsigne === 'function') ? (getMenuStyleConsigne(p) || '') : '';
const restrictionTxt = [
p.eviter || '', p.regime || '', (p.allergies || []).join(' '), consigneStyleTxt
].join(' ').toLowerCase();
// Volontairement large : la simple MENTION d'une famille dans une consigne ou une
// restriction suffit à désactiver la règle (fail-safe).
// FIX 2026-09-14 (Manon TENTELIER) — mais une SOUS-CATÉGORIE n'est pas une famille.
// « viande rouge » interdite laisse toute la volaille disponible : désactiver le quota
// dans ce cas rendait le validateur muet, et la patiente a reçu 6 déjeuners de poisson
// sur 7 sans la moindre alerte. On neutralise donc les sous-catégories AVANT le test —
// sur une COPIE locale, jamais sur p.eviter, qui continue de partir intégralement dans
// le prompt comme interdit. Si un mot de famille subsiste après neutralisation
// (« viande rouge, volaille »), la règle se désactive comme avant : le fail-safe tient.
const sousCategorieRx = /viandes?\s+(?:rouges?|blanches?|hach[ée]es?)|poissons?\s+(?:gras|maigres?|fum[ée]s?|pan[ée]s?|crus?|d'[ée]levage|sauvages?)|(?:oeuf|œuf)s?\s+crus?/gi;
const familleMentionRx = /viandes?|volailles?|poissons?|fruits\s+de\s+mer|crustac[ée]s?|coquillages?|(?:oeuf|œuf)s?|pesc[ée]?tarien|v[ée]g[ée]tarien|v[ée]g[ée]talien|vegan|flexitarien/i;
const restrictionNette = restrictionTxt.replace(sousCategorieRx, ' ');
const varieteDesactivee = familleMentionRx.test(restrictionNette);
// Motif lisible : QUI a désactivé le quota (le silence du validateur a déjà coûté une
// session de recherche à Guillaume le 14/09 — il doit être explicable en 2 secondes).
let varieteMotif = '';
if(varieteDesactivee){
const sources = [
['aliments à éviter', p.eviter || ''],
['régime', p.regime || ''],
['allergies', (p.allergies || []).join(' ')],
['consigne de style', consigneStyleTxt],
];
const trouve = sources.find(([, txt]) => familleMentionRx.test((txt || '').toLowerCase().replace(sousCategorieRx, ' ')));
if(trouve){
const mot = ((trouve[1] || '').toLowerCase().replace(sousCategorieRx, ' ').match(familleMentionRx) || [''])[0];
varieteMotif = 'mention « ' + mot + ' » dans ' + trouve[0];
}
}
if(!varieteDesactivee){
[['dejeuner', 'Déjeuner', 'déjeuners'], ['diner', 'Dîner', 'dîners']].forEach(([type, label, pluriel]) => {
if(!vpoRequired[type]) return;
const compteur = {};
p.menus.forEach((j, i) => {
const day = JOURS[i] || ('Jour ' + (i+1));
const txt = (type === 'dejeuner' ? (j.dejeuner || j.lunch || '') : (j.diner || j.dinner || '')).toString();
if(!txt.trim()) return;
const found = famillesVPO.filter(f => f.rx.some(rx => rx.test(txt)));
if(found.length !== 1) return; // ambigu ou non reconnu → non compté
const fam = found[0].nom;
compteur[fam] = (compteur[fam] || 0) + 1;
if(compteur[fam] > MAX_FAMILLE_SEM){
const ruleName = 'MONOTONIE PROTÉINE (' + label.toLowerCase() + ')';
if(!rules.includes(ruleName)) rules.push(ruleName);
addV(day, label, txt, ruleName, fam + ' pour la ' + compteur[fam] + 'e fois sur 7 ' + pluriel + ' (maximum ' + MAX_FAMILLE_SEM + ')');
}
});
});
}
// Diagnostic (même esprit que window.__ndCoursesDiag) : dit pourquoi le quota famille
// s'est tu, sans rien afficher de plus à l'écran quand tout va bien (mode silencieux).
const quotaFamille = { actif: !varieteDesactivee, motif: varieteMotif };
try { window.__ndMenuDiag = { patient: (p.prenom||'') + ' ' + (p.nom||''), regles: rules.slice(), violations: violations.length, quotaFamille: quotaFamille }; } catch(e){}
return { rules, violations, quotaFamille };
}
// Bloc HTML pour afficher le résultat du validateur
function ndRenderValidationBlock(p){
const r = ndValidateMenuAgainstPlan(p);
if(!r.rules.length) return ''; // Aucune règle inférée → rien à valider
// FIX 2026-09-14 : quand le panneau s'affiche, dire aussi si le quota famille est muet
// et pourquoi (sinon une monotonie passe inaperçue sans explication possible).
const quotaNote = (r.quotaFamille && !r.quotaFamille.actif && r.quotaFamille.motif)
? `
ℹ️ Quota « variété des familles de protéines » désactivé (${esc(r.quotaFamille.motif)}) — la monotonie n'est pas surveillée sur cette fiche.
`
: '';
const rulesBadges = r.rules.map(rule =>
`${esc(rule)}`
).join('');
// FIX 2026-05-18 — Mode silencieux : pas de panneau vert si tout OK (Guillaume veut une UX épurée).
// On affiche le panneau UNIQUEMENT si des violations restent malgré l'auto-correction.
if(r.violations.length === 0) return '';
// Regrouper par règle pour lisibilité
const byRule = {};
r.violations.forEach(v => { (byRule[v.rule] = byRule[v.rule] || []).push(v); });
const rulesHtml = Object.entries(byRule).map(([rule, list]) => `
🛡️ Validation auto : ⚠️ ${r.violations.length} violation${r.violations.length>1?'s':''} détectée${r.violations.length>1?'s':''}
Règles inférées depuis le plan : ${rulesBadges}
${quotaNote}
${rulesHtml}
💡 Clique sur « Regénérer les menus IA » pour relancer la génération avec ces contraintes — si ça plante encore, vérifie que ton plan ne contient pas d'incohérence (ex : « skyr Alpro » au déjeuner + « skyr » au dîner = ambigu).
`;
}
function renderMenus(p){
if(!p.menus||p.menus.length===0){
get('tabContent').innerHTML=`
🗓️
Aucun menu généré
Clique sur "Générer les menus IA" pour créer les menus de la semaine adaptés à ${esc(p.prenom)}
🔄 Régénérer ce plat par l'IA (adapté aux contraintes)
${options}
✏️ Saisir un autre plat…
Garder tel quel
`;
}
// Pas de problème mais on garde la clarification cosmétique (ex: gluten → précise céréales)
const wasClarified = displayContent !== content;
return `
🚫 Intolérances/allergies actives : ${allergies.map(a=>esc(a)).join(' · ')}
${totalProblems > 0
? ` 💡 ${totalProblems} plat${totalProblems>1?'s':''} à adapter — clique dessus pour choisir un remplacement.`
: ` ✓ Tous les menus respectent les contraintes.`}
` : '';
get('tabContent').innerHTML=`
Menus de la semaine
Actions sur les menus
🖨️ Imprimer la semaine
📧 Envoyer par mail
📖 Version détaillée
🛒 Générer la liste de courses
⚡ Regénérer sans IA (local, plus rapide)
${renderMenuGenOptionsPanel(p)}
${filterBlock}
${ndRenderValidationBlock(p)}
${html}`;
}
// ═════════════════════════════════════════════════════════════
// 🖨️ IMPRESSION DES MENUS DE LA SEMAINE
// ═════════════════════════════════════════════════════════════
/* ============================================================================
COMPTE-RENDU DE CONSULTATION — Ordonnance diététique (IA + impression A4)
Ajouté 2026-06-17. Stockage : p.comptesRendus[] (localStorage chiffré, autosave).
IA : proxy Netlify Haiku, avec fallback heuristique local si l'appel échoue.
============================================================================ */
const CR_CATALOGS = {
gen: { title:'Compléments — Classique', items:[
{nom:"Tardyferon B9", posolo:"1 comprimé par jour", ind:"fer / folates"},
{nom:"Granion de chrome", posolo:"2 ampoules de 200 µg le matin à jeun (2 boîtes)", ind:"glycémie / fringales"},
{nom:"Berbérine", posolo:"500 mg au repas (3×/jour)", ind:"glycémie"},
{nom:"Debridat", posolo:"1 cp avant les repas — matin, midi et soir — pendant 10 j", ind:"digestif"},
{nom:"NAC", posolo:"600 mg matin et fin d'après-midi", ind:"antioxydant"},
{nom:"Griffonia simplicifolia", posolo:"2 gélules matin et soir", ind:"satiété / humeur"},
{nom:"Lactibiane Gasseri (PiLeJe) ou iProbio Gasseri SBT2055", posolo:"1 gélule par jour le matin à jeun", ind:"probiotique"},
{nom:"Nopal", posolo:"2 gélules midi et soir", ind:"satiété"},
{nom:"Chitosan", posolo:"2 gélules midi et soir", ind:"captation des graisses"},
{nom:"Huile de nigelle", posolo:"1 c. à café le matin à jeun", ind:""},
{nom:"Spasmine", posolo:"en cas de crampe / spasme", ind:"antispasmodique"},
{nom:"Isoflavone", posolo:"80 mg par jour (2 gélules)", ind:""},
{nom:"Granion Chrome – Konjac – Psyllium", posolo:"3 gélules au moment des fringales", ind:"fringales"},
{nom:"Vitamine C liposomale", posolo:"1 g par jour", ind:""},
{nom:"IBS Colilen", posolo:"2 capsules par jour", ind:"colopathie"},
{nom:"Permea Regul", posolo:"1 stick par jour", ind:"colopathie"},
{nom:"Glucomannane (poudre de konjac)", posolo:"3 g, 30 min avant les repas", ind:"satiété — fibre soluble"},
{nom:"L-glutamine", posolo:"5 à 10 g par jour (entre les repas)", ind:"paroi intestinale"},
{nom:"HE Menthe poivrée", posolo:"1 goutte dans 1 c. à café de miel après le repas, max 2×/jour", ind:"antispasmodique"},
{nom:"Konjac (poudre)", posolo:"1,3 g/j (3 gélules le midi avant les repas)", ind:"satiété"},
{nom:"Nutergia Ergyzym", posolo:"1 à 2 gélules avant les repas", ind:"enzymes digestives"},
{nom:"Pâte d'edamame", posolo:"en collation protéinée", ind:"protéines"}
]},
sii: { title:'Compléments — Côlon irritable', items:[
{nom:"Zinc bisglycinate", posolo:"15 à 30 mg/j", ind:"colopathie"},
{nom:"Symbiosys Alflorex", posolo:"1 gélule le matin à jeun, pendant 1 mois minimum", ind:"probiotique"},
{nom:"Ergyzym (enzymes digestives)", posolo:"1 gélule aux 2 principaux repas (2/j)", ind:"enzymes"},
{nom:"Enzymae", posolo:"1 gélule avant les repas", ind:"enzymes"},
{nom:"Oméga-3 (EPA/DHA)", posolo:"1000 à 2000 mg/j (1 g EPA+DHA)", ind:"anti-inflammatoire"},
{nom:"Magnésium bisglycinate", posolo:"200 à 400 mg/j", ind:"colopathie"},
{nom:"Curcuma + poivre noir", posolo:"1000 mg/j", ind:"anti-inflammatoire"},
{nom:"Charbon actif", posolo:"1 à 2 gélules en cas de crise", ind:"ballonnements"},
{nom:"Charbon végétal actif", posolo:"1000 mg/j (5 gélules avant les repas) pendant 10 j", ind:"ballonnements"},
{nom:"Psyllium blond", posolo:"1 c. à café/j avec de l'eau", ind:"fibres / transit"},
{nom:"Phloroglucinol (Spasfon)", posolo:"1 à 2 comprimés en cas de crise", ind:"spasmes"},
{nom:"Skyr végétal au lait d'amande bio", posolo:"1 par jour", ind:"colopathie"},
{nom:"Bouillon d'os", posolo:"1 tasse/j", ind:"paroi intestinale"},
{nom:"Kéfir d'eau / lait de coco", posolo:"100 ml/j", ind:"probiotique"},
{nom:"Météospasmyl", posolo:"après les repas si ballonnement", ind:"ballonnements"},
{nom:"S. boulardii (Ultra-Levure)", posolo:"250 mg matin et soir, pendant 10 j", ind:"diarrhée"},
{nom:"Carbosymag", posolo:"1 dose avant les repas (3/j) pendant 1 mois", ind:"troubles digestifs"},
{nom:"Flore Duo Excence", posolo:"1 gélule/j le matin, à distance des repas", ind:"probiotique"}
]},
constip: { title:'Compléments — Constipation', items:[
{nom:"Météoxane", posolo:"1 gélule avant chaque repas", ind:"ballonnements"},
{nom:"Macrogol", posolo:"1 sachet/j (si besoin rapide)", ind:"laxatif osmotique"},
{nom:"Forlax", posolo:"1 à 2 sachets par jour", ind:"laxatif osmotique"},
{nom:"Activia transit turbo", posolo:"1 par jour", ind:"transit"},
{nom:"Paragar", posolo:"1 c. à soupe le soir", ind:"laxatif lubrifiant"},
{nom:"Dulcolax", posolo:"1 comprimé le soir, ponctuellement", ind:"laxatif stimulant"},
{nom:"Glycérine (suppositoire)", posolo:"1 si besoin", ind:"laxatif local"},
{nom:"Spagulax", posolo:"1 sachet ou 2 c. à café par jour", ind:"fibres / mucilage"},
{nom:"Huile de paraffine", posolo:"1 c. à soupe par jour", ind:"lubrifiant"},
{nom:"Hépar (eau)", posolo:"1 verre le matin à jeun", ind:"magnésium / transit"},
{nom:"Psyllium blond", posolo:"1 c. à café/j avec de l'eau", ind:"fibres / transit"},
{nom:"Pruneau", posolo:"2 à 3 le matin", ind:"transit"},
{nom:"Magnésium", posolo:"300 à 400 mg par jour", ind:"transit"},
{nom:"Eau de Quinton", posolo:"1 ampoule par jour", ind:"minéralisation"},
{nom:"All-Bran (son de blé)", posolo:"30 g au petit-déjeuner", ind:"fibres"},
{nom:"Hygiaflore transit", posolo:"2 cp le soir", ind:"transit"},
{nom:"Aloe vera (capsule)", posolo:"1 capsule par jour", ind:"transit"},
{nom:"Infusion Richter", posolo:"1 tasse le soir", ind:"transit"},
{nom:"Digestic", posolo:"selon notice", ind:"transit"},
{nom:"Jus de betterave", posolo:"1 verre par jour", ind:"transit"},
{nom:"Boire min. 2 L d'eau par jour", posolo:"réparti sur la journée", ind:"hydratation"}
]}
};
const CR_ALL = [].concat(CR_CATALOGS.gen.items, CR_CATALOGS.sii.items, CR_CATALOGS.constip.items);
let crPicked = new Map(); // nom -> {nom,posolo}
let crCurrent = null; // {pid, dateAff, dateISO, docHTML}
let crTempSel = new Map(); // sélection en cours dans la modale
// ═════════════════════════════════════════════════════════════
// 💊 BASE COMPLÉMENTS DU CABINET (extraite des 132 ordonnances Word)
// Fichier : complements-cabinet.json — [{nom, posologie, variantes[], type, freq, key}]
// Sert : (1) barre de recherche dans le compte-rendu, (2) injection des
// posologies RÉELLES du cabinet dans la génération IA (prioritaires sur
// une posologie standard générique).
// ═════════════════════════════════════════════════════════════
window.CABINET_COMPLEMENTS = [];
window.CABINET_LOADED = false;
function ndCabinetNormKey(n){
n = (n||'').toLowerCase().trim();
n = n.normalize('NFD').replace(/[̀-ͯ]/g,''); // retire accents
n = n.replace(/\(.*?\)/g,'').replace(/[^a-z0-9 ]/g,' ').replace(/\s+/g,' ').trim();
const alias = {'granion de chrome':'chrome','griffonia simplicifolia':'griffonia',
'glucomannane ou poudre de konjac':'glucomannane','konjac poudre':'konjac',
'poudre de konjac':'konjac','l glutamine':'l-glutamine'};
return alias[n] || n;
}
async function ndLoadCabinet(){
if(window.CABINET_LOADED) return window.CABINET_COMPLEMENTS;
try{
const r = await fetch('/complements-cabinet.json?v=' + Date.now(), {cache:'no-store'});
if(r.ok){ window.CABINET_COMPLEMENTS = await r.json(); }
}catch(e){ /* silencieux : base indisponible = fonctionne sans */ }
window.CABINET_LOADED = true;
return window.CABINET_COMPLEMENTS;
}
// map key -> entrée (pour l'override IA)
function ndCabinetMatch(nom){
const k = ndCabinetNormKey(nom);
if(!k) return null;
return (window.CABINET_COMPLEMENTS||[]).find(e => e.key === k) || null;
}
// recherche pour la barre du compte-rendu
function crDbSearch(q){
const box = get('cr_dbresults'); if(!box) return;
q = (q||'').toLowerCase().trim();
const norm = s => (s||'').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'');
const nq = norm(q);
if(!nq){ box.style.display='none'; box.innerHTML=''; return; }
const list = (window.CABINET_COMPLEMENTS||[]).filter(e =>
norm(e.nom).includes(nq) || (e.key||'').includes(nq) || norm(e.posologie).includes(nq))
.slice(0, 12);
if(!list.length){
box.style.display='block';
box.innerHTML = `
`;
}).join('');
}
const sc=get('cr_selcnt'); if(sc) sc.textContent = crTempSel.size;
}
function crToggleTemp(key, nom){
if(crTempSel.has(nom)) crTempSel.delete(nom);
else { const c = CR_ALL.find(x=>x.nom===nom); if(c) crTempSel.set(nom,{nom:c.nom,posolo:c.posolo}); }
crRenderModalList(key, get('cr_search') ? get('cr_search').value : '');
}
function crCommit(){
crPicked = new Map(crTempSel);
crCloseModal();
crRenderChips();
if(crPicked.size) showToast(crPicked.size+' complément'+(crPicked.size>1?'s':'')+' sélectionné'+(crPicked.size>1?'s':''));
}
function crRenderChips(){
const z = get('cr_chips'); if(!z) return;
z.innerHTML = [...crPicked.values()].map(c=>
`${esc(c.nom.split(' (')[0])}✕`).join('');
document.querySelectorAll('.cr-cc').forEach(el=>{
const items = CR_CATALOGS[el.dataset.cc].items;
let n=0; items.forEach(c=>{ if(crPicked.has(c.nom)) n++; });
el.textContent = n ? '('+n+')' : '';
});
}
function crRemoveChip(nom){ crPicked.delete(nom); crRenderChips(); }
/* ---- analyse locale (fallback si l'IA échoue) ---- */
function crLocalAnalyse(raw){
const KW = {
complement:["complément","complement","magnésium","magnesium","chrome","vitamine","gélule","gelule","ampoule","probiotique","oméga","omega","zinc","spiruline","levure","psyllium","granion","cure","comprimé","comprime","berbérine","berberine","charbon","glutamine","curcuma"],
activite:["marche","marcher","sport","activité physique","course","courir","natation","nager","vélo","velo","musculation","footing","yoga","pilates","renforcement","cardio","gainage","randonnée","randonnee"],
progres:["perte","perdu","perdre","kg","kilo","maigri","progrès","progres","amélioration","amelioration","stable","stabilis","motivé","motive","confort digestif","énergie","energie","tour de taille","imc","bravo","félicit","felicit","baisse","diminution"],
rdv:["revoir","rendez-vous","rdv","prochain","reviens","dans un mois","dans 3 semaines","dans 2 semaines","dans 15 jours","dans 6 semaines","dans 4 semaines"]
};
const has=(t,l)=>{const x=t.toLowerCase();return l.some(k=>x.includes(k));};
const frags = raw.replace(/\r/g,'').split(/[\n;.]+/).map(s=>s.trim()).filter(s=>s.length>1);
const splitPos=(f)=>{let m=f.match(/^(.{2,40}?)\s*[—\-:,]\s*(.+)$/);if(m&&/\d/.test(m[2]))return{nom:m[1].trim(),posolo:m[2].trim()};m=f.match(/^(.+?)\s+(\d.+)$/);if(m)return{nom:m[1].trim(),posolo:m[2].trim()};return{nom:f.trim(),posolo:''};};
const res={complements:[],recommandations:[],activite:[],progres:'',prochainRDV:''};
const prog=[];
frags.forEach(f=>{
if(has(f,KW.rdv)&&f.length<60){ const m=f.match(/dans\s+\d+\s*(jours?|semaines?|mois)/i)||f.match(/dans\s+(un|une|deux|trois)\s*(mois|semaines?)/i); res.prochainRDV=m?(m[0].charAt(0).toUpperCase()+m[0].slice(1)):f.trim(); return; }
if(has(f,KW.complement)){
const parsed = splitPos(f);
if(parsed.posolo){ res.complements.push(parsed); }
else {
// pas de posologie détectée → découper une liste « X et Y + Z » en compléments séparés
f.split(/\s*(?:\+|,|\bet\b|\bpuis\b|\bavec\b|&)\s*/i)
.map(s=>s.trim()).filter(s=>s.length>1)
.forEach(nom=> res.complements.push({nom:nom.charAt(0).toUpperCase()+nom.slice(1), posolo:'', duree:''}));
if(!res.complements.length) res.complements.push(parsed);
}
return;
}
if(has(f,KW.activite)){ res.activite.push(splitPos(f)); return; }
if(has(f,KW.progres)){ prog.push(f.charAt(0).toUpperCase()+f.slice(1)); return; }
res.recommandations.push(f.charAt(0).toUpperCase()+f.slice(1));
});
res.progres = prog.join('. ');
return res;
}
/* ---- génération (IA proxy + fallback) ---- */
async function crGenerate(pid){
const p = patients.find(x=>x.id===pid); if(!p) return;
let raw = (get('cr_notes') ? get('cr_notes').value : '').trim();
if(!raw && crPicked.size===0){ showToast('✍️ Saisissez quelques notes ou cochez des compléments'); return; }
const btn = get('cr_genbtn'); if(btn){ btn.disabled=true; btn.textContent='Rédaction en cours…'; btn.style.opacity='.6'; }
await ndLoadCabinet(); // s'assure que la base cabinet est prête pour l'injection des posologies
let sections=null;
try {
const ctx = [];
if((p.motifs||[]).length) ctx.push('Motifs : '+p.motifs.join(', '));
if((p.allergies||[]).length) ctx.push('Allergies/intolérances : '+p.allergies.join(', '));
if(p.regime) ctx.push('Régime : '+p.regime);
const pickedArr = [...crPicked.values()];
const prompt = `Tu assistes un diététicien-nutritionniste (homme) qui vouvoie ses patientes. À partir de ses notes de consultation (souvent saisies vite, abrégées et avec des fautes), structure une ordonnance diététique en CORRIGEANT et en reformulant proprement le texte.
Patiente : ${p.prenom} ${(p.nom||'')}. ${ctx.join(' · ')}
Notes du praticien : """${raw||'(aucune note libre)'}"""
${pickedArr.length? 'Compléments DÉJÀ sélectionnés (conserve la posologie EXACTE, ne la modifie pas) : '+JSON.stringify(pickedArr) : ''}
Réponds UNIQUEMENT par un JSON valide (aucun texte autour), au format :
{"complements":[{"nom":"","posolo":"","duree":""}],"recommandations":["..."],"activite":[{"nom":"","posolo":""}],"progres":"","prochainRDV":""}
Règles : CORRIGE systématiquement toutes les fautes d'orthographe, de grammaire, de conjugaison, d'accord et de syntaxe présentes dans les notes ; rédige chaque champ texte en français professionnel, clair et grammaticalement correct, au vouvoiement — SANS inventer aucune donnée clinique sur la patiente (poids, symptômes, antécédents) non mentionnée ; "progres" = une phrase encourageante au vouvoiement (ou "") ; place d'abord les compléments sélectionnés puis ceux éventuellement cités dans les notes (sans doublon) ; "prochainRDV" ex "Dans 3 semaines" (ou "").
IMPORTANT pour "recommandations" (les CONSEILS du praticien) : reste STRICTEMENT FIDÈLE à l'intention du praticien. Corrige seulement l'orthographe/grammaire et rends la phrase claire, mais NE CHANGE PAS le sens, N'INVERSE JAMAIS une instruction, et N'AJOUTE PAS de justification médicale non écrite. Reformulation MINIMALE, courte, proche de ses mots.
Exemples : "arrete les fodmap 1 semaine pour voir au niveau digestif" → "Arrêter les FODMAP pendant 1 semaine pour évaluer l'effet digestif" (et surtout PAS "arrêt de l'éviction des FODMAP" ni "réintroduire les aliments" — ce serait l'inverse). "boire 1,5L" → "Boire 1,5 L d'eau par jour".
POUR CHAQUE COMPLÉMENT, l'ordonnance doit être complète et exploitable :
- "posolo" = la posologie journalière : dose + nombre de prises + moment (ex "1 gélule (300 mg) le matin", "1 gélule matin et soir au repas", "2 mesures (200 mg) le soir au coucher"). Si le praticien a DÉJÀ indiqué une posologie dans les notes, conserve-la EXACTEMENT (ne corrige que la casse/typographie, jamais les doses). Si aucune posologie n'est indiquée pour un complément cité, RENSEIGNE la posologie usuelle/standard reconnue de ce complément pour un adulte.
- "duree" = la durée de cure usuelle recommandée pour ce complément (ex "1 mois", "2 mois", "cure de 3 mois puis réévaluation"). Renseigne-la toujours, avec la durée standard si le praticien ne l'a pas précisée.
Utilise uniquement les posologies et durées usuelles standard de compléments alimentaires pour adulte ; en cas de doute reste sur une dose prudente. Ce document est relu et modifiable par le praticien avant impression.`;
const resp = await fetch('/.netlify/functions/api-proxy', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ model:'claude-haiku-4-5-20251001', max_tokens: 4000, messages:[{role:'user',content:prompt}] })
});
const rawText = await resp.text();
if(!rawText || !resp.ok) throw new Error('proxy');
const data = JSON.parse(rawText);
if(data.error) throw new Error('api');
const text = (data.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('').trim();
// Parsing autonome (ex-bug : tryParseJSON était une fonction imbriquée du générateur de menus,
// invisible ici → l'ordonnance basculait TOUJOURS en secours local sans posologie).
// On retire les fences ```json et on extrait le 1er objet {...}.
let jtxt = text.replace(/```json/gi,'').replace(/```/g,'').trim();
const jm = jtxt.match(/\{[\s\S]*\}/);
sections = JSON.parse(jm ? jm[0] : jtxt);
if(!sections || typeof sections!=='object') throw new Error('parse');
} catch(e){
sections = crLocalAnalyse(raw); // fallback local, jamais bloquant
}
// normalisation + garantie que les compléments cochés sont présents avec leur posologie exacte
sections.complements = Array.isArray(sections.complements)?sections.complements:[];
sections.recommandations = Array.isArray(sections.recommandations)?sections.recommandations:[];
sections.activite = Array.isArray(sections.activite)?sections.activite:[];
sections.progres = sections.progres || '';
sections.prochainRDV = sections.prochainRDV || '';
const merged = [...crPicked.values()].map(c=>({nom:c.nom, posolo:c.posolo||'', duree:c.duree||''}));
const idx = new Map(merged.map((c,i)=>[(c.nom||'').toLowerCase(), i]));
sections.complements.forEach(c=>{
if(!c || !c.nom) return;
const k = c.nom.toLowerCase();
if(idx.has(k)){
// pré-sélectionné : on garde SA posologie exacte, on complète juste la durée (et la posolo si vide)
const m = merged[idx.get(k)];
if(!m.duree && c.duree) m.duree = c.duree;
if(!m.posolo && c.posolo) m.posolo = c.posolo;
} else {
merged.push({nom:c.nom, posolo:c.posolo||'', duree:c.duree||''});
idx.set(k, merged.length-1);
}
});
sections.complements = merged;
// 💊 INJECTION DES POSOLOGIES DU CABINET : pour tout complément reconnu dans la base
// du cabinet, on utilise TA posologie (issue de tes ordonnances) plutôt qu'une standard
// générique de l'IA. On respecte : (1) les compléments choisis manuellement (crPicked),
// (2) une posologie que TU as explicitement écrite dans les notes.
try {
const rawLow = (raw||'').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'');
const pickedKeys = new Set([...crPicked.values()].map(c=>ndCabinetNormKey(c.nom)));
sections.complements.forEach(c=>{
if(!c || !c.nom) return;
const key = ndCabinetNormKey(c.nom);
if(pickedKeys.has(key)) return; // choix manuel → on respecte
const hit = ndCabinetMatch(c.nom);
if(!hit) return;
const toks = key.split(' ').filter(t=>t.length>2);
const noteHasDose = toks.some(t=> new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'[^.;\\n]{0,40}\\d').test(rawLow));
if(!c.posolo || !noteHasDose){ c.posolo = hit.posologie; } // sinon on garde la dose écrite dans les notes
});
} catch(e){ /* base indisponible → on garde les posologies de l'IA */ }
const dateAff = new Date().toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'});
const docHTML = crBuildDoc(p, sections, dateAff, false, raw);
crCurrent = { pid:p.id, dateAff, dateISO: (typeof todayISO==='function'?todayISO():new Date().toISOString().slice(0,10)), docHTML };
crShowPreview(docHTML);
crRenderActions(p.id);
await crUpsertArchive(p, docHTML); // 2026-06-18 — archivage AUTOMATIQUE à la génération (upsert sur la date du jour, pas de doublon)
if(btn){ btn.disabled=false; btn.textContent='✦ Régénérer le compte-rendu'; btn.style.opacity='1'; btn.style.background='#107ACA'; }
crDirty=false;
showToast('✓ Compte-rendu généré et archivé au dossier — vérifiez et corrigez si besoin');
}
// signale que les notes ont changé après une génération → l'A4 est à mettre à jour
let crDirty=false;
function crNotesDirty(){
if(!crCurrent || crDirty) return;
crDirty=true;
const b=document.getElementById('cr_genbtn');
if(b){ b.textContent='↻ Mettre à jour le compte-rendu'; b.style.background='#C2410C'; }
}
function crShowPreview(html){
const box = get('cr_preview'); if(!box) return;
box.innerHTML = ``;
const f = get('cr_frame');
f.srcdoc = html;
}
function crRenderActions(pid){
const z = get('cr_actions'); if(!z) return;
z.innerHTML = `
✓ Archivé au dossier
`;
}
/* ---- archive (p.comptesRendus[]) ---- */
// Upsert : 1 entrée par date de consultation. Régénérer/rééditer le même jour
// met à jour l'entrée existante au lieu d'empiler des doublons (archivage auto).
async function crUpsertArchive(p, html){
if(!p || !crCurrent) return;
if(!p.comptesRendus) p.comptesRendus = [];
const iso = crCurrent.dateISO;
const motsCles = (get('cr_notes') ? get('cr_notes').value.trim() : '');
const existing = p.comptesRendus.find(d=>d.dateISO===iso);
if(existing){ existing.html = html; existing.motsCles = motsCles; existing.date = crCurrent.dateAff; }
else { p.comptesRendus.unshift({ date: crCurrent.dateAff, dateISO: iso, motsCles, html }); }
await autosave();
crRenderArchive(p);
}
// 2026-08-20 — FIX IMPRESSION : capture du DOM VIVANT de l'aperçu A4 (la zone .rx-wrap
// est contenteditable). Avant ce fix, seule crArchive() lisait l'iframe : crPrint()
// imprimait crCurrent.docHTML, c.-à-d. le texte D'ORIGINE généré par l'IA — les
// corrections tapées à la main dans l'aperçu ne sortaient JAMAIS sur le papier.
// Repli sur crCurrent.docHTML si l'iframe n'est pas lisible (aperçu fermé, archive rouverte).
function crSnapshotLive(){
let snapshot = crCurrent ? crCurrent.docHTML : '';
try { const f=get('cr_frame'); if(f && f.contentDocument && f.contentDocument.documentElement) snapshot = ''+f.contentDocument.documentElement.outerHTML; } catch(_){}
return snapshot;
}
// Resauvegarde manuelle : capture les modifications faites dans l'aperçu A4.
async function crArchive(pid){
const p = patients.find(x=>x.id===pid); if(!p || !crCurrent) { showToast('Générez d\'abord un compte-rendu'); return; }
const snapshot = crSnapshotLive();
crCurrent.docHTML = snapshot;
await crUpsertArchive(p, snapshot);
showToast('💾 Modifications enregistrées au dossier ('+crCurrent.dateAff+')');
}
function crRenderArchive(p){
const list = get('cr_archive'); if(!list) return;
const arch = p.comptesRendus || [];
const cnt = get('cr_archcnt'); if(cnt) cnt.textContent = arch.length ? '('+arch.length+')' : '';
if(!arch.length){ list.innerHTML = `
Aucun compte-rendu archivé.
`; return; }
list.innerHTML = arch.map((d,i)=>`
℞
Ordonnance diététique
Consultation du ${esc(d.date)}
`).join('');
}
function crOpenArchive(pid, i){
const p = patients.find(x=>x.id===pid); if(!p || !p.comptesRendus || !p.comptesRendus[i]) return;
const d = p.comptesRendus[i];
crCurrent = { pid:p.id, dateAff:d.date, dateISO:d.dateISO, docHTML:d.html };
crShowPreview(d.html); crRenderActions(p.id);
showToast('Document du '+d.date+' rouvert');
}
// 2026-08-20 — imprime CE QUI EST À L'ÉCRAN (corrections manuelles comprises), plus le
// HTML d'origine. crCurrent.docHTML est réaligné pour que « ⟳ Resauvegarder » et une
// réimpression ultérieure repartent du même texte.
function crPrint(){ if(!crCurrent) return; const live = crSnapshotLive(); crCurrent.docHTML = live; crPrintHTML(live); }
function crPrintArchive(pid, i){
const p = patients.find(x=>x.id===pid); if(!p || !p.comptesRendus || !p.comptesRendus[i]) return;
crPrintHTML(p.comptesRendus[i].html);
}
function crPrintHTML(html){
const w = window.open('', '_blank', 'width=900,height=1200');
// injecte le bouton imprimer + auto-print : on réutilise le doc avec withPrintBtn
// FIX 2026-08-28 — bouton « ✕ Fermer » (nd-close-btn) : ces fenêtres sont des pop-up
// sans barre d'onglets, on ne pouvait en sortir qu'au clavier (⌘W). Masqué à l'impression.
w.document.write(html.replace('', ''));
w.document.close();
setTimeout(()=>{ try{ w.print(); }catch(_){} }, 700);
}
async function crDeleteArchive(pid, i){
const p = patients.find(x=>x.id===pid); if(!p || !p.comptesRendus) return;
if(!confirm('Supprimer ce compte-rendu du dossier ?')) return;
p.comptesRendus.splice(i,1);
await autosave();
crRenderArchive(p);
showToast('Compte-rendu supprimé');
}
/* ---- construction du document A4 (format Ordonnance diététique du cabinet) ---- */
function crBuildDoc(p, s, dateAff, withPrintBtn, raw){
const civ = (p.sexe==='F'||p.sexe==='f') ? 'Madame' : ((p.sexe==='H'||p.sexe==='M'||p.sexe==='m') ? 'Monsieur' : '');
const nomPatient = (civ ? civ+' ' : '') + esc(((p.prenom ? p.prenom+' ' : '') + (p.nom||'').toUpperCase()).trim() || p.prenom || '');
let blocks='';
// 2026-07-09 — Mise en page « à plat » : section « — TITRE » + item ℞ (posologie + durée en badge). Blocs dynamiques inchangés.
const secRx = (title, arr, withDuree) => `
${days.map((dayName, i) => {
// FIX 2026-05-05 — p.menus est un Array indexé numériquement avec {dejeuner, diner}.
// Si p.menus_detailed existe, on prend aussi le breakfast détaillé.
const dayKey = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'][i];
const simple = (Array.isArray(p.menus) ? p.menus[i] : (p.menus && p.menus[dayKey])) || {};
const det = (!ndDetailPerime(p) && p.menus_detailed && p.menus_detailed[dayKey]) || {}; // FIX 2026-09-29 : jamais un détail périmé
const breakfast = det.breakfast?.titre || simple.breakfast || '—';
const lunch = simple.dejeuner || simple.lunch || det.lunch?.titre || '—';
const dinner = simple.diner || simple.dinner || det.dinner?.titre || '—';
return `
${dayName}
${esc(breakfast)}
${esc(lunch)}
${esc(dinner)}
`;
}).join('')}
`);
w.document.close();
setTimeout(() => w.print(), 600);
}
// ═════════════════════════════════════════════════════════════
// 📧 ENVOI MENU SEMAINE PAR EMAIL
// ═════════════════════════════════════════════════════════════
function emailWeekMenu(pid){
const p = patients.find(x => x.id === pid);
if(!p || !p.menus) { showToast('⚠ Génère d\'abord les menus'); return; }
if(!p.email){ showToast('⚠ Pas d\'email enregistré dans la fiche'); return; }
if(p.menus_detailed && Object.keys(p.menus_detailed).length && !ndDetailPerime(p) && !ndGardeEnvoiMenuDetaille(p, 'je l\'envoie à la patiente')) return; // FIX 2026-09-29
// FIX 2026-05-05 — bugs corrigés :
// 1) p.menus est un array indexé numériquement, pas un objet par jour
// 2) Format simple = {dejeuner, diner} (pas breakfast/lunch/dinner)
// 3) Si p.menus_detailed existe → on l'utilise pour inclure ingrédients + recettes
const days = ['Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi','Dimanche'];
const dayKeys = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'];
const objectif = p.objectif_kcal || 1500;
const hasDetailed = p.menus_detailed && typeof p.menus_detailed === 'object' && !ndDetailPerime(p); // FIX 2026-09-29
let body = `Bonjour ${p.prenom},\n\nVoici votre menu personnalisé pour la semaine, conçu pour respecter vos objectifs nutritionnels (${objectif} kcal/jour).\n\n`;
if(p.allergies && p.allergies.length){
body += `⚠ Allergies/intolérances prises en compte : ${p.allergies.join(', ')}\n\n`;
}
// Helper : formate un repas avec ingrédients + préparation si version détaillée dispo
const formatRepas = (label, icon, plat, detail) => {
if(!plat && !detail) return '';
let txt = `${icon} ${label} : ${plat || (detail && detail.titre) || '—'}\n`;
if(detail){
if(detail.kcal_total) txt += ` ${detail.kcal_total} kcal`;
if(detail.macros) txt += ` · P${detail.macros.p||0} L${detail.macros.l||0} G${detail.macros.g||0} F${detail.macros.f||0}`;
txt += '\n';
if(Array.isArray(detail.ingredients) && detail.ingredients.length){
txt += ` 📋 Ingrédients :\n`;
detail.ingredients.forEach(ing => { txt += ` • ${ing}\n`; });
}
if(detail.preparation){
txt += ` 👩🍳 Préparation : ${detail.preparation}\n`;
}
}
return txt + '\n';
};
days.forEach((dayName, i) => {
const simple = (p.menus && p.menus[i]) || {};
const det = hasDetailed ? (p.menus_detailed[dayKeys[i]] || {}) : {};
body += `━━━ ${dayName.toUpperCase()} ━━━\n`;
if(hasDetailed && det.breakfast){
body += formatRepas('Petit-déjeuner', '☀️', null, det.breakfast);
}
body += formatRepas('Déjeuner', '🌿', simple.dejeuner, det.lunch);
body += formatRepas('Dîner', '🌙', simple.diner, det.dinner);
body += '\n';
});
if(hasDetailed && p.menus_detailed.conseils){
body += `\n💡 Conseils pour la semaine :\n${p.menus_detailed.conseils}\n`;
}
if((p.notes_diet||'').trim()){
body += `\n📝 Notes de votre diététicien :\n${p.notes_diet}\n`;
}
body += `\n\nN'hésitez pas à me contacter si vous avez des questions.\n\nÀ bientôt,\nVotre diététicien`;
const subject = hasDetailed
? `Votre menu détaillé de la semaine — ${p.prenom}`
: `Votre menu de la semaine — ${p.prenom}`;
const mailto = `mailto:${encodeURIComponent(p.email)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailto;
showToast(hasDetailed
? '✉️ Email pré-rempli avec menus + recettes — clique Envoyer'
: '✉️ Email pré-rempli avec menus — clique Envoyer (génère la "Version détaillée" pour avoir les recettes)');
}
// ═════════════════════════════════════════════════════════════
// 📖 GÉNÉRATION VERSION DÉTAILLÉE DU MENU (avec quantités, recettes)
// ═════════════════════════════════════════════════════════════
// 🌿 SAISONNALITÉ (ajout 2026-06-24) — calendrier France des fruits & légumes
// de saison, par mois. Sert à injecter une contrainte "produits de saison" dans
// les prompts de génération de menus (generateMenus + generateDetailedMenu) pour
// que l'IA ne propose plus de tomate/fraise/melon en hiver ni de courge en été.
// La saisonnalité NE PRIME PAS sur allergies / plan prescrit / aliments à éviter.
function ndGetSeasonalProduce(d){
d = d || new Date();
const m = d.getMonth(); // 0 = janvier … 11 = décembre
const moisNoms = ['janvier','février','mars','avril','mai','juin','juillet','août','septembre','octobre','novembre','décembre'];
const LEG = [
/*janv*/ 'betterave, carotte, céleri, chou (vert, rouge, frisé, de Bruxelles), chou-fleur, courge, endive, mâche, navet, oignon, panais, poireau, potiron, salsifis, topinambour',
/*fév */ 'betterave, carotte, céleri, chou, chou-fleur, courge, endive, mâche, navet, panais, poireau, potiron, salsifis, topinambour',
/*mars*/ 'betterave, carotte, céleri, chou-fleur, endive, épinard, mâche, navet, oignon, poireau, radis, salsifis, blette',
/*avr */ 'asperge, betterave, carotte, chou-fleur, concombre, endive, épinard, navet, oignon nouveau, petit pois, radis, blette',
/*mai */ 'asperge, aubergine, betterave, carotte, concombre, courgette, épinard, fenouil, navet, oignon, petit pois, radis, blette, artichaut',
/*juin*/ 'artichaut, asperge, aubergine, betterave, brocoli, carotte, concombre, courgette, épinard, fenouil, haricot vert, petit pois, poivron, radis, tomate, blette',
/*juil*/ 'artichaut, aubergine, betterave, brocoli, carotte, concombre, courgette, fenouil, haricot vert, maïs doux, poivron, radis, tomate',
/*août*/ 'artichaut, aubergine, betterave, brocoli, carotte, concombre, courgette, fenouil, haricot vert, maïs doux, poivron, radis, tomate',
/*sept*/ 'aubergine, betterave, brocoli, carotte, céleri, concombre, courge, courgette, épinard, fenouil, haricot vert, poireau, poivron, potiron, radis, tomate',
/*oct */ 'betterave, brocoli, carotte, céleri, chou, chou-fleur, courge, épinard, fenouil, mâche, navet, panais, poireau, potiron, radis, topinambour',
/*nov */ 'betterave, carotte, céleri, chou (vert, rouge, de Bruxelles), chou-fleur, courge, endive, mâche, navet, panais, poireau, potiron, salsifis, topinambour',
/*déc */ 'betterave, carotte, céleri, chou, chou-fleur, courge, endive, mâche, navet, panais, poireau, potiron, salsifis, topinambour'
];
const FRU = [
/*janv*/ 'clémentine, citron, kiwi, mandarine, orange, pamplemousse, poire, pomme',
/*fév */ 'citron, kiwi, mandarine, orange, pamplemousse, poire, pomme',
/*mars*/ 'citron, kiwi, orange, pamplemousse, poire, pomme',
/*avr */ 'citron, kiwi, pomme, rhubarbe',
/*mai */ 'cerise, fraise, kiwi, pomme, rhubarbe',
/*juin*/ 'abricot, cassis, cerise, fraise, framboise, groseille, melon, nectarine, pêche, rhubarbe',
/*juil*/ 'abricot, cassis, cerise, figue, framboise, groseille, melon, mûre, myrtille, nectarine, pêche, prune',
/*août*/ 'abricot, figue, framboise, melon, mirabelle, mûre, myrtille, nectarine, pêche, prune, raisin',
/*sept*/ 'figue, framboise, melon, mirabelle, mûre, poire, pomme, prune, raisin',
/*oct */ 'châtaigne, coing, figue, kiwi, poire, pomme, raisin',
/*nov */ 'clémentine, coing, kiwi, mandarine, orange, poire, pomme',
/*déc */ 'clémentine, citron, kiwi, mandarine, orange, pamplemousse, poire, pomme'
];
return { mois: moisNoms[m], legumes: LEG[m], fruits: FRU[m] };
}
// FIX 2026-07-21 — CAUSE RACINE menus non conformes FODMAP (cas Fatima CHEVENAT) :
// le bloc saison de juillet disait "privilégie FORTEMENT" mûre, cassis, nectarine,
// pêche, abricot, prune, figue (fruits) et betterave, artichaut (légumes) — tous
// riches en FODMAP — alors que le plan de la patiente était "Cf fodmap". L'IA
// suivait la recommandation de saison car ces aliments manquaient AUSSI dans la
// liste d'interdits FODMAP du prompt. Fix : quand le plan active SANS FODMAP, on
// retire les produits FODMAP des listes de saison AVANT injection dans le prompt.
// Détecte si le plan visible du patient active SANS FODMAP (même logique et mêmes
// déclencheurs que ndValidateMenuAgainstPlan — garder les deux synchronisés).
function ndPlanIsSansFodmap(p){
if(!p) return false;
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const base = BASES[baseKey] || {};
const planText = [
...(p.plan?.petitDejeuner || base.petitDejeuner || []),
...(p.plan?.dejeuner || base.dejeuner || []),
...(p.plan?.collation || base.collation || []),
...(p.plan?.diner || base.diner || []),
].join(' | ').toLowerCase();
const fodmapTriggers = [
/cf\s*fodmap/, /sans\s*fodmap/, /low\s*fodmap/, /pauvre\s*en\s*fodmap/,
/\bsii\b/, /\bcolopathie\b/, /irritable/
];
return fodmapTriggers.some(rx => rx.test(planText));
}
// Produits de saison à retirer des listes "à privilégier" quand SANS FODMAP actif.
// (fruits à polyols/fructose + légumes à fructanes/GOS — inclut brugnon = synonyme
// de nectarine, et mirabelle = variété de prune)
const ND_SAISON_FODMAP_EXCLUS = /\b(?:pommes?|poires?|mangues?|past[èe]ques?|cerises?|abricots?|prunes?|mirabelles?|figues?|nectarines?|brugnons?|p[êe]ches?|m[ûu]res?|cassis|coings?|ch[âa]taignes?|oignons?|ail|[ée]chalotes?|poireaux?|chou-fleur|choux?|artichauts?|asperges?|champignons?|betteraves?|topinambours?|salsifis|petits?\s+pois|ma[iï]s\s+doux|c[ée]leris?)\b/i;
// Filtre une liste de saison "a, b, c (x, y), d" en retirant les items FODMAP.
// Le split ignore les virgules à l'intérieur des parenthèses (ex : "chou (vert, rouge)").
function ndFiltreSaisonFodmap(liste){
return (liste || '')
.split(/,(?![^()]*\))/)
.map(s => s.trim())
.filter(item => item && !ND_SAISON_FODMAP_EXCLUS.test(item))
.join(', ');
}
// Construit le bloc de prompt "produits de saison" pour le mois courant.
// FIX 2026-07-21 — accepte le patient : si son plan active SANS FODMAP, les
// listes sont filtrées pour ne jamais recommander un produit FODMAP.
function ndBuildSaisonBlock(p){
const s = ndGetSeasonalProduce();
const sansFodmap = ndPlanIsSansFodmap(p);
const legumes = sansFodmap ? ndFiltreSaisonFodmap(s.legumes) : s.legumes;
const fruits = sansFodmap ? ndFiltreSaisonFodmap(s.fruits) : s.fruits;
const toujoursDispo = sansFodmap
? 'salade verte, herbes aromatiques fraîches, citron'
: 'salade verte, champignon de Paris, herbes aromatiques, citron, ail, échalote selon tolérance';
const noteFodmap = sansFodmap
? '\n⚠️ Ces listes de saison ont déjà été filtrées pour le régime pauvre en FODMAP de ce patient : n\'y réintroduis AUCUN autre fruit/légume de saison qui serait riche en FODMAP (mûre, cassis, nectarine, brugnon, pêche, abricot, prune, figue, cerise, pomme, poire, pastèque, betterave, artichaut, champignon, chou-fleur…).'
: '';
return `
🌿 SAISONNALITÉ — PRODUITS DE SAISON (mois de ${s.mois}) — RÈGLE IMPORTANTE :
Nous sommes en ${s.mois}. Privilégie FORTEMENT les fruits et légumes DE SAISON ce mois-ci : ils sont plus savoureux, moins chers et plus écologiques. N'utilise PAS de produits manifestement hors saison (ex : tomate, fraise, melon, courgette ou abricot en plein hiver ; courge, endive, mâche ou clémentine en plein été).
• LÉGUMES de saison à privilégier : ${legumes}.
• FRUITS de saison à privilégier : ${fruits}.${noteFodmap}
Tu peux aussi utiliser les produits disponibles toute l'année (${toujoursDispo}). Cette règle de saisonnalité NE PRIME PAS sur les allergies, le plan alimentaire prescrit et les aliments à éviter, qui restent prioritaires : si un fruit/légume de saison est interdit pour ce patient, choisis-en un autre de saison.`;
}
// FIX 2026-06-24 — Version ALLÉGÉE du bloc saison, réservée au prompt du menu
// DÉTAILLÉ. Le menu standard reçu en entrée est DÉJÀ composé de produits de
// saison (le bloc complet ndBuildSaisonBlock() est appliqué à l'étape standard,
// là où les produits sont réellement choisis). L'étape détaillée ne fait
// qu'expliciter quantités/préparations : pas besoin de re-lister les produits du
// mois. On retire donc les listes légumes/fruits (~150 tokens) pour alléger ce
// prompt et réduire le risque de dépasser le plafond Netlify de 26s (cause des
// 504 du 2026-06-24). La saisonnalité réelle reste pilotée à l'étape standard.
function ndBuildSaisonBlockLite(){
const s = ndGetSeasonalProduce();
return `
🌿 SAISON (${s.mois}) : le menu standard ci-dessus est déjà composé de produits de saison — conserve-les tels quels. Si tu dois compléter un élément manquant, choisis un produit de saison et n'introduis JAMAIS de produit manifestement hors saison. La saisonnalité ne prime pas sur le plan prescrit, les allergies et les aliments à éviter.`;
}
async function generateDetailedMenu(pid, force){
const p = patients.find(x => x.id === pid);
if(!p || !p.menus) { showToast('⚠ Génère d\'abord les menus standards'); return; }
// FIX 2026-07-23 — même filet que generateMenus : persister la consigne LIVE du
// champ style avant de construire le prompt détaillé (cf. ndBuildStyleConsigneBlock).
try { const _styleEl = document.getElementById('menuStyleConsigne_' + pid); if(_styleEl) setMenuStyleConsigneById(pid, _styleEl.value); } catch(_){}
// FIX 2026-05-06 — Crée d'abord la modale (squelette vide), puis décide cache vs IA.
// Avant : si cache existait, on appelait renderDetailedMenuView() avant que la modale
// existe → get('detmenu-body') retournait null → rien ne s'affichait → chargement
// inutile. Maintenant la modale existe TOUJOURS quand on rend le contenu.
const existing = document.querySelector('.modal-overlay');
if(existing) existing.remove();
const div = document.createElement('div');
div.className = 'modal-overlay';
const hasCache = !force && p.menus_detailed && Object.keys(p.menus_detailed).length > 0 && !ndDetailPerime(p); // FIX 2026-09-29 : un menu détaillé périmé n'est jamais resservi
const initialBody = hasCache
? '
⏳ Affichage…
'
: `
L'IA détaille les quantités et recettes (~10-30s)…
`;
div.innerHTML = `
📖 Version détaillée du menu — ${esc(p.prenom)} ${esc(p.nom||'')}
${initialBody}
`;
document.body.appendChild(div);
// Si cache → rendu immédiat (microtask pour laisser la modale s'afficher d'abord)
if(hasCache){
requestAnimationFrame(() => {
try { ndControlerMenuDetaille(p); } catch(e){ console.error('[correcteur menu]', e); }
renderDetailedMenuView(p, p.menus_detailed);
showToast('📖 Version détaillée chargée (cache)');
});
return;
}
const objectif = p.objectif_kcal || 1500;
const allergies = (p.allergies || []).join(', ') || 'aucune';
const motifs = (p.motifs || []).join(', ') || '—';
// FIX 2026-05-06 — Plan alimentaire STRUCTURÉ par repas pour cohérence stricte
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const baseRef = BASES[baseKey] || {};
const planPDej = (p.plan?.petitDejeuner || baseRef.petitDejeuner || []).join(' | ') || '—';
const planDej = (p.plan?.dejeuner || baseRef.dejeuner || []).join(' | ') || '—';
const planCol = (p.plan?.collation || baseRef.collation || []).join(' | ') || '—';
const planDin = (p.plan?.diner || baseRef.diner || []).join(' | ') || '—';
// FIX 2026-05-05 — p.menus est un Array indexé numériquement avec {dejeuner, diner}
// (pas un objet par jour avec breakfast/lunch/dinner). L'ancien code passait juste
// des "—" à l'IA, qui n'avait donc pas le menu standard pour générer la version détaillée.
const days = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'];
const menusText = days.map((d, i) => {
const m = (Array.isArray(p.menus) ? p.menus[i] : p.menus[d]) || {};
const dej = m.dejeuner || m.lunch || '—';
const din = m.diner || m.dinner || '—';
return `${d.toUpperCase()} :\n Midi : ${dej}\n Soir : ${din}`;
}).join('\n\n');
const prompt = `Tu es un diététicien expert. À partir du menu standard suivant, produis une **version détaillée** avec quantités précises et une préparation claire de 2 à 3 phrases par repas, en RESPECTANT STRICTEMENT le plan alimentaire prescrit pour chaque repas.
PATIENTE : ${p.prenom} ${p.nom}
Objectif : ${objectif} kcal/jour
Motifs : ${motifs}
Allergies/intolérances : ${allergies}
═══════════════════════════════════════════════════════════════
🏥 RÈGLES OFFICIELLES DU CABINET (intégrées 2026-05-17 — appliquer STRICTEMENT)
═══════════════════════════════════════════════════════════════
🥩 PROTÉINES AUTORISÉES (Liste prot.docx) — utilise UNIQUEMENT ces aliments :
• VIANDES : filet mignon porc, rôti/filet bœuf, roastbeef, viande des grisons, jambon dégraissé sans gras/couenne, steak haché 5%MG, veau/côte veau, gésiers volaille, bavette, aiguillettes canard, magret canard SANS peau
• VOLAILLES SANS PEAU : poulet, dinde, pintade, coquelet, caille
• POISSONS : cabillaud, colin, dorade, espadon, flétan, lieu, limande, lotte, loup, maquereau, merlan, mulet, raie, rouget, sole, thon (frais/naturel), turbot
• FRUITS DE MER : crevette, gambas, langoustine, moule, huître, coquille St Jacques, calamar, seiche, poulpe, crabe, tourteau, bulot, coque, oursin
• ŒUFS : poule, caille
🚫 INTERDITS : agneau, mouton, magret AVEC peau, lardons (→ allumettes bacon), saucisses (sauf 100% volaille light), pâté, saucisson, mortadelle, chorizo, saumon (pas dans la liste cabinet)
🥛 LAITAGES — TOUJOURS 0% MG : fromage blanc 0% (3% UNIQUEMENT si le plan écrit 3%), skyr 0%, yaourts 0%, petit-suisse 0%, faisselle 0%. Lait demi-écrémé/écrémé. Crème fraîche 4% (jamais 30%). Beurre allégé 41% (St Hubert Omega 3).
🚫 SAUCES STRICT : par défaut PAS de sauce. Si nécessaire : version light (mayo allégée, vinaigrette = huile+vinaigre/citron, sauce tomate nature). JAMAIS César/ranch/tartare/cocktail/andalouse/sauces crémées.
🧴 MG cuisson : spray ou MAX 1 càs (10g) huile olive. Pas de friture.
🍳 CUISSONS : Vapeur > papillote > grillé > poêlé sans MG > rôti. Pas de gratin gras, pas de plat en sauce.
🥗 SUCRES : édulcorant > sucre. Confitures sans sucre. Chocolat noir 70%+ portion 20g.
🌙 RÈGLE DÎNER CABINET (IMPORTANT) :
Au dîner, le cabinet NE PROPOSE GÉNÉRALEMENT PAS DE FÉCULENT. Si le plan dîner ne mentionne PAS explicitement "féculent/riz/pâtes/lentilles/quinoa", alors AUCUN féculent ne doit apparaître dans la version détaillée du dîner.
Composition typique du dîner cabinet : protéine maigre + légumes + laitage 0% + fruit (PAS de riz, PAS de pâtes, PAS de pain, PAS de pomme de terre, PAS de patate douce).
═══════════════════════════════════════════════════════════════
🥗 PLAN ALIMENTAIRE PRESCRIT (à respecter ABSOLUMENT) :
- PETIT-DÉJEUNER autorisé : ${planPDej}
- DÉJEUNER autorisé : ${planDej}
- COLLATION autorisée : ${planCol}
- DÎNER autorisé : ${planDin}
${ndBuildSaisonBlockLite()}${ndBuildStyleConsigneBlock(p)}
🧠 INFÉRENCE DES EXCLUSIONS DEPUIS LE PLAN (FIX 2026-05-18) :
Le plan ci-dessus EST LA SOURCE DE VÉRITÉ. Analyse son contenu :
- Si UNIQUEMENT céréales/pains sans gluten ("sans gluten", "farine de riz/châtaigne/schar", "quinoa", "sarrasin", "millet") → SANS GLUTEN strict (interdits : blé, seigle, orge, avoine, épeautre, pain classique, pâtes blé, semoule, couscous, boulgour).
- 🔴 RÈGLE D'OR LACTOSE (raffinée 2026-05-28) : SANS LACTOSE GLOBAL ne s'active QUE si UN MOT EXPLICITE dans le plan visible mentionne "sans lactose" / "Alpro" / "0% lactose" / "lactose-free" / "skyr alpro" / "yaourt soja-amande-coco" / "lait végétal" / "lait sans lactose" / "Saint Hubert oméga 3". Les motifs (colopathie/SII, etc.) NE DÉCLENCHENT PAS la règle — ce sont juste des templates de cabinet, c'est le plan visible qui prime. Si UN déclencheur textuel est trouvé, la règle s'applique sur les 14 plats. Si le plan dit "Skyr Alpro" au déj ET "skyr ou fromage blanc 3%" au dîner → le mot "Alpro" gagne et tu réinterprètes les autres laitages classiques comme leurs versions sans lactose.
- TRADUCTION OBLIGATOIRE quand SANS LACTOSE actif : "Petit suisse 0%" → "Petit suisse sans lactose" ou "Skyr Alpro" · "skyr" → "Skyr Alpro" ou "Skyr sans lactose" · "fromage blanc 0%/3%" → "Fromage blanc sans lactose Lactel/Matin Léger" ou "Faisselle de soja" · "yaourt nature" → "Yaourt soja Alpro" · "lait" → "Lait sans lactose" ou "lait d'amande/avoine/soja/coco".
- INTERDITS STRICTS (toutes versions animales standard contiennent du lactose résiduel, JAMAIS dans tes ingrédients quand SANS LACTOSE actif) : lait vache/chèvre/brebis, yaourt classique (Activia/Danone/nature 0% ou pas), skyr classique (~4g lactose/100g), petit-suisse classique (0% ou pas), fromage blanc classique (0% ou pas), ricotta, mascarpone, mozzarella, feta, brie, camembert, crème fraîche animale, beurre classique. Si en plus SANS CASÉINE → exclure aussi versions "sans lactose" animales et n'utiliser QUE les alternatives 100% végétales.
- Si le plan visible mentionne "Cf FODMAP" / "(FODMAP)" / "low FODMAP" / "pauvre en FODMAP" / "sans FODMAP" → SANS FODMAP (interdits : oignon, ail, échalote, poireau partie blanche, chou-fleur, asperge, artichaut, champignon, betterave, topinambour, pomme, poire, mangue, pastèque, cerise, abricot, prune, mirabelle, figue, nectarine, brugnon, pêche, mûre, cassis, miel, polyols). Alternatives : ciboulette, vert d'oignon nouveau, huile d'ail infusée filtrée, courgette, carotte cuite, épinards, aubergine, kiwi, fraise, framboise, myrtille, clémentine, orange, banane mûre ≤100g. (Raffiné 2026-05-28 : le motif Colopathie/SII ne déclenche plus la règle, seul le plan visible compte.)
- Si aucun repas ne mentionne "légumineuses/lentilles/pois chiches/haricots secs/fèves/soja/tofu" → SANS LÉGUMINEUSES strict.
Applique ces exclusions inférées à TOUS les ingrédients de la version détaillée. Une exception explicite dans le plan (ex : "Skyr ALPRO") reste autorisée.
⚠️ RÈGLE ABSOLUE : pour CHAQUE repas, n'utilise QUE les catégories d'aliments mentionnées pour ce repas dans le plan.
🗓️ FRÉQUENCES HEBDOMADAIRES (FIX 2026-05-28) : si le plan contient une mention "X à Y fois par semaine" / "max N fois/sem" / "rare/occasionnel" (typiquement pour les féculents/légumineuses au dîner), respecte-la strictement. Pour les plats qui n'ont PAS droit au féculent ce jour-là (en dépassement du quota), retire-le des ingrédients détaillés et compense par +50g de légumes ou des nouilles de konjac/shirataki si autorisé par le plan.
📏 RÈGLE QUANTITÉS — STRICT (FIX 2026-05-14 v23) :
Si le plan mentionne une quantité explicite (ex: "Féculents 100g cuits", "VPO 130g", "Pain 60g", "Légumes 200g", "Huile 1cs", "Fromage 30g"), la version détaillée DOIT utiliser EXACTEMENT cette quantité dans les ingrédients. Pas d'arrondi à 150g pour faire joli, pas d'ajustement à la hausse "pour le poids", pas d'imagination.
Exemples concrets :
• Plan dit "Féculents cuits 100g" → écris "Riz basmati cuit : 100g" (PAS 180g, PAS 150g)
• Plan dit "VPO 100-150g" → utilise la valeur du milieu, soit "Filet de poulet : 125g" (et JAMAIS au-delà de 150g)
• Plan dit "Légumes 200g" → "Brocolis vapeur : 200g" (PAS 150, PAS 250)
• Plan dit "Pain complet 50g" → "Pain complet : 50g" (PAS 60, PAS 80)
• Plan dit "Huile d'olive 1 càs" → "Huile d'olive : 1 càs (10g)" (PAS 2 càs)
• Plan dit "Fruit" sans quantité → utilise portion standard 150g (ex: pomme moyenne, 1 banane = 120g)
• Plan dit "Yaourt 0% nature" sans quantité → utilise 125g (1 yaourt standard)
Si une catégorie est mentionnée SANS quantité explicite, utilise les portions CABINET CONFIRMÉES (intégrées 2026-05-17) :
- **VIANDE/VOLAILLE** : 150-180g cru (180g pour poulet/dinde/canard, 150g pour bœuf 5%/porc filet)
- **ŒUFS** : portion standard = **2 œufs ≈ 100g** (1 œuf ≈ 50g). ⚠️ RÈGLE IMPORTANTE (FIX 2026-07-10) : 2 œufs constituent une portion protéique complète et N'ÉQUIVALENT PAS à 150-200g de viande/poisson. NE JAMAIS écrire "Œufs : 150g / 180g / 200g", ni monter à 3-4 œufs pour "atteindre" le poids d'une portion de viande. Reste sur 2 œufs (max 3 si gros appétit explicite). Écris toujours en nombre d'œufs, pas en grammes (ex : "Œufs de poule : 2 (≈100g)").
- **POISSON** (rouget, cabillaud, sole, etc.) : **200g** (le poisson en plat unique est plus généreux)
- **Féculents cuits** (riz, pâtes, quinoa, boulgour, épeautre) : **60g cuits** ⚠️ REPLI UNIQUEMENT (FIX 2026-09-21) — ce 60g ne s'applique QUE si le plan ne chiffre RIEN pour ce repas. Dès que le plan donne un grammage (ex : « Céréales et/ou légumineuses : 150g cuits »), c'est CE grammage qui s'applique, jamais 60g.
- **Légumineuses** (lentilles, pois chiches) : **60g cuites** — même réserve : repli seulement, le grammage du plan prime toujours.
- **Légumes** : 200g (cuits ou crus)
- **Crudités** : 100g
- **Laitage 0%** : 125g (1 pot yaourt, fromage blanc, skyr)
- **Fromage** : 30g
- **Pain** : 30-50g
- **Huile** : 1 càs = 10g
- **Fruit** : 150g (1 pomme moyenne, 1 banane, 1 kiwi, etc.)
🌾 UNE SEULE LIGNE FÉCULENT PAR REPAS — LE GRAMMAGE DU PLAN EST UN TOTAL (FIX 2026-09-18) :
Dans les plans du cabinet, « Céréales + légumineuses : Xg cuits » est UNE SEULE ligne : X est le TOTAL d'amidon du repas, JAMAIS une portion par aliment. Le « + » veut dire « céréales ET/OU légumineuses, au choix ou en mélange », jamais « X de céréales PUIS X de légumineuses ».
• Plan « Céréales + légumineuses : 150g cuits » → le repas contient SOIT 150g d'une céréale, SOIT 150g d'une légumineuse, SOIT un mélange dont la SOMME fait 150g (ex : riz 90g + lentilles 60g).
• ❌ REPAS REFUSÉS (portion doublée) : « riz 150g + lentilles 150g » · « polenta 160g + maïs 80g » · « riz basmati 180g + maïs 80g » · « quinoa 100g + pois chiches 100g » · « semoule 120g + pois chiches 100g ».
• COMPTENT DANS CE TOTAL (additionne-les tous) : riz, pâtes, semoule, couscous, boulgour, polenta et semoule de maïs, MAÏS en grains/épi/choclo, millet, sarrasin, quinoa, avoine, épeautre, orge, pain sous toutes ses formes, biscottes, tortilla/arepa/galette de maïs, pomme de terre, patate douce, manioc/attiéké, igname, banane plantain, châtaigne, lentilles, pois chiches, pois cassés, haricots secs (rouges/blancs/noirs), fèves, edamame, gnocchis, vermicelles.
⚠️ Le MAÏS n'est PAS un légume — le plan du cabinet l'exclut explicitement de la ligne légumes (« sauf avocats, maïs »). Idem pomme de terre, patate douce, plantain, manioc : ce sont des FÉCULENTS, ils entrent dans le total, jamais dans les 200-250g de légumes.
• AVANT d'écrire les ingrédients d'un repas : additionne les grammages de tous les aliments de cette liste. Si la somme dépasse le grammage du plan, supprime une des sources (garde la plus typée culturellement) ou baisse les grammages jusqu'à tomber exactement sur le total prescrit.
• Cette règle PRIME sur la couleur culturelle : une recette sud-américaine, africaine ou asiatique ne justifie pas d'ajouter du maïs à côté de la polenta, ni du plantain à côté du riz. Si tu veux du maïs, le maïs EST le féculent du repas — et il n'y en a pas d'autre.
⚖️ POIDS CRU OBLIGATOIRE POUR CHAQUE FÉCULENT (FIX 2026-09-21) :
Chaque ligne d'ingrédient qui est un féculent (céréale, légumineuse, pomme de terre, patate douce, pain excepté) doit s'écrire dans CET ORDRE, jamais l'inverse :
« : g cuit(e)(s) (g cru(e)(s)) » → ex : « Pâtes complètes : 150g cuites (60g crues) », « Lentilles corail : 70g cuites (28g crues) ».
🔴 L'ORDRE EST NON NÉGOCIABLE : le poids CUIT d'abord, le poids CRU entre parenthèses. Les contrôles de sécurité du cabinet lisent le PREMIER grammage de la ligne pour vérifier la portion du plan — inverser l'ordre fait lire le poids cru et fausse le contrôle.
Facteurs de conversion à utiliser (cru = cuit ÷ facteur) :
• Pâtes (toutes formes, orzo, vermicelles) : ÷ 2,5 → 150g cuites = 60g crues
• Riz (basmati, complet, rond) : ÷ 2,8 → 150g cuit = 55g cru
• Semoule / couscous : ÷ 3 → 150g cuit = 50g cru
• Boulgour : ÷ 2,8 → 150g cuit = 55g cru
• Quinoa : ÷ 3 → 150g cuit = 50g cru
• Lentilles (vertes, blondes, corail), pois chiches, haricots secs, pois cassés, fèves : ÷ 2,5 → 150g cuites = 60g crues
• Polenta / semoule de maïs : ÷ 4 → 150g cuite = 38g crue
• Pomme de terre, patate douce, manioc, igname, plantain, maïs en grains : ÷ 1 → le poids cru = le poids cuit, écris simplement « 150g »
• Pain, biscottes, gnocchis frais, châtaignes : pas de conversion, écris le poids tel quel
Pour les légumineuses et céréales achetées DÉJÀ CUITES (sachet Bjorg, conserve égouttée), le poids cuit EST le poids d'achat : écris « 150g cuits (sachet, prêt à l'emploi) » sans poids cru.
Ne mets JAMAIS de poids cru sur une viande, un poisson, un légume ou un laitage — uniquement sur les féculents.
🇫🇷 LANGUE DES ALIMENTS — FRANÇAIS OBLIGATOIRE (FIX 2026-09-18) : quand une consigne de style oriente vers une cuisine étrangère, le TITRE du plat peut garder son nom local (« Pastel de choclo », « Cazuela de ave », « Ceviche », « Bibimbap »), mais TOUS LES ALIMENTS et toutes les unités sont nommés EN FRANÇAIS : maïs (jamais « maíz » ni « choclo » dans les ingrédients), haricots noirs (jamais « porotos negros »), cabillaud (jamais « cabillada » ni « merluza »), poulet (jamais « pollo »), riz (jamais « arroz »), pomme de terre (jamais « papas »), courgette (jamais « zapallo italiano »), blette (jamais « acelga »), yaourt nature 0% (jamais « yogur natural »), 1 càc (jamais « 1 cdta »). Deux raisons non négociables : la patiente est française et doit pouvoir lire sa liste de courses ; et les contrôles de sécurité du cabinet (allergies, aliments interdits, portions de féculents) lisent ces libellés — un aliment écrit en langue étrangère n'est pas vu et passe au travers.
🚨🚨🚨 RÈGLE D'INCLUSION ABSOLUE (intégrée 2026-05-17 — STRICT, AUCUNE EXCEPTION) :
Pour CHAQUE repas (breakfast/lunch/dinner), la version détaillée DOIT contenir TOUS les éléments listés dans le plan correspondant. Pas le droit d'en zapper un, même si le menu standard reçu en entrée en oublie un (dans ce cas tu AJOUTES l'élément manquant pour respecter le plan).
📋 CHECKLIST OBLIGATOIRE AVANT DE GÉNÉRER CHAQUE REPAS (mentalement) :
DÉJEUNER classique du cabinet contient TYPIQUEMENT :
✅ 1 VPO (viande / poisson / œufs) — JAMAIS oublié : viande/poisson 150-200g, MAIS œufs = 2 œufs ≈ 100g (jamais "œufs 150-200g")
✅ 1 féculent (riz, pâtes, lentilles, pois chiches, quinoa…) — quantité = **EXACTEMENT le grammage écrit dans le plan du déjeuner** (FIX 2026-09-21). Relis la ligne « Céréales / légumineuses / féculents » du plan et recopie son chiffre : c'est un TOTAL À ATTEINDRE, pas seulement un plafond. Un repas sous-dosé (80g quand le plan dit 150g) est AUSSI un échec qu'un repas sur-dosé. Uniquement si le plan ne chiffre rien : 60-80g cuit.
✅ 1 portion de légumes cuits OU crudités
✅ 1 matière grasse (huile/St Hubert) — quantité respectée
✅ 1 laitage 0% (yaourt, fromage blanc, skyr) — JAMAIS oublié si dans le plan
✅ 1 fruit OU 1 dessert protéiné
DÎNER cabinet :
✅ 1 VPO — JAMAIS oublié : viande/poisson 150-180g, MAIS œufs = 2 œufs ≈ 100g (jamais "œufs 150-180g")
✅ NE CONTIENT PAS DE FÉCULENT (sauf si plan dit explicitement "féculent")
✅ NE CONTIENT PAS de pomme de terre / patate douce (= féculents !)
✅ Légumes verts (haricots verts, brocolis, courgette, fenouil, épinards, etc.)
✅ 1 laitage 0%
✅ 1 fruit
❌ EXEMPLES DE REPAS REFUSÉS (NE FAIS JAMAIS ÇA) :
- "Riz basmati + huile St Hubert + kiwi" → MANQUE LE VPO ET LE LAITAGE
- "Filet de cabillaud + brocolis vapeur" → MANQUE LE FÉCULENT (si déjeuner), LE LAITAGE ET LE FRUIT
- "Steak 5% + riz + yaourt 0%" → MANQUE LES LÉGUMES
✅ EXEMPLE DE REPAS CORRECT :
"Filet de cabillaud 150g + brocolis vapeur 200g + riz basmati 60g cuit (20g cru) + huile olive 1 càs + yaourt 0% 125g + kiwi 150g" → COMPLET (ici 60g parce que ce plan-exemple ne chiffre pas le féculent ; si le plan dit 150g cuits, on écrit 150g cuit (55g cru))
🛡️ AUTO-VÉRIFICATION AVANT DE FERMER LE JSON : relis CHAQUE repas et vérifie mentalement que chaque catégorie du plan est présente. Si un repas est incomplet, COMPLÈTE-LE.
🍜 SHIRATAKI = LÉGUME, PAS UN FÉCULENT (FIX 2026-05-07) — les shirataki (nouilles de konjac, ~6 kcal/100g) sont classés comme LÉGUMES. Si "shirataki" est mentionné dans le plan d'un repas, propose littéralement des shirataki dans les ingrédients (ex: "Shirataki : 200g") — JAMAIS de pâtes (même sans gluten), nouilles de blé, vermicelles, ni aucun substitut amidonné. Si le menu standard mentionne "pâtes" alors que le plan ne contient PAS de féculent (juste shirataki), CORRIGE l'erreur en remplaçant par des shirataki.
🥛 LAITAGES — règle stricte (raffinée 2026-05-28) : si le plan visible spécifie un pourcentage précis (ex "fromage blanc 3%", "petit-suisse 0%", "skyr 0%", "Saint Moret 8%"), tu DOIS respecter EXACTEMENT ce pourcentage à la lettre dans le menu. Si le plan ne précise PAS de %, utilise 0% par défaut. Tu n'as JAMAIS le droit de modifier le pourcentage écrit par le diététicien dans le plan (ex : ne JAMAIS proposer "fromage blanc 0%" si le plan dit "fromage blanc 3%"). Précise toujours le % dans le nom de l'ingrédient.
🚫 BLACKLIST AMIDON — RÈGLE STRICTE CABINET (renforcée 2026-05-17)
Si NI "céréales", NI "légumineuses", NI "féculent", NI "pain", NI "pomme de terre", NI "patate douce" n'est mentionné dans le plan du repas, alors AUCUN des aliments suivants ne doit apparaître :
• Pain (baguette, pain de mie, complet, sans gluten, biscottes, wraps, tortillas, pita, naan, brioche)
• Céréales (riz toutes variétés, pâtes, semoule, boulgour, couscous, polenta, millet, sarrasin, quinoa, avoine, orge, épeautre, blé)
• Légumineuses (lentilles, pois chiches, haricots secs, fèves, pois cassés)
• **Tubercules amidonnés** (pomme de terre, patate douce, manioc, igname, topinambour) ← ATTENTION : la pomme de terre ET la patate douce sont des FÉCULENTS, JAMAIS des légumes. Ne les classe JAMAIS comme légume vert d'accompagnement.
• Maïs, châtaigne, marron
• Toute préparation dérivée (purée, gratin, gnocchis, risotto, paella, soupe épaisse de céréales, frites, wedges, rissolées, dauphinoise)
Note : les shirataki ne sont PAS un amidon et restent autorisés s'ils sont dans le plan.
🎯 RAPPEL CRITIQUE : si le plan dîner ne contient PAS "féculent" → pas de pomme de terre, pas de patate douce, pas de purée. Remplace par : haricots verts, courgettes, brocolis, chou-fleur, fenouil, aubergine, poivron, épinards, salade verte, etc. (vrais légumes verts).
Autres règles : si "fruit" / "skyr" / "yaourt" / "fromage" n'apparaît pas pour un repas → ne pas en proposer pour ce repas. N'ajoute JAMAIS un aliment qui n'est pas listé pour le repas concerné.
MENU STANDARD (déjà validé par la diététicienne) :
${menusText}
POUR CHAQUE REPAS, retourne en JSON strict :
{
"lundi": {
"breakfast": {"titre":"","kcal_total":,"ingredients":[" : ",...],"preparation":"<2 à 3 phrases : étapes de préparation et cuissons, sans retour à la ligne>","macros":{"p":,"l":,"g":,"f":}},
"lunch": { ... },
"dinner": { ... }
},
"mardi": { ... },
... // les 7 jours
"conseils": "<2-3 conseils brefs pour la semaine>"
}
RÈGLES :
• Quantités précises en grammes ou ml (ex: "Cabillaud : 150g", "Riz basmati : 150g cuit (55g cru)"). Féculents : toujours le format « Xg cuit (Yg cru) » décrit plus haut.
• Préparation : **2 à 3 phrases** par repas (environ 30 à 55 mots), décrivant les étapes principales et les cuissons de façon concrète et compréhensible par la patiente. Pas de retour à la ligne dans la valeur (tout sur une seule chaîne, phrases séparées par un point et une espace).
• Max 5 ingrédients par repas (les principaux uniquement)
• Pas de retour à la ligne ni de guillemets dans les valeurs string
• Respecte strictement les allergies/intolérances ET le plan alimentaire prescrit ci-dessus
${ndBuildStyleConsigneRappel(p)}
• Réponds UNIQUEMENT en JSON brut, pas de markdown ni texte avant/après.
• IMPORTANT : le JSON entier doit tenir en moins de 18 000 caractères (préparations en 2-3 phrases comprises). Garde les ingrédients concis pour laisser de la place aux préparations.`;
// FIX 2026-05-06 — Parsing JSON robuste : si tronqué, on essaye de réparer
function tryParseJSON(rawText){
const m = rawText.match(/\{[\s\S]*\}/);
const candidate = m ? m[0] : rawText;
try { return JSON.parse(candidate); } catch(e){ /* fallthrough */ }
// Tentative de réparation : tronque les structures non terminées et ferme proprement
let s = candidate;
// Retire la dernière virgule orpheline + clé/valeur partielle
// Ferme les accolades ouvertes
let openBraces = 0, openBrackets = 0, inStr = false, esc = false, lastValid = -1;
for(let i = 0; i < s.length; i++){
const c = s[i];
if(esc){ esc = false; continue; }
if(c === '\\'){ esc = true; continue; }
if(c === '"'){ inStr = !inStr; continue; }
if(inStr) continue;
if(c === '{') openBraces++;
else if(c === '}'){ openBraces--; if(openBraces === 0) lastValid = i; }
else if(c === '[') openBrackets++;
else if(c === ']') openBrackets--;
}
if(lastValid > 0){
// Garde jusqu'au dernier } qui équilibre, plus rien
const truncated = s.slice(0, lastValid + 1);
try { return JSON.parse(truncated); } catch(e){}
}
// Dernier recours : ajout des accolades manquantes à la fin
let repaired = s.replace(/,\s*$/, '');
repaired = repaired.replace(/,\s*"[^"]*$/, ''); // coupe une clé ouverte
repaired = repaired + '}'.repeat(Math.max(0, openBraces)) + ']'.repeat(Math.max(0, openBrackets));
try { return JSON.parse(repaired); } catch(e){}
throw new Error('JSON irrécupérable. L\'IA a probablement renvoyé une réponse trop longue. Réessaie.');
}
// FIX 2026-06-24 (v2) — Anti-504 DURABLE. La génération des 7 jours en UN SEUL
// appel dépassait le plafond DUR de Netlify (26s, cf netlify.toml
// [functions."api-proxy"]) → 504, parfois systématiquement pour un plan donné
// (le retry seul ne suffit pas : le 2e appel dépasse aussi 26s). Solution : on
// découpe en DEUX appels PARALLÈLES — jours 1-4 d'un côté, jours 5-7 + conseils
// de l'autre. Chaque appel ne génère que ~la moitié de la sortie donc termine
// largement sous 26s. Chacun garde sa propre relance auto + timeout client. Les
// deux moitiés sont fusionnées côté client. Contenu généré strictement inchangé.
//
// FIX 2026-08-28 — RENFORCÉ à QUATRE appels (récidive du 504 sur la fiche
// Bascoul, même symptôme que Belmas le 06-24 : « d'habitude ça fonctionne »).
// Diagnostic : le découpage en 2 restait correct dans son principe, mais sa
// marge s'est érodée — le bloc de règles fixe renvoyé à CHAQUE appel a grossi
// depuis (règles œufs 07-10, FODMAP + consigne de style 07-21/07-23 : ~13 000
// caractères aujourd'hui), et le chunk le plus lourd (4 jours × 3 repas,
// ~2000-2500 tokens de sortie) tournait déjà près du plafond de 26s. On passe
// donc de 2 groupes de ~4 jours à 4 groupes de ≤2 jours : chaque appel ne
// génère plus qu'un quart de semaine, ce qui réduit fortement le risque de
// dépassement — SANS changer le temps perçu (les appels restent en parallèle)
// ni le contenu généré (même prompt, mêmes règles, même rendu final).
const DM_MAX_TRIES = 2;
const DM_CLIENT_TIMEOUT_MS = 32000;
const DM_DAY_GROUPS = [
{ jours: ['lundi','mardi'], conseils: false },
{ jours: ['mercredi','jeudi'], conseils: false },
{ jours: ['vendredi','samedi'], conseils: false },
{ jours: ['dimanche'], conseils: true },
];
// Construit un sous-prompt qui ne demande QUE certains jours (et éventuellement
// les conseils), en réutilisant tout le prompt de base (plan, règles, exclusions).
function chunkPromptFor(daysList, wantConseils){
return prompt + `
⚠️ PORTÉE DE CETTE REQUÊTE (TRÈS IMPORTANT) : génère UNIQUEMENT les jours suivants : ${daysList.join(', ')}. Le JSON de sortie ne doit contenir QUE ces jours${wantConseils ? ' PLUS la clé "conseils"' : ' (n\'inclus PAS la clé "conseils" dans cette réponse)'}. N'ajoute AUCUN autre jour. Toutes les règles ci-dessus (plan prescrit, allergies, exclusions, quantités exactes) restent strictement valables pour ces jours.`;
}
async function attemptChunk(chunkPrompt){
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), DM_CLIENT_TIMEOUT_MS);
let resp, rawText;
try {
resp = await fetch('/.netlify/functions/api-proxy', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
// FIX 2026-05-05 — Haiku (rapide) ; max_tokens 8000 largement suffisant
// pour une demi-semaine (≤ 4 jours) après le découpage.
model:'claude-haiku-4-5-20251001',
max_tokens: 8000,
messages:[{role:'user',content:chunkPrompt}]
}),
signal: ctrl.signal
});
// FIX 2026-05-06 — lecture en text pour éviter "Unexpected end of JSON input" en cas de timeout
rawText = await resp.text();
} finally {
clearTimeout(timer);
}
if(!rawText){ const e = new Error('Réponse vide du serveur (timeout).'); e.retryable = true; throw e; }
if(!resp.ok){
let msg;
try { const errObj = JSON.parse(rawText); msg = errObj.error?.message || errObj.error || ('HTTP '+resp.status); }
catch(_) { msg = 'HTTP '+resp.status; }
const e = new Error(msg);
// 502/503/504 = passerelle / timeout Netlify → transitoire, on relance
e.retryable = (resp.status === 502 || resp.status === 503 || resp.status === 504);
throw e;
}
let data;
try { data = JSON.parse(rawText); }
catch(_) { const e = new Error('Réponse non-JSON du proxy.'); e.retryable = true; throw e; }
if(data.error) throw new Error(data.error.message || data.error);
const text = (data.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('').trim();
return tryParseJSON(text);
}
// Un appel = 1 essai + 1 relance auto sur 504/timeout/abort.
async function runChunkWithRetry(chunkPrompt){
let lastErr;
for(let attempt = 1; attempt <= DM_MAX_TRIES; attempt++){
try { return await attemptChunk(chunkPrompt); }
catch(e){
lastErr = e;
const retryable = e.retryable || e.name === 'AbortError';
if(retryable && attempt < DM_MAX_TRIES) continue;
throw e;
}
}
throw lastErr;
}
try {
// 4 appels en parallèle (chacun ~un quart de semaine, large marge sous les 26s).
const parts = await Promise.all(
DM_DAY_GROUPS.map(g => runChunkWithRetry(chunkPromptFor(g.jours, g.conseils)))
);
const detailed = Object.assign({}, ...parts);
const dayKeys = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'].filter(d => detailed[d]);
if(dayKeys.length === 0) throw new Error('Réponse IA vide. Réessaie.');
p.menus_detailed = detailed;
p.menus_detailed_source = ndMenusSig(p); // FIX 2026-09-29 : empreinte des menus d'origine
// FIX 2026-09-29 — correcteur automatique : ramène le menu au plan (féculents,
// légumes, laitage, sucrant, sans lactose, kcal CIQUAL) AVANT tout affichage/envoi.
try { await ndCmChargerDb(); } catch(_){}
try { ndControlerMenuDetaille(p, true); } catch(e){ console.error('[correcteur menu]', e); }
autosave();
renderDetailedMenuView(p, p.menus_detailed);
} catch(e){
// Message toujours en FR (jamais d'erreur réseau brute en anglais)
const dmMsg = (e && e.name === 'AbortError')
? 'Le serveur met trop de temps à répondre. Réessaie dans un instant.'
: (e ? e.message : 'Erreur inconnue');
const dmBody = get('detmenu-body');
if(dmBody) dmBody.innerHTML = `
❌ Erreur : ${esc(dmMsg)}
`;
}
}
/* ═══ GARDE AMIDON — FIX 2026-09-18 (cas REPETTI) ═══════════════════════════
Dans les plans du cabinet, « Céréales + légumineuses : Xg cuits » est UNE ligne :
X est le TOTAL d'amidon du repas. L'IA cumulait les sources (polenta 160g + maïs
80g = 240g pour un plan à 150g). Ce garde ne corrige RIEN tout seul : il SIGNALE
dans la modale cabinet, avant l'envoi à la patiente. Il n'apparaît jamais dans le
menu publié (printDetailedMenu / emailDetailedMenu ne l'appellent pas). */
const ND_AMIDON_TERMES = [
[/\briz\b/i,'riz'], [/p[\u00e2a]tes?\b/i,'pâtes'], [/semoule/i,'semoule'], [/couscous/i,'couscous'],
[/boulgour/i,'boulgour'], [/polenta/i,'polenta'], [/ma[\u00efi]s\b/i,'maïs'], [/choclo/i,'maïs'],
[/millet/i,'millet'], [/sarrasin/i,'sarrasin'], [/quinoa/i,'quinoa'], [/avoine/i,'avoine'],
[/[\u00e9e]peautre/i,'épeautre'], [/\borge\b/i,'orge'], [/\bpain\b/i,'pain'], [/biscotte/i,'biscotte'],
[/tortilla/i,'tortilla'], [/arepa/i,'arepa'], [/pomme\s+de\s+terre/i,'pomme de terre'],
[/patate\s+douce/i,'patate douce'], [/manioc/i,'manioc'], [/atti[\u00e9e]k[\u00e9e]/i,'attiéké'],
[/\bigname/i,'igname'], [/plantain/i,'plantain'], [/ch[\u00e2a]taigne/i,'châtaigne'],
[/lentille/i,'lentilles'], [/pois\s+chiche/i,'pois chiches'], [/pois\s+cass[\u00e9e]/i,'pois cassés'],
[/haricots?\s+(?:secs?|rouges?|blancs?|noirs?|coco)/i,'haricots secs'], [/f[\u00e8e]ves?\b/i,'fèves'],
[/[\u00e9e]damame/i,'edamame'], [/gnocchi/i,'gnocchis'], [/vermicelle/i,'vermicelles'],
// FIX 2026-09-21 — trous decouverts au banc : l'orzo (pates en grain, tres present des
// qu'une consigne de style oriente vers l'Italie) n'etait reconnu par AUCUN terme, donc
// 80g d'orzo comptaient pour 0g d'amidon. Idem pour les pates nommees par leur forme.
[/\borzo\b/i,'orzo (pâtes)'], [/risoni/i,'risoni (pâtes)'],
[/spaghetti|tagliatelle|linguine|penne|fusilli|farfalle|rigatoni|macaroni|coquillette|lasagne|cannelloni|tortellini|ravioles?\b|raviolis?\b|conchiglie|trofie|orecchiette/i,'pâtes'],
[/\budon\b|\bsoba\b|\bramen\b|nouilles?\s+(?:de\s+riz|de\s+bl[\u00e9e]|chinoises|aux\s+[\u0153oe]ufs)/i,'nouilles'],
[/\bebly\b|\b[\u00e9e]bly\b/i,'blé'], [/flageolet/i,'haricots secs'], [/a[dz]zuki|azuki/i,'haricots secs'],
[/\bfrites?\b|wedges|rissol[\u00e9e]es/i,'pomme de terre'], [/taboul[\u00e9e]/i,'semoule'],
[/\bfonio\b/i,'fonio'], [/\bteff\b/i,'teff']
];
// Plafond d'amidon lu dans les lignes du plan d'un repas (null = non chiffré).
function ndPlafondAmidon(lignes){
const rxCat = /(c[\u00e9e]r[\u00e9e]ale|l[\u00e9e]gumineuse|f[\u00e9e]culent|\bpain\b|pomme de terre|patate douce)/i;
let max = null;
(lignes || []).forEach(l => {
const t = String(l || '');
if(!rxCat.test(t)) return;
const nums = (t.match(/(\d+(?:[.,]\d+)?)\s*g\b/gi) || [])
.map(x => parseFloat(x.replace(/[^\d.,]/g,'').replace(',','.')))
.filter(n => !isNaN(n));
if(nums.length){ const v = Math.max.apply(null, nums); if(max === null || v > max) max = v; }
});
return max;
}
// Sources d'amidon d'un repas détaillé, avec leur grammage.
function ndAmidonSources(ings){
const res = [];
(ings || []).forEach(raw => {
const t = String(raw || '');
// FIX 2026-09-21 — les shirataki / konjac sont classes LEGUMES (regle du 07/05).
// Sans ce filtre, "vermicelles de konjac" ou "nouilles shirataki" etaient comptes
// comme de l'amidon et faussaient le total du repas.
if(/shirataki|konjac/i.test(t)) return;
// FIX 2026-09-29 — « pâte miso » (5g dans une soupe) était comptée comme des PÂTES :
// fausse alerte amidon, et le correcteur l'aurait « remontée » à 100g. Idem pâte de
// curry / à tartiner / d'amande / de sésame : des condiments, pas des féculents.
if(/p[âa]te\s+(?:de\s+|d['’]\s*|[àa]\s+)?(?:miso|curry|tartiner|amande|s[ée]same|piment|tamarin|arachide|cacahu[èe]te|pistache|noisette|fruits?|coing)/i.test(t)) return;
const hit = ND_AMIDON_TERMES.find(pair => pair[0].test(t));
if(!hit) return;
const m = t.match(/(\d+(?:[.,]\d+)?)\s*g\b/i);
const g = m ? parseFloat(m[1].replace(',','.')) : 0;
res.push({ label: t, terme: hit[1], g: isNaN(g) ? 0 : g });
});
return res;
}
// Repas dont le total d'amidon dépasse le plan (tolérance 15 %).
function ndAuditAmidon(p, detailed){
const out = [];
try {
const baseKey = (p.plan && p.plan.baseKey) || (typeof detectBase === 'function' ? detectBase(p.motifs) : null) || 'colopathie';
const baseRef = (typeof BASES !== 'undefined' && BASES[baseKey]) || {};
const plafonds = {
breakfast: ndPlafondAmidon((p.plan && p.plan.petitDejeuner) || baseRef.petitDejeuner || []),
lunch: ndPlafondAmidon((p.plan && p.plan.dejeuner) || baseRef.dejeuner || []),
dinner: ndPlafondAmidon((p.plan && p.plan.diner) || baseRef.diner || [])
};
const jours = [['lundi','Lundi'],['mardi','Mardi'],['mercredi','Mercredi'],['jeudi','Jeudi'],['vendredi','Vendredi'],['samedi','Samedi'],['dimanche','Dimanche']];
const repas = [['breakfast','Petit-déjeuner'],['lunch','Déjeuner'],['dinner','Dîner']];
jours.forEach(pair => {
const day = detailed[pair[0]];
if(!day) return;
repas.forEach(r => {
const meal = day[r[0]];
if(!meal) return;
const src = ndAmidonSources(meal.ingredients || []);
if(!src.length) return;
const total = src.reduce((a,b) => a + b.g, 0);
const plafond = plafonds[r[0]];
const trop = (plafond !== null && total > plafond * 1.15)
|| (plafond === null && src.length > 1 && total > 200);
// FIX 2026-09-21 (cas SCANO) : le sous-dosage était invisible. Plan à 150g cuits,
// l'IA sortait 80g de pâtes → aucun bandeau. La ligne du plan est un TOTAL À
// ATTEINDRE, pas seulement un plafond : on signale les deux sens.
const insuffisant = (plafond !== null && !trop && total < plafond * 0.85);
if(trop || insuffisant) out.push({ jour: pair[1], repas: r[1], sources: src, total: total, plafond: plafond, sens: trop ? 'trop' : 'pas_assez' });
});
});
if(out.length) console.warn('[garde amidon] ' + out.length + ' repas hors plan (' + out.filter(function(a){return a.sens==='trop';}).length + ' au-dessus, ' + out.filter(function(a){return a.sens==='pas_assez';}).length + ' en dessous)', out);
} catch(e){ console.warn('[garde amidon] audit impossible', e); }
return out;
}
function ndAmidonBanner(anos){
if(!anos || !anos.length) return '';
const li = anos.map(a => {
const det = a.sources.map(s => esc(s.label)).join(' + ');
const plaf = (a.plafond !== null) ? (' — le plan prévoit ' + a.plafond + 'g au total') : '';
// FIX 2026-09-21 : le bandeau dit maintenant dans quel sens ça cloche.
const fleche = (a.sens === 'pas_assez') ? '🔻 pas assez' : '🔺 trop';
return '
' +
'⚠️ Portion de féculents à vérifier — ' + esc(titre) + ' ' +
'La ligne « Céréales + légumineuses » du plan est un total à atteindre, ni dépassé ni sous-dosé : maïs, polenta, pomme de terre, patate douce et plantain comptent dedans.' +
'
' + li + '
' +
'À corriger avant envoi : modifie à la main, ou clique « 🔄 Regénérer IA » en bas.
';
}
/* ═══ CORRECTEUR DU MENU DÉTAILLÉ — FIX 2026-09-29 (cas jean francois RÉMY) ═══
L'IA (Haiku) ne respecte pas toujours le plan malgré le prompt : féculent à 150g
pour un plan à 100g, fromage blanc 3% pour un plan à 0%, « sans lactose » ajouté
sans raison, 400g de légumes pour 150-200g, agave/miel du plan oubliés, fibres
absurdes (F0, F420). Aucun contrôle en code ne corrigeait ni ne bloquait avant
l'envoi à la patiente.
Ce module, appliqué juste après la génération ET sur tout menu déjà en cache :
1. CORRIGE ce qui se calcule à partir du plan visible (seule source de vérité) :
grammage des féculents (total du repas), des légumes et de la VPO, type et %
du laitage, sucrant du plan (agave/miel), mentions « sans lactose » non
justifiées, kcal et macros recalculés sur nutrition-db.json (CIQUAL) ;
2. SIGNALE ce qui ne se corrige pas seul (2 fruits, aliment « sauf » du plan,
laitage manquant, féculent au-delà de la fréquence du plan…) → ces écarts
BLOQUENT l'envoi / l'impression tant que Guillaume ne les a pas validés.
Idempotent : repasser le correcteur sur un menu corrigé ne change rien. */
const ND_CORRECTEUR_VERSION = '2026-09-29-correcteur-menu-detaille';
function ndCmNorm(s){ return String(s || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0153/g, 'oe').replace(/\u00e6/g, 'ae'); }
// Découpe « Nom : quantité reste » ; renvoie {nom, qte} (qte = '' si pas de « : »).
function ndCmSplit(line){
const s = String(line || '');
const i = s.indexOf(' : ');
if(i < 0){ const j = s.indexOf(':'); if(j < 0) return { nom: s.trim(), qte: '' }; return { nom: s.slice(0, j).trim(), qte: s.slice(j + 1).trim() }; }
return { nom: s.slice(0, i).trim(), qte: s.slice(i + 3).trim() };
}
function ndCmFirstG(txt){
const m = String(txt || '').match(/(\d+(?:[.,]\d+)?)\s*(?:g|ml)\b/i);
return m ? parseFloat(m[1].replace(',', '.')) : null;
}
function ndCmRound5(x){ return Math.max(5, Math.round(x / 5) * 5); }
// Remplace le PREMIER grammage de la partie quantité d'une ligne.
function ndCmSetFirstG(line, g){
const sp = ndCmSplit(line);
if(!sp.qte) return line;
const q = sp.qte.replace(/(\d+(?:[.,]\d+)?)(\s*)(g|ml)\b/i, (m0, n, sep, u) => String(g) + sep + u);
return sp.nom + ' : ' + q;
}
// ── Lexiques ────────────────────────────────────────────────────────────────
const ND_CM_LEGUMES = /\b(tomates?|courgettes?|aubergines?|poivrons?|brocolis?|choux?|chou[- ]fleur|romanesco|bok\s*choy|pak\s*choi|carottes?|epinards?|roquette|salade|laitue|mache|concombres?|radis|fenouil|haricots?\s+verts?|haricots?\s+beurre|champignons?|shiitake|poireaux?|celeri|betteraves?|asperges?|artichauts?|navets?|potiron|courges?|butternut|potimarron|endives?|blettes?|cresson|germes?\s+de\s+soja|pousses?\s+d|oignons?|echalotes?|ratatouille|legumes?|crudites|mesclun|chou\s+chinois|cornichons?|panais|topinambours?|brunoise|julienne\s+de\s+l|petits?\s+legumes|soupe\s+de\s+l|veloute)\b/;
const ND_CM_HERBES = /\b(persil|basilic|coriandre|aneth|ciboulette|menthe|thym|romarin|estragon|origan|laurier|gingembre|ail\b|ail\s|zeste|epices?|curry|paprika|cumin|curcuma|tandoori|sel|poivre\b|muscade|cannelle|vanille)/;
const ND_CM_FRUITS = /\b(pommes?(?!\s+de\s+terre)|poires?|prunes?|mirabelles?|figues?|kiwis?|oranges?|clementines?|mandarines?|pamplemousses?|bananes?|fraises?|framboises?|myrtilles?|mures?|cassis|groseilles?|cerises?|raisins?(?!\s+secs?)|abricots?|peches?|nectarines?|brugnons?|melons?|pasteques?|ananas|mangues?|papayes?|grenades?|litchis?|fruits?\s+(?:rouges?|frais|de\s+saison)|compote|coulis\s+de\s+fruits)\b/;
const ND_CM_LAITAGE = /\b(skyr|fromage\s+blanc|petits?[- ]suisses?|yaourts?|yogourts?|faisselles?)\b/;
const ND_CM_SUCRANT = /\b(sirop\s+d.?agave|agave|miel|confiture|sucre|edulcorant|sirop\s+d.?erable)\b/;
const ND_CM_VPO = /\b(poulet|dinde|pintade|coquelet|caille|volaille|boeuf|veau|porc|filet\s+mignon|roti\s+de\s+(?:porc|boeuf|veau|dinde)|roastbeef|bavette|steak\s+hache|jambon|canard|magret|aiguillettes?|gesiers?|lapin|cabillaud|colin|dorade|daurade|espadon|fletan|lieu|limande|lotte|loup|bar\b|branzino|maquereau|merlan|mulet|raie|rouget|sole|thon|turbot|sardines?|truite|saumon|pagre|merlu|haddock|crevettes?|gambas|langoustines?|moules?|huitres?|saint[- ]jacques|noix\s+de\s+st|calamars?|encornets?|seiche|poulpe|crabe|tourteau|bulots?|coques?|poisson)\b/;
const ND_CM_OEUF = /\boeufs?\b/;
// Facteur cuit/cru par féculent (même table que le prompt, FIX 2026-09-21).
function ndCmFacteurCru(t){
const n = ndCmNorm(t);
if(/pomme\s+de\s+terre|patate|manioc|igname|plantain|mais\b|pain|biscotte|gnocchi|chataigne/.test(n)) return 1;
if(/polenta|semoule\s+de\s+mais/.test(n)) return 4;
if(/semoule|couscous|quinoa|taboule/.test(n)) return 3;
if(/\briz\b|boulgour/.test(n)) return 2.8;
return 2.5; // pâtes, orzo, lentilles, pois chiches, haricots secs, fèves…
}
// ── Lecture du plan d'un repas ──────────────────────────────────────────────
function ndCmRange(txt){
const nums = (String(txt || '').match(/(\d+(?:[.,]\d+)?)\s*g\b/gi) || []).map(x => parseFloat(x.replace(/[^\d.,]/g, '').replace(',', '.'))).filter(n => !isNaN(n));
if(!nums.length) return null;
return { min: Math.min.apply(null, nums), max: Math.max.apply(null, nums) };
}
function ndCmLirePlanRepas(lignes){
const res = { legumes: null, vpo: null, laitage: null, fruit: null, sauf: [], freqAmidon: null };
(lignes || []).forEach(raw => {
const l = String(raw || ''); const n = ndCmNorm(l);
const saufM = n.match(/\bsauf\s+([^)]+)/);
if(saufM) saufM[1].split(/,|\bet\b/).map(s => s.trim()).filter(s => s.length > 2).forEach(s => res.sauf.push(s.replace(/s$/, '')));
if(/cereale|legumineuse|feculent/.test(n)){
const f = n.match(/(\d)\s*(?:a|-)\s*(\d)\s*fois\s+par\s+semaine/) || n.match(/max(?:imum)?\s*(\d)\s*fois/);
if(f) res.freqAmidon = parseInt(f[2] || f[1], 10);
return;
}
if(/legume|crudite/.test(n) && !res.legumes){ res.legumes = ndCmRange(l); return; }
if(/\bvpo\b|viande|poisson/.test(n) && !res.vpo){ res.vpo = ndCmRange(l); return; }
if(ND_CM_LAITAGE.test(n) && !res.laitage){
const avant = n.split(':')[0];
const options = avant.split(/\bou\b/).map(o => o.trim()).filter(Boolean).map(o => {
const tm = o.match(ND_CM_LAITAGE);
const pm = o.match(/(\d+(?:[.,]\d+)?)\s*%/);
return { type: tm ? ndCmTypeLaitage(tm[1]) : (/compote/.test(o) ? 'compote' : null), pct: pm ? pm[1].replace('.', ',') + '%' : null };
}).filter(o => o.type);
const apres = n.split(':').slice(1).join(':');
const sucr = apres.match(/\+\s*\d*\s*(?:cac|c\.?\s*a\s*c|cuillere\s+a\s+cafe)?\s*(?:de\s+)?(sirop\s+d.?agave|agave|miel|confiture)/);
res.laitage = { options: options, qte: ndCmFirstG(apres), sucrant: sucr ? (/agave/.test(sucr[1]) ? 'agave' : (/miel/.test(sucr[1]) ? 'miel' : 'confiture')) : null, optionnel: /\+\/-/.test(n) };
return;
}
if(/^\s*fruits?\b/.test(n)) res.fruit = { optionnel: /\+\/-|facultatif/.test(n) };
});
return res;
}
function ndCmTypeLaitage(t){
const n = ndCmNorm(t);
if(/skyr/.test(n)) return 'skyr';
if(/fromage\s+blanc/.test(n)) return 'fromage blanc';
if(/petits?[- ]suisse/.test(n)) return 'petit-suisse';
if(/faisselle/.test(n)) return 'faisselle';
if(/yaourt|yogourt/.test(n)) return 'yaourt';
return null;
}
const ND_CM_LAITAGE_LABEL = { 'skyr': 'Skyr', 'fromage blanc': 'Fromage blanc', 'petit-suisse': 'Petit-suisse', 'faisselle': 'Faisselle', 'yaourt': 'Yaourt nature' };
// Le plan (ou la fiche) justifie-t-il le « sans lactose » ? Mêmes déclencheurs que
// ndValidateMenuAgainstPlan + les allergies/intolérances et aliments à éviter.
function ndCmLactoseActif(p, planText){
const t = ndCmNorm(planText);
const trig = [/sans\s*lactose/, /\b0%\s*lactose/, /lactose[- ]?free/, /\balpro\b/, /yaourt\s+(?:de\s+)?(?:soja|amande|coco)/, /lait\s+vegetal/, /lait\s+(?:d.?\s*)?(?:amande|soja|coco|riz|avoine)/, /saint[- ]?hubert/, /creme\s+(?:de\s+)?(?:soja|amande|coco)/, /faisselle\s+de\s+soja/];
if(trig.some(rx => rx.test(t))) return true;
const fiche = ndCmNorm([(p.allergies || []).join(' '), p.eviter || '', p.regime || ''].join(' '));
return /lactose|lait\b|laitier|caseine|vegan|vegetalien/.test(fiche);
}
function ndCmRetirerSansLactose(s){
return String(s || '')
.replace(/\s+sans\s+lactose/gi, '')
.replace(/\bskyr\s+alpro\b/gi, 'Skyr')
.replace(/\s+alpro\b/gi, '')
.replace(/\s{2,}/g, ' ').trim();
}
// ── Valeurs nutritionnelles (nutrition-db.json, CIQUAL) ─────────────────────
let ndCmDbCache = null;
async function ndCmChargerDb(){
if(ndCmDbCache) return ndCmDbCache;
try {
const r = await fetch('/nutrition-db.json', { cache: 'force-cache' });
if(r.ok){ const j = await r.json(); ndCmDbCache = (j && j.foods) || null; }
} catch(e){ console.warn('[correcteur menu] nutrition-db indisponible', e); }
return ndCmDbCache;
}
// Correspondances explicites (l'ordre compte : la 1re règle qui matche gagne).
const ND_CM_PROXY = [
[/shirataki|konjac/, 'shirataki'],
[/krisproll|biscotte/, 'biscotte'], [/craquotte/, 'craquottes complètes'],
[/\borzo\b|risoni|pates|spaghetti|tagliatelle|penne|fusilli|farfalle|macaroni|coquillette|vermicelle|nouilles/, 'pates cuites'],
[/riz\s+complet/, 'riz complet cuit'], [/\briz\b/, 'riz blanc cuit'], [/semoule|couscous/, 'semoule cuite'],
[/boulgour/, 'boulgour cuit'], [/quinoa/, 'quinoa cuit'], [/lentille/, 'lentilles cuites'], [/pois\s+chiche/, 'pois chiches cuits'],
[/pomme\s+de\s+terre/, 'pommes de terre cuites'],
[/oeufs?\s+de\s+caille/, 'oeuf caille'], [/\boeufs?\b|omelette/, 'oeuf'],
[/fromage\s+blanc\s+(?:a\s+)?(?:3|20)\s*%/, 'fromage blanc 20'], [/fromage\s+blanc/, 'fromage blanc 0'], [/faisselle/, 'fromage blanc 0'],
[/skyr/, 'skyr nature'], [/petits?[- ]suisses?/, 'petit suisse 0'], [/yaourt|yogourt/, 'yaourt nature 0'],
[/saint\s*moret|st\s*moret/, 'st moret'], [/jambon\s+(?:de\s+)?(?:dinde|volaille)/, 'jambon dinde'], [/jambon/, 'jambon blanc'],
[/compote/, 'compote pomme allegee'],
[/beurre\s+allege|st\s*hubert|saint\s*hubert/, 'beurre allege'], [/beurre/, 'beurre'], [/huile/, 'huile olive'],
[/sirop\s+d.?agave|agave/, 'sirop agave'], [/\bmiel\b/, 'miel'], [/balsamique/, 'vinaigre balsamique'], [/sauce\s+soja/, 'sauce soja'],
[/branzino|\bbar\b|\bloup\b/, 'bar'], [/dorade|daurade/, 'daurade'], [/cabillaud|colin|lieu|merlu/, 'cabillaud'],
[/magret|aiguillette|canard/, 'aiguillette canard'], [/poulet|volaille/, 'filet poulet'], [/dinde/, 'dinde escalope'],
[/calamar|encornet|seiche|supion/, 'calamar cuit'], [/poulpe/, 'poulpe cuit'], [/crevette|gambas/, 'crevettes'],
[/mirabelle/, 'prune'], [/bok\s*choy|pak\s*choi|chou\s+chinois/, 'chou blanc cru'],
[/champignon/, 'champignon de paris'], [/tomates?\s+cerises?/, 'tomate cerise'], [/tomate/, 'tomate'],
[/haricots?\s+verts?/, 'haricot vert cuit'], [/epinard/, 'epinard cuit'], [/brocoli/, 'brocoli cuit'],
[/carotte/, 'carotte cuite'], [/courgette/, 'courgette cuite'], [/aubergine/, 'aubergine cuite'], [/fenouil/, 'fenouil cuit'],
[/oignon|echalote/, 'oignon cru'], [/artichaut/, 'artichaut cuit'], [/olives?\s+noires?/, 'olive noire'], [/olives?/, 'olive verte'],
[/figue/, 'figue fraiche'], [/framboise/, 'framboise'], [/citron\s+vert/, 'citron vert'], [/citron/, 'citron']
];
// Aliments comptés comme couverts à ~0 kcal (quantités négligeables).
const ND_CM_ZERO = /\b(eau|cafe|the|tisane|infusion|bouillon|vinaigre(?!\s+balsamique)|epices?|sel|poivre\b|herbes|ail\s+infuse|zeste|spray|pulverisation|germes?\s+de\s+soja|pate\s+miso|miso)\b/;
function ndCmIndexDb(db){
if(!db) return null;
if(db.__ndIdx) return db.__ndIdx;
const idx = {};
Object.keys(db).forEach(k => {
if(k.indexOf('__') === 0) return;
idx[ndCmNorm(k)] = db[k];
(db[k].aliases || []).forEach(a => { const na = ndCmNorm(a); if(!idx[na]) idx[na] = db[k]; });
});
Object.defineProperty(db, '__ndIdx', { value: idx, enumerable: false });
return idx;
}
function ndCmTrouverAliment(nom, db){
const idx = ndCmIndexDb(db); if(!idx) return null;
const n = ndCmNorm(nom);
for(const pr of ND_CM_PROXY){ if(pr[0].test(n)){ const e = idx[ndCmNorm(pr[1])]; if(e) return e; } }
const clean = n.replace(/\b(frais|fraiche|fraiches|nature|cuit|cuite|cuits|cuites|vapeur|grille|grillee|roti|rotie|rotis|poele|poelee|entier|entiere|sans\s+peau|bio|maison|leger|legere|\d+\s*%)\b/g, ' ').replace(/\s+/g, ' ').trim();
if(idx[clean]) return idx[clean];
// Plus longue clé de la base entièrement contenue dans le nom.
let best = null, bestLen = 0;
Object.keys(idx).forEach(k => {
if(k.length < 4 || k.length <= bestLen || clean.indexOf(k) < 0) return;
if(new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 's?\\b').test(clean)){ best = idx[k]; bestLen = k.length; }
});
return best;
}
// Poids d'une ligne d'ingrédient en grammes (null = inconnu).
function ndCmPoids(line){
const sp = ndCmSplit(line); const q = ndCmNorm(sp.qte); const nom = ndCmNorm(sp.nom);
const g = ndCmFirstG(sp.qte);
if(g !== null) return g;
const nb = q.match(/^(\d+(?:[.,]\d+)?)/); const k = /demi|1\/2/.test(q) ? 0.5 : (nb ? parseFloat(nb[1].replace(',', '.')) : null);
if(/krisproll|biscotte/.test(nom) && k) return k * 9.5;
if(/craquotte/.test(nom) && k) return k * 7;
if(/\boeufs?\b/.test(nom) && k) return k * 50;
if(/cas|c\.?\s*a\s*s|cuillere\s+a\s+soupe/.test(q) && k) return k * 10;
if(/cac|c\.?\s*a\s*c|cuillere\s+a\s+cafe/.test(q) && k) return k * 5;
if(/citron/.test(nom) && k) return k * 50;
return null;
}
// Recalcule kcal/macros d'un repas ; ne remplace que si ≥ 90 % du poids est reconnu.
function ndCmRecalcNutri(meal, db){
if(!db) return { ok: false, couverture: 0 };
let tot = 0, cov = 0; const s = { kcal: 0, p: 0, l: 0, g: 0, f: 0 };
(meal.ingredients || []).forEach(line => {
const sp = ndCmSplit(line); const n = ndCmNorm(sp.nom);
const g = ndCmPoids(line);
if(ND_CM_ZERO.test(n)){ return; }
if(g === null || g <= 0) return;
if(ND_CM_HERBES.test(n) && g <= 10) return;
tot += g;
const e = ndCmTrouverAliment(sp.nom, db);
if(!e) return;
cov += g;
['kcal', 'p', 'l', 'g', 'f'].forEach(k => { s[k] += (Number(e[k]) || 0) * g / 100; });
});
const couverture = tot > 0 ? cov / tot : 0;
if(couverture < 0.9) return { ok: false, couverture: couverture };
return { ok: true, couverture: couverture, kcal: Math.round(s.kcal / 5) * 5, macros: { p: Math.round(s.p), l: Math.round(s.l), g: Math.round(s.g), f: Math.round(s.f) } };
}
// ── Correction d'un menu détaillé complet ───────────────────────────────────
// Renvoie { detailed (copie corrigée), corrections:[texte], residus:[{jour,repas,texte}], infos:[texte] }
function ndCorrigerMenuDetaille(p, detailedIn, db){
const detailed = JSON.parse(JSON.stringify(detailedIn || {}));
const corrections = [], residus = [], infos = [];
const baseKey = (p.plan && p.plan.baseKey) || (typeof detectBase === 'function' ? detectBase(p.motifs) : null) || 'colopathie';
const baseRef = (typeof BASES !== 'undefined' && BASES[baseKey]) || {};
const planLignes = {
breakfast: (p.plan && p.plan.petitDejeuner) || baseRef.petitDejeuner || [],
lunch: (p.plan && p.plan.dejeuner) || baseRef.dejeuner || [],
dinner: (p.plan && p.plan.diner) || baseRef.diner || []
};
const planText = [].concat(planLignes.breakfast, planLignes.lunch, (p.plan && p.plan.collation) || baseRef.collation || [], planLignes.dinner).join(' | ');
const lactoseOk = ndCmLactoseActif(p, planText);
const jours = [['lundi','Lundi'],['mardi','Mardi'],['mercredi','Mercredi'],['jeudi','Jeudi'],['vendredi','Vendredi'],['samedi','Samedi'],['dimanche','Dimanche']];
const repasL = { breakfast: 'Petit-déjeuner', lunch: 'Déjeuner', dinner: 'Dîner' };
const nbAmidonParRepas = { breakfast: 0, lunch: 0, dinner: 0 };
Object.keys(repasL).forEach(slot => {
const plan = ndCmLirePlanRepas(planLignes[slot]);
const plafond = (typeof ndPlafondAmidon === 'function') ? ndPlafondAmidon(planLignes[slot]) : null;
jours.forEach(([jk, jl]) => {
const meal = detailed[jk] && detailed[jk][slot];
if(!meal || !Array.isArray(meal.ingredients)) return;
const ou = jl + ' · ' + repasL[slot];
const note = (t) => corrections.push(ou + ' — ' + t);
const bloque = (t) => residus.push({ jour: jl, repas: repasL[slot], texte: t });
let ings = meal.ingredients.map(String);
// 1) Sans lactose non justifié
if(!lactoseOk){
let touche = false;
ings = ings.map(l => { const n = ndCmRetirerSansLactose(l); if(n !== l) touche = true; return n; });
['titre', 'preparation'].forEach(k => { if(meal[k]){ const n = ndCmRetirerSansLactose(meal[k]); if(n !== meal[k]){ meal[k] = n; touche = true; } } });
if(touche) note('mention « sans lactose / Alpro » retirée (ni le plan ni la fiche ne la demandent)');
}
// 2) Féculents : le grammage du plan est un TOTAL à atteindre
const src = (typeof ndAmidonSources === 'function') ? ndAmidonSources(ings) : [];
if(src.length){
nbAmidonParRepas[slot]++;
const total = src.reduce((a, b) => a + b.g, 0);
if(plafond !== null && total > 0 && (total > plafond * 1.05 || total < plafond * 0.95)){
const ratio = plafond / total;
let reste = plafond;
src.forEach((s, i) => {
const cuit = (i === src.length - 1) ? Math.max(5, reste) : ndCmRound5(s.g * ratio);
reste -= cuit;
const idx = ings.indexOf(s.label);
if(idx < 0) return;
let l = ndCmSetFirstG(ings[idx], cuit);
const f = ndCmFacteurCru(s.label);
if(f !== 1) l = l.replace(/\((\d+(?:[.,]\d+)?)(\s*g\s+cru)/i, (m0, n, rest) => '(' + Math.round(cuit / f) + rest);
ings[idx] = l;
});
note('féculent ramené de ' + Math.round(total) + 'g à ' + plafond + 'g cuits (grammage du plan)');
} else if(plafond === null && !/pain|biscotte|krisproll|craquotte/i.test(src.map(s => s.label).join(' '))){
bloque('féculent présent (' + src.map(s => s.label).join(' + ') + ') alors que le plan de ce repas n\'en prévoit pas');
}
} else if(plafond !== null && slot === 'lunch'){
bloque('aucun féculent alors que le plan en prévoit ' + plafond + 'g');
}
// 3) VPO dans la fourchette du plan (hors œufs : règle cabinet « 2 œufs »)
if(plan.vpo){
ings = ings.map(l => {
const sp = ndCmSplit(l); const n = ndCmNorm(sp.nom);
if(!ND_CM_VPO.test(n) || ND_CM_OEUF.test(n) || /bouillon|sauce|fumet/.test(n)) return l;
const g = ndCmFirstG(sp.qte); if(g === null) return l;
const cible = g > plan.vpo.max ? plan.vpo.max : (g < plan.vpo.min ? plan.vpo.min : g);
if(cible === g) return l;
note(sp.nom + ' : ' + g + 'g → ' + cible + 'g (VPO du plan ' + plan.vpo.min + '-' + plan.vpo.max + 'g)');
return ndCmSetFirstG(l, cible);
});
}
// 4) Légumes dans la fourchette du plan
if(plan.legumes){
const legIdx = [];
ings.forEach((l, i) => {
const sp = ndCmSplit(l); const n = ndCmNorm(sp.nom);
if(!ND_CM_LEGUMES.test(n) || /shirataki|konjac|bouillon|sauce|olive|huile|vinaigre/.test(n)) return;
if(src.some(s => s.label === l)) return;
const g = ndCmFirstG(sp.qte); if(g === null) return;
if(ND_CM_HERBES.test(n) && g <= 10) return;
legIdx.push({ i: i, g: g });
});
const tot = legIdx.reduce((a, b) => a + b.g, 0);
if(legIdx.length && (tot > plan.legumes.max * 1.05 || tot < plan.legumes.min * 0.95)){
const cible = tot > plan.legumes.max ? plan.legumes.max : plan.legumes.min;
legIdx.forEach(x => { ings[x.i] = ndCmSetFirstG(ings[x.i], ndCmRound5(x.g * cible / tot)); });
note('légumes ramenés de ' + Math.round(tot) + 'g à ~' + cible + 'g (plan ' + plan.legumes.min + '-' + plan.legumes.max + 'g)');
}
}
// 5) Laitage : type et % du plan, quantité du plan, sucrant du plan
if(plan.laitage && plan.laitage.options.length){
const opts = plan.laitage.options;
const optsLait = opts.filter(o => o.type !== 'compote');
let aLaitage = false;
ings = ings.map(l => {
const sp = ndCmSplit(l); const n = ndCmNorm(sp.nom);
const tm = n.match(ND_CM_LAITAGE);
if(!tm) return l;
if(/marinade/.test(ndCmNorm(sp.qte)) || /marinade/.test(n)) return l;
aLaitage = true;
if(!optsLait.length) return l;
const type = ndCmTypeLaitage(tm[1]);
const opt = optsLait.find(o => o.type === type) || optsLait[0];
const pctActuel = (n.match(/(\d+(?:[.,]\d+)?)\s*%/) || [])[1];
const pctCible = opt.pct || (pctActuel ? pctActuel.replace('.', ',') + '%' : '0%');
const g = ndCmFirstG(sp.qte);
const gCible = plan.laitage.qte || g;
const lactoseSuffixe = (lactoseOk && /sans\s+lactose|alpro|soja|vegetal/.test(n)) ? (/alpro/.test(n) ? ' Alpro' : ' sans lactose') : '';
const neuf = ND_CM_LAITAGE_LABEL[opt.type] + lactoseSuffixe + ' ' + pctCible + ' : ' + (gCible ? gCible + 'g' : sp.qte);
if(ndCmNorm(neuf) === ndCmNorm(l)) return l;
if(opt.type !== type){
note(sp.nom + ' remplacé par ' + ND_CM_LAITAGE_LABEL[opt.type] + ' ' + pctCible + ' (laitage prévu par le plan)');
const rxT = new RegExp(tm[1].replace(/[-\s]/g, '[-\\s]'), 'gi');
['titre', 'preparation'].forEach(k => { if(meal[k]) meal[k] = meal[k].replace(rxT, ND_CM_LAITAGE_LABEL[opt.type].toLowerCase()); });
} else if(pctActuel && opt.pct && ndCmNorm(pctActuel + '%').replace(',', '.') !== ndCmNorm(opt.pct).replace(',', '.')){
note(sp.nom + ' : ' + pctActuel + '% → ' + opt.pct + ' (pourcentage écrit dans le plan)');
} else if(g && gCible && g !== gCible){
note(sp.nom + ' : ' + g + 'g → ' + gCible + 'g (quantité du plan)');
}
return neuf;
});
const aCompote = ings.some(l => /compote/.test(ndCmNorm(ndCmSplit(l).nom)));
if(!aLaitage && !(aCompote && opts.some(o => o.type === 'compote')) && !plan.laitage.optionnel){
bloque('laitage du plan absent (' + opts.map(o => (ND_CM_LAITAGE_LABEL[o.type] || o.type) + (o.pct ? ' ' + o.pct : '')).join(' ou ') + ')');
}
if(plan.laitage.sucrant && aLaitage && !ings.some(l => ND_CM_SUCRANT.test(ndCmNorm(ndCmSplit(l).nom)))){
const ligne = plan.laitage.sucrant === 'agave' ? 'Sirop d\'agave : 1 càc (7g)' : (plan.laitage.sucrant === 'miel' ? 'Miel : 1 càc (7g)' : 'Confiture : 1 càc (7g)');
const iL = ings.findIndex(l => ND_CM_LAITAGE.test(ndCmNorm(ndCmSplit(l).nom)) && !/marinade/.test(ndCmNorm(l)));
ings.splice(iL >= 0 ? iL + 1 : ings.length, 0, ligne);
note(ligne.split(' :')[0] + ' ajouté (prévu par le plan avec le laitage)');
}
}
// 6) Fruits : un seul si le plan dit « Fruit »
const fruits = ings.filter(l => { const n = ndCmNorm(ndCmSplit(l).nom); return ND_CM_FRUITS.test(n) && !/jus\s+de\s+citron|zeste|tomates?\s+cerises?|vinaigre|sirop|confiture/.test(n) && !/^citron/.test(n); });
if(plan.fruit && fruits.length > 1 && !(fruits.length === 2 && fruits.some(l => /compote/.test(ndCmNorm(l))) && slot === 'breakfast')){
bloque(fruits.length + ' fruits dans le même repas (' + fruits.map(l => ndCmSplit(l).nom).join(' + ') + ') — le plan en prévoit un');
}
if(plan.fruit && !plan.fruit.optionnel && fruits.length === 0){
bloque('fruit du plan absent');
}
// 7) Aliments exclus par un « sauf » du plan
if(plan.sauf.length){
ings.forEach(l => {
const n = ndCmNorm(ndCmSplit(l).nom);
plan.sauf.forEach(x => { if(x.length > 2 && new RegExp('\\b' + x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 's?\\b').test(n)) bloque('« ' + ndCmSplit(l).nom + ' » : exclu par le plan (« sauf ' + x + ' »)'); });
});
}
meal.ingredients = ings;
// 8) Valeurs nutritionnelles recalculées (CIQUAL)
const nut = ndCmRecalcNutri(meal, db);
if(nut.ok){
const avant = (meal.kcal_total || 0) + ' kcal F' + ((meal.macros && meal.macros.f) || 0);
meal.kcal_total = nut.kcal; meal.macros = nut.macros;
meal._nutri = 'ciqual';
if(avant !== nut.kcal + ' kcal F' + nut.macros.f) corrections.push(ou + ' — kcal et macros recalculés (CIQUAL) : ' + avant + ' → ' + nut.kcal + ' kcal F' + nut.macros.f);
} else {
meal._nutri = 'ia';
if(meal.macros && (Number(meal.macros.f) > 40)){ meal.macros.f = 0; }
infos.push(ou + ' — valeurs nutritionnelles non recalculées (aliments non reconnus : ' + Math.round((1 - nut.couverture) * 100) + '% du poids)');
}
});
});
// 9) Fréquence hebdomadaire du féculent (ex. dîner « 1 à 2 fois par semaine »)
Object.keys(repasL).forEach(slot => {
const plan = ndCmLirePlanRepas(planLignes[slot]);
if(plan.freqAmidon && nbAmidonParRepas[slot] > plan.freqAmidon){
residus.push({ jour: 'Semaine', repas: repasL[slot], texte: 'féculent servi ' + nbAmidonParRepas[slot] + ' fois alors que le plan dit maximum ' + plan.freqAmidon + ' fois par semaine' });
}
});
if(!lactoseOk && typeof detailed.conseils === 'string'){
const n = ndCmRetirerSansLactose(detailed.conseils);
if(n !== detailed.conseils){ detailed.conseils = n; corrections.push('Conseils de la semaine — mention « sans lactose / Alpro » retirée'); }
}
return { detailed: detailed, corrections: corrections, residus: residus, infos: infos, lactoseActif: lactoseOk };
}
function ndCmSignature(detailed){
const s = JSON.stringify(detailed || {});
let h = 0; for(let i = 0; i < s.length; i++){ h = ((h << 5) - h + s.charCodeAt(i)) | 0; }
return s.length + ':' + h;
}
// Applique le correcteur si besoin (menu neuf, régénéré, contrôlé par une version
// antérieure, ou contrôlé avant que la base CIQUAL soit chargée). SYNCHRONE : utilise
// la base CIQUAL déjà préchargée (ndCmChargerDb au démarrage) ; sans elle, les
// corrections du plan s'appliquent quand même et les kcal seront recalculées au
// prochain passage. Persiste sur la fiche. En cas d'erreur : menu laissé tel quel,
// erreur signalée comme écart bloquant.
function ndControlerMenuDetaille(p, force){
if(!p || !p.menus_detailed || !Object.keys(p.menus_detailed).length) return null;
const db = ndCmDbCache;
const c = p.menus_detailed_controle;
if(!force && c && c.version === ND_CORRECTEUR_VERSION && c.signature === ndCmSignature(p.menus_detailed) && (c.nutri || !db)) return c;
let res;
try {
res = ndCorrigerMenuDetaille(p, p.menus_detailed, db);
} catch(e){
console.error('[correcteur menu] échec', e);
p.menus_detailed_controle = { version: ND_CORRECTEUR_VERSION, signature: ndCmSignature(p.menus_detailed), nutri: !!db, corrections: [], infos: [], residus: [{ jour: '—', repas: '—', texte: 'contrôle automatique impossible (' + ((e && e.message) || e) + ') — relisez le menu avant envoi' }], valide: false, ts: Date.now() };
return p.menus_detailed_controle;
}
// Repasse sur un menu déjà corrigé (ex. base CIQUAL arrivée entre-temps) : on garde
// la liste des corrections déjà faites et la validation éventuelle.
const memeMenu = c && c.version === ND_CORRECTEUR_VERSION && c.signature === ndCmSignature(p.menus_detailed);
p.menus_detailed = res.detailed;
p.menus_detailed_controle = {
version: ND_CORRECTEUR_VERSION,
signature: ndCmSignature(res.detailed),
nutri: !!db,
corrections: memeMenu ? (c.corrections || []).concat(res.corrections) : res.corrections,
infos: res.infos, residus: res.residus,
valide: memeMenu ? !!c.valide : false, ts: Date.now()
};
if(res.corrections.length) console.info('[correcteur menu] ' + res.corrections.length + ' correction(s)', res.corrections);
if(res.residus.length) console.warn('[correcteur menu] ' + res.residus.length + ' écart(s) à valider', res.residus);
if(res.corrections.length || !memeMenu){ try { if(typeof autosave === 'function') autosave(); } catch(_){} }
return p.menus_detailed_controle;
}
// Garde d'envoi / d'impression (SYNCHRONE, pour rester dans le clic de l'utilisateur :
// sinon le navigateur bloque la fenêtre d'impression). true = on peut continuer.
// Bloque tant que des écarts non corrigeables n'ont pas été validés explicitement.
function ndGardeEnvoiMenuDetaille(p, action){
const c = ndControlerMenuDetaille(p);
if(!c || !c.residus || !c.residus.length || c.valide) return true;
const liste = c.residus.map(r => '• ' + r.jour + ' · ' + r.repas + ' : ' + r.texte).join('\n');
const ok = confirm('⚠️ Menu détaillé de ' + (p.prenom || '') + ' ' + (p.nom || '') + ' — ' + c.residus.length + ' écart(s) avec le plan que le correcteur ne peut pas régler seul :\n\n' + liste +
'\n\nAnnuler = revenir corriger (« Regénérer IA »).\nOK = je valide ce menu tel quel et ' + (action || 'je l\'envoie') + '.');
if(ok){ c.valide = true; c.valideTs = Date.now(); try { if(typeof autosave === 'function') autosave(); } catch(_){} }
else { try { showToast('✋ Annulé — menu détaillé à revoir'); } catch(_){} }
return ok;
}
// Bandeau cabinet (jamais côté patiente) : corrections faites + écarts à valider.
function ndCorrecteurBanner(p){
const c = p && p.menus_detailed_controle;
if(!c) return '';
let h = '';
if(c.residus && c.residus.length){
h += '
' +
'✋ ' + c.residus.length + ' écart(s) avec le plan à valider avant envoi' + (c.valide ? ' — validé(s) par vous' : '') + '' +
'
' +
'L\'envoi et l\'impression demanderont votre validation. Pour corriger : « 🔄 Regénérer IA ».
';
}
if(c.corrections && c.corrections.length){
h += '' +
'✓ ' + c.corrections.length + ' correction(s) automatique(s) pour coller au plan' +
'
' + c.corrections.map(t => '
' + esc(t) + '
').join('') + '
';
}
return h;
}
// Précharge la base CIQUAL (178 Ko, cache navigateur) pour que le contrôle soit prêt au premier clic.
try { setTimeout(function(){ ndCmChargerDb(); }, 1500); } catch(_){}
/* ═══ CACHES PÉRIMÉS — FIX 2026-09-29 (cas jean francois RÉMY, 2e signalement) ═══
Après « Regénérer les menus IA », l'envoi « Menu + courses » repartait avec l'ANCIEN
menu détaillé et l'ANCIENNE liste de courses : ndSendMenuAndCourses / ndSendFullBundle
ne régénéraient ces deux documents que s'ils étaient ABSENTS, et rien ne les effaçait
quand les menus de la semaine changeaient (génération IA, mode local, plat modifié à
la main ou remplacé). Désormais :
- chaque document retient l'empreinte des menus dont il est issu (…_source) ;
- une régénération des menus efface explicitement les deux documents ;
- un document dont l'empreinte ne correspond plus aux menus est PÉRIMÉ → régénéré
avant tout envoi, jamais envoyé tel quel ;
- documents anciens sans empreinte (créés avant ce fix) : jugés sur le contenu — si les
déjeuners du menu détaillé ne reprennent pas les plats de la semaine, il est périmé. */
function ndMenusSig(p){ return (p && Array.isArray(p.menus) && p.menus.length) ? ndCmSignature(p.menus) : ''; }
function ndCpMots(s){
const stop = /^(avec|sans|leger|legere|facon|maison|plat|sauce|aux|des|les|une|pour|puis|cuit|cuits|cuite|cuites|frais|fraiche|nature|grille|grillee|roti|rotie|vapeur|epices|asiatique|italienne|mediterraneen|mediterraneenne|mediterraneennes|skyr|sirop|agave|fromage|blanc|miel|huile|olive)$/;
return ndCmNorm(s).replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(w => w.length >= 5 && !stop.test(w));
}
// Menu détaillé ancien (sans empreinte) : correspond-il encore aux menus de la semaine ?
function ndDetailCorrespondAuxMenus(p){
const jours = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'];
let comparés = 0, ok = 0;
jours.forEach((j, i) => {
const std = (p.menus || [])[i]; const det = p.menus_detailed && p.menus_detailed[j];
if(!std || !det) return;
['dejeuner', 'diner'].forEach((k, n) => {
const meal = det[n === 0 ? 'lunch' : 'dinner'];
const plat = std[k] || std[n === 0 ? 'lunch' : 'dinner'];
if(!meal || !plat) return;
const mots = ndCpMots(plat).slice(0, 1); // le 1er mot porteur = la protéine / le plat (« Poulet rôti… », « Colin vapeur… »)
if(!mots.length) return;
comparés++;
const cible = ndCmNorm((meal.titre || '') + ' ' + (meal.ingredients || []).join(' '));
if(mots.some(m => cible.indexOf(m.slice(0, Math.max(5, m.length - 1))) >= 0)) ok++;
});
});
return comparés === 0 || ok / comparés >= 0.6;
}
function ndDetailPerime(p){
if(!p || !p.menus_detailed || !Object.keys(p.menus_detailed).length) return false;
if(!Array.isArray(p.menus) || !p.menus.length) return false;
if(p.menus_detailed_source) return p.menus_detailed_source !== ndMenusSig(p);
return !ndDetailCorrespondAuxMenus(p);
}
function ndCoursesPerime(p){
if(!p || !p.courses || !Array.isArray(p.menus) || !p.menus.length) return false;
if(p.courses_source) return p.courses_source !== ndMenusSig(p);
// Liste ancienne sans empreinte : périmée si le menu détaillé ancien l'est (même époque).
return !!(p.menus_detailed && !p.menus_detailed_source && !ndDetailCorrespondAuxMenus(p));
}
// Appelé dès que les menus de la semaine sont régénérés.
function ndInvaliderCachesMenus(p){
if(!p) return;
delete p.menus_detailed; delete p.menus_detailed_controle; delete p.menus_detailed_source;
delete p.courses; delete p.coursesParRepas; delete p.courses_source;
}
function renderDetailedMenuView(p, detailed){
const days = [['lundi','Lundi'],['mardi','Mardi'],['mercredi','Mercredi'],['jeudi','Jeudi'],['vendredi','Vendredi'],['samedi','Samedi'],['dimanche','Dimanche']];
const slots = [['breakfast','☀️ Petit-déjeuner'],['lunch','🌿 Déjeuner'],['dinner','🌙 Dîner']];
let html = `
`;
try { html += ndCorrecteurBanner(p); } catch(e){ console.warn('[correcteur menu] bandeau non affiché', e); }
try { html += ndAmidonBanner(ndAuditAmidon(p, detailed)); } catch(e){ console.warn('[garde amidon] bandeau non affiché', e); }
if(detailed.conseils){
html += `
💡 Conseils diététiques pour la semaine ${esc(detailed.conseils).replace(/\n/g,' ')}
`;
}
days.forEach(([key, label]) => {
const day = detailed[key];
if(!day) return;
html += `
Cabinet de Diététique · Document personnel · ${escH(today())}
`;
const safeName = `${(p.prenom||'patient').replace(/[^a-z0-9]/gi,'_')}_${(p.nom||'').replace(/[^a-z0-9]/gi,'_')}`;
const filename = `Menu-detaille_${safeName}_${today()}.html`;
const downloadBlob = () => {
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
// FIX 2026-05-07 — refonte complète du flow envoi mail :
// 1. Toujours tenter l'upload Supabase (lien public dans le mail = pas besoin
// de pièce jointe → fonctionne sur tous les clients mail)
// 2. Si upload OK : mailto avec lien dans le body, PAS de download local
// 3. Si upload KO : modale claire avec 3 options (réessayer, télécharger en
// local et attacher manuellement, ou copier le lien)
// 4. Tentative auto de refresh JWT si expiré (avant l'erreur upload)
showToast('⏳ Upload du menu sur le cloud…');
const tryUpload = async () => {
const c = ntGetConfig();
if(!c.url || !c.jwt){
throw new Error('NOT_CONNECTED');
}
const path = `course-lists/${encodeURIComponent(patientEmail)}/menu_${Date.now()}.html`;
// FIX 2026-05-12 — Supabase Storage rejette 'text/html;charset=utf-8' avec HTTP 415
// invalid_mime_type (le bucket meal-photo n'accepte que les MIME exacts).
// → on envoie 'text/html' tout court. L'encodage UTF-8 reste OK car le HTML
// déclare dans son .
const blob = new Blob([html], { type: 'text/html' });
let r = await fetch(c.url + '/storage/v1/object/meal-photo/' + path, {
method: 'POST',
headers: {
'apikey': c.anonKey,
'Authorization': 'Bearer ' + c.jwt,
'Content-Type': 'text/html',
'x-upsert': 'true',
'cache-control': 'no-cache, max-age=0',
},
body: blob,
});
// FIX 2026-05-14 v22 — détection JWT expiré élargie. Supabase Storage
// renvoie HTTP 400 avec body `{statusCode:"403","error":"Unauthorized",
// "message":"exp claim timestamp check failed"}` quand le JWT est mort.
// Avant on ne retentait que sur 401 → upload échouait pour rien.
let needsRefresh = (r.status === 401);
if(!r.ok && !needsRefresh && c.refreshToken){
const peek = await r.clone().text().catch(()=>'');
if(/exp.*claim|unauthorized|jwt.*expir|invalid.*token|statusCode.*403/i.test(peek)){
needsRefresh = true;
}
}
if(needsRefresh && c.refreshToken){
try {
const rr = await fetch(c.url + '/auth/v1/token?grant_type=refresh_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'apikey': c.anonKey },
body: JSON.stringify({ refresh_token: c.refreshToken }),
});
if(rr.ok){
const data = await rr.json();
c.jwt = data.access_token;
c.refreshToken = data.refresh_token;
c.jwtExpiresAt = Date.now() + (data.expires_in || 3600) * 1000;
ntSaveConfig(c);
// Retry upload avec nouveau JWT
r = await fetch(c.url + '/storage/v1/object/meal-photo/' + path, {
method: 'POST',
headers: {
'apikey': c.anonKey,
'Authorization': 'Bearer ' + c.jwt,
'Content-Type': 'text/html', // FIX 2026-05-12 (cf. ci-dessus)
'x-upsert': 'true',
'cache-control': 'no-cache, max-age=0',
},
body: blob,
});
}
} catch(e){ /* refresh fail, on continue avec l'erreur originale */ }
}
if(!r.ok){
const errTxt = await r.text().catch(() => '');
throw new Error(`HTTP ${r.status} ${errTxt.slice(0, 80)}`);
}
// FIX 2026-05-14 — Supabase force text/plain sur les HTML (anti-XSS) → on
// sert via la Netlify Function view-menu (alias /menu/*) qui rejoue le bon
// Content-Type: text/html; charset=utf-8. Sinon le navigateur du patient
// affiche le HTML brut et les accents en mojibake.
return location.origin + '/menu/' + path;
};
const subject = `📖 Votre menu détaillé de la semaine — ${today()}`;
const openMailto = (publicUrl) => {
const body = `Bonjour ${p.prenom||''},
Voici votre menu détaillé pour la semaine avec les quantités précises et les modes de préparation.
👉 Cliquez sur ce lien pour ouvrir votre version détaillée :
${publicUrl}
(Le lien s'ouvre directement dans votre navigateur — pas besoin de télécharger)
Bonne semaine !
Votre diététicien`;
const mailto = `mailto:${encodeURIComponent(patientEmail)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailto;
};
try {
const publicUrl = await tryUpload();
openMailto(publicUrl);
showToast('✉️ Email pré-rempli avec le lien — clique Envoyer dans ta messagerie');
return;
} catch(uploadError){
console.warn('Upload menu détaillé failed:', uploadError.message);
// Modale d'options en cas d'échec
const reasonText = uploadError.message === 'NOT_CONNECTED'
? "Tu n'es pas connectée à Supabase. Reconnecte-toi pour générer un lien."
: `L'upload du menu sur le cloud a échoué (${uploadError.message}). Cela peut arriver si tu n'es plus connectée à Supabase, si la connexion réseau est instable, ou si le bucket Storage a un problème.`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'background:rgba(0,0,0,0.5);';
overlay.innerHTML = `
📧 Envoyer le menu — option à choisir
${reasonText}
`;
document.body.appendChild(overlay);
document.getElementById('emm_retry').onclick = () => {
overlay.remove();
emailDetailedMenu(pid); // relance complète
};
document.getElementById('emm_download').onclick = () => {
overlay.remove();
downloadBlob();
const body = `Bonjour ${p.prenom||''},
Voici votre menu détaillé pour la semaine — il est en pièce jointe (fichier "${filename}").
Ouvrez-le dans votre navigateur (double-clic) pour voir les recettes mises en page.
Bonne semaine !
Votre diététicien`;
const mailto = `mailto:${encodeURIComponent(patientEmail)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailto;
showToast(`📥 HTML téléchargé — n'oublie pas d'attacher le fichier "${filename}" avant d'envoyer le mail`);
};
document.getElementById('emm_cancel').onclick = () => overlay.remove();
}
}
// ═════════════════════════════════════════════════════════════
// 🛒 ONGLET COURSES
// ═════════════════════════════════════════════════════════════
function renderCourses(p){
if(!p.courses){
get('tabContent').innerHTML=`
🛒
Aucune liste générée
Génère d'abord les menus, puis la liste sera créée automatiquement.
🚫 Intolérances/allergies actives : ${allergies.map(a=>esc(a)).join(' · ')}
${totalProblems > 0
? ` 💡 ${totalProblems} article${totalProblems>1?'s':''} à adapter — clique dessus pour choisir un remplacement.`
: ` ✓ Tous les articles respectent les contraintes.`}
` : ''}
💡 Coche les articles au fur et à mesure
${ndCoursesPerime(p) ? '
⚠️ Liste faite pour d\'anciens menus — les menus de la semaine ont changé depuis. Régénère la liste (elle le sera aussi automatiquement à l\'envoi « Menu + courses »).
' : ''}
${html}
`;
}
function toggleCourseItem(cb){
const lbl = cb.parentElement.querySelector('label');
lbl.style.textDecoration=cb.checked?'line-through':'none';
lbl.style.color=cb.checked?'var(--mid)':'#1a1a1a';
}
// ═════════════════════════════════════════════════════════════
// 📈 ONGLET HISTORIQUE (nouveau)
// ═════════════════════════════════════════════════════════════
// ───── CALCULS MÉTABOLIQUES ─────
// BMR (Métabolisme de base) — formule Mifflin-St Jeor (la plus précise pour adultes)
function calcBMR(p){
const poids = parseFloat(p.poids), taille = parseFloat(p.taille);
if(!poids || !taille || !p.dob) return null;
const age = Math.floor((Date.now()-new Date(p.dob))/(365.25*24*3600*1000));
if(!age) return null;
const s = (p.sexe||'').toLowerCase();
// Femme par défaut si non renseigné (profil dominant en cabinet de diététique)
if(s === 'homme') return Math.round(10*poids + 6.25*taille - 5*age + 5);
return Math.round(10*poids + 6.25*taille - 5*age - 161);
}
// Facteur d'activité (TDEE = BMR × facteur)
function activityFactor(act){
if(!act) return 1.2;
const a = act.toLowerCase();
if(a.includes('tr[èe]s actif')||a.includes('très actif')) return 1.725;
if(a.includes('mod[ée]r')||a.includes('modér')) return 1.55;
if(a.includes('l[ée]g[èe]r')||a.includes('léger')) return 1.375;
return 1.2; // sédentaire par défaut
}
function calcTDEE(p){
const bmr = calcBMR(p);
if(!bmr) return null;
return Math.round(bmr * activityFactor(p.activite));
}
// Masse grasse en kg depuis %
function masseGrasseKg(poids, pctMG){
if(!poids || !pctMG) return null;
return +(parseFloat(poids) * parseFloat(pctMG) / 100).toFixed(1);
}
// Évaluation indice graisse viscérale (norme Tanita : 1-9 sain, 10-14 élevé, 15+ très élevé)
function evalVisceral(v){
if(v === null || v === undefined || v === '') return null;
const n = parseFloat(v);
if(n < 10) return {label:'Sain', color:'#107ACA'};
if(n < 15) return {label:'Élevé', color:'#B85C35'};
return {label:'Très élevé', color:'#C0392B'};
}
// Évaluation % masse grasse (normes : Femme 21-33%, Homme 8-20%)
function evalMG(pct, sexe){
if(!pct) return null;
const n = parseFloat(pct);
const isF = (sexe||'').toLowerCase()!=='homme';
if(isF){
if(n<21) return {label:'Faible', color:'#4A6FA5'};
if(n<=33) return {label:'Normal', color:'#107ACA'};
if(n<=39) return {label:'Élevé', color:'#B85C35'};
return {label:'Obésité', color:'#C0392B'};
}else{
if(n<8) return {label:'Faible', color:'#4A6FA5'};
if(n<=20) return {label:'Normal', color:'#107ACA'};
if(n<=25) return {label:'Élevé', color:'#B85C35'};
return {label:'Obésité', color:'#C0392B'};
}
}
// Évaluation % hydrique (normes : Femme 45-60%, Homme 50-65%)
function evalHydra(pct, sexe){
if(!pct) return null;
const n = parseFloat(pct);
const isF = (sexe||'').toLowerCase()!=='homme';
const low = isF?45:50, high = isF?60:65;
if(n25% = risqué, <10% = lent
severity: pctDeficit < 10 ? 'lent' : pctDeficit <= 20 ? 'ideal' : pctDeficit <= 25 ? 'agressif' : 'risque'
};
}
}
// ═══ INSIGHTS AUTOMATIQUES ═══
// Rythme poids
if(r.rythmePoids !== undefined){
const rp = r.rythmePoids;
if(rp < -1){
r.insights.push({icon:'⚠️', type:'warn', txt:`Perte rapide (${rp} kg/sem) — surveiller la fonte musculaire et fatigue.`});
} else if(rp < -0.3){
r.insights.push({icon:'✅', type:'good', txt:`Rythme de perte adapté (${rp} kg/sem, idéal: -0.5 à -1 kg/sem).`});
} else if(rp >= -0.3 && rp <= 0.1){
r.insights.push({icon:'⏸', type:'neutral', txt:`Stabilité du poids (${rp>=0?'+':''}${rp} kg/sem) — plateau potentiel ou phase de recomposition.`});
} else if(rp > 0.5){
r.insights.push({icon:'📈', type:'warn', txt:`Prise de poids (${rp>=0?'+':''}${rp} kg/sem) — réévaluer l'apport calorique.`});
}
}
// Qualité de la perte : poids vs masse grasse
if(r.deltaPoids !== undefined && r.deltaMG !== undefined && r.deltaPoids < 0){
const ratio = r.deltaMG / r.deltaPoids; // proche de 1 = quasi tout graisse, proche de 0 = peu graisse
if(ratio >= 0.75){
r.insights.push({icon:'💪', type:'good', txt:`Qualité de perte excellente : ${Math.round(ratio*100)}% de la perte vient de la masse grasse.`});
} else if(ratio >= 0.5){
r.insights.push({icon:'👍', type:'good', txt:`Bonne qualité de perte : ${Math.round(ratio*100)}% issue de la masse grasse.`});
} else if(ratio > 0){
r.insights.push({icon:'⚠️', type:'warn', txt:`Seulement ${Math.round(ratio*100)}% de la perte vient de la graisse — renforcer apport protéique et activité.`});
}
}
// Masse musculaire
if(r.deltaMM !== undefined){
if(r.deltaMM > 0.3){
r.insights.push({icon:'💪', type:'good', txt:`Gain de masse musculaire de +${r.deltaMM} kg — excellent.`});
} else if(r.deltaMM < -0.5){
r.insights.push({icon:'⚠️', type:'warn', txt:`Perte de masse musculaire de ${r.deltaMM} kg — augmenter protéines (1.6–2 g/kg/j) et résistance physique.`});
}
}
// Hydratation
if(r.deltaHydra !== undefined){
if(r.deltaHydra > 1){
r.insights.push({icon:'💧', type:'good', txt:`Hydratation en progression (+${r.deltaHydra} %).`});
} else if(r.deltaHydra < -1){
r.insights.push({icon:'💧', type:'warn', txt:`Hydratation en baisse (${r.deltaHydra} %) — viser 1.5-2 L d'eau/jour.`});
}
}
// Viscéral
if(r.deltaVisceral !== undefined){
if(r.deltaVisceral < 0){
r.insights.push({icon:'✅', type:'good', txt:`Indice graisse viscérale en baisse (${r.deltaVisceral}) — bénéfice cardio-métabolique majeur.`});
} else if(r.deltaVisceral > 1){
r.insights.push({icon:'⚠️', type:'warn', txt:`Indice viscéral en hausse (+${r.deltaVisceral}) — risque cardio-métabolique accru.`});
}
}
return r;
}
// ═════════════════════════════════════════════════════════════
// ⚖️ ONGLET IMPÉDANCEMÉTRIQUE — Saisie + graphes d'évolution
// ═════════════════════════════════════════════════════════════
// Identifiant stable pour la patiente (email réel OU placeholder basé sur ID local)
function ntPatientKey(p){
return (p.email && p.email.trim()) ? p.email.trim().toLowerCase() : `local-${p.id}@cabinet.nutridiet`;
}
async function renderImpedance(p){
const c = ntGetConfig();
if(!c.url || !c.jwt){
get('tabContent').innerHTML = `
⚠ Connexion NutriTrack requise pour la sauvegarde cloud
Connecte-toi à NutriTrack (onglet 📊 Suivi NutriTrack) pour activer la synchronisation cloud des mesures impédancemétriques.
`;
return;
}
get('tabContent').innerHTML = `
Chargement…
`;
const patientKey = ntPatientKey(p);
let measurements = [];
try {
measurements = await ntFetch(`/rest/v1/impedance_data?patient_email=eq.${encodeURIComponent(patientKey)}&order=recorded_at.desc`);
window._impedanceCache = measurements;
} catch(e){
if(/relation .*impedance_data.* does not exist/i.test(e.message)){
get('tabContent').innerHTML = `
⚠ La table « impedance_data » n'est pas créée dans Supabase.
Demande à exécuter le fichier SQL-IMPEDANCE.sql dans Supabase → SQL Editor.
⚠ Session expirée. Reconnecte-toi à NutriTrack (onglet 📊 Suivi NutriTrack).
`;
return;
}
get('tabContent').innerHTML = `
❌ Erreur : ${esc(e.message)}
`;
return;
}
ntRenderImpedanceUI(p, measurements);
}
function ntRenderImpedanceUI(p, measurements){
const today = new Date().toISOString().split('T')[0];
const last = measurements[0] || {};
const fmtDate = d => new Date(d).toLocaleDateString('fr-FR', {day:'2-digit',month:'short',year:'2-digit'});
// Calcul auto IMC à partir du poids et de la taille (si saisie taille connue)
const taille = p.recueil?.taille || (last.taille ? null : null); // fallback
let html = `
💡 Méthode : ${c.methode}. Référence physiologique : 1 kg de tissu adipeux ≈ 7700 kcal. Le déficit journalier est obtenu en divisant le total brûlé par la durée entre les consultations.
${c.severity === 'risque' ? ' ⚠️ Déficit supérieur à 25% — risque de fonte musculaire, fatigue, perte de cheveux, troubles du cycle. Remonter l\'apport calorique.' : ''}
${c.severity === 'agressif' ? ' Attention : surveiller masse musculaire et énergie. Prioriser les protéines (1.6-2 g/kg).' : ''}
📊 Bilan de progression — Sur ${a.days} jour${a.days>1?'s':''} (${a.weeks.toFixed(1)} sem)
${deltas.join('')}
${caloBlock}
${insightsBlock}
`;
}
function addConsultation(pid){
const p = patients.find(x=>x.id===pid);
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
+ Nouvelle consultation
Mesures d'impédancemétrie + observations pour ${esc(p.prenom)}.
`;
document.body.appendChild(overlay);
overlay.onclick = e => { if(e.target===overlay) overlay.remove(); };
}
async function saveConsultation(pid){
const p = patients.find(x=>x.id===pid);
const entry = {
date: val('consult_date') || todayISO(),
poids: val('consult_poids'),
mgPct: val('consult_mgPct'),
mmKg: val('consult_mmKg'),
hydraPct: val('consult_hydraPct'),
visceral: val('consult_visceral'),
notes: get('consult_notes')?.value?.trim() || ''
};
if(!p.historique) p.historique = [];
p.historique.push(entry);
// Mise à jour auto du poids courant si date la plus récente
const sorted = [...p.historique].sort((a,b)=>b.date.localeCompare(a.date));
if(sorted[0].date === entry.date && entry.poids) p.poids = entry.poids;
await autosave();
document.querySelector('.modal-overlay')?.remove();
renderHistorique(p);
showToast('✅ Consultation enregistrée');
}
function editConsultation(pid, idx){
const p = patients.find(x=>x.id===pid);
const sorted = [...p.historique].sort((a,b)=>a.date.localeCompare(b.date));
const h = sorted[idx];
if(!h) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
✏️ Modifier la consultation
`;
document.body.appendChild(overlay);
overlay.onclick = e => { if(e.target===overlay) overlay.remove(); };
}
async function saveEditConsult(pid, idx){
const p = patients.find(x=>x.id===pid);
const sorted = [...p.historique].sort((a,b)=>a.date.localeCompare(b.date));
const original = sorted[idx];
const newData = {
date: val('consult_date'),
poids: val('consult_poids'),
mgPct: val('consult_mgPct'),
mmKg: val('consult_mmKg'),
hydraPct: val('consult_hydraPct'),
visceral: val('consult_visceral'),
notes: get('consult_notes')?.value?.trim() || ''
};
const realIdx = p.historique.findIndex(h=>h===original);
if(realIdx>=0) p.historique[realIdx] = newData;
await autosave();
document.querySelector('.modal-overlay')?.remove();
renderHistorique(p);
showToast('✅ Consultation modifiée');
}
async function deleteConsultation(pid, idx){
if(!confirm('Supprimer cette consultation ?')) return;
const p = patients.find(x=>x.id===pid);
const sorted = [...p.historique].sort((a,b)=>a.date.localeCompare(b.date));
const toDelete = sorted[idx];
p.historique = p.historique.filter(h=>h!==toDelete);
await autosave();
renderHistorique(p);
showToast('🗑 Consultation supprimée');
}
function toggleArchive(id){
const el = get(id);
if(el) el.classList.toggle('open');
}
async function deleteArchive(pid, idx){
if(!confirm('Supprimer cette archive ?')) return;
const p = patients.find(x=>x.id===pid);
if(!p.archives) return;
p.archives.splice(idx, 1);
await autosave();
renderHistorique(p);
showToast('🗑 Archive supprimée');
}
// ═════════════════════════════════════════════════════════════
// 🤖 GÉNÉRATION MENUS PAR IA (avec contraintes allergies)
// ═════════════════════════════════════════════════════════════
async function generateMenus(pid){
const p=patients.find(x=>x.id===pid);
// FIX 2026-07-23 — Filet consigne de style : le textarea ne persistait qu'au
// blur (onchange). Sur Mac, cliquer un bouton ne fait pas toujours perdre le
// focus au textarea → la consigne saisie n'était pas sauvegardée avant que la
// génération efface le DOM (innerHTML), et le prompt partait SANS la consigne
// (« que des salades » ignoré). On lit ici la valeur LIVE du champ et on la
// persiste, en plus du passage onchange→oninput. Double sécurité.
try { const _styleEl = document.getElementById('menuStyleConsigne_' + pid); if(_styleEl) setMenuStyleConsigneById(pid, _styleEl.value); } catch(_){}
currentTab='menus';
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',i===2));
// Archivage avant régénération (si menus existants)
if(p.menus && p.menus.length > 0){
if(!p.archives) p.archives = [];
p.archives.push({
date: today(),
type: 'menus',
menus: JSON.parse(JSON.stringify(p.menus)),
courses: p.courses ? JSON.parse(JSON.stringify(p.courses)) : null
});
// Garder max 10 archives
if(p.archives.length > 10) p.archives = p.archives.slice(-10);
}
const compatRecipes = getCompatibleRecipes(p);
const recipesCount = compatRecipes.length;
get('tabContent').innerHTML=`
L'IA génère les menus personnalisés pour ${esc(p.prenom)}…
${recipesCount>0?`📖 ${recipesCount} recette${recipesCount>1?'s':''} perso compatible${recipesCount>1?'s':''} prise${recipesCount>1?'s':''} en compte · `:''}Inspiration grands chefs · 15-25 secondes
`;
const baseKey=p.plan?.baseKey||detectBase(p.motifs)||'colopathie';
const base=BASES[baseKey];
// FIX 2026-05-06 — passer le plan STRUCTURÉ par repas pour que l'IA respecte
// précisément ce qui est autorisé pour chaque moment (ex: pas de féculent au dîner).
const planPetitDej = (p.plan?.petitDejeuner||base.petitDejeuner||[]).join(' | ') || '—';
const planDej = (p.plan?.dejeuner||base.dejeuner||[]).join(' | ') || '—';
const planCol = (p.plan?.collation||base.collation||[]).join(' | ') || '—';
const planDiner = (p.plan?.diner||base.diner||[]).join(' | ') || '—';
const planStructure = `
- PETIT-DÉJEUNER (autorisé) : ${planPetitDej}
- DÉJEUNER (autorisé) : ${planDej}
- COLLATION (autorisée) : ${planCol}
- DÎNER (autorisé) : ${planDiner}`;
const allergyConstraints = buildAllergyRules(p.allergies||[]);
// Construction section recettes perso (max 8 recettes, tronquées à 1000 caractères chacune)
let recipesSection = '';
if(compatRecipes.length > 0){
const top = compatRecipes.slice(0, 8);
recipesSection = `\n\n📖 RECETTES PERSONNELLES DE LA DIÉTÉTICIENNE — À PRIVILÉGIER QUAND C'EST POSSIBLE :
Utilise prioritairement ces recettes (adapte si besoin pour respecter les allergies). Mélange-les avec des créations originales inspirées de grands chefs pour garantir la variété.
${top.map((r,i)=>`[Recette ${i+1}: "${r.nom}"]\n${r.texte.slice(0, 1000)}${r.texte.length>1000?'…':''}`).join('\n\n---\n\n')}`;
}
// Liste explicite des aliments à éviter (critique : ton exemple du thon)
// FIX 2026-05-27 v2.2 — Renforcement : mention dérivés/traces + suppression nuance "n'aime pas / supporte pas"
// (le diet a tranché, c'est un veto strict sans argument)
let eviterSection = '';
if(p.eviter){
eviterSection = `\n\n⛔ ALIMENTS À ÉVITER ABSOLUMENT (veto strict du diététicien) : ${p.eviter}
🔴 RÈGLE (v2.2) : aucun menu ne doit contenir ces aliments, NI leurs dérivés, NI les plats à base. Exemple : si "oignon" est listé, oignon cru, oignon caramélisé, oignon en poudre, soupe à l'oignon, bouillon contenant des oignons sont tous interdits — le bouillon de légumes utilisé doit être choisi sans oignon. Pas d'argument, pas de seuil, pas d'exception.`;
}
// FIX 2026-05-27 v2.2 — Allergènes phonétiques : interdire le mot d'allergène (ou sa racine)
// dans les libellés de plats / techniques / ingrédients, même si l'allergène réel est absent.
// Protège la confiance du patient (anxiété face à un nom qui évoque l'allergène).
let phoneticAllergenSection = '';
if((p.allergies||[]).length > 0){
phoneticAllergenSection = `\n\n🔤 ALLERGÈNES PHONÉTIQUES (v2.2) : interdiction d'utiliser le mot d'un allergène listé du patient (${(p.allergies||[]).join(', ')}) ou sa racine dans le nom du plat, la technique culinaire ou un ingrédient mentionné, MÊME si l'allergène réel n'est pas dans la recette. Exemples : patient allergique aux noisettes → "beurre noisette" (technique culinaire) interdit en libellé, utiliser "beurre fondu coloré" ou "beurre cuit ambré" ; patient allergique aux arachides → ne pas écrire "huile d'arachide" même si l'huile utilisée est différente. Le but est de ne pas créer d'anxiété chez le patient en lisant le menu.`;
}
// FIX 2026-05-27 v2.2 — Profil patient conditionné par les toggles d'options
// (cf getMenuGenOptions et panel UI ⚙). Seules les infos dont le diet a coché la case
// sont injectées dans le prompt. Sécurité = ne pas surcharger le contexte LLM.
const opts = getMenuGenOptions(p);
const profilLines = [];
profilLines.push(`- ${p.prenom} ${p.nom} (${p.sexe||''}, ${calcAge(p.dob)||'âge non précisé'})`); // identité — toujours
if(opts.motif) profilLines.push(`- Motifs : ${(p.motifs||[]).join(', ')||'—'}`);
profilLines.push(`- Base alimentaire : ${base.label}`); // toujours (lié au plan)
if(opts.aimes && p.aimes) profilLines.push(`- Aliments appréciés : ${p.aimes}`);
profilLines.push(`- Régime : ${p.regime||'omnivore'}`); // forcé ON (sécurité)
if(opts.nbPersonnes) profilLines.push(`- Nombre de personnes dans le foyer : ${p.nbPersonnes||'2'}`);
if(opts.cuisine && p.cuisine) profilLines.push(`- Cuisine à domicile : ${p.cuisine}`);
if(opts.activite && p.activite) profilLines.push(`- Activité : ${p.activite}`);
const profilBlock = 'PROFIL DU PATIENT :\n' + profilLines.join('\n');
// Prompt v2.2 — référence : prompts/GENERATEUR-MENUS-v2.2.md
// Version livrée le 2026-05-27, après audit du prompt v1, v2.0, v2.1, v2.2.
// Modifs ciblées vs version précédente (post fix VPO 15:30 Lola FABRE) :
// - Renforcement eviterSection (dérivés/traces explicites)
// - Ajout phoneticAllergenSection (anti-anxiété patient)
// - Profil patient conditionné par toggles UI (motif/aimes/cuisine/activite OFF par défaut)
// - Le reste du prompt (Q1-Q8, BLACKLISTs, inférences, recettes signatures) est PRÉSERVÉ.
// FIX 2026-05-28 v3 — Renforcement ALIMENTS À ÉVITER :
// Bloc en TÊTE du prompt (avant tout le reste) pour ne pas le noyer dans 6000+ tokens.
// Si p.eviter est non vide, on l'expose en majuscules, en tête, avec emphase visuelle.
const eviterHeadBlock = (p.eviter && p.eviter.trim())
? `\n\n🚨🚨🚨 INTERDICTION ABSOLUE — ALIMENTS À ÉVITER (veto strict du diététicien, NON NÉGOCIABLE) 🚨🚨🚨\n` +
`Liste exacte : ${p.eviter.toUpperCase()}\n` +
`Ces aliments ne doivent apparaître dans AUCUN des 14 plats que tu vas générer, NI sous forme entière, NI en garniture, NI en dérivé (exemple : si "oignon" est interdit → pas d'oignon cru, oignon caramélisé, oignon en poudre, soupe à l'oignon, bouillon contenant des oignons), NI en assaisonnement, NI en accompagnement, NI "en trace", NI "selon tolérance". Cette règle PRIME sur toutes les autres règles du prompt, y compris les recettes signatures du cabinet. Si une recette classique contient un de ces aliments, NE LA PROPOSE PAS et choisis une autre composition. Avant de finaliser CHAQUE plat, relis la liste ci-dessus et vérifie mot par mot qu'aucun n'apparaît dans ta proposition.\n`
: '';
const prompt = `Tu es une diététicienne française experte, passionnée de gastronomie. Tu dois créer 7 menus pour la semaine (lundi → dimanche) pour ce patient.${eviterHeadBlock}
Tes menus s'inspirent des grands chefs français contemporains : Alain Ducasse (simplicité des produits), Joël Robuchon (précision technique), Anne-Sophie Pic (créativité et équilibre), Alain Passard (cuisine des légumes sublimés), Michel Bras (aligot, cuisine du terroir revisitée), Pierre Gagnaire (rustique raffiné), Michel Troisgros (élégance simple), Thierry Marx (rigueur et finesse), Paul Bocuse (classiques revisités), Régis Marcon (champignons et terroir), Yannick Alléno (cuisine moderne française). Tu proposes des plats élégants et variés, faisables à la maison, jamais répétitifs. Jamais deux fois le même plat dans la semaine.
═══════════════════════════════════════════════════════════════
🏥 RÈGLES OFFICIELLES DU CABINET (intégrées 2026-05-17 — s'appliquent à TOUS tes menus)
═══════════════════════════════════════════════════════════════
🥩 PROTÉINES AUTORISÉES — utilise UNIQUEMENT celles-ci (Liste prot.docx officielle du cabinet) :
• VIANDES : filet mignon de porc, rôti/filet de bœuf, roastbeef, viande des grisons, jambon dégraissé (porc/poulet/dinde, sans gras/couenne), steak haché 5%MG, veau/côte de veau, gésiers de volaille, bavette, aiguillettes de canard, magret de canard SANS peau
• VOLAILLES SANS PEAU obligatoire : poulet, dinde, pintade, coquelet, caille
• POISSONS : cabillaud, colin, dorade, espadon, flétan, lieu, limande, lotte, loup, maquereau, merlan, mulet, raie, rouget, sole, thon (frais ou au naturel), turbot
• FRUITS DE MER : crevette, gambas, langoustine, moule, huître, coquille St Jacques, calamar, seiche, poulpe, crabe, tourteau, bulot, coque, oursin
• ŒUFS : poule, caille
🥚 PORTION ŒUFS (FIX 2026-07-10) : la portion protéique standard d'un repas aux œufs = **2 œufs (≈100g)**, pas plus. 2 œufs N'ÉQUIVALENT PAS à 150-200g de viande/poisson — ne monte JAMAIS à 3-4 œufs pour "égaler" une portion de viande. Exprime toujours en nombre d'œufs (ex : "2 œufs"), jamais en grammes type "œufs 200g".
🚫 INTERDITS dans tes menus : agneau, mouton, magret AVEC peau, lardons (utilise toujours allumettes de bacon à la place), saucisses (sauf 100% volaille light), pâté, saucisson, mortadelle, chorizo, saumon (pas dans la liste autorisée du cabinet), autres charcuteries grasses
🥛 LAITAGES — TOUJOURS 0% MG :
• Yaourts 0%, fromage blanc 0% ou 3%, skyr 0%, petit-suisse 0%, faisselle 0%
• Lait demi-écrémé ou écrémé — JAMAIS entier
• Crème fraîche 4% semi-épaisse — JAMAIS 30% entière
• Beurre allégé 41% (St Hubert Omega 3) ou margarine légère, JAMAIS beurre classique
🚫 SAUCES — règle stricte cabinet :
• PAR DÉFAUT : aucune sauce dans tes propositions
• Si vraiment nécessaire à la recette : version light obligatoire (mayo allégée, vinaigrette = filet huile + vinaigre/citron, sauce tomate nature)
• JAMAIS : César, ranch, tartare, américaine, cocktail, andalouse, sauces crémées, plats noyés en sauce
• Privilégier : herbes fraîches, épices, citron, vinaigre pour le goût
🧴 MATIÈRES GRASSES : cuisson au spray ou MAX 1 càs (10g) huile d'olive. Aucune friture, aucun gratin gras.
🍳 CUISSONS PRIORITAIRES (du meilleur au moins bon) : Vapeur > papillote > grillé > poêlé sans MG > rôti. JAMAIS friture, gratin avec crème/fromage, plat noyé en sauce.
🥗 SUCRES — limités : édulcorant (Canderel, Stevia) > sucre. Confitures sans sucre ajouté. Chocolat noir 70%+ portion 20g max.
🌙 RÈGLE DÎNER CABINET : généralement SANS FÉCULENT (sauf si plan explicite). Composition typique dîner : protéine maigre + légumes + laitage 0% + fruit. PAS de riz, PAS de pâtes, PAS de pomme de terre, PAS de patate douce.
📖 INSPIRATION 5 RECETTES SIGNATURE CABINET (niveau gastronomique visé) :
⚠️ ATTENTION : ces 5 exemples contiennent un féculent (60g). Si le PLAN ALIMENTAIRE PRESCRIT du patient ci-dessous NE CONTIENT PAS de féculent pour un repas donné, tu DOIS retirer le féculent de ton inspiration et le remplacer par une portion de légumes supplémentaire. Ne copie JAMAIS aveuglément le féculent si le plan ne l'autorise pas.
① "Sud Sauvage" façon Ducasse — rouget grillé 200g · fenouil, courgette, poivron jaune · orange + zeste · 60g petit épeautre [SI féculent autorisé] · basilic, cerfeuil · huile olive + Xérès
② "Terre & Rosée" façon Troisgros — poulet poché snacké 180g · haricots verts, carotte, radis · framboises · 60g lentilles vertes du Puy [SI féculent/légumineuse autorisé] · estragon · vinaigrette yaourt + moutarde
③ "Jardin Grand Palais" façon Passard — œufs pochés ou dinde · betterave, céleri, jeunes pousses · pomme verte · 60g quinoa [SI féculent autorisé] · ciboulette, menthe · vinaigre cidre + huile noix
④ "Atlantique Chic" façon Robuchon — cabillaud vapeur 200g · concombre, fenouil, courgette · pamplemousse rose · 60g riz basmati [SI féculent autorisé] · aneth, coriandre · yaourt grec 0% + wasabi doux
⑤ "Rustique raffinée" façon Gagnaire — aiguillettes canard 180g · mâche, chou rouge · poires balsamique · 60g pois chiches [SI féculent/légumineuse autorisé] · persil · huile noisette
💡 Tours de main : terroirs français nommés (petit épeautre Haute-Provence, lentilles du Puy, vinaigre de Xérès), herbes fraîches obligatoires, mandoline pour finesse, fruit subtil dans les salades (équilibre sucré/salé).
🚨 RÈGLE NOMMAGE — CONDITIONNELLE selon le plan du repas (FIX 2026-05-27 — Lola FABRE) :
• Si le plan du repas mentionne VPO / viande / poisson / œuf / un nom d'animal ou de poisson → le titre DOIT mentionner cette protéine animale.
✅ CORRECT : "Filet de cabillaud nacré aux légumes méditerranéens, riz basmati 60g", "Pavé de rouget grillé, légumes confits à l'orange"
• Si le plan du repas NE MENTIONNE PAS de VPO/viande/poisson/œuf (régime dissocié : féculent + légumes + laitage + fruit seulement) → le titre NE DOIT PAS contenir de protéine animale. Mets en valeur le féculent ou un légume signature.
✅ CORRECT (repas sans VPO) : "Risotto de petit épeautre aux légumes du soleil et basilic", "Quinoa façon taboulé d'herbes fraîches, betterave et grenade", "Buddha bowl de pois chiches rôtis, kale et patate douce"
❌ REFUSÉ (repas sans VPO) : "Filet de cabillaud + riz" (le plan n'autorise PAS de poisson au déjeuner)
📏 GRAMMAGES OBLIGATOIRES DANS CHAQUE PLAT (FIX 2026-07-21) :
• Chaque plat contenant une VPO (viande, volaille, poisson, fruits de mer) DOIT indiquer son grammage en grammes, choisi À L'INTÉRIEUR de la fourchette prescrite par le plan du repas correspondant. Exemple : plan déjeuner "VPO : 150g – 200g" → écris "filet de cabillaud vapeur 180g" ; plan dîner "VPO : 50g – 150g" → écris "pavé de rouget 120g". AUCUN des 14 plats ne doit avoir une VPO sans grammage — vérifie-le dans ton auto-contrôle final.
• Chaque féculent/céréale doit indiquer son poids CUIT, dans la fourchette du plan (ex : plan "Céréales : 60g – 80g cuits" → "riz basmati 70g cuit").
• Exception œufs : exprime en nombre d'œufs ("2 œufs"), jamais en grammes.
═══════════════════════════════════════════════════════════════
${profilBlock}${eviterSection}${allergyConstraints}${phoneticAllergenSection}${recipesSection}
🥗 PLAN ALIMENTAIRE PRESCRIT (à respecter STRICTEMENT pour chaque repas) :${planStructure}
${ndBuildSaisonBlock(p)}${ndBuildStyleConsigneBlock(p)}
🗓️ FRÉQUENCES HEBDOMADAIRES — RÈGLE STRICTE (FIX 2026-05-28) :
Quand une ligne du plan contient une mention de fréquence ("1 à 2 fois par semaine", "max 3 fois/sem", "1 fois par semaine", "rare", "occasionnel"), tu DOIS la respecter à la lettre sur les 7 jours. Pour une fourchette "1 à 2 fois", utilise 2 par défaut. Pour "rare/occasionnel", utilise 1.
🔴 CAS PRINCIPAL : féculents/légumineuses au DÎNER. Beaucoup de plans cabinet limitent les féculents/légumineuses au dîner à "1 à 2 fois par semaine" (typiquement noté entre parenthèses après la ligne céréales/légumineuses/pomme de terre). Si tu vois cette mention :
• Maximum 2 dîners sur 7 avec féculent OU légumineuse (riz, quinoa, sarrasin, boulgour, lentilles, pois chiches, haricots, fèves, edamame, pomme de terre, polenta, semoule, millet, épeautre, avoine, pâtes, pain, patate douce, châtaignes…)
• Les 5 autres dîners : VPO + légumes + laitage + fruit, éventuellement avec nouilles de konjac/shirataki (qui sont des LÉGUMES, pas un féculent)
• Quand tu utilises ton "quota" de 2 féculents/sem au dîner, choisis prioritairement ce qui est explicitement mentionné dans le plan (ex : "sachet bjorg" ou "pomme de terre") plutôt qu'une légumineuse aléatoire
🚨 CONTRÔLE OBLIGATOIRE avant restitution : pour chaque ligne du plan avec mention de fréquence, COMPTE tes 7 plats correspondants. Si dépassement, RÉÉCRIS les plats excédentaires en utilisant les autres options du plan (nouilles konjac, légumes augmentés, etc.).
🧠🧠🧠 INFÉRENCE INTELLIGENTE DES EXCLUSIONS À PARTIR DU PLAN (FIX 2026-05-18) :
Le plan alimentaire ci-dessus EST LA SOURCE DE VÉRITÉ. Avant de générer les menus, tu DOIS analyser SON CONTENU pour déduire les exclusions implicites. Applique cette logique de raisonnement :
🌾 GLUTEN — Si le plan mentionne UNIQUEMENT des céréales/pains sans gluten ("Pain sans gluten", "farine de riz", "farine de châtaigne", "Schar", "flocons de quinoa", "flocons de riz", "riz", "quinoa", "sarrasin", "millet", "pain aux fleurs") et JAMAIS de blé/seigle/orge/avoine/épeautre/semoule/couscous/boulgour/pâtes de blé → APPLIQUE SANS GLUTEN dans tous tes menus (interdits : blé, seigle, orge, avoine, épeautre, kamut, malt, pain classique, pâtes blé, biscottes blé, semoule, couscous, boulgour, croûtons, panure blé, sauce soja contenant blé).
🥛 LACTOSE — RÈGLE DE PRIORITÉ ABSOLUE (FIX 2026-05-18 v2, raffiné 2026-05-28) :
🔴 RÈGLE D'OR : SANS LACTOSE GLOBAL ne s'active QUE si UN MOT EXPLICITE dans le plan visible mentionne une intolérance ou un produit sans lactose / végétal. Les motifs (colopathie/SII, etc.) NE DÉCLENCHENT PAS la règle car ce sont des templates de base que le diététicien adapte ensuite. SEUL ce qui est écrit dans le plan visible compte.
DÉCLENCHEURS — au moins UN suffit pour activer SANS LACTOSE GLOBAL :
(a) Mots-clés littéraux n'importe où dans le plan : "sans lactose", "0% lactose", "lactose-free", "ALPRO" / "Alpro" / "alpro"
(b) Aliments végétaux n'importe où : "skyr alpro soja", "skyr alpro amande", "yaourt soja/amande/coco", "lait végétal", "lait amande/soja/coco/riz/avoine", "Saint Hubert oméga 3", "crème de soja/amande/coco", "lait sans lactose"
⚠️ AUCUN déclencheur basé sur les motifs / la base alimentaire (colopathie, SII, perte de poids, etc.) — ces tags sont des templates de cabinet, pas des contraintes patient. Le plan visible prime toujours.
→ Si et seulement si UN déclencheur (a) ou (b) est trouvé, applique SANS LACTOSE de façon GLOBALE à TOUS les 14 plats. Même si une ligne du plan dit textuellement "Petit suisse 0% ou skyr ou fromage blanc 3%", tu LIS ces noms comme des CATÉGORIES À TRADUIRE en version sans lactose :
• "Petit suisse 0%" → "Petit suisse sans lactose" ou "Skyr Alpro"
• "skyr" → "Skyr Alpro" ou "Skyr sans lactose"
• "fromage blanc 0%" / "3%" → "Fromage blanc sans lactose Lactel/Matin Léger" ou "Faisselle de soja"
• "yaourt nature" → "Yaourt soja Alpro" ou "yaourt sans lactose"
• "lait" → "Lait sans lactose" ou "lait d'amande/avoine/soja/coco"
CECI N'EST PAS NÉGOCIABLE : tu n'as JAMAIS le droit de mettre du skyr classique, petit-suisse classique, fromage blanc classique (même 0%), yaourt classique, lait classique, fromage frais, mascarpone, ricotta, mozzarella, feta, brie, camembert, parmesan, comté, emmental, crème fraîche animale ou beurre classique dans un menu généré pour ce patient si la règle SANS LACTOSE est active.
🚫 INTERDITS STRICTS quand SANS LACTOSE actif (liste exhaustive — n'utilise JAMAIS ces produits en version animale standard) :
• Lait : lait de vache/chèvre/brebis (entier, demi-écrémé, écrémé) → utiliser lait sans lactose OU lait végétal
• Yaourts classiques (toutes marques sans mention "sans lactose") → utiliser yaourt sans lactose OU yaourt végétal soja/amande/coco
• **SKYR classique** (nature, 0% ou pas) → CONTIENT du lactose résiduel (~4g/100g) → utiliser "Skyr sans lactose" OU "Skyr Alpro végétal"
• **PETIT-SUISSE classique** (0% ou pas) → CONTIENT du lactose → utiliser "petit-suisse sans lactose" OU yaourt végétal
• **FROMAGE BLANC classique** (0% ou pas) → CONTIENT du lactose → utiliser "fromage blanc sans lactose" OU faisselle de soja
• Fromages frais : ricotta, mascarpone, mozzarella, feta, brie, camembert
• Crème fraîche animale, beurre classique
• Glaces lactées
✅ TOLÉRÉS (peu de lactose) si patiente non hypersensible : parmesan vieilli 24+ mois, comté/emmental affinés 12+ mois (mentions à éviter par défaut si zéro lactose strict).
✅ ALTERNATIVES À UTILISER À LA PLACE (à privilégier dans tes menus quand SANS LACTOSE actif) :
- "Skyr Alpro nature" (amande ou soja)
- "Yaourt nature au soja/amande/coco"
- "Fromage blanc sans lactose" (marques : Lactel sans lactose, Matin Léger)
- "Lait sans lactose" (Lactel, Matin Léger) OU "lait d'amande/avoine/soja/coco"
- "Faisselle de soja"
- "Saint Hubert oméga 3" (margarine végétale) au lieu de beurre
🧀 CASÉINE — Si le plan ne mentionne QUE des laitages végétaux (cf ci-dessus) → APPLIQUE SANS CASÉINE (idem interdits que lactose + tout fromage animal même affiné/sans-lactose).
🌱 FODMAP — Si le plan mentionne "Cf FODMAP", "(FODMAP)", "pauvre en FODMAP", "low FODMAP", "sans FODMAP", OU si la patiente est en plan colopathie/SII → APPLIQUE SANS FODMAP (interdits : oignon, échalote, ail, poireau partie blanche, chou-fleur, brocoli (sauf petite portion), asperge, artichaut, champignon, pomme, poire, mangue, pastèque, cerise, abricot, prune, mirabelle, figue, nectarine, brugnon, pêche, mûre, cassis, betterave, topinambour, miel, sirop d'agave, blé, seigle, orge, légumineuses, lactose, polyols/sorbitol/mannitol/xylitol/maltitol, édulcorants en -ol). Alternatives autorisées : ciboulette, vert d'oignon nouveau, huile d'ail infusée (ail filtré), courgette, carotte, épinards, aubergine, haricots verts, kiwi, fraise, framboise, myrtille, clémentine, orange, ananas (modéré), melon charentais (petite portion 90g), banane mûre (100g max), raisin (petite portion).
🫘 LÉGUMINEUSES — Si le plan NE MENTIONNE PAS explicitement "légumineuses", "lentilles", "pois chiches", "haricots secs", "fèves", "soja", "tofu", "edamame" pour un repas donné → APPLIQUE SANS LÉGUMINEUSES pour ce repas (interdits : lentilles, pois chiches, haricots rouges/blancs/noirs, fèves, edamame, soja, tofu, tempeh, miso). Si AUCUN repas n'en mentionne → applique sans légumineuses à TOUS les menus.
⚠️ RÈGLE D'OR : si le plan permet une exception (ex : "Skyr ALPRO + 1 càc sirop d'érable"), tu peux conserver cette exception. Mais si le plan EXCLUT clairement un groupe d'aliments par sa rédaction (ex : aucun fromage classique mentionné, uniquement Skyr ALPRO), tu n'as PAS le droit de réintroduire ce groupe par ailleurs dans tes menus.
🚨🚨🚨 ÉTAPE OBLIGATOIRE — DÉTECTION FÉCULENTS AVANT DE COMMENCER (FIX 2026-05-18 — Albertella) :
AVANT de générer le moindre menu, tu DOIS te poser ces 4 questions (mentalement) :
Q1. Le plan PETIT-DÉJEUNER contient-il : "féculent", "céréales", "pain", "biscottes", "flocons", "muesli", "riz", "pâtes", "quinoa", "boulgour", "polenta", "épeautre", "avoine", "millet", "sarrasin", "semoule", "couscous", "pomme de terre", "patate douce", "légumineuses", "lentilles", "pois chiches", "haricots secs", "fèves" ?
Q2. Idem pour le DÉJEUNER ?
Q3. Idem pour la COLLATION ?
Q4. Idem pour le DÎNER ?
Pour chaque repas où la réponse est NON, tu écris EN INTERNE (sans le sortir au final) : "Repas X : ZÉRO féculent autorisé, ZÉRO légumineuse autorisée."
Tu DOIS ensuite générer les 7 menus en respectant CETTE LISTE D'INTERDITS, sans aucune exception, même si ça contredit les recettes signatures du cabinet ci-dessus.
🚨🚨🚨 ÉTAPE OBLIGATOIRE 2 — DÉTECTION VPO (PROTÉINES ANIMALES) AVANT DE COMMENCER (FIX 2026-05-27 — Lola FABRE) :
Beaucoup de plans cabinet sont DISSOCIÉS : féculent le midi SANS protéine animale, VPO le soir SEULEMENT. Tu DOIS donc te poser ces 4 questions supplémentaires :
Q5. Le plan PETIT-DÉJEUNER mentionne-t-il "VPO", "viande", "poisson", "œuf", "oeuf", "jambon", ou un nom spécifique d'animal/poisson/fruit de mer (poulet, dinde, bœuf, veau, porc, agneau, canard, magret, lapin, cabillaud, saumon, thon, dorade, sole, lieu, merlan, sardine, maquereau, hareng, truite, rouget, lotte, raie, espadon, turbot, mulet, colin, limande, loup, crevette, gambas, langoustine, moule, huître, st jacques, calamar, seiche, poulpe, crabe, tourteau, bulot, coque, oursin) ?
Q6. Idem pour le DÉJEUNER ?
Q7. Idem pour la COLLATION ?
Q8. Idem pour le DÎNER ?
⚠️ ATTENTION : "Skyr", "yaourt", "petit-suisse", "fromage blanc", "lait", "lentilles", "pois chiches", "tofu", "haricots secs", "fèves", "edamame", "légumineuses" ne comptent PAS comme VPO (ce sont des laitages ou légumineuses) — ils ne suffisent PAS à autoriser viande/poisson/œuf.
Pour chaque repas où la réponse est NON, tu écris EN INTERNE (sans le sortir au final) : "Repas X : ZÉRO viande, ZÉRO poisson, ZÉRO œuf, ZÉRO fruit de mer, ZÉRO jambon autorisé pour ce repas."
Tu DOIS ensuite générer les 7 menus correspondants SANS aucune protéine animale, même si ça contredit les recettes signatures ou la règle "varie les protéines". Pour ces repas, le plat est composé UNIQUEMENT des catégories listées dans le plan (typiquement : légumes/crudités + féculent ou légumineuse + laitage + fruit). Tu peux mettre en valeur le féculent (risotto végétal, taboulé, buddha bowl…), une légumineuse (curry de lentilles, houmous), un œuf végétal (tofu — UNIQUEMENT si "tofu" ou "légumineuses" est explicitement listé), ou un assortiment de légumes signature comme plat principal.
⚠️ RÈGLE ABSOLUE : pour CHAQUE repas, n'utilise QUE les catégories d'aliments mentionnées pour ce repas dans le plan.
✅ RÈGLE POSITIVE D'INCLUSION (FIX 2026-05-07) — pour CHAQUE repas, le menu DOIT contenir au moins un élément de CHAQUE catégorie listée dans le plan pour ce repas. Si le plan dit "VPO + crudités + féculents + laitier + fruit", le menu doit contenir les 5 — pas le droit d'en zapper un.
🥩 VPO OBLIGATOIRE QUAND LE PLAN LA LISTE (FIX 2026-09-07 — Hélène JACHT) : si le plan d'un repas mentionne VPO / viande / poisson / œuf (réponse OUI à Q5-Q8), CHACUN des 7 plats de ce repas DOIT contenir une protéine animale de la liste autorisée du cabinet, avec son grammage. Une légumineuse (lentilles, pois chiches, haricots, tofu…) ne remplace JAMAIS la VPO : dans le plan, « (Céréales + légumineuses) » est la ligne FÉCULENT, pas la ligne protéine. « Curry de lentilles corail, riz, yaourt » pour un déjeuner dont le plan dit « VPO : 200g – 250g » est un ÉCHEC — il faut « Poulet 220g au curry, lentilles corail 100g cuites, yaourt ». Cette règle vaut aussi quand une consigne de style oriente vers une cuisine à répertoire végétarien (indienne, asiatique…) : la couleur culturelle se rend AVEC la VPO, jamais à sa place.
🍜 SHIRATAKI = LÉGUME, PAS UN FÉCULENT (FIX 2026-05-07) — les shirataki (nouilles de konjac, ~6 kcal/100g) sont classés comme LÉGUMES. Si "shirataki" est mentionné dans le plan d'un repas, propose littéralement des shirataki — JAMAIS de pâtes (même sans gluten), nouilles de blé, vermicelles, ni aucun substitut amidonné.
🥛 LAITAGES — règle stricte (raffinée 2026-05-28) : si le plan visible spécifie un pourcentage précis (ex "fromage blanc 3%", "petit-suisse 0%", "skyr 0%", "Saint Moret 8%"), tu DOIS respecter EXACTEMENT ce pourcentage à la lettre dans le menu. Si le plan ne précise PAS de %, utilise 0% par défaut. Tu n'as JAMAIS le droit de modifier le pourcentage écrit par le diététicien dans le plan (ex : ne JAMAIS proposer "fromage blanc 0%" si le plan dit "fromage blanc 3%"). Précise toujours le % dans le nom.
🌾 TOTAL AMIDON (FIX 2026-09-18) — quand le plan d'un repas autorise « céréales + légumineuses » avec un grammage, ce grammage est le TOTAL du repas : un seul féculent dans le plat, ou plusieurs dont la somme ne dépasse pas ce total. Le maïs (grains, épi, choclo, polenta, semoule de maïs), la pomme de terre, la patate douce, le manioc/attiéké, l'igname, la banane plantain et la châtaigne sont des AMIDONS et comptent dans ce total — jamais dans la portion de légumes.
🚫 BLACKLIST AMIDON — si NI "céréales/féculent/pain" NI "légumineuses" n'est mentionné pour un repas, alors AUCUN aliment riche en amidon (pain toutes formes, riz, pâtes, semoule, boulgour, couscous, polenta, millet, sarrasin, quinoa, avoine, lentilles, pois chiches, haricots secs, pomme de terre, patate douce, maïs, châtaigne, ni leurs préparations) ne doit apparaître pour ce repas. Note : les shirataki ne sont PAS un amidon, ils restent autorisés s'ils sont dans le plan.
🚫 BLACKLIST VPO (FIX 2026-05-27) — si NI "VPO" NI "viande" NI "poisson" NI "œuf" NI nom spécifique d'animal/poisson/fruit de mer/jambon n'est mentionné pour un repas, alors AUCUNE protéine animale (toute viande, toute volaille, tout poisson, tout fruit de mer, tout œuf, tout jambon, toute charcuterie) ne doit apparaître pour ce repas — même en garniture, même en trace, même "déglacée au". Le plat pour ce repas doit être 100% végétal côté protéine (légumes + féculent/légumineuse autorisé + laitage si listé + fruit). Note : tofu, légumineuses (lentilles, pois chiches, haricots, fèves, edamame) restent autorisés s'ils sont dans le plan — ce sont des protéines végétales.
Autres règles : si "fruit"/"laitier"/"skyr"/"yaourt"/"fromage" n'apparaît pas pour un repas → ne pas en proposer. N'ajoute JAMAIS un aliment qui n'est pas listé pour le repas concerné, même si "logique" ou "habituellement servi avec".
CONSIGNES ESSENTIELLES :
1. ZÉRO ALLERGÈNE / ZÉRO ALIMENT INTERDIT — chaque plat doit être directement consommable par le patient sans aucune adaptation. N'utilise JAMAIS la mention "à adapter", "ou substitut", "selon tolérance" ou équivalent. Les plats que tu proposes sont DÉFINITIFS et conformes.
2. Si une recette classique contient un ingrédient interdit, NE LA PROPOSE PAS : choisis-en une autre nativement conforme (ex : si lactose interdit, ne propose pas "gratin dauphinois sans crème", propose plutôt "tian de légumes" ou "papillote au four").
3. Varie absolument les plats — pas de répétition sur 7 jours, pas de même protéine ou même féculent plus de 2 fois.
4. Alterne viandes, volailles, poissons et œufs (selon allergies) pour la VPO. Les légumineuses, quand le plan les autorise, alternent avec les céréales sur la ligne FÉCULENT — elles ne remplacent jamais une VPO exigée par le plan (FIX 2026-09-07). (Pour un repas SANS VPO au plan — régime dissocié — la règle BLACKLIST VPO plus bas s'applique, rien ne change.)
4bis. 🔀 QUOTA PAR FAMILLE DE PROTÉINE — RÈGLE CHIFFRÉE (FIX 2026-09-08 — kevin SECK). « Alterne » sans chiffre ne suffit pas : compter dorade, lieu, rouget et cabillaud comme 4 protéines différentes t'a déjà fait sortir 7 déjeuners de poisson d'affilée. Tu comptes désormais par FAMILLE, pas par espèce. Les 3 familles sont : (A) POISSONS ET FRUITS DE MER, (B) VIANDES ET VOLAILLES, (C) ŒUFS.
• Sur les 7 DÉJEUNERS : aucune famille plus de 4 fois. Idem, séparément, sur les 7 DÎNERS.
• Vise au moins 2 familles différentes par repas de la semaine, et 3 quand le plan et les interdits le permettent.
• Une consigne de style « méditerranéenne », « orientale », « asiatique », « côtière » n'autorise PAS à ne servir que du poisson : ces cuisines ont toutes un répertoire de viandes et de volailles (poulet aux épices, brochettes de veau, keftas de bœuf, tajine de poulet, dinde marinée…). Rends la couleur culturelle avec la famille voulue par le quota, jamais l'inverse.
• Les exemples de plats donnés plus haut dans ce prompt (cabillaud, rouget…) illustrent le NIVEAU et le FORMAT attendus, ce ne sont PAS des ingrédients à recopier : ne laisse pas leur répertoire décider de tes protéines.
• ⚠️ Cette règle s'efface devant les interdits : si les allergies, les aliments à éviter, le régime ou la consigne du diététicien excluent une famille (« pas de viande », « que du poisson », végétarien, allergie aux fruits de mer…), ces interdits gagnent et tu ne forces AUCUN quota.
5. Équilibre chaque repas en respectant le plan alimentaire prescrit ci-dessus.
6. Plats nommés de façon évocatrice et SANS mention d'adaptation (ex: "Filet de cabillaud nacré aux légumes confits, purée de patate douce à la vanille").
7. Inclus 1-2 plats inspirés directement des recettes personnelles fournies (si disponibles et nativement conformes).
8. Les autres plats : créations originales inspirées des grands chefs mentionnés, en respectant strictement les contraintes ET le plan alimentaire prescrit.
🛡️ AUTO-VÉRIFICATION OBLIGATOIRE AVANT DE RÉPONDRE (FIX 2026-05-18 + 2026-05-27 + 2026-05-28) :
Avant de retourner le JSON, relis MENTALEMENT chacun des 14 plats (7 déjeuners + 7 dîners). Pour chaque plat, vérifie :
✓ Aucun féculent/légumineuse mentionné si le plan du repas correspondant ne les autorise pas (voir détection Q1-Q4 + BLACKLIST AMIDON)
✓ UN SEUL amidon par plat (FIX 2026-09-18) : jamais « riz + maïs », « polenta + maïs », « semoule + pois chiches » — le maïs, la polenta, la pomme de terre, la patate douce, le manioc et le plantain comptent comme amidon, et la ligne « céréales + légumineuses » du plan est un TOTAL, pas une portion par aliment.
✓ Aucune protéine animale (viande, poisson, œuf, fruit de mer, jambon, charcuterie) mentionnée si le plan du repas correspondant ne l'autorise pas (voir détection Q5-Q8 + BLACKLIST VPO)
✓ Aucun aliment d'aucune des deux blacklists s'il n'est pas explicitement listé pour ce repas dans le plan
✓ FRÉQUENCES HEBDOMADAIRES respectées : pour chaque ligne du plan avec mention "X à Y fois par semaine" ou similaire, COMPTE tes 7 plats correspondants. Cas typique = féculents/légumineuses au dîner limités à 1-2 fois/sem. Si la mention dit "1 à 2 fois/sem", maximum 2 occurrences sur 7. Si dépassement, le ou les plats excédentaires DOIVENT être réécrits sans féculent ni légumineuse (utilise nouilles de konjac/shirataki = LÉGUME, ou portion légumes augmentée + VPO + laitage + fruit).
✓ VPO PRÉSENTE (FIX 2026-09-07) : pour chaque repas où le plan mentionne VPO/viande/poisson/œuf (OUI à Q5-Q8), CHACUN des 7 plats contient une protéine animale nommée avec son grammage. Un plat dont la seule « protéine » est une légumineuse, un tofu ou un laitage est NON CONFORME → RÉÉCRIS-LE en ajoutant la VPO (la légumineuse peut rester comme féculent si le plan la liste).
✓ VARIÉTÉ DES FAMILLES (FIX 2026-09-08) : COMPTE tes 7 déjeuners par famille — (A) poissons et fruits de mer, (B) viandes et volailles, (C) œufs — puis fais le même comptage sur tes 7 dîners. Si une famille dépasse 4 sur 7 dans l'un des deux repas, RÉÉCRIS les plats excédentaires avec une famille sous-représentée, en gardant le style et la cuisine demandés. Ne saute pas ce comptage : c'est l'erreur qui a produit 7 déjeuners de poisson d'affilée. (Ne l'applique pas si les allergies, les aliments à éviter, le régime ou la consigne de style excluent une famille — les interdits priment.)
✓ GRAMMAGES (FIX 2026-07-21) : chaque plat contenant une VPO indique un grammage à l'intérieur de la fourchette du plan du repas ; chaque féculent indique son poids cuit dans la fourchette du plan (exception : œufs exprimés en nombre).
✓ ALIMENTS À ÉVITER (FIX 2026-05-28 v3) : ${p.eviter ? 'la liste "' + p.eviter + '" n\'apparaît sous aucune forme dans aucun des 14 plats (ni entier, ni en garniture, ni en dérivé). Relis CHAQUE plat mot par mot avant restitution. Si tu trouves un seul terme de cette liste dans un titre de plat, RÉÉCRIS LE PLAT sans cet ingrédient.' : 'n/a (aucune liste éviter pour ce patient)'}
Si tu détectes un plat avec un féculent/légumineuse non autorisé : RÉÉCRIS-LE en retirant l'élément interdit et en le remplaçant par une portion de légumes (ou de protéine animale UNIQUEMENT si autorisée à ce repas).
Si tu détectes un plat avec une protéine animale non autorisée : RÉÉCRIS-LE en retirant complètement la viande/poisson/œuf et en transformant le plat en composition végétale (féculent valorisé, légumineuse si listée, légumes, laitage si listé, fruit). Le titre du plat doit être ré-écrit en conséquence (pas de "filet de…", "pavé de…", "blanc de…" pour ce repas).
Recommence cette vérification jusqu'à ce que chaque plat soit conforme. NE SOIS PAS pressée — la conformité prime sur la rapidité.
${ndBuildStyleConsigneRappel(p)}
Réponds UNIQUEMENT en JSON valide strict, sans texte avant/après, sans balises markdown :
[{"dejeuner":"Nom évocateur du plat avec ingrédients clés","diner":"Nom évocateur du plat avec ingrédients clés"},...]
Exactement 7 objets pour les 7 jours de la semaine.`;
try{
// FIX 2026-05-06 — fetch robuste : lecture en text d'abord pour éviter
// "Unexpected end of JSON input" quand Netlify timeout (26s) renvoie un body vide
const resp = await fetch('/.netlify/functions/api-proxy',{
method:'POST',
headers:{'Content-Type':'application/json'},
// FIX 2026-05-18 — max_tokens 2500 → 4000 : avec les nouveaux blocs de prompt
// (détection féculents Q1-Q4, auto-vérif finale), la réponse était tronquée
// ("Unterminated string in JSON at position 3957")
body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:4000,messages:[{role:'user',content:prompt}]})
});
const rawText = await resp.text();
if(!rawText) throw new Error('Réponse vide du serveur (timeout probable). Réessaie ou utilise le mode local.');
if(!resp.ok){
// Tente de parser l'erreur JSON Netlify ; sinon, retourne le statut HTTP
let msg;
try { const errObj = JSON.parse(rawText); msg = errObj.error?.message || errObj.error || ('HTTP '+resp.status); }
catch(_) { msg = 'HTTP '+resp.status+' — '+rawText.slice(0,120); }
throw new Error(msg);
}
let data;
try { data = JSON.parse(rawText); }
catch(_) { throw new Error('Réponse non-JSON (le proxy a peut-être renvoyé une page d\'erreur HTML). Réessaie.'); }
if(data.error) throw new Error(data.error.message || data.error);
const text=data.content?.filter(c=>c.type==='text').map(c=>c.text).join('');
let clean=text.replace(/```json|```/g,'').trim();
// Défensif (FIX 2026-07-23) : si l'IA ajoute du texte avant/après le tableau
// (ex. une phrase de style), on isole le tableau JSON entre le 1er '[' et le
// dernier ']' pour ne pas casser sur « Unexpected non-whitespace after JSON ».
{ const iA=clean.indexOf('['), iZ=clean.lastIndexOf(']'); if(iA>=0 && iZ>iA) clean=clean.slice(iA, iZ+1); }
const menus=JSON.parse(clean);
if(!Array.isArray(menus)||menus.length!==7)throw new Error('Format invalide');
p.menus=menus;
ndInvaliderCachesMenus(p); // FIX 2026-09-29 : nouveaux menus → ancien menu détaillé et ancienne liste de courses effacés
// 🔁 Auto-correction : remplacer les plats qui contiennent encore un allergène détecté
let autoFixed = 0;
if((p.allergies||[]).length > 0){
autoFixed = await autoFixMenusAllergens(p);
}
// FIX 2026-05-18 — Auto-correction silencieuse des violations inférées du plan
// (lactose, gluten, FODMAP, légumineuses). Indépendant des allergies p.allergies.
// Si l'IA a quand même produit des plats non conformes, on les corrige en arrière-plan
// SANS prévenir Guillaume — il voit juste des menus propres au final.
try {
const fixedViolations = await autoFixMenusViolations(p);
if(fixedViolations > 0) autoFixed += fixedViolations;
} catch(e){ console.error('autoFixMenusViolations error:', e); }
await autosave();
renderMenus(p);
showToast(autoFixed > 0
? `✅ Menus générés (${autoFixed} plat${autoFixed>1?'s':''} adapté${autoFixed>1?'s':''} aux allergies)`
: '✅ Menus générés !');
}catch(e){
get('tabContent').innerHTML=`
❌
Erreur IA : ${esc(e.message||'')}. Tu peux utiliser le mode local en attendant.
`;
console.error(e);
}
}
// ═════════════════════════════════════════════════════════════
// 🛡️ AUTO-CORRECTION DES PLATS CONTENANT DES ALLERGÈNES
// (relance l'IA pour réécrire les plats détectés comme problématiques)
// ═════════════════════════════════════════════════════════════
async function autoFixMenusAllergens(p, attempt = 1){
if(!p.menus || !p.menus.length) return 0;
const allergies = p.allergies || [];
if(!allergies.length) return 0;
// Identifier tous les plats encore problématiques
const problems = [];
p.menus.forEach((m, i) => {
if(detectProblem(m.dejeuner, allergies)) problems.push({day:i, type:'dejeuner', dish:m.dejeuner});
if(detectProblem(m.diner, allergies)) problems.push({day:i, type:'diner', dish:m.diner});
});
if(!problems.length) return 0;
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const base = BASES[baseKey];
const planItems = [...(p.plan?.dejeuner||base.dejeuner),...(p.plan?.diner||base.diner)].join(' | ');
const allergyConstraints = buildAllergyRules(allergies);
const eviterSection = p.eviter ? `\n⛔ ALIMENTS INTERDITS (en plus des allergies) : ${p.eviter}` : '';
// Plats déjà prévus pour la semaine (à ne pas dupliquer)
const allDishes = [];
p.menus.forEach(m => { allDishes.push(m.dejeuner); allDishes.push(m.diner); });
const fixPrompt = `Tu es une diététicienne française gastronome (style Ducasse, Pic, Robuchon, Passard).
PATIENT : ${p.prenom} ${p.nom}
Base alimentaire : ${base.label}
Règles du plan : ${planItems}
🚫 ALLERGIES / INTOLÉRANCES STRICTES : ${allergies.join(', ')}${eviterSection}${allergyConstraints}
CES PLATS DE LA SEMAINE CONTIENNENT ENCORE DES ALLERGÈNES INTERDITS — IL FAUT LES REMPLACER PAR DES PLATS NATIVEMENT CONFORMES :
${problems.map((pb,i)=>`${i+1}. ${JOURS[pb.day]} ${pb.type==='dejeuner'?'déjeuner':'dîner'} (à remplacer) : "${pb.dish}"`).join('\n')}
Plats déjà présents cette semaine (à NE PAS dupliquer) :
${allDishes.map(d=>`- ${d}`).join('\n')}
Pour CHAQUE plat ci-dessus, propose UN plat de remplacement :
- Élégant, varié, original, nommé de façon évocatrice
- AUCUN allergène, AUCUN aliment interdit (ne mentionne JAMAIS "à adapter", "selon tolérance", "ou substitut")
- DIFFÉRENT des plats déjà prévus
- Respect des règles du plan alimentaire
${ndBuildStyleConsigneRappel(p)}
Réponds UNIQUEMENT en JSON valide strict, sans texte avant/après, sans balises markdown :
[{"day":,"type":"dejeuner"ou"diner","dish":"Nom du plat"}, ...]
Exactement ${problems.length} objet${problems.length>1?'s':''}.`;
try{
const resp = await fetch('/.netlify/functions/api-proxy', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:'claude-haiku-4-5-20251001', max_tokens:1500, messages:[{role:'user',content:fixPrompt}]})
});
const data = await resp.json();
if(data.error) throw new Error(data.error);
const text = (data.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('');
const clean = text.replace(/```json|```/g,'').trim();
const fixes = JSON.parse(clean);
if(!Array.isArray(fixes)) throw new Error('Format invalide');
let applied = 0;
fixes.forEach(f => {
if(typeof f.day !== 'number' || !f.dish || !p.menus[f.day]) return;
if(f.type !== 'dejeuner' && f.type !== 'diner') return;
// Ne remplace QUE si la nouvelle version est réellement conforme
if(detectProblem(f.dish, allergies)) return;
p.menus[f.day][f.type] = f.dish;
applied++;
});
// 2e passe si certains plats restent problématiques (max 2 essais pour éviter boucle infinie)
if(applied < problems.length && attempt < 2){
const more = await autoFixMenusAllergens(p, attempt + 1);
return applied + more;
}
return applied;
}catch(e){
console.error('autoFixMenusAllergens error:', e);
return 0;
}
}
// ═════════════════════════════════════════════════════════════
// 🛡️ AUTO-CORRECTION DES VIOLATIONS INFÉRÉES DU PLAN
// (FIX 2026-05-18 — silencieuse, post-génération IA)
// Complète autoFixMenusAllergens en couvrant les exclusions implicites
// déduites du plan : sans lactose, sans gluten, sans FODMAP, sans légumineuses.
// L'IA est relancée sur les plats fautifs uniquement.
// ═════════════════════════════════════════════════════════════
async function autoFixMenusViolations(p, attempt = 1){
if(!p.menus || !p.menus.length) return 0;
if(typeof ndValidateMenuAgainstPlan !== 'function') return 0;
const validation = ndValidateMenuAgainstPlan(p);
if(!validation.rules.length || !validation.violations.length) return 0;
// Regroupe les violations par (jour, repas) pour ne réécrire chaque plat qu'une fois
const problemsByKey = {};
validation.violations.forEach(v => {
const dayIdx = JOURS.indexOf(v.day);
if(dayIdx < 0) return;
const type = v.meal.toLowerCase().startsWith('déj') ? 'dejeuner' : 'diner';
const key = dayIdx + '|' + type;
if(!problemsByKey[key]){
problemsByKey[key] = { day: dayIdx, type, dish: p.menus[dayIdx][type], rules: new Set(), terms: new Set() };
}
problemsByKey[key].rules.add(v.rule);
problemsByKey[key].terms.add(v.term);
});
const problems = Object.values(problemsByKey);
if(!problems.length) return 0;
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const base = BASES[baseKey] || {};
const planItems = [...(p.plan?.dejeuner||base.dejeuner||[]),...(p.plan?.diner||base.diner||[])].join(' | ');
const allDishes = [];
p.menus.forEach(m => { allDishes.push(m.dejeuner); allDishes.push(m.diner); });
// Consigne ciblée par règle inférée (concise pour rester court en tokens)
const rulesGuidance = Array.from(new Set(validation.rules)).map(r => {
if(r === 'SANS LACTOSE') return 'SANS LACTOSE : aucun lait/yaourt/skyr/petit-suisse/fromage blanc classique. Utiliser Skyr Alpro, yaourt soja/amande/coco, fromage blanc sans lactose Lactel, faisselle de soja, lait amande/avoine/soja, Saint Hubert oméga 3.';
if(r === 'SANS GLUTEN') return 'SANS GLUTEN : aucun blé/seigle/orge/épeautre/avoine, aucun pain classique, pâtes blé, semoule, couscous, boulgour. Utiliser riz, quinoa, sarrasin, millet, pain sans gluten.';
if(r === 'SANS FODMAP') return 'SANS FODMAP : aucun oignon/ail/échalote/poireau blanc (sauf huile d\'ail infusée, vert d\'oignon nouveau), aucun chou-fleur/artichaut/asperge/champignon/betterave/topinambour, aucune pomme/poire/mangue/pastèque/cerise/abricot/prune/mirabelle/figue/nectarine/brugnon/pêche/mûre/cassis, aucun miel/agave, aucun polyol. Fruits autorisés : kiwi, fraise, framboise, myrtille, clémentine, orange, banane mûre ≤100g.';
if(r === 'SANS LÉGUMINEUSES') return 'SANS LÉGUMINEUSES : aucune lentille/pois chiche/haricot sec/fève/edamame/soja/tofu/tempeh.';
if(r === 'VPO INTERDIT (déjeuner)') return 'VPO INTERDIT AU DÉJEUNER (régime dissocié) : aucune viande, aucune volaille, aucun poisson, aucun fruit de mer, aucun œuf, aucun jambon, aucune charcuterie au déjeuner. Le plat de déjeuner doit être 100% végétal côté protéine — valorise le féculent (risotto végétal, taboulé, buddha bowl) ou une légumineuse si listée dans le plan. Le titre ne doit PAS commencer par "filet de", "pavé de", "blanc de", "magret de", "rôti de".';
if(r === 'VPO INTERDIT (dîner)') return 'VPO INTERDIT AU DÎNER (régime dissocié) : aucune viande, aucune volaille, aucun poisson, aucun fruit de mer, aucun œuf, aucun jambon, aucune charcuterie au dîner. Composition végétale uniquement (légumes + féculent/légumineuse si listé + laitage + fruit).';
if(r === 'VPO MANQUANTE (déjeuner)' || r === 'VPO MANQUANTE (dîner)') return r.toUpperCase().replace('VPO MANQUANTE', 'VPO MANQUANTE AU') + ' (FIX 2026-09-07) : le plan IMPOSE une protéine animale à ce repas. Le plat réécrit DOIT contenir une viande, volaille, poisson, fruit de mer ou œufs de la liste autorisée du cabinet, NOMMÉE avec son grammage à l\'intérieur de la fourchette citée dans le problème (œufs : 2 œufs). Une légumineuse (lentilles, pois chiches, haricots, tofu, tempeh) ne remplace JAMAIS la VPO — elle peut rester dans le plat comme féculent si le plan la liste (ex : "Poulet 220g au curry, lentilles corail 100g cuites, yaourt soja"). Garde le registre/style du plat d\'origine (épices, cuisine) en y ajoutant la VPO.';
if(r.indexOf('MONOTONIE PROTÉINE') === 0) return 'MONOTONIE PROTÉINE (FIX 2026-09-08) : une même famille de protéine (poissons/fruits de mer, OU viandes/volailles, OU œufs) revient plus de 4 fois sur les 7 plats de ce repas. Les plats marqués en violation sont les occurrences EXCÉDENTAIRES : réécris-les avec une famille SOUS-REPRÉSENTÉE dans la semaine (si le trop-plein est du poisson → viande ou volaille de la liste autorisée du cabinet, et inversement). ⛔ FIX 2026-09-14 : choisis cette nouvelle protéine DANS LE RESPECT ABSOLU des aliments à éviter listés plus haut — si le diététicien interdit la viande rouge, prends volaille (poulet, dinde, pintade) ou œufs, JAMAIS bœuf, veau, agneau ou porc. Un interdit du diététicien prime toujours sur l\'équilibre des familles, en gardant EXACTEMENT le registre, la cuisine et le style du plat d\'origine (une cuisine méditerranéenne ou orientale a tout un répertoire de poulet, veau, bœuf, dinde). La nouvelle VPO est nommée avec son grammage dans la fourchette du plan (œufs : 2 œufs). Ne touche à rien d\'autre dans le plat.';
if(r === 'ALIMENTS À ÉVITER') return 'ALIMENTS À ÉVITER (veto strict du diététicien — liste : ' + (p.eviter||'') + ') : aucun de ces aliments NI leurs dérivés ou plats à base. Substituer par un aliment équivalent dans la même catégorie (ex : si "oignon" interdit, utiliser échalote vert/blanc d\'oignon nouveau si le plan le permet, sinon supprimer et compenser par herbes fraîches). Pas d\'argument, pas de seuil, pas d\'exception.';
if(r === 'LIBELLÉ NON FRANÇAIS') return 'LIBELLÉ NON FRANÇAIS (FIX 2026-09-18) : la patiente est française et les contrôles du cabinet (allergies, aliments interdits, portions de féculents) lisent des libellés FRANÇAIS. Le TITRE du plat peut garder le nom local de la cuisine demandée (« Pastel de choclo », « Cazuela de ave », « Ceviche »), mais CHAQUE ALIMENT et chaque unité sont écrits en français : maïs (pas « maíz » ni « choclo »), haricots noirs (pas « porotos negros »), cabillaud (pas « cabillada » ni « merluza »), poulet (pas « pollo »), riz (pas « arroz »), pomme de terre (pas « papas »), courgette (pas « zapallo italiano »), blette (pas « acelga »), yaourt nature 0% (pas « yogur natural »), 1 càc (pas « 1 cdta »). Ne change QUE la langue des aliments : mêmes ingrédients, mêmes grammages, même style de cuisine.';
if(r.indexOf('FRÉQUENCE FÉCULENT DÎNER') === 0) return 'FRÉQUENCE FÉCULENT DÎNER : le plan limite les féculents/légumineuses au dîner à un nombre maximal de fois par semaine (cf libellé de la règle). Les plats marqués en violation DÉPASSENT ce quota et doivent être réécrits SANS féculent ni légumineuse. Composition autorisée pour ces plats excédentaires : VPO + légumes (volume augmenté ~+50g) + nouilles de konjac/shirataki (= LÉGUMES, pas un féculent) si autorisées dans le plan + laitage si listé + fruit. INTERDIT pour ces plats : riz, quinoa, sarrasin, boulgour, lentilles, pois chiches, haricots, fèves, edamame, polenta, semoule, couscous, millet, épeautre, avoine, patate douce, pomme de terre, châtaignes, gnocchi, pâtes.';
return r;
}).join('\n• ');
const fixPrompt = `Tu es une diététicienne française gastronome. Tu dois CORRIGER ${problems.length} plat${problems.length>1?'s':''} qui ne respecte${problems.length>1?'nt':''} pas le plan alimentaire du patient (aliment interdit présent, OU catégorie obligatoire absente — ex. VPO manquante).
PATIENT : ${p.prenom} ${p.nom}
Plan : ${planItems}
🔴 EXCLUSIONS DÉDUITES DU PLAN (à respecter STRICTEMENT) :
• ${rulesGuidance}
PLATS À RÉÉCRIRE (problème détecté après la flèche) :
${problems.map((pb,i)=>`${i+1}. ${JOURS[pb.day]} ${pb.type==='dejeuner'?'déjeuner':'dîner'} — "${pb.dish}" → problème : ${Array.from(pb.terms).join(', ')}`).join('\n')}
Plats déjà présents cette semaine (à NE PAS dupliquer) :
${allDishes.map(d=>`- ${d}`).join('\n')}
Pour CHAQUE plat ci-dessus, propose UN plat de remplacement :
- Élégant, varié, nommé de façon évocatrice (style Ducasse/Robuchon/Passard — SAUF si une consigne de style du diététicien est rappelée plus bas, auquel cas c'est ELLE qui fixe le registre)
- AUCUN aliment cité dans les exclusions déduites
- DIFFÉRENT des plats déjà prévus
- Respect du plan alimentaire (catégories d'aliments du plan : protéine, légumes, féculent si autorisé, laitage version conforme, fruit)
- N'utilise JAMAIS les mentions "à adapter", "ou substitut", "selon tolérance"
${ndBuildStyleConsigneRappel(p)}
Réponds UNIQUEMENT en JSON valide strict, sans texte avant/après, sans balises markdown :
[{"day":,"type":"dejeuner"ou"diner","dish":"Nom du plat conforme"}, ...]
Exactement ${problems.length} objet${problems.length>1?'s':''}.`;
try {
const resp = await fetch('/.netlify/functions/api-proxy', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ model:'claude-haiku-4-5-20251001', max_tokens:1800, messages:[{role:'user',content:fixPrompt}] })
});
const data = await resp.json();
if(data.error) throw new Error(data.error.message || data.error);
const text = (data.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('');
const clean = text.replace(/```json|```/g,'').trim();
const fixes = JSON.parse(clean);
if(!Array.isArray(fixes)) throw new Error('Format invalide');
let applied = 0;
fixes.forEach(f => {
if(typeof f.day !== 'number' || !f.dish || !p.menus[f.day]) return;
if(f.type !== 'dejeuner' && f.type !== 'diner') return;
p.menus[f.day][f.type] = f.dish;
applied++;
});
// Re-valide et relance si encore des violations (FIX 2026-05-28 v3 : max 3 essais au lieu de 2,
// car les violations ALIMENTS À ÉVITER sont tenaces sur des ingrédients très courants comme oignon)
if(attempt < 3){
const reCheck = ndValidateMenuAgainstPlan(p);
if(reCheck.violations.length > 0){
const more = await autoFixMenusViolations(p, attempt + 1);
return applied + more;
}
}
return applied;
} catch(e){
console.error('autoFixMenusViolations error (attempt ' + attempt + '):', e);
return 0;
}
}
// ═════════════════════════════════════════════════════════════
// ⚡ GÉNÉRATION MENUS LOCALE (fallback sans IA)
// ═════════════════════════════════════════════════════════════
const MENU_TEMPLATES = {
colopathie: {
dejeuner: [
'Poulet rôti + riz basmati + courgettes vapeur',
'Filet de cabillaud + quinoa + carottes sautées',
'Dinde grillée + millet + épinards à l\'huile d\'olive',
'Filet de saumon + riz + courgettes grillées',
'Émincé de veau + quinoa + carottes',
'Cabillaud vapeur + sarrasin + haricots verts',
'Filet de merlan + riz basmati + carottes vapeur'
],
diner: [
'Soupe de courgettes au basilic + jambon blanc + riz',
'Filet de merlan vapeur + purée de patate douce',
'Velouté de carottes au cumin + dinde grillée + sarrasin',
'Quinoa aux légumes (carotte, courgette) + jambon blanc',
'Velouté de courgettes + poisson blanc + riz',
'Bouillon clair aux herbes + filet de poulet + millet',
'Soupe de potimarron + dinde + riz basmati'
]
},
poids: {
dejeuner: [
'Filet de poisson blanc + brocolis + quinoa',
'Saumon en papillote + courgettes + millet',
'Émincé de bœuf + haricots verts + patate douce',
'Filet de cabillaud + ratatouille + quinoa',
'Blanc de poulet + légumes vapeur + riz complet',
'Dinde grillée + courgettes + lentilles corail',
'Filet de saumon + épinards + quinoa'
],
diner: [
'Velouté de légumes + tranche de jambon blanc + riz',
'Soupe de courgettes + filet de poulet + quinoa',
'Soupe de légumes + filet de poisson blanc + sarrasin',
'Poêlée de légumes + blanc de dinde + millet',
'Velouté de potimarron + filet de poulet + riz',
'Soupe de carottes + cabillaud vapeur + quinoa',
'Bouillon de légumes + dinde grillée + sarrasin'
]
},
combinee: {
dejeuner: [
'Poulet grillé + riz basmati + courgettes vapeur',
'Filet de cabillaud + quinoa + carottes',
'Dinde + millet + haricots verts',
'Saumon vapeur + riz + courgettes',
'Émincé de veau + sarrasin + épinards',
'Poisson blanc + patate douce + carottes',
'Blanc de poulet + riz + courgettes grillées'
],
diner: [
'Velouté courgettes + jambon blanc + riz',
'Soupe carottes + poisson blanc + quinoa',
'Poulet + riz + haricots verts',
'Cabillaud vapeur + purée de patate douce + courgettes',
'Soupe de potimarron + dinde grillée + millet',
'Velouté de légumes + filet de poulet + sarrasin',
'Soupe de courgettes + dinde grillée + riz'
]
},
diabete: {
dejeuner: [
'Filet de poulet grillé + lentilles + brocolis vapeur',
'Saumon en papillote + quinoa + courgettes',
'Dinde aux herbes + patate douce + haricots verts',
'Cabillaud + riz complet + ratatouille',
'Bœuf maigre + boulgour + épinards',
'Filet de poisson blanc + lentilles corail + carottes',
'Émincé de poulet + quinoa + petits légumes'
],
diner: [
'Soupe de légumes + dinde + lentilles',
'Velouté de courgettes + poisson blanc + quinoa',
'Salade de pois chiches + œuf dur + courgettes vapeur',
'Bouillon de légumes + filet de poulet + patate douce',
'Soupe de potimarron + cabillaud + boulgour',
'Curry de légumes léger + dinde + riz complet',
'Velouté de carottes + filet de poisson + lentilles'
]
},
cholesterol: {
dejeuner: [
'Saumon vapeur + quinoa + brocolis (oméga 3)',
'Filet de maquereau grillé + riz complet + courgettes',
'Sardines fraîches + lentilles + carottes',
'Cabillaud + boulgour + ratatouille + huile colza',
'Filet de hareng + patate douce + haricots verts',
'Truite saumonée + quinoa + petits légumes',
'Poulet aux herbes + riz complet + courgettes vapeur'
],
diner: [
'Soupe de lentilles + filet de saumon froid',
'Velouté de potimarron + sardines à l\'huile d\'olive + riz',
'Soupe de pois chiches + maquereau grillé',
'Bouillon de légumes + cabillaud + quinoa',
'Soupe de courgettes + filet de hareng + lentilles',
'Velouté de carottes + saumon + boulgour',
'Soupe de légumes verts + filet de poisson + riz complet'
]
},
sport: {
dejeuner: [
'Pavé de bœuf + riz complet + brocolis (180g protéines)',
'Filet de poulet (200g) + pâtes complètes + courgettes',
'Saumon + quinoa (150g cuit) + épinards',
'Dinde grillée + patate douce + haricots verts',
'Steak haché 5% + riz basmati + ratatouille',
'Cabillaud + boulgour + petits légumes',
'Filet de poulet + lentilles + carottes vapeur'
],
diner: [
'Pavé de saumon + quinoa + courgettes',
'Filet de poulet + riz complet + brocolis',
'Bœuf + patate douce + haricots verts',
'Dinde aux herbes + pâtes complètes + épinards',
'Cabillaud + lentilles + carottes',
'Œufs brouillés + pain complet + avocat + tomates',
'Steak haché + boulgour + ratatouille'
]
},
tca: {
dejeuner: [
'Pâtes carbonara légères + salade verte + 1 fruit',
'Filet de poulet rôti + frites maison + haricots verts',
'Risotto aux champignons + roquette + 1 fruit',
'Filet mignon de porc + purée maison + carottes',
'Quiche lorraine maison + salade + 1 fruit',
'Lasagnes aux légumes + petite salade + compote',
'Hachis parmentier maison + salade verte'
],
diner: [
'Soupe de légumes + tartine au fromage + 1 fruit',
'Omelette aux herbes + petite salade + pain frais',
'Velouté de courgettes + jambon-fromage + compote',
'Soupe paysanne + pain au levain + yaourt',
'Poêlée de légumes + œufs au plat + pain + fromage',
'Soupe + croque-monsieur léger + 1 fruit',
'Velouté + tartine au beurre + œuf coque + fruit'
]
}
};
async function regenerateSingleMeal(event, pid, dayIdx, mealType){
if(event) event.stopPropagation();
closeAllAltMenus();
const p = patients.find(x=>x.id===pid);
if(!p || !p.menus || !p.menus[dayIdx]) return;
showToast('⏳ Régénération du plat en cours…');
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const base = BASES[baseKey];
const planItems = [...(p.plan?.dejeuner||base.dejeuner),...(p.plan?.diner||base.diner)].join(' | ');
const allergyConstraints = buildAllergyRules(p.allergies||[]);
// Liste des autres plats déjà dans la semaine (pour éviter répétition)
const otherDishes = [];
p.menus.forEach((m,i) => {
if(i !== dayIdx || mealType !== 'dejeuner') otherDishes.push(m.dejeuner);
if(i !== dayIdx || mealType !== 'diner') otherDishes.push(m.diner);
});
const compatRecipes = getCompatibleRecipes(p);
let recipesSection = '';
if(compatRecipes.length > 0){
const top = compatRecipes.slice(0, 5);
recipesSection = `\n\nRECETTES PERSONNELLES À PRIVILÉGIER (si compatibles) :\n${top.map(r=>`- "${r.nom}" : ${r.texte.slice(0,400)}${r.texte.length>400?'…':''}`).join('\n')}`;
}
const eviterSection = p.eviter ? `\n⛔ ALIMENTS INTERDITS : ${p.eviter}` : '';
const mealLabel = mealType === 'dejeuner' ? 'déjeuner' : 'dîner';
const currentDish = p.menus[dayIdx][mealType];
const prompt = `Tu es une diététicienne française gastronome, inspirée des grands chefs (Ducasse, Robuchon, Pic, Passard, Bras, Marx, Bocuse, Marcon, Alléno).
Le plat actuel de ${JOURS[dayIdx]} ${mealLabel} : "${currentDish}" pose un problème d'allergie/intolérance et doit être remplacé.
Patient : ${p.prenom} ${p.nom}
Base alimentaire : ${base.label}
Règles du plan : ${planItems}
Allergies : ${(p.allergies||[]).join(', ')||'aucune'}${eviterSection}${allergyConstraints}${recipesSection}
Plats déjà prévus cette semaine (À ÉVITER pour garantir la variété) :
${otherDishes.map(d=>`- ${d}`).join('\n')}
Propose UN SEUL plat de remplacement pour ce ${mealLabel}, élégant, évocateur, original, DIFFÉRENT des autres plats de la semaine, respectant STRICTEMENT toutes les contraintes d'allergies et aliments interdits.
Réponds UNIQUEMENT avec le nom du plat (une seule ligne, sans guillemets, sans markdown, sans explication). Format : "Nom du plat avec ingrédients clés".`;
try{
const resp = await fetch('/.netlify/functions/api-proxy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:200,messages:[{role:'user',content:prompt}]})});
const data = await resp.json();
if(data.error) throw new Error(data.error);
const text = data.content?.filter(c=>c.type==='text').map(c=>c.text).join('').trim();
if(!text) throw new Error('Réponse vide');
// Nettoyage : enlever guillemets, puces éventuelles
const cleanDish = text.replace(/^["'\-\*\s]+|["'\s]+$/g, '').split('\n')[0].trim();
if(!cleanDish) throw new Error('Plat vide');
p.menus[dayIdx][mealType] = cleanDish;
await autosave();
renderMenus(p);
showToast('✅ Plat régénéré !');
}catch(e){
console.error(e);
showToast('❌ Erreur : '+(e.message||'inconnue'));
}
}
async function generateMenusLocal(pid){
const p = patients.find(x=>x.id===pid);
currentTab = 'menus';
const baseKey = p.plan?.baseKey || detectBase(p.motifs) || 'colopathie';
const templates = MENU_TEMPLATES[baseKey];
const allergies = p.allergies || [];
// Garde uniquement les recettes nativement conformes aux allergies
const safeDej = templates.dejeuner.filter(r => !isAllergenic(r, allergies));
const safeDin = templates.diner.filter(r => !isAllergenic(r, allergies));
// Plats de secours universels (sans gluten, lactose, œuf, fruit-à-coque, soja, poisson, crustacé, sésame)
const FALLBACK_DEJ = [
'Blanc de poulet grillé + riz basmati + courgettes vapeur',
'Émincé de dinde aux herbes + quinoa + carottes fondantes',
'Filet de poulet à la sauge + riz + haricots verts',
'Veau aux légumes + sarrasin + épinards à l\'huile d\'olive',
'Poulet rôti + millet + ratatouille minute (sans oignon si nécessaire)',
'Brochette de poulet aux herbes + riz + courgettes grillées',
'Émincé de veau au thym + quinoa + carottes vapeur'
];
const FALLBACK_DIN = [
'Soupe de courgettes au basilic + blanc de dinde + riz',
'Velouté de carottes + poulet grillé + quinoa',
'Bouillon de légumes + filet de dinde + millet',
'Soupe de courgettes + brochette de poulet + sarrasin',
'Velouté de potimarron + dinde grillée + riz',
'Soupe paysanne (carottes, courgettes) + poulet rôti + riz',
'Bouillon clair aux herbes + filet de poulet + quinoa'
];
const fallbackDej = FALLBACK_DEJ.filter(r => !isAllergenic(r, allergies));
const fallbackDin = FALLBACK_DIN.filter(r => !isAllergenic(r, allergies));
// Pool final : recettes safe du template + fallbacks safe (en complément)
const poolDej = (safeDej.length >= 7 ? safeDej : [...safeDej, ...fallbackDej]);
const poolDin = (safeDin.length >= 7 ? safeDin : [...safeDin, ...fallbackDin]);
const shuffle = a => [...a].sort(()=>Math.random()-0.5);
const dej = shuffle(poolDej);
const din = shuffle(poolDin);
const menus = [];
for(let i=0;i<7;i++){
menus.push({
dejeuner: dej[i % Math.max(1, dej.length)] || 'Plat à composer : protéine + légumes + féculent toléré',
diner: din[i % Math.max(1, din.length)] || 'Soupe de légumes + protéine maigre + féculent toléré'
});
}
p.menus = menus;
ndInvaliderCachesMenus(p); // FIX 2026-09-29
await autosave();
renderMenus(p);
showToast(allergies.length
? '✅ Menus générés (mode local) — adaptés à tes contraintes'
: '✅ Menus générés (mode local)');
}
// ═════════════════════════════════════════════════════════════
// 🧩 PARSEUR JSON RÉCUPÉRABLE (FIX 2026-07-02)
// Cause racine du bug "Unterminated string in JSON at position …" côté liste de
// courses : réponse IA coupée par max_tokens → JSON.parse brut jetait tout.
// Ce helper isole le 1er bloc {…}/[…], puis, si le parse échoue, referme
// proprement la dernière chaîne / structure non terminée. Retourne null si
// vraiment irrécupérable. (Même logique que tryParseJSON du menu détaillé,
// factorisée pour être réutilisable.)
// ═════════════════════════════════════════════════════════════
// FIX 2026-09-07 — « Liste de courses illisible (réponse IA incomplète) »,
// erreur ALÉATOIRE vue par Guillaume (fiche GISBERT). Cause reproduite en test :
// Haiku ajoute parfois une phrase AVANT ou APRÈS le JSON (« Note : j'ai retiré
// les aliments [FODMAP]… »). L'ancien scan gardait le DERNIER point équilibré :
// un crochet ou un guillemet dans la phrase parasite déplaçait ce point hors du
// JSON → JSON.parse échouait → null. Deux autres trous : une troncature en plein
// nom de clé (`, "Viandes et p`) n'était pas retirée (la regex tournait APRÈS la
// fermeture de la chaîne), et un `\` final faisait tout échouer.
// Nouvelle logique : (1) on essaie CHAQUE bloc équilibré, à partir de CHAQUE
// début possible, le premier qui parse gagne ; (2) sinon réparation de troncature
// en retirant le fragment partiel AVANT de refermer. Filet : test-salvage-json.mjs.
function ndSalvageJSON(rawText){
if(!rawText || typeof rawText !== 'string') return null;
// Scan d'un texte : pile des fermetures attendues, état « dans une chaîne »,
// index du guillemet ouvrant courant, et tous les points d'équilibre.
function scan(s){
let inStr = false, escp = false, strStart = -1;
const stack = [], ends = [];
for(let i = 0; i < s.length; i++){
const c = s[i];
if(escp){ escp = false; continue; }
if(inStr){
if(c === '\\') escp = true;
else if(c === '"') inStr = false;
continue;
}
if(c === '"'){ inStr = true; strStart = i; continue; }
if(c === '{') stack.push('}');
else if(c === '[') stack.push(']');
else if(c === '}' || c === ']'){
if(stack.length && stack[stack.length - 1] === c) stack.pop();
if(stack.length === 0) ends.push(i);
}
}
return { stack, inStr, escp, strStart, ends };
}
function tryParse(t){
try { const v = JSON.parse(t); return (v && typeof v === 'object') ? v : null; } catch(_){ return null; }
}
// 1) Essai direct (texte propre)
const direct = tryParse(rawText.trim());
if(direct) return direct;
// 2) Réparation d'une troncature (réponse coupée par max_tokens) à partir d'un
// début donné : on retire le fragment partiel, puis on referme la pile.
function repairFrom(s){
let r = s;
for(let pass = 0; pass < 3; pass++){
let info = scan(r);
if(info.escp) r = r.slice(0, -1); // `\` final
info = scan(r);
if(info.inStr) r = r.slice(0, info.strStart); // chaîne coupée → retirée
r = r.replace(/,?\s*"(?:[^"\\]|\\.)*"\s*:\s*$/, ''); // clé complète sans valeur
r = r.replace(/[^,\[\]{}"\s]+\s*$/, ''); // jeton incomplet (tru / 12.)
r = r.replace(/,?\s*"(?:[^"\\]|\\.)*"\s*:\s*$/, ''); // (re) clé orpheline après retrait
r = r.replace(/,\s*$/, ''); // virgule orpheline
info = scan(r);
let closed = r;
for(let i = info.stack.length - 1; i >= 0; i--) closed += info.stack[i];
const v = tryParse(closed);
if(v) return v;
// pass suivante : on recule d'un élément (dernière virgule)
const lastComma = r.lastIndexOf(',');
if(lastComma <= 0) break;
r = r.slice(0, lastComma);
}
return null;
}
// 3) Chaque début possible ({ ou [), dans l'ordre d'apparition : (a) chaque
// bloc équilibré à partir de ce début, le PREMIER qui parse gagne (une note
// après le JSON est ignorée) ; (b) sinon réparation de troncature depuis ce
// début. On passe au début suivant seulement si tout échoue — ainsi un
// « [adapté] » dans une phrase AVANT le JSON est sauté, et un tableau
// interne n'est jamais pris pour la liste entière.
const starts = [];
for(let i = 0; i < rawText.length && starts.length < 40; i++){
if(rawText[i] === '{' || rawText[i] === '[') starts.push(i);
}
for(const st of starts){
const s = rawText.slice(st);
const info = scan(s);
for(const e of info.ends){
const v = tryParse(s.slice(0, e + 1));
if(v) return v;
}
const rep = repairFrom(s);
if(rep) return rep;
}
return null;
}
// FIX 2026-09-07 — la liste globale attendue est { catégorie: [chaînes] }.
// Normalise ce que renvoie l'IA : catégories non-tableau ignorées, éléments
// non-chaîne convertis, catégories vides retirées. Renvoie null si rien d'utile
// → protège renderCourses / sendCoursesToPatient / ndBuildRichCoursesHtml /
// printCourses, qui font `p.courses[cat].map(...)` sans filtre.
function ndNormalizeCourses(raw){
if(!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
Object.keys(raw).forEach(cat => {
if(cat === 'parRepas') return;
const v = raw[cat];
if(!Array.isArray(v)) return;
const items = v.map(it => {
if(typeof it === 'string') return it.trim();
if(it && typeof it === 'object'){
const n = it.n || it.nom || it.name || it.item || '';
const q = it.q || it.quantite || it.qty || '';
return String(n).trim() + (q ? ' : ' + String(q).trim() + (it.u ? String(it.u).trim() : '') : '');
}
return (it === null || it === undefined) ? '' : String(it).trim();
}).filter(Boolean);
if(items.length) out[String(cat).trim() || 'Divers'] = items;
});
return Object.keys(out).length ? out : null;
}
// FIX 2026-08-28 — validation/nettoyage de "parRepas" (détail par repas pour
// la fonctionnalité "cocher ses repas" côté liste de courses patiente).
// Ne DOIT jamais lever d'exception : une entrée malformée est simplement
// ignorée, un résultat vide renvoie null → le patient retombe sur la liste
// statique classique (fallback silencieux, voir ndBuildRichCoursesHtml).
function ndValidateCoursesParRepas(raw){
try{
if(!raw || typeof raw !== 'object') return null;
const jours = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'];
const repasKeys = ['dejeuner','diner'];
const out = {};
let total = 0;
jours.forEach(j=>{
const src = raw[j];
if(!src || typeof src !== 'object') return;
const dayOut = {};
repasKeys.forEach(r=>{
const list = Array.isArray(src[r]) ? src[r] : [];
const clean = list.filter(it=>{
return it && typeof it === 'object'
&& typeof it.n === 'string' && it.n.trim()
&& typeof it.q === 'number' && isFinite(it.q) && it.q > 0
&& typeof it.u === 'string';
}).map(it=>({ n: it.n.trim(), q: it.q, u: it.u.trim(), c: (typeof it.c === 'string' && it.c.trim()) ? it.c.trim() : 'Divers' }));
if(clean.length){ dayOut[r] = clean; total += clean.length; }
});
if(Object.keys(dayOut).length) out[j] = dayOut;
});
return total > 0 ? out : null;
}catch(_){ return null; }
}
// ═════════════════════════════════════════════════════════════
// 🛒 GÉNÉRATION COURSES
// ═════════════════════════════════════════════════════════════
async function generateCourses(pid){
const p=patients.find(x=>x.id===pid);
// Vérification AVANT de changer d'onglet
if(!p.menus||p.menus.length===0){
showModal('Menus manquants','Tu dois d\'abord générer les menus de la semaine. Clique sur "🗓️ Générer les menus IA" avant de créer la liste de courses.',pid);
return;
}
// Archiver les anciennes courses avant régénération
if(p.courses){
if(!p.archives) p.archives = [];
p.archives.push({
date: today(),
type: 'courses',
courses: JSON.parse(JSON.stringify(p.courses)),
// FIX 2026-08-28 — snapshot du détail par repas (clé sœur, JAMAIS nichée
// dans .courses : voir ndValidateCoursesParRepas et les 4 lieux qui font
// Object.keys(a.courses) sans filtre).
coursesParRepas: p.coursesParRepas ? JSON.parse(JSON.stringify(p.coursesParRepas)) : null,
menus: JSON.parse(JSON.stringify(p.menus))
});
if(p.archives.length > 10) p.archives = p.archives.slice(-10);
}
currentTab='courses';
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',i===3));
get('tabContent').innerHTML=`
Création de la liste de courses adaptée…
`;
const menuText=p.menus.map((j,i)=>`${JOURS[i]}: Déjeuner: ${j.dejeuner} | Dîner: ${j.diner}`).join('\n');
const allergyConstraints = buildAllergyRules(p.allergies||[]);
const eviterSection = p.eviter ? `\n\n⛔ ALIMENTS À EXCLURE ABSOLUMENT (aversion du patient) : ${p.eviter}
Ne liste AUCUN de ces aliments dans les courses.` : '';
// FIX 2026-05-05 — chantier 1.3 : enrichir le prompt avec calcul grammages × nbPersonnes × 7 jours
const nbPers = parseInt(p.nbPersonnes) || 2;
const planContext = p.plan ? `
📋 Plan alimentaire prescrit (porte sur 1 personne pour 1 jour, à multiplier) :
- Petit-déjeuner : ${(p.plan.petitDejeuner||[]).join(' / ')||'libre'}
- Déjeuner : ${(p.plan.dejeuner||[]).join(' / ')||'libre'}
- Collation : ${(p.plan.collation||[]).join(' / ')||'libre'}
- Dîner : ${(p.plan.diner||[]).join(' / ')||'libre'}` : '';
// FIX 2026-09-03 — HTTP 504 sur la liste de courses : depuis le 28/08 (clé
// "parRepas"), UN SEUL appel Haiku non streamé devait produire ~6500 tokens,
// ce qui dépasse le plafond dur de 26 s d'api-proxy (netlify.toml). Même remède
// que generateDetailedMenu (fix 504 du 28/08) : 3 appels EN PARALLÈLE, chacun
// court — (A) liste globale 7 catégories, (B1) détail par repas lundi→jeudi,
// (B2) détail vendredi→dimanche — avec 1 relance auto sur 502/503/504/abort.
// Le détail par repas est BEST-EFFORT : s'il échoue, p.coursesParRepas = null
// et la patiente voit la liste statique classique (repli déjà prévu le 28/08).
const contexteCommun=`Menus de la semaine :
${menuText}${planContext}
Allergies/intolérances à respecter : ${(p.allergies||[]).join(', ')||'aucune'}${eviterSection}${allergyConstraints}`;
const prompt=`Tu es une diététicienne. À partir des menus + du plan alimentaire, génère une liste de courses PRÉCISE pour ${nbPers} personne(s) sur 7 jours.
${contexteCommun}
🧮 RÈGLES DE CALCUL DES QUANTITÉS (CRITIQUE) :
1. Calcule chaque ingrédient comme : grammage_par_repas × nb_repas_dans_la_semaine × ${nbPers} personne(s).
- Ex : "VPO 150-200g" prescrit le midi tous les jours → 175g × 7 jours × ${nbPers} = ${175 * 7 * nbPers}g de viande/poisson/œuf à acheter
- Ex : "Féculents 100g cuits" → tenir compte que 100g cuit ≈ 35g cru pour pâtes/riz, 25g pour quinoa
- Ex : "Légumes 200g" → 200g × 7 × ${nbPers} = ${200 * 7 * nbPers}g de légumes
2. Indique la quantité totale en clair dans le nom : "Filet de cabillaud : ${175 * 7 * nbPers}g (≈ ${nbPers === 1 ? '7' : nbPers * 7} portions de 175g)"
3. Pour les ingrédients de base utilisés régulièrement (huile, vinaigre, beurre, sel, herbes), donne une estimation hebdomadaire raisonnable (ex: "Huile d'olive : 250ml")
4. Pour les fruits, compte ${nbPers} fruits/jour minimum × 7 jours = ${nbPers * 7} fruits
📦 ORGANISATION :
Regroupe par catégorie : Fruits et légumes, Viandes et poissons, Épicerie, Produits laitiers et alternatives, Boulangerie, Surgelés, Divers.
Liste les alternatives végétales / sans gluten / sans lactose selon allergies.
Réponds UNIQUEMENT en JSON valide sans texte ni balises markdown — aucune phrase, note ou remarque avant ou après l'accolade (les explications ne sont pas lues) :
{"Fruits et légumes":["item1","item2"],"Viandes et poissons":["item1"],"Épicerie":["item1"],"Produits laitiers et alternatives":["item1"],"Boulangerie":["item1"],"Surgelés":["item1"]}`;
// Détail par repas (FIX 2026-08-28), désormais demandé à part et par moitié de semaine.
const CP_DAY_GROUPS = [['lundi','mardi','mercredi','jeudi'], ['vendredi','samedi','dimanche']];
const promptParRepas = (jours) => `Tu es une diététicienne. À partir des menus + du plan alimentaire, détaille les ingrédients de CHAQUE déjeuner et CHAQUE dîner pour les jours suivants UNIQUEMENT : ${jours.join(', ')}.
${contexteCommun}
Règles :
- "q" = quantité pour 1 SEULE personne, pour CE seul repas (PAS de ×7, PAS de ×${nbPers} — juste une portion normale).
- "q" est un NOMBRE décimal (ex: 175 ou 1.5), jamais une chaîne de caractères.
- "u" = unité courte (g, ml, pièce, càs, càc…).
- "c" = catégorie, EXACTEMENT une de : Fruits et légumes, Viandes et poissons, Épicerie, Produits laitiers et alternatives, Boulangerie, Surgelés, Divers.
- N'inclus PAS les ingrédients de base hebdomadaires (huile, sel, vinaigre, épices) — seulement les ingrédients propres au plat.
- Respecte strictement les allergies et exclusions ci-dessus.
Réponds UNIQUEMENT en JSON valide sans texte ni balises markdown, avec exactement ces clés de jour : ${jours.map(j=>'"'+j+'"').join(', ')} :
{${jours.map(j=>'"'+j+'":{"dejeuner":[{"n":"Filet de cabillaud","q":175,"u":"g","c":"Viandes et poissons"}],"diner":[{"n":"...","q":0,"u":"g","c":"..."}]}').join(',')}}`;
const CP_MAX_TRIES = 2;
const CP_TIMEOUT_MS = 40000; // > 26 s Netlify : c'est le proxy qui coupe, pas nous
async function cpAttempt(promptTxt, maxTokens){
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), CP_TIMEOUT_MS);
let resp, rawText;
try {
resp = await fetch('/.netlify/functions/api-proxy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:maxTokens,messages:[{role:'user',content:promptTxt}]}),signal:ctrl.signal});
rawText = await resp.text();
} finally { clearTimeout(timer); }
if(!rawText){ const e = new Error('Réponse vide du serveur (timeout probable). Réessaie.'); e.retryable = true; throw e; }
if(!resp.ok){
let msg;
try { const errObj = JSON.parse(rawText); msg = errObj.error?.message || errObj.error || ('HTTP '+resp.status); }
catch(_) { msg = 'HTTP '+resp.status; }
const e = new Error(msg);
e.retryable = (resp.status === 502 || resp.status === 503 || resp.status === 504);
throw e;
}
let data;
try { data = JSON.parse(rawText); }
catch(_) { const e = new Error('Réponse non-JSON du proxy (page d\'erreur HTML ?). Réessaie.'); e.retryable = true; throw e; }
if(data.error)throw new Error(data.error.message || data.error);
const text=(data.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('');
const clean=text.replace(/```json|```/g,'').trim();
let obj;
try { obj = JSON.parse(clean); }
catch(_){ obj = ndSalvageJSON(clean); }
// FIX 2026-09-07 — on renvoie AUSSI le texte brut : sert au repli de
// réparation et au diagnostic quand le JSON reste illisible.
return { obj, clean, stop: data.stop_reason || null };
}
async function cpWithRetry(promptTxt, maxTokens){
let lastErr;
for(let attempt = 1; attempt <= CP_MAX_TRIES; attempt++){
try { return await cpAttempt(promptTxt, maxTokens); }
catch(e){
lastErr = e;
const retryable = e.retryable || e.name === 'AbortError';
if(retryable && attempt < CP_MAX_TRIES) continue;
throw e;
}
}
throw lastErr;
}
try{
// (A) liste globale — max_tokens 4000 = valeur qui tenait sous 26 s avant le 28/08.
// (B1/B2) détail par repas — best-effort, jamais bloquant.
const [resA, ...partsParRepas] = await Promise.all([
cpWithRetry(prompt, 4000),
...CP_DAY_GROUPS.map(jours => cpWithRetry(promptParRepas(jours), 3500).then(r => r && r.obj).catch(e => { console.warn('[courses] détail par repas indisponible ('+jours.join('/')+') :', e.message); return null; }))
]);
let courses = resA && resA.obj;
// FIX 2026-09-07 — repli de RÉPARATION : si le texte de l'IA n'a pas pu être
// lu (phrase parasite, JSON cassé…), on lui renvoie SON texte et on lui
// demande uniquement le JSON. Un appel Haiku court, seulement en cas d'échec.
if((!courses || typeof courses !== 'object' || Object.keys(courses).length === 0) && resA && resA.clean){
window.__ndCoursesDiag = { quand: new Date().toISOString(), etape: 'salvage-echec', stop: resA.stop, texte: resA.clean };
console.warn('[courses] JSON illisible (stop=' + resA.stop + ') — tentative de réparation. Début : «' + resA.clean.slice(0, 160) + '» … fin : «' + resA.clean.slice(-160) + '»');
try {
const promptReparation = `Le texte ci-dessous devait être UNIQUEMENT un objet JSON de liste de courses de la forme {"Catégorie":["article : quantité", ...], ...}. Il contient du texte parasite ou une erreur de syntaxe. Renvoie EXACTEMENT le même contenu sous forme de JSON valide strict — sans aucune phrase avant ou après, sans balises markdown, sans commentaire. Si le texte est coupé, termine proprement la dernière catégorie.
TEXTE À RÉPARER :
${resA.clean.slice(0, 12000)}`;
const resR = await cpWithRetry(promptReparation, 4000);
courses = resR && resR.obj;
if(courses) window.__ndCoursesDiag.etape = 'repare';
} catch(eR){ console.warn('[courses] réparation impossible :', eR.message); }
}
// Fusion du détail par repas. Tolère qu'une réponse enveloppe les jours dans
// une clé "parRepas" (ancien schéma) — et n'accepte JAMAIS cette clé dans
// p.courses (4 endroits font Object.keys(p.courses) sans filtre).
const rawParRepas = {};
partsParRepas.forEach(part => {
if(!part || typeof part !== 'object') return;
const src = (part.parRepas && typeof part.parRepas === 'object') ? part.parRepas : part;
Object.assign(rawParRepas, src);
});
if(courses && typeof courses === 'object' && courses.parRepas){
Object.assign(rawParRepas, courses.parRepas);
delete courses.parRepas;
}
const coursesParRepas = Object.keys(rawParRepas).length ? ndValidateCoursesParRepas(rawParRepas) : null;
courses = ndNormalizeCourses(courses);
if(!courses){
const extrait = (resA && resA.clean) ? resA.clean.replace(/\s+/g,' ').slice(0, 140) : '(réponse vide)';
window.__ndCoursesDiag = Object.assign(window.__ndCoursesDiag || {}, { etape: 'echec-final', texte: resA && resA.clean });
console.error('[courses] liste illisible après réparation — texte complet dans window.__ndCoursesDiag');
throw new Error('Liste de courses illisible : l\'IA n\'a pas renvoyé un JSON exploitable. Extrait reçu : « ' + extrait + ' … ». Réessaie.');
}
p.courses=courses;
p.courses_source = ndMenusSig(p); // FIX 2026-09-29 : empreinte des menus d'origine
// peut être null (détail indisponible) : le module client (ndBuildRichCoursesHtml)
// retombe sur la liste statique classique, sans erreur visible pour la patiente.
p.coursesParRepas = coursesParRepas;
await autosave();
renderCourses(p);
showToast('✅ Liste générée !');
}catch(e){
// FIX 2026-09-07 — plus de « Réessaie.. Réessaie. » : le suffixe n'est ajouté
// que si le message ne se termine pas déjà par une invitation à réessayer.
const msg = String(e.message || 'Erreur inconnue').trim();
const suffixe = /r[ée]essa/i.test(msg) ? '' : ' Réessaie.';
get('tabContent').innerHTML=`
Base : ${bLabel[baseKey]||baseKey} · Créé le ${today()}
${allergies.length?`
⚠️ Allergies : ${allergies.join(', ')}
`:''}
${noteH?`
${noteH}
`:''}
${mealHTML}
${noteG?`
${noteG}
`:''}
`);
pw.document.close();
}
// ═════════════════════════════════════════════════════════════
// 📧 ENVOI LISTE DE COURSES À LA PATIENTE — chantier 1.4
// Génère un HTML autonome (CSS inline, sans CDN) avec liens cliquables
// par enseigne pour chaque ingrédient. Téléchargement Blob.
// ═════════════════════════════════════════════════════════════
async function sendCoursesToPatient(pid){
const p = patients.find(x => x.id === pid);
if(!p || !p.courses){ showToast('❌ Aucune liste à envoyer — génère-la d\'abord'); return; }
const allergies = p.allergies || [];
// Si la patiente n'a pas choisi d'enseignes, on prend les 4 principales par défaut
const selectedEnseignes = (p.enseignes && p.enseignes.length)
? p.enseignes
: ['Carrefour','Leclerc','Auchan','Intermarché'];
// Filtre DRIVE_SEARCHES par les enseignes choisies
const enseignesData = DRIVE_SEARCHES.filter(d => selectedEnseignes.includes(d.name));
// FIX 2026-05-05 — pour Leclerc/Intermarché, demander la ville si pas connue
// (les drives sont locaux, on ne peut pas faire de recherche directe sans la ville)
const needsVille = selectedEnseignes.some(e => e === 'Leclerc' || e === 'Intermarché');
let ville = (p.villeDrive || '').trim();
if(needsVille && !ville){
ville = (prompt(
`📍 Pour les Drive Leclerc et Intermarché, j'ai besoin de la ville de ${p.prenom} pour générer des liens vers le bon drive local.\n\n` +
`Saisis la ville (ex: Montpellier, Perpignan, Castelnau-le-Lez…) :`
) || '').trim();
if(ville){
// Sauvegarde la ville sur la fiche pour les prochaines fois
p.villeDrive = ville;
await autosave().catch(()=>{});
}
}
const cats = Object.keys(p.courses).filter(c => (p.courses[c]||[]).length > 0);
const totalArticles = cats.reduce((n, c) => n + (p.courses[c]||[]).length, 0);
// Helpers HTML safe
const escH = (s) => String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
const cleanForSearch = (raw) => String(raw||'')
.split(':')[0]
.replace(/\([^)]*\)/g,'')
.replace(/\b\d+([.,]\d+)?\s*(g|gr|kg|ml|cl|l|pc|pi[èe]ces?|tranches?|tasses?|c[àa]s|c[àa]c)\b/gi,'')
.replace(/\bx\s*\d+/gi,'')
.replace(/\s+/g,' ')
.trim();
// Construit l'URL de recherche pour une enseigne donnée + un ingrédient
// Pour Leclerc/Intermarché avec ville → Google search ciblé "drive {enseigne} {ville} {produit}"
// Pour les autres → URL directe enseigne + recherche pré-remplie
const buildEnseigneUrl = (d, raw) => {
if((d.name === 'Leclerc' || d.name === 'Intermarché') && ville){
const q = encodeURIComponent(d.name + ' drive ' + ville + ' ' + raw);
return 'https://www.google.com/search?q=' + q;
}
return d.url + encodeURIComponent(raw);
};
const sectionsHTML = cats.map(cat => {
const items = p.courses[cat] || [];
return `
💡 Comment ça marche : coche les articles que tu as achetés. Clique sur le nom d'une enseigne à côté d'un produit pour ouvrir directement la recherche dans le Drive choisi (la recherche est déjà pré-remplie avec le nom du produit).
Liste générée par NutriDiet · Cabinet de diététique · ${escH(today())}
Imprime cette page (⌘+P / Ctrl+P) pour la version papier sans boutons.
function toggleEnseigne(input){
const name = input.dataset.toggleEnseigne;
const show = input.checked;
document.querySelectorAll('.enseigne[data-enseigne="' + name + '"]').forEach(a => {
a.style.display = show ? 'inline-block' : 'none';
});
}
${ndBuildCoursesParRepasScript(p, enseignesData, ville)}
<\/scr` + `ipt>
`;
// FIX 2026-05-05 — chantier 1.5 v3 : approche simple
// 1. Upload du HTML dans Supabase Storage (bucket meal-photo, sous-dossier course-lists)
// 2. Récupère l'URL publique du fichier
// 3. Ouvre le client mail du diét avec destinataire + sujet + lien pré-remplis
// 4. Diét clique "Envoyer" dans son client mail, fini.
const safeName = `${(p.prenom||'patient').replace(/[^a-z0-9]/gi,'_')}_${(p.nom||'').replace(/[^a-z0-9]/gi,'_')}`;
const filename = `Liste-courses_${safeName}_${today()}.html`;
const downloadBlob = () => {
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const patientEmail = (p.email || '').trim();
if(!patientEmail){
if(confirm('⚠ Aucun email enregistré pour cette patiente.\n\nTélécharger le fichier HTML pour envoi manuel ?')){
downloadBlob();
showToast('📥 Liste téléchargée');
}
return;
}
// Upload du HTML dans Supabase Storage (si connectée)
// FIX 2026-05-05 — fallback automatique si pas connectée : on télécharge direct
// le HTML et on ouvre un mailto qui dit "joindre en pièce jointe".
showToast('⏳ Préparation du lien…');
let publicUrl = null;
let connectedSupabase = false;
try {
const c = ntGetConfig();
if(!c.url || !c.jwt) {
throw new Error('NOT_CONNECTED');
}
connectedSupabase = true;
const path = `course-lists/${encodeURIComponent(patientEmail)}/${Date.now()}.html`;
// FIX 2026-05-12 — Supabase Storage rejette 'text/html;charset=utf-8' (HTTP 415
// invalid_mime_type). On envoie 'text/html' clean. L'UTF-8 reste correct via
// dans le head du HTML.
const blob = new Blob([html], { type: 'text/html' });
const uploadOnce = async (tok) => fetch(c.url + '/storage/v1/object/meal-photo/' + path, {
method: 'POST',
headers: {
'apikey': c.anonKey,
'Authorization': 'Bearer ' + tok,
'Content-Type': 'text/html',
'x-upsert': 'true',
'cache-control': 'no-cache, max-age=0',
},
body: blob,
});
let r = await uploadOnce(c.jwt);
// FIX 2026-05-14 v22 — même fix que pour menu détaillé : Supabase Storage
// renvoie 400 + body "exp claim" quand JWT expiré. Détecter + refresh + retry.
if(!r.ok){
const peek = await r.clone().text().catch(()=>'');
if(/exp.*claim|unauthorized|jwt.*expir|statusCode.*403|401|403/i.test(peek + ' ' + r.status) && c.refreshToken){
const refreshed = await ntRefreshJWT();
if(refreshed){
const fresh = ntGetConfig();
r = await uploadOnce(fresh.jwt);
}
}
}
if(!r.ok){
const errTxt = await r.text();
throw new Error('Upload: ' + r.status + ' ' + errTxt);
}
// FIX 2026-05-14 — pareil que pour le menu détaillé : Supabase force
// text/plain sur les HTML, on sert via la Netlify Function view-menu
// (alias /menu/*) pour avoir un Content-Type correct.
publicUrl = location.origin + '/menu/' + path;
} catch(e){
console.warn('Upload liste failed:', e.message);
publicUrl = null;
}
const subject = `🛒 Votre liste de courses de la semaine — ${today()}`;
let body, toastMsg;
if(publicUrl){
// Cas idéal : lien hébergé
body = `Bonjour ${p.prenom||''},
Voici votre liste de courses pour la semaine.
👉 Cliquez sur ce lien pour ouvrir votre liste interactive (avec boutons cliquables vers vos Drives) :
${publicUrl}
Vous pouvez cocher les articles au fur et à mesure et cliquer sur l'enseigne de votre choix pour ouvrir directement la recherche dans le Drive.
Bonne semaine !
Votre diététicien`;
toastMsg = '✉️ Email pré-rempli avec lien — clique Envoyer';
} else {
// Fallback : on télécharge le HTML et on demande à la diét de l'attacher manuellement
downloadBlob();
body = `Bonjour ${p.prenom||''},
Voici votre liste de courses pour la semaine — vous la trouverez en pièce jointe (fichier "${filename}").
Ouvrez-la dans votre navigateur (double-clic), vous pourrez cocher les articles et cliquer sur l'enseigne de votre choix pour ouvrir la recherche pré-remplie dans le Drive.
Bonne semaine !
Votre diététicien`;
const reasonText = connectedSupabase ? 'L\'envoi par lien hébergé n\'a pas marché' : 'Pas connecté à Supabase';
toastMsg = `📥 ${reasonText} — fichier "${filename}" téléchargé, attache-le dans le mail avant d'envoyer`;
}
const mailto = `mailto:${encodeURIComponent(patientEmail)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailto;
showToast(toastMsg);
}
// ═════════════════════════════════════════════════════════════
// 🖨️ IMPRESSION DÉDIÉE — LISTE DE COURSES
// ═════════════════════════════════════════════════════════════
function printCourses(pid){
const p = patients.find(x=>x.id===pid);
if(!p || !p.courses){ showToast('❌ Aucune liste à imprimer'); return; }
const allergies = p.allergies || [];
const cats = Object.keys(p.courses);
const sectionsHTML = cats.map(cat => {
const items = p.courses[cat] || [];
if(!items.length) return '';
return `
${esc(cat)}
${items.map(it => `
${esc(it)}
`).join('')}
`;
}).join('');
const totalArticles = cats.reduce((n, c) => n + (p.courses[c]||[]).length, 0);
const pw = window.open('', '_blank');
pw.document.write(`Liste de courses — ${esc(p.prenom)} ${esc(p.nom)}
🛒 Liste de courses
${esc(p.prenom)} ${esc((p.nom||'').toUpperCase())} · Semaine du ${new Date().toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'})}