sektaolga-fodmapcommits68017f1
Analyzing diff...
.oxygit/ci.yml+9 −0
new file mode 100644 index 0000000..364b8a2 @@ -0,0 +1,9 @@ +stages: [deploy] + +pages: + stage: deploy + script: + - mkdir -p public + - cp index.html public/index.html + pages: + path: public
README.md+3 −0
new file mode 100644 index 0000000..9a0b658 @@ -0,0 +1,3 @@ +# Olga FODMAP + +Standalone static application published with Oxygit Pages.
index.html+307 −0
new file mode 100644 index 0000000..8d12f09 @@ -0,0 +1,307 @@ +<!doctype html> +<html lang="ru"> +<head> +<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> +<title>FODMAP-конструктор приёма пищи</title> +<style> +:root{--bg:#f6f3ed;--card:#fff;--ink:#282723;--muted:#77736a;--green:#5c9b6a;--amber:#d8a23d;--red:#cf615a;--line:#e6e0d7;--accent:#7569a8}*{box-sizing:border-box}body{margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,Arial,sans-serif;background:var(--bg);color:var(--ink)}.wrap{max-width:1180px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;margin-bottom:20px}h1{font-size:30px;margin:0 0 6px}.sub{color:var(--muted);max-width:760px}.grid{display:grid;grid-template-columns:1.35fr .65fr;gap:18px}.card{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:20px;box-shadow:0 8px 30px #00000008}h2{font-size:18px;margin:0 0 14px}.add{display:grid;grid-template-columns:1fr 110px 105px 48px;gap:10px;margin-bottom:16px}select,input,textarea,button{font:inherit;border:1px solid var(--line);border-radius:12px;padding:11px 12px;background:white;color:var(--ink)}button{cursor:pointer;font-weight:650}.primary{background:var(--ink);color:white;border-color:var(--ink)}.meal-row{display:grid;grid-template-columns:1fr 105px 88px 38px;gap:9px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}.tag{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:700}.dot{width:10px;height:10px;border-radius:50%}.g{background:var(--green)}.a{background:var(--amber)}.r{background:var(--red)}.u{background:#9a958c}.remove{border:0;background:transparent;font-size:20px;padding:4px}.meterbox{display:flex;gap:22px;align-items:center}.glass{width:105px;height:220px;border:4px solid #b9b3a9;border-top:0;border-radius:0 0 28px 28px;position:relative;overflow:hidden;background:#faf9f6}.fill{position:absolute;left:0;right:0;bottom:0;height:0%;background:var(--green);transition:.3s}.critical{position:absolute;left:0;right:0;bottom:75%;border-top:2px dashed #7e786e;z-index:2}.critical span{position:absolute;right:4px;top:-21px;font-size:10px;color:#6e685f}.score{font-size:42px;font-weight:800}.status{font-weight:750;margin-top:5px}.small{font-size:12px;color:var(--muted);line-height:1.45}.groups{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:15px}.group{background:#f7f5f1;padding:8px 10px;border-radius:10px;font-size:12px}.advice{margin-top:14px;padding:13px;border-radius:14px;background:#f6f4fb;line-height:1.5}.advice b{display:block;margin-bottom:4px}.adapt{display:grid;gap:8px;margin-top:10px}.adapt button{text-align:left;background:white}.journal{margin-top:18px}.symptoms{display:grid;grid-template-columns:1fr 1fr;gap:10px}.history{margin-top:12px}.entry{padding:14px 0;border-top:1px solid var(--line);font-size:13px} +.entry-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start} +.entry-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px} +.entry-actions button{padding:7px 9px;font-size:12px} +.secondary{background:#f7f5f1} +.danger{color:#9b3d38} +.legend{margin:10px 0 14px;padding:12px;background:#f7f5f1;border-radius:12px} +.legend div{margin:4px 0} +.modal{position:fixed;inset:0;background:#0007;display:none;align-items:center;justify-content:center;padding:18px;z-index:20} +.modal.open{display:flex}.modal-card{background:white;max-width:620px;width:100%;border-radius:20px;padding:20px;max-height:90vh;overflow:auto} +.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} +.custom-badge{font-size:10px;background:#eee8ff;padding:2px 6px;border-radius:99px;margin-left:5px} +.analytics{margin-top:12px;padding:12px;background:#f6f4fb;border-radius:14px;line-height:1.45} +.personal{margin-top:5px;font-size:11px;padding:6px 8px;border-radius:9px;background:#f4f7f4;line-height:1.35} +.personal.warn{background:#fff4df}.personal.alert{background:#fdebea} +.evidence{font-size:10px;opacity:.72;margin-top:2px} + +.warn{background:#fff6e5;border:1px solid #edd8ac;padding:10px 12px;border-radius:12px;font-size:12px;margin-bottom:14px}.source{font-size:11px;color:var(--muted);margin-top:14px}.pill{font-size:11px;padding:3px 7px;border-radius:99px;background:#f0ede7;color:#68635b}.flex{display:flex;gap:8px;align-items:center;flex-wrap:wrap}@media(max-width:800px){.grid{grid-template-columns:1fr}.add{grid-template-columns:1fr 85px 48px}.add .primary{grid-column:auto}.meal-row{grid-template-columns:1fr 85px 70px 32px}.wrap{padding:15px}.top{display:block}} +</style></head> +<body><div class="wrap"> +<div class="top"><div><h1>FODMAP-конструктор</h1><div class="sub">Соберите приём пищи по граммам. Цвет продукта меняется с порцией, а индикатор оценивает совокупную FODMAP-нагрузку и предлагает, что сократить.</div></div><span class="pill">локально • данные сохраняются в браузере</span></div> +<div class="warn"><b>Важно:</b> это ориентировочный инструмент, а не медицинская диагностика. Публичные источники обычно дают пороговые порции, а не лабораторные граммы каждого FODMAP. Поэтому общий «стакан» — модель риска stacking, а не измерение реальных граммов FODMAP.</div> +<div class="grid"><main> +<div class="card"><h2>🍽 Приём пищи</h2> +<div class="add"><select id="food"></select><input id="grams" type="number" min="1" value="100" placeholder="г"><button class="primary" onclick="addFood()">Добавить</button><button title="Добавить новый продукт" onclick="openFoodModal()">+</button></div> +<div class="symptoms" style="margin-bottom:12px"> +<div><div class="small">Дата приёма пищи</div><input id="mealDate" type="date" style="width:100%"></div> +<div><div class="small">Время приёма пищи</div><input id="mealTime" type="time" style="width:100%"></div> +</div> +<div id="meal"></div> +<div class="flex" style="margin-top:14px"><button class="primary" onclick="saveMeal()">Сохранить приём пищи</button><button class="secondary" onclick="newMeal()">Новый приём пищи</button><span id="editingMeal" class="small"></span></div> +</div> +<div class="card journal"><h2>📚 Сохранённые приёмы пищи</h2> +<div class="small">Сначала сохраните состав еды. Реакцию можно добавить позже — через 30 минут, несколько часов или на следующий день.</div> +<div id="history" class="history"></div> +<div id="analytics" class="analytics"></div> +</div> +</main><aside> +<div class="card"><h2>FODMAP-нагрузка</h2><div class="meterbox"><div class="glass"><div class="critical"><span>критическая зона</span></div><div id="fill" class="fill"></div></div><div><div id="score" class="score">0%</div><div id="status" class="status">Добавьте продукты</div><div class="small" style="margin-top:8px">100% = условная граница высокой нагрузки модели.</div></div></div><div id="groups" class="groups"></div><div id="advice" class="advice"><b>Рекомендация</b>Начните собирать приём пищи.</div><div id="adapt" class="adapt"></div></div> +<div class="card" style="margin-top:18px"><h2>Как читать цвета</h2><div class="small"><p>🟢 порция находится в найденной low-FODMAP зоне.</p><p>🟡 порция выше low-порога или приближается к известной moderate/high зоне.</p><p>🔴 порция соответствует высокой/явно рискованной зоне по доступным данным.</p><p>⚪️ нет надёжного порога для точного расчёта — продукт учитывается осторожно.</p></div></div> +</aside></div><div id="reactionModal" class="modal"><div class="modal-card"> +<h2>📝 Добавить реакцию</h2><div id="reactionMeal" class="small"></div> +<div class="legend"><b>Единая шкала 0–10</b> +<div><b>0</b> — симптомов нет</div><div><b>1–2</b> — едва заметные, не мешают</div> +<div><b>3–4</b> — лёгкие, заметны, но обычные дела не меняются</div> +<div><b>5–6</b> — умеренные, отвлекают или требуют адаптировать планы</div> +<div><b>7–8</b> — сильные, заметно ограничивают активность</div> +<div><b>9–10</b> — очень сильные / невыносимые, обычная активность невозможна</div></div> +<div class="symptoms"><div><div class="small">Выраженность</div><input id="severity" type="range" min="0" max="10" value="0" style="width:100%"><div id="sevLabel" class="small">0 / 10 — симптомов нет</div></div> +<div><div class="small">Через сколько появилась реакция?</div><select id="delay"><option>реакции не было</option><option>до 30 минут</option><option>30–60 минут</option><option>1–2 часа</option><option>2–4 часа</option><option>позже 4 часов</option><option>не могу точно определить</option></select></div></div> +<div class="small" style="margin-top:10px">Что именно было?</div> +<div class="flex"><label><input type="checkbox" class="symptomType" value="вздутие"> вздутие</label><label><input type="checkbox" class="symptomType" value="боль"> боль</label><label><input type="checkbox" class="symptomType" value="газы"> газы</label><label><input type="checkbox" class="symptomType" value="урчание"> урчание</label><label><input type="checkbox" class="symptomType" value="диарея"> диарея</label><label><input type="checkbox" class="symptomType" value="запор"> запор</label><label><input type="checkbox" class="symptomType" value="тошнота"> тошнота</label></div> +<textarea id="comment" rows="4" style="width:100%;margin-top:10px" placeholder="Комментарий: обстоятельства, стресс, необычная еда, лекарства, менструальный цикл и другие наблюдения"></textarea> +<div class="modal-actions"><button onclick="closeReactionModal()">Отмена</button><button class="primary" onclick="saveReaction()">Сохранить реакцию</button></div> +</div></div> + +<div id="foodModal" class="modal"><div class="modal-card"> +<h2>+ Новый продукт</h2> +<div class="small">Пользовательский продукт сохранится только в этом браузере. Если точных FODMAP-порогов нет, лучше оставить статус «неизвестно», а не угадывать.</div> +<input id="newFoodName" style="width:100%;margin-top:12px" placeholder="Название продукта"> +<div class="symptoms" style="margin-top:10px"><div><div class="small">Green до, г</div><input id="newFoodGreen" type="number" min="0" style="width:100%" placeholder="например 50"></div> +<div><div class="small">Amber до, г</div><input id="newFoodAmber" type="number" min="0" style="width:100%" placeholder="например 100"></div></div> +<div class="small" style="margin-top:10px">Основные FODMAP-группы через запятую (необязательно)</div> +<input id="newFoodGroups" style="width:100%" placeholder="фруктоза, сорбитол"> +<textarea id="newFoodNote" rows="2" style="width:100%;margin-top:10px" placeholder="Заметка / источник порога"></textarea> +<div class="modal-actions"><button onclick="closeFoodModal()">Отмена</button><button class="primary" onclick="saveCustomFood()">Добавить продукт</button></div> +</div></div> + +<div class="source">База MVP собрана по публичным материалам Monash University и сводкам, цитирующим порции Monash/FODMAP Friendly. Для продуктов с меняющимися результатами тестирования использованы консервативные актуальные пороги. Мясо, рыба и яйца без высоко-FODMAP добавок считаются FODMAP-free.</div> +</div> +<script> +// green = верхняя ориентировочная low-FODMAP порция; amber = граница перед high, если найдена. +// load = вклад при достижении green-порции. Это эвристика stacking, НЕ лабораторные граммы FODMAP. +const foods=[ +{name:'Яблоки',g:20,a:40,groups:['фруктоза','сорбитол'],load:.42,n:'Pink Lady: консервативный low-порог 20 г'}, +{name:'Груши',g:4,a:13,groups:['фруктоза','сорбитол'],load:.5,n:'очень малая low-порция; результаты тестов менялись'}, +{name:'Нектарины',g:18,a:67,groups:['сорбитол','фруктаны'],load:.45,n:'консервативно как жёлтый нектарин'}, +{name:'Помидоры',g:65,a:83,groups:['фруктаны'],load:.25,n:'обычный томат; сорт имеет значение'}, +{name:'Бананы',g:100,a:120,groups:['фруктаны'],load:.18,n:'для плотного/недозрелого; спелый: low около 37 г',variant:'ripeness'}, +{name:'Арбуз',g:18,a:50,groups:['фруктоза','фруктаны','полиолы'],load:.48}, +{name:'Куриные бёдра с кожей',g:9999,a:9999,groups:[],load:0,n:'без маринада/лука/чеснока'}, +{name:'Варёные яйца',g:9999,a:9999,groups:[],load:0}, +{name:'Фарш из мяса куриной груди',g:9999,a:9999,groups:[],load:0,n:'если без добавок'}, +{name:'Курогрудь',g:9999,a:9999,groups:[],load:0}, +{name:'Говяжий фарш',g:9999,a:9999,groups:[],load:0,n:'если без добавок'}, +{name:'Сушёное манго',g:10,a:20,groups:['фруктоза','полиолы'],load:.48,n:'точный публичный порог ограничен; консервативная оценка для сухофрукта',uncertain:true}, +{name:'Жареный арахис',g:28,a:60,groups:['GOS'],load:.12,n:'без высоко-FODMAP вкусовых добавок'}, +{name:'Грецкие орехи',g:30,a:36,groups:['GOS'],load:.22}, +{name:'Семена тыквенные',g:81,a:100,groups:['фруктаны'],load:.2}, +{name:'Семена подсолнечника',g:15,a:30,groups:['GOS'],load:.22}, +{name:'Белый кунжут',g:51,a:70,groups:['фруктаны'],load:.18}, +{name:'Кешью',g:5,a:20,groups:['GOS','фруктаны'],load:.5,n:'кешью обычно high-FODMAP; 5 г — консервативная малая порция',uncertain:true}, +{name:'Фундук',g:15,a:30,groups:['GOS'],load:.25}, +{name:'Миндаль',g:12,a:24,groups:['GOS'],load:.27}, +{name:'Овсянка варёная',g:150,a:250,groups:['фруктаны','GOS'],load:.14,n:'оценка из ~52 г сухих овсяных хлопьев; зависит от воды',uncertain:true}, +{name:'Греческий йогурт Теос 2%',g:40,a:100,groups:['лактоза'],load:.3,n:'бренд не имеет публичного Monash-теста; оценка по strained Greek yogurt/лактозе',uncertain:true}, +{name:'Кефир',g:24,a:96,red:200,groups:['лактоза'],load:.38,n:'обычный молочный; безлактозный оценивается иначе'}, +{name:'Молоко',g:30,a:100,red:250,groups:['лактоза'],load:.42,n:'обычное коровье; 250 мл содержит ~12.5–15.75 г лактозы'}, +{name:'Слива',g:15,a:30,groups:['сорбитол','фруктаны'],load:.48,n:'публичные точные пороги ограничены; консервативно',uncertain:true}, +{name:'Картофель запечённый',g:500,a:800,groups:[],load:0,n:'обычный картофель естественно low-FODMAP; учитывайте добавки'}, +{name:'Виноград',g:28,a:100,groups:['фруктоза'],load:.18,n:'сорт влияет; для красного винограда данные менялись'}, +{name:'Варёная свёкла',g:20,a:75,groups:['фруктаны'],load:.28,n:'малые порции; Monash также использует 2 ломтика cooked beetroot в рецепте'}, +{name:'Варёный нут',g:23,a:46,groups:['GOS','фруктаны'],load:.35,n:'именно boiled & drained; консервированный промытый переносится лучше'}, +{name:'Минтай',g:9999,a:9999,groups:[],load:0}, +{name:'Форель',g:9999,a:9999,groups:[],load:0}, +{name:'Свиная корейка',g:9999,a:9999,groups:[],load:0}, +{name:'Огурцы',g:75,a:150,groups:['фруктаны'],load:.1,n:'данные тестов различаются; 75 г — стандартная low-порция'}, +{name:'Дыня',g:90,a:150,groups:['фруктаны'],load:.18,n:'модель для cantaloupe/honeydew; сорт важен'}, +{name:'Квашеная капуста',g:20,a:70,groups:['маннитол'],load:.35,n:'ферментированная белокочанная'}, +{name:'Нори',g:50,a:100,groups:[],load:0,n:'в обычных пищевых порциях low-FODMAP'}, +{name:'Морковка сырая',g:500,a:800,groups:[],load:0,n:'Monash указывает отсутствие верхнего FODMAP-лимита для моркови'}, +{name:'Мандарины',g:125,a:200,groups:['фруктаны'],load:.12}, +{name:'Апельсины',g:130,a:200,groups:['фруктаны'],load:.12}, +{name:'Стручковая фасоль варёная',g:75,a:94,groups:['сорбитол'],load:.22}, +{name:'Клубника замороженная',g:65,a:100,groups:['фруктоза'],load:.12,n:'ориентир по свежей; заморозка без добавок обычно сопоставима',uncertain:true}, +{name:'Куриная печень',g:9999,a:9999,groups:[],load:0,n:'без лука/чеснока и высоко-FODMAP соусов'}, +{name:'Сушёная хурма',g:10,a:20,groups:['фруктоза','полиолы'],load:.48,n:'нет надёжного публичного порога; консервативная оценка сухофрукта',uncertain:true}, +{name:'Хурма',g:60,a:100,groups:['фруктоза','полиолы'],load:.3,n:'Sharon/persimmon: около 60 г low-порция'},{name:'Болгарский перец',g:43,a:75,groups:['фруктоза','полиолы'],load:.18,n:'консервативно для красного; зелёный имеет другой профиль'}, +{name:'Тыква запечённая',g:63,a:75,groups:['фруктоза','фруктаны'],load:.22,n:'консервативно как butternut; kabocha переносится в больших порциях'} +]; + + +let customFoods=JSON.parse(localStorage.getItem('fodmapCustomFoods')||'[]'); +customFoods.forEach(f=>foods.push(f)); +let meal=[], editingMealId=null, reactionMealId=null; + +function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))} +function uid(){return Date.now().toString(36)+Math.random().toString(36).slice(2,7)} +function getEntries(){return JSON.parse(localStorage.getItem('fodmapEntries')||'[]')} +function setEntries(e){localStorage.setItem('fodmapEntries',JSON.stringify(e))} +function foodKey(f){return f.name.toLocaleLowerCase('ru')} +function localDateString(d=new Date()){let z=new Date(d.getTime()-d.getTimezoneOffset()*60000);return z.toISOString().slice(0,10)} +function localTimeString(d=new Date()){return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')} +function mealDateTime(e){ + if(e.mealDate&&e.mealTime)return new Date(e.mealDate+'T'+e.mealTime); + return new Date(e.createdAt) +} +function mealDateLabel(e){return mealDateTime(e).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})} + +const foodSel=document.getElementById('food'); +function populateFoods(){ + let current=foodSel.value;foodSel.innerHTML=''; + foods.map((f,i)=>({f,i})).sort((a,b)=>a.f.name.localeCompare(b.f.name,'ru')).forEach(({f,i})=>{ + let o=document.createElement('option');o.value=i;o.textContent=f.name+(f.custom?' • свой':'');foodSel.appendChild(o) + }); + if([...foodSel.options].some(o=>o.value===current))foodSel.value=current; +} +populateFoods(); +document.getElementById('mealDate').value=localDateString(); +document.getElementById('mealTime').value=localTimeString(); + +const severityText={0:'симптомов нет',1:'едва заметные',2:'едва заметные',3:'лёгкие',4:'лёгкие',5:'умеренные',6:'умеренные',7:'сильные',8:'сильные',9:'очень сильные',10:'очень сильные'}; +document.getElementById('severity').oninput=e=>document.getElementById('sevLabel').textContent=e.target.value+' / 10 — '+severityText[e.target.value]; + +function level(f,g){ + if(f.unknown)return 'u'; if(f.load===0)return 'g'; if(f.red&&g>=f.red)return'r'; + if(g<=f.g)return'g'; if(g<=f.a)return'a'; return'r' +} +function contribution(f,g){ + if(f.unknown||f.load===0)return 0;let ratio=g/f.g; + if(ratio<=1)return f.load*ratio;if(ratio<=2)return f.load*(1+(ratio-1)*1.3);return f.load*(2.3+(ratio-2)*1.7) +} +function addFood(){let i=+foodSel.value,g=+document.getElementById('grams').value;if(!g)return;let ex=meal.find(x=>x.i===i);if(ex)ex.g+=g;else meal.push({i,g});render()} +function removeFood(k){meal.splice(k,1);render()} +function updateGram(k,v){meal[k].g=Math.max(0,+v||0);render()} + +/* PERSONAL LEARNING + Reaction-positive = at least one reaction >=3/10 within 0–4h / unknown timing. + A product-specific threshold is only shown with >=3 positive and >=2 tolerated exposures. + To reduce false attribution from stacking, "cleaner" meals (<=3 foods) are weighted more heavily. +*/ +function exposureData(name){ + let rows=[]; + getEntries().forEach(e=>{ + let item=(e.meal||[]).find(m=>m.name===name);if(!item)return; + let reactions=e.reactions||[]; + let relevant=reactions.filter(r=>r.delay!=='позже 4 часов'); + let maxSev=relevant.length?Math.max(...relevant.map(r=>+r.severity||0)):null; + let documented=reactions.length>0; + let positive=documented&&maxSev>=3; + let tolerated=documented&&maxSev<3; + let purity=(e.meal||[]).length<=2?1.25:(e.meal||[]).length===3?1.1:1; + rows.push({g:+item.g,positive,tolerated,documented,maxSev,purity,mealSize:(e.meal||[]).length,date:mealDateTime(e)}) + }); + return rows +} +function personalProfile(name){ + let rows=exposureData(name), doc=rows.filter(r=>r.documented), pos=doc.filter(r=>r.positive), tol=doc.filter(r=>r.tolerated); + if(doc.length<3)return {state:'learning',text:`Персонально: собираю данные (${doc.length}/3 отмеченных реакций)`,detail:'Для первых наблюдений нужны хотя бы 3 приёма с отмеченным самочувствием.'}; + let freq=pos.length/doc.length; + let out={state:'presence',doc:doc.length,pos:pos.length,tol:tol.length,freq}; + if(pos.length>=3&&tol.length>=2){ + let p=pos.slice().sort((a,b)=>a.g-b.g),t=tol.slice().sort((a,b)=>a.g-b.g); + // Robust onset estimate: weighted lower positive doses, avoid single outlier + let onset=p[Math.min(1,p.length-1)].g; + let toleratedMax=Math.max(...t.map(x=>x.g).filter(g=>g<onset),0); + // If tolerated exposures overlap positives heavily, flag uncertain rather than claim threshold + let overlap=tol.filter(x=>x.g>=onset).length; + if(onset>0 && overlap<=Math.max(1,Math.floor(tol.length*.25))){ + out.state='dose';out.onset=onset;out.toleratedMax=toleratedMax;out.overlap=overlap; + out.text=toleratedMax>0?`Персонально: до ~${toleratedMax} г чаще переносилось; реакции повторялись примерно от ${onset} г`:`Персонально: реакции повторялись примерно от ${onset} г`; + out.detail=`Основано на ${doc.length} отмеченных приёмах: ${pos.length} с реакцией ≥3/10, ${tol.length} без значимой реакции. Это ориентир, не медицинский порог.`; + return out + } + } + out.text=freq>=.6?`Персонально: продукт часто присутствовал при реакции (${pos.length} из ${doc.length})`:`Персонально: связь пока непостоянная (${pos.length} из ${doc.length} с реакцией)`; + out.detail='Пока недостаточно устойчивых данных, чтобы связывать реакцию с конкретной граммовкой.'; + return out +} +function personalHtml(f,g){ + let p=personalProfile(f.name),cls='personal'; + if(p.state==='dose'&&g>=p.onset)cls+=' alert';else if(p.state==='presence'&&p.freq>=.6)cls+=' warn'; + let current=''; + if(p.state==='dose'){ + if(p.toleratedMax&&g<=p.toleratedMax)current=` Сейчас ${g} г — внутри вашего наблюдаемого переносимого диапазона. `; + else if(g>=p.onset)current=` Сейчас ${g} г — в диапазоне, где у вас уже повторялись реакции. `; + else current=` Сейчас ${g} г — между наблюдаемыми зонами, данных пока мало. `; + } + return `<div class="${cls}">${esc(p.text)}${esc(current)}<div class="evidence">${esc(p.detail)}</div></div>` +} +function render(){ + let el=document.getElementById('meal'); + if(!meal.length)el.innerHTML='<div class="small">Пока ничего не добавлено.</div>'; + else el.innerHTML=meal.map((x,k)=>{ + let f=foods[x.i],l=level(f,x.g),label=l==='g'?'GREEN':l==='a'?'AMBER':l==='r'?'RED':'НЕТ ДАННЫХ'; + let general=f.unknown?'Общий порог: нет данных':f.load===0?'Общий диапазон: FODMAP практически отсутствуют':`Общий: green ≤ ${f.g} г${f.a?`, amber ≤ ${f.a} г`:''}`; + return `<div class="meal-row"><div><b>${esc(f.name)}</b>${f.custom?'<span class="custom-badge">свой</span>':''}<div class="small">${esc(general)}${f.uncertain?' • оценочно':''}</div>${personalHtml(f,x.g)}</div><input type="number" value="${x.g}" onchange="updateGram(${k},this.value)"><span class="tag"><i class="dot ${l}"></i>${label}</span><button class="remove" onclick="removeFood(${k})">×</button></div>` + }).join(''); + document.getElementById('editingMeal').textContent=editingMealId?'редактируется сохранённый приём пищи':''; + calc() +} +function calc(){ + let total=meal.reduce((s,x)=>s+contribution(foods[x.i],x.g),0),pct=Math.round(total*100); + document.getElementById('score').textContent=pct+'%';let fe=document.getElementById('fill');fe.style.height=Math.min(100,pct)+'%';fe.style.background=pct<60?'var(--green)':pct<100?'var(--amber)':'var(--red)'; + document.getElementById('status').textContent=pct<1?'Добавьте продукты':pct<60?'Низкая нагрузка':pct<100?'Нагрузка растёт':'Высокая нагрузка';let gs={};meal.forEach(x=>{let f=foods[x.i],c=contribution(f,x.g);(f.groups||[]).forEach(q=>gs[q]=(gs[q]||0)+c)}); + document.getElementById('groups').innerHTML=['фруктоза','лактоза','фруктаны','GOS','сорбитол','маннитол','полиолы'].map(q=>`<div class="group">${q}<br><b>${Math.round((gs[q]||0)*100)}%</b></div>`).join(''); + let advice=document.getElementById('advice'); + if(pct<60)advice.innerHTML='<b>Приём пищи выглядит мягко по FODMAP</b>Если самочувствие хорошее, нет необходимости дополнительно урезать рацион. Индивидуальная переносимость важнее цвета.'; + else if(pct<100)advice.innerHTML='<b>Стакан постепенно наполняется</b>Есть несколько источников FODMAP. Можно немного сократить наиболее нагружающую позицию. Следующий FODMAP-содержащий приём пищи разумно планировать примерно через 2–3 часа.'; + else advice.innerHTML='<b>Высокая совокупная нагрузка</b>Попробуйте адаптировать этот приём пищи. После него оставьте около 2–3 часов до следующего FODMAP-содержащего приёма пищи и ориентируйтесь на собственную реакцию.'; + let ranked=meal.map((x,k)=>({k,c:contribution(foods[x.i],x.g),f:foods[x.i],g:x.g})).filter(z=>z.c>0).sort((a,b)=>b.c-a.c),ad=document.getElementById('adapt');ad.innerHTML=''; + if(pct>=60&&ranked.length){let top=ranked[0],target=Math.min(top.g,Math.max(1,Math.floor(top.f.g*.8)));ad.innerHTML+=`<button onclick="meal[${top.k}].g=${target};render()"><b>Сократить один продукт</b><br><span class="small">${esc(top.f.name)}: ${top.g} → ${target} г</span></button>`;ad.innerHTML+=`<button onclick="meal.splice(${top.k},1);render()"><b>Убрать главный источник</b><br><span class="small">Убрать ${esc(top.f.name)}</span></button>`;let factor=Math.min(1,.55/Math.max(total,.01));ad.innerHTML+=`<button onclick="scaleMeal(${factor})"><b>Оставить все продукты</b><br><span class="small">Пропорционально уменьшить FODMAP-содержащие позиции</span></button>`} +} +function scaleMeal(factor){meal.forEach(x=>{if(foods[x.i].load>0)x.g=Math.max(1,Math.round(x.g*factor))});render()} +function snapshotMeal(){return meal.map(x=>({name:foods[x.i].name,g:x.g,level:level(foods[x.i],x.g),groups:foods[x.i].groups||[],custom:!!foods[x.i].custom}))} + +function saveMeal(){ + if(!meal.length)return alert('Сначала добавьте продукты.'); + let entries=getEntries(),now=new Date(),mealDate=document.getElementById('mealDate').value,mealTime=document.getElementById('mealTime').value; + if(!mealDate||!mealTime)return alert('Укажите дату и время приёма пищи.'); + if(editingMealId){let e=entries.find(x=>x.id===editingMealId);if(e){e.meal=snapshotMeal();e.mealDate=mealDate;e.mealTime=mealTime;e.updatedAt=now.toISOString()}} + else{entries.unshift({id:uid(),createdAt:now.toISOString(),updatedAt:now.toISOString(),mealDate,mealTime,meal:snapshotMeal(),reactions:[]});editingMealId=entries[0].id} + entries.sort((a,b)=>mealDateTime(b)-mealDateTime(a));setEntries(entries.slice(0,200));showHistory();render();alert('Состав и время приёма пищи сохранены. Реакцию можно добавить позже.') +} +function newMeal(){meal=[];editingMealId=null;document.getElementById('mealDate').value=localDateString();document.getElementById('mealTime').value=localTimeString();render()} +function editMeal(id){ + let e=getEntries().find(x=>x.id===id);if(!e)return; + meal=e.meal.map(m=>{let i=foods.findIndex(f=>f.name===m.name);if(i<0){foods.push({name:m.name,g:0,a:0,groups:m.groups||[],load:0,unknown:true,custom:true,n:'восстановлено из дневника'});i=foods.length-1;populateFoods()}return {i,g:m.g}}); + editingMealId=id;document.getElementById('mealDate').value=e.mealDate||localDateString(new Date(e.createdAt));document.getElementById('mealTime').value=e.mealTime||localTimeString(new Date(e.createdAt));render();window.scrollTo({top:0,behavior:'smooth'}) +} +function deleteMeal(id){if(!confirm('Удалить этот приём пищи и все комментарии к нему?'))return;setEntries(getEntries().filter(x=>x.id!==id));if(editingMealId===id)newMeal();showHistory()} +function openReactionModal(id){ + reactionMealId=id;let e=getEntries().find(x=>x.id===id);if(!e)return; + document.getElementById('reactionMeal').innerHTML='<b>Приём пищи: '+mealDateLabel(e)+'</b><br>'+e.meal.map(m=>esc(m.name)+' — '+m.g+' г').join(', '); + document.getElementById('severity').value=0;document.getElementById('sevLabel').textContent='0 / 10 — симптомов нет';document.getElementById('delay').value='реакции не было';document.getElementById('comment').value='';document.querySelectorAll('.symptomType').forEach(x=>x.checked=false);document.getElementById('reactionModal').classList.add('open') +} +function closeReactionModal(){document.getElementById('reactionModal').classList.remove('open');reactionMealId=null} +function saveReaction(){ + let entries=getEntries(),e=entries.find(x=>x.id===reactionMealId);if(!e)return;let sev=+document.getElementById('severity').value; + let reaction={id:uid(),createdAt:new Date().toISOString(),severity:sev,severityLabel:severityText[sev],delay:document.getElementById('delay').value,types:[...document.querySelectorAll('.symptomType:checked')].map(x=>x.value),comment:document.getElementById('comment').value.trim()}; + e.reactions=e.reactions||[];e.reactions.push(reaction);setEntries(entries);closeReactionModal();showHistory();render() +} +function deleteReaction(mealId,reactionId){let entries=getEntries(),e=entries.find(x=>x.id===mealId);if(!e)return;e.reactions=(e.reactions||[]).filter(r=>r.id!==reactionId);setEntries(entries);showHistory();render()} +function showHistory(){ + let entries=getEntries().sort((a,b)=>mealDateTime(b)-mealDateTime(a)),h=document.getElementById('history'); + if(!entries.length)h.innerHTML='<div class="small">Сохранённых приёмов пищи пока нет.</div>'; + else h.innerHTML=entries.map(e=>{let rs=e.reactions||[];let reactionHtml=rs.length?rs.map(r=>`<div class="advice" style="margin-top:8px"><b>Запись ${new Date(r.createdAt).toLocaleString('ru-RU')} • ${r.severity}/10 — ${esc(r.severityLabel||severityText[r.severity])}</b>${esc(r.delay)}${r.types?.length?' • '+r.types.map(esc).join(', '):''}${r.comment?'<br>'+esc(r.comment):''}<br><button class="remove danger" onclick="deleteReaction('${e.id}','${r.id}')">× удалить запись реакции</button></div>`).join(''):'<div class="small" style="margin-top:6px">Реакция пока не отмечена.</div>'; + return `<div class="entry"><div class="entry-head"><div><b>🍽 ${mealDateLabel(e)}</b><br>${e.meal.map(m=>`${esc(m.name)} — ${m.g} г`).join(', ')}</div></div>${reactionHtml}<div class="entry-actions"><button onclick="openReactionModal('${e.id}')">+ Добавить реакцию</button><button onclick="editMeal('${e.id}')">Открыть / изменить состав</button><button class="danger" onclick="deleteMeal('${e.id}')">Удалить</button></div></div>`}).join(''); + renderAnalytics(entries) +} +function renderAnalytics(entries){ + let names=[...new Set(entries.flatMap(e=>(e.meal||[]).map(m=>m.name)))],profiles=names.map(n=>({n,p:personalProfile(n)})); + let dose=profiles.filter(x=>x.p.state==='dose').sort((a,b)=>b.p.doc-a.p.doc).slice(0,4); + let presence=profiles.filter(x=>x.p.state==='presence'&&x.p.freq>=.5).sort((a,b)=>b.p.freq-a.p.freq).slice(0,4); + let parts=['<b>Персональные наблюдения</b>']; + if(dose.length){parts.push('<div style="margin-top:7px"><b>Похоже на зависимость от количества</b></div>');dose.forEach(x=>parts.push(`${esc(x.n)}: ${esc(x.p.text)}.`))} + if(presence.length){parts.push('<div style="margin-top:7px"><b>Часто присутствует при реакции, но порог ещё неясен</b></div>');presence.forEach(x=>parts.push(`${esc(x.n)}: ${x.p.pos} из ${x.p.doc} отмеченных приёмов.`))} + if(!dose.length&&!presence.length)parts.push('<br><span class="small">Приложение пока собирает данные. Для оценки граммовки нужны повторные приёмы как с реакцией, так и без неё.</span>'); + parts.push('<div class="small" style="margin-top:7px">Персональный диапазон — статистическая подсказка из вашего дневника, а не диагноз. Чем больше продуктов в одном приёме, тем осторожнее нужно трактовать связь с одним продуктом.</div>'); + document.getElementById('analytics').innerHTML=parts.join('<br>') +} +function openFoodModal(){document.getElementById('foodModal').classList.add('open')}function closeFoodModal(){document.getElementById('foodModal').classList.remove('open')} +function saveCustomFood(){ + let name=document.getElementById('newFoodName').value.trim();if(!name)return alert('Введите название продукта.');if(foods.some(f=>foodKey(f)===name.toLocaleLowerCase('ru')))return alert('Такой продукт уже есть в базе.'); + let g=+document.getElementById('newFoodGreen').value,a=+document.getElementById('newFoodAmber').value,groups=document.getElementById('newFoodGroups').value.split(',').map(x=>x.trim()).filter(Boolean),note=document.getElementById('newFoodNote').value.trim(),f; + if(g>0){if(!a||a<g)a=g*2;f={name,g,a,groups,load:.25,n:note||'пользовательский порог',uncertain:true,custom:true}}else f={name,g:0,a:0,groups,load:0,n:note||'FODMAP-пороги не заданы',unknown:true,custom:true}; + customFoods.push(f);localStorage.setItem('fodmapCustomFoods',JSON.stringify(customFoods));foods.push(f);populateFoods();foodSel.value=foods.length-1;closeFoodModal();['newFoodName','newFoodGreen','newFoodAmber','newFoodGroups','newFoodNote'].forEach(id=>document.getElementById(id).value='') +} +showHistory();render(); + +</script></body></html>