Publish Olga FODMAP app
AI Just committed 2026-08-18 06:33:55 +0000

Analyzing diff...

.oxygit/ci.yml+9 −0
new file mode 100644
index 0000000..364b8a2
--- /dev/null
+++ b/.oxygit/ci.yml
@@ -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
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# Olga FODMAP
+
+Standalone static application published with Oxygit Pages.
index.html+307 −0
new file mode 100644
index 0000000..8d12f09
--- /dev/null
+++ b/index.html
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>