sektaolga-fodmapcommits802d6c1
Analyzing diff...
index.html+118 −26
index 550b9d8..2a4d760 100644 @@ -4,7 +4,7 @@ <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}.typeahead{position:relative}.typeahead input{width:100%}.suggest{display:none;position:absolute;z-index:30;left:0;right:0;top:100%;margin-top:4px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 30px #0000001f;max-height:260px;overflow:auto}.suggest.open{display:block}.suggest div{padding:9px 12px;cursor:pointer;font-size:14px}.suggest div.sel,.suggest div:hover{background:#f2efe8}.suggest .hi{color:var(--muted);font-size:12px;margin-left:6px}.suggest .sechead{padding:7px 12px 4px;font-size:11px;font-weight:750;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);background:#faf9f6;cursor:default;position:sticky;top:0}.suggest .none{color:var(--muted);cursor:default}.dayfold{border:1px solid var(--line);border-radius:14px;margin-top:12px;background:#fff;overflow:hidden}.dayfold[open]{box-shadow:0 6px 20px #00000008}.dayfold>summary{list-style:none;cursor:pointer;padding:13px 15px;display:flex;flex-wrap:wrap;align-items:center;gap:10px;background:#faf9f6;font-size:14px}.dayfold>summary::-webkit-details-marker{display:none}.dayfold>summary:before{content:'▸';color:var(--muted);transition:.15s;display:inline-block}.dayfold[open]>summary:before{transform:rotate(90deg)}.dayfold>summary:hover{background:#f2efe8}.dayname{font-weight:750;text-transform:capitalize}.daychips{display:inline-flex;gap:6px}.chip{display:inline-flex;align-items:center;gap:4px;background:#fff;border:1px solid var(--line);border-radius:20px;padding:2px 8px;font-size:12px;font-weight:700}.daymeta{color:var(--muted);font-size:12px;margin-left:auto}.dayload{flex-basis:100%;display:flex;align-items:center;gap:9px;font-size:12px}.dayload-track{flex:1;height:8px;background:#ece8e0;border-radius:6px;overflow:hidden;min-width:80px}.dayload-fill{display:block;height:100%;border-radius:6px;transition:.3s}.dayload-num{font-weight:800;min-width:38px;text-align:right}.dayload-word{color:var(--muted);min-width:120px}.dayactions{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px 15px 4px}.dayexport{margin-left:auto;font-size:12px;padding:5px 11px;border-radius:10px;background:#fff}.dayexport:hover{background:#f2efe8}.daygroups{display:flex;flex-wrap:wrap;gap:6px;padding:0 15px 4px}.gchip{background:#f6f4fb;color:var(--accent);border-radius:20px;padding:2px 9px;font-size:11px;font-weight:700}.daybody{padding:0 15px 8px}.daybody .entry:first-child{border-top:0}.daychart{margin:16px 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#faf9f6}.chart-controls{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}.chart-body{display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start}.chart-legend{display:grid;gap:5px;font-size:12px;min-width:190px}.chart-legend div{display:flex;align-items:center;gap:7px;cursor:pointer;padding:3px 5px;border-radius:8px}.chart-legend div:hover{background:#efece5}.chart-legend i{width:11px;height:11px;border-radius:3px;display:inline-block;flex:none}.chart-bars{display:grid;gap:6px;font-size:12px;min-width:200px;flex:1}.bar-track{background:#ece8e0;border-radius:6px;height:12px;overflow:hidden}.bar-fill{height:100%;border-radius:6px}.chart-detail{margin-top:10px;line-height:1.5}.chart-slice{cursor:pointer}.chart-slice:hover{opacity:.82} +: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}.typeahead{position:relative}.typeahead input{width:100%}.suggest{display:none;position:absolute;z-index:30;left:0;right:0;top:100%;margin-top:4px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 30px #0000001f;max-height:260px;overflow:auto}.suggest.open{display:block}.suggest div{padding:9px 12px;cursor:pointer;font-size:14px}.suggest div.sel,.suggest div:hover{background:#f2efe8}.suggest .hi{color:var(--muted);font-size:12px;margin-left:6px}.suggest .sechead{padding:7px 12px 4px;font-size:11px;font-weight:750;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);background:#faf9f6;cursor:default;position:sticky;top:0}.suggest .none{color:var(--muted);cursor:default}.dayfold{border:1px solid var(--line);border-radius:14px;margin-top:12px;background:#fff;overflow:hidden}.dayfold[open]{box-shadow:0 6px 20px #00000008}.dayfold>summary{list-style:none;cursor:pointer;padding:13px 15px;display:flex;flex-wrap:wrap;align-items:center;gap:10px;background:#faf9f6;font-size:14px}.dayfold>summary::-webkit-details-marker{display:none}.dayfold>summary:before{content:'▸';color:var(--muted);transition:.15s;display:inline-block}.dayfold[open]>summary:before{transform:rotate(90deg)}.dayfold>summary:hover{background:#f2efe8}.dayname{font-weight:750;text-transform:capitalize}.daychips{display:inline-flex;gap:6px}.chip{display:inline-flex;align-items:center;gap:4px;background:#fff;border:1px solid var(--line);border-radius:20px;padding:2px 8px;font-size:12px;font-weight:700}.daymeta{color:var(--muted);font-size:12px;margin-left:auto}.dayload{flex-basis:100%;display:flex;align-items:center;gap:9px;font-size:12px}.dayload-track{flex:1;height:8px;background:#ece8e0;border-radius:6px;overflow:hidden;min-width:80px}.dayload-fill{display:block;height:100%;border-radius:6px;transition:.3s}.dayload-num{font-weight:800;min-width:38px;text-align:right}.dayload-word{color:var(--muted);min-width:120px}.dayactions{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px 15px 4px}.dayexport{margin-left:auto;font-size:12px;padding:5px 11px;border-radius:10px;background:#fff}.dayexport:hover{background:#f2efe8}.daygroups{display:flex;flex-wrap:wrap;gap:6px;padding:0 15px 4px}.gchip{background:#f6f4fb;color:var(--accent);border-radius:20px;padding:2px 9px;font-size:11px;font-weight:700}.daybody{padding:0 15px 8px}.daybody .entry:first-child{border-top:0}.daychart{margin:16px 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#faf9f6}.chart-controls{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}.chart-body{display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start}.chart-legend{display:grid;gap:5px;font-size:12px;min-width:190px}.chart-legend div{display:flex;align-items:center;gap:7px;cursor:pointer;padding:3px 5px;border-radius:8px}.chart-legend div:hover{background:#efece5}.chart-legend i{width:11px;height:11px;border-radius:3px;display:inline-block;flex:none}.chart-bars{display:grid;gap:6px;font-size:12px;min-width:200px;flex:1}.bar-track{background:#ece8e0;border-radius:6px;height:12px;overflow:hidden}.bar-fill{height:100%;border-radius:6px}.chart-detail{margin-top:10px;line-height:1.5}.chart-slice{cursor:pointer}.chart-slice:hover{opacity:.82}.verdict{padding:13px 14px;border-radius:14px;background:#f4f7f4;line-height:1.45;margin-bottom:14px}.verdict b{display:block;font-size:16px;margin-bottom:3px}.verdict.warn{background:#fff6e5}.verdict.alert{background:#fdebea}.gglasses{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.gg{background:#f7f5f1;border-radius:12px;padding:9px 10px}.gg-name{font-size:11px;font-weight:750;letter-spacing:.02em}.gg-track{height:9px;background:#e6e1d8;border-radius:6px;overflow:hidden;margin:6px 0 4px}.gg-fill{display:block;height:100%;border-radius:6px;transition:.3s}.gg-state{font-size:11px;color:var(--muted)}.gg.free .gg-state{color:var(--green)}.gg-items{font-size:10px;color:var(--muted);margin-top:3px;line-height:1.35}.sugg{margin-top:14px;padding:13px;border-radius:14px;background:#f4f7f4;line-height:1.5}.sugg b{display:block;margin-bottom:6px}.sugg button{display:block;width:100%;text-align:left;background:#fff;margin-top:6px;padding:8px 10px;font-size:13px}.sugg .small{margin-top:6px} .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} @@ -25,7 +25,7 @@ .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}.export-controls{grid-template-columns:1fr}.export-controls button{width:100%}} </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="top"><div><h1>FODMAP-конструктор</h1><div class="sub">Соберите приём пищи по граммам. Цвет продукта меняется с порцией, а каждая FODMAP-группа считается отдельно — приложение показывает, какая именно наполняется, и что можно добавить, оставшись в low-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> @@ -48,8 +48,8 @@ <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> +<div class="card"><h2>FODMAP по группам</h2><div class="small" style="margin:-8px 0 12px">Каждая группа считается отдельно: переносимость лактозы и фруктанов — разные вещи, складывать их в одну цифру некорректно. Один стаканчик ≈ одна порция группы за приём.</div><div id="verdict" class="verdict"><b>Соберите приём пищи</b>Добавьте продукты — и увидите, какие именно группы наполняются.</div><div id="gglasses" class="gglasses"></div><div id="gnote" class="small" style="margin-top:10px"></div><div id="advice" class="advice"><b>Рекомендация</b>Начните собирать приём пищи.</div><div id="adapt" class="adapt"></div><div id="suggestAdd" class="sugg" style="display:none"></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><p style="margin-top:10px">Стаканчики по группам заполняются <b>независимо</b>: фруктаны и лактоза — разные механизмы, и переносимость к ним разная. Единой суммарной цифры нет намеренно — она ничего не означает.</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> @@ -593,18 +593,107 @@ function render(){ 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>`} +const GROUPS=['фруктаны','лактоза','фруктоза','GOS','сорбитол','маннитол']; +const GROUP_ALIAS={'полиолы':'полиолы'}; +const GCOL={'фруктаны':'#cf615a','лактоза':'#7569a8','фруктоза':'#d8a23d','GOS':'#5c9b6a','сорбитол':'#3f8fa8','маннитол':'#b0743c','полиолы':'#9a7fbc'}; +/* Доля стаканчика группы, которую даёт продукт в этой порции. + Ориентир: зелёная порция = 0.5 стаканчика, верх amber = 1.0, дальше — выше единицы. + Это порядковая шкала на наших порогах, а не граммы FODMAP. */ +function groupShare(f,g){ + if(!g||g<=0)return 0; + if(f.g>=9999)return 0; + if(f.unknown)return f.hint==='high'?.7:f.hint==='low'?.15:0; + if(f.load===0)return 0; + if(!f.g)return f.red===1?1.15:1; + if(g<=f.g)return g/f.g*.5; + let a=(f.a&&f.a>f.g)?f.a:f.g*2; + if(g<=a)return .5+(g-f.g)/(a-f.g)*.5; + return 1+(g-a)/a*.5; +} +function mealGroupFill(list){ + let fill={},items={},unknown=[]; + (list||meal).forEach(x=>{ + let f=foods[x.i],sh=groupShare(f,x.g),gs=f.groups||[]; + if(sh<=0)return; + if(!gs.length){unknown.push(f.name);return} + gs.forEach(q=>{let k=GROUPS.includes(q)?q:(q==='полиолы'?'полиолы':null);if(!k)return; + fill[k]=(fill[k]||0)+sh;(items[k]=items[k]||[]).push(f.name+' '+x.g+' г')}); + }); + return {fill,items,unknown}; } +function gState(v){return v<.6?['g','свободно']:v<1?['a','наполняется']:['r','полный']} +function calc(){ + let {fill,items,unknown}=mealGroupFill(); + let keys=GROUPS.concat(fill['полиолы']?['полиолы']:[]); + document.getElementById('gglasses').innerHTML=keys.map(q=>{ + let v=fill[q]||0,[c,word]=gState(v),col=v<.6?'var(--green)':v<1?'var(--amber)':'var(--red)'; + return '<div class="gg'+(v?'':' free')+'"><div class="gg-name">'+esc(q==='полиолы'?'полиолы (не уточ.)':q)+'</div>'+ + '<div class="gg-track"><span class="gg-fill" style="width:'+Math.min(100,Math.round(v*100))+'%;background:'+col+'"></span></div>'+ + '<div class="gg-state" style="'+(v?'color:'+col+';font-weight:700':'')+'">'+(v?word:'свободно')+'</div>'+ + (items[q]?'<div class="gg-items">'+esc(items[q].join(', '))+'</div>':'')+'</div>'; + }).join(''); + let note=[]; + if(unknown.length)note.push('Без разметки групп в этом приёме: '+unknown.join(', ')+'. Такие продукты ни в один стаканчик не попадают — это честнее, чем угадать группу.'); + document.getElementById('gnote').innerHTML=note.map(esc).join('<br>'); + let max=0,maxQ=null;keys.forEach(q=>{if((fill[q]||0)>max){max=fill[q];maxQ=q}}); + let full=keys.filter(q=>(fill[q]||0)>=1),near=keys.filter(q=>(fill[q]||0)>=.6&&(fill[q]||0)<1); + let v=document.getElementById('verdict'),advice=document.getElementById('advice'); + if(!meal.length){v.className='verdict';v.innerHTML='<b>Соберите приём пищи</b>Добавьте продукты — и увидите, какие именно группы наполняются.'; + advice.innerHTML='<b>Рекомендация</b>Начните собирать приём пищи.'} + else if(!max){v.className='verdict';v.innerHTML='<b>Можно сочетать</b>Ни одна FODMAP-группа в этом приёме не задействована.'; + advice.innerHTML='<b>Рекомендация</b>Приём выглядит спокойно. Ориентируйтесь на собственное самочувствие, дополнительно урезать нечего.'} + else if(full.length){v.className='verdict alert';v.innerHTML='<b>Стоит остановиться</b>Полные стаканчики: '+esc(full.join(', '))+'. Остальные группы свободны — ограничение касается только этих.'; + advice.innerHTML='<b>Рекомендация</b>Сократите порцию того, что даёт '+esc(full[0])+', либо перенесите часть на следующий приём через 2–3 часа. Продукты из других групп трогать не нужно.'} + else if(near.length){v.className='verdict warn';v.innerHTML='<b>Нагрузка нарастает</b>Ближе всего к пределу: '+esc(near.join(', '))+'. Остальное свободно.'; + advice.innerHTML='<b>Рекомендация</b>Запас по группе «'+esc(maxQ)+'» почти выбран. Добавлять в этот приём лучше то, что её не трогает.'} + else{v.className='verdict';v.innerHTML='<b>Можно сочетать</b>Самая наполненная группа — '+esc(maxQ)+', и та ниже половины.'; + advice.innerHTML='<b>Рекомендация</b>Есть запас по всем группам. Если самочувствие хорошее, дополнительно урезать рацион не нужно.'} + let ranked=meal.map((x,k)=>({k,c:groupShare(foods[x.i],x.g)*((foods[x.i].groups||[]).length?1:0),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((full.length||near.length)&&ranked.length){let top=ranked[0],target=Math.min(top.g,Math.max(1,Math.floor((top.f.g||top.g)*.8))); + if(target<top.g)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>'; + ad.innerHTML+='<button onclick="scaleMeal(0.6)"><b>Оставить все продукты</b><br><span class="small">Пропорционально уменьшить FODMAP-содержащие позиции</span></button>'} + renderSuggestions(fill); +} +/* «Что ещё можно добавить»: берём часто употребляемые, затем остальную базу. + Оставляем только то, что не выводит ни одну группу выше половины стаканчика. */ +/* Не предлагаем то, что не кладут в тарелку самостоятельно: алкоголь, масла, соусы, + сиропы, муку, специи и подсластители. Они остаются в базе и в поиске — только не в подсказках. */ +const NO_SUGGEST=/вино|пиво|джин|водк|ром\b|виск|ликёр|ликер|сидр|шампанск|коньяк|текил|масл|соус|кетчуп|майонез|горчиц|сироп|уксус|специ|приправ|мука|муки|мисо|экстракт|желатин|агар|дрожж|разрыхлит|подсласт|сахар|сорбит|ксилит|маннит|мальтит|изомальт|глюкоз|патока|^мак$|^ром$|ваниль|корица|куркума|перец|соль/i; +function renderSuggestions(fill){ + let box=document.getElementById('suggestAdd'); + if(!meal.length){box.style.display='none';return} + let inMeal=new Set(meal.map(x=>foods[x.i].name)); + let freq=topUsed(),rank=new Map();freq.forEach((n,i)=>rank.set(n,i)); + let cand=foods.map((f,i)=>({f,i})).filter(({f})=>{ + if(inMeal.has(f.name))return false; + if(f.unknown&&f.hint!=='low')return false; + if(f.hint==='high')return false; + if(!f.g&&f.red===1)return false; + return true; + }).map(({f,i})=>{ + let portion=f.g>=9999?100:(f.g||0); + // в подсказки не берём совсем мелкие порции (мёд, специи, приправы) и оценочные пороги — + // рекомендация должна быть тем, что реально кладут в тарелку + if(!portion||portion<20||f.uncertain||NO_SUGGEST.test(f.name))return null; + let sh=groupShare(f,portion),ok=true; + (f.groups||[]).forEach(q=>{if(((fill[q]||0)+sh)>.6)ok=false}); + if(!ok)return null; + let r=rank.has(f.name)?rank.get(f.name):999; + return {f,i,portion,r,free:f.g>=9999||f.load===0}; + }).filter(Boolean); + // частые — строго первыми; остальные перемешиваем стабильным хэшем, чтобы список не был пятью видами мяса + let hash=n=>{let h=0;for(let i=0;i<n.length;i++)h=(h*31+n.charCodeAt(i))>>>0;return h}; + cand=cand.sort((a,b)=>a.r-b.r||hash(a.f.name)-hash(b.f.name)).slice(0,5); + if(!cand.length){box.style.display='none';return} + box.style.display=''; + box.innerHTML='<b>Что ещё можно добавить</b><div class="small">Не трогает те группы, которые уже наполняются. Сначала — то, что вы едите чаще всего.</div>'+ + cand.map(c=>'<button onclick="addSuggested('+c.i+','+c.portion+')">'+esc(c.f.name)+' — '+ + (c.free?'без ограничения по порции, ориентир '+c.portion+' г':'до '+c.portion+' г')+ + '<br><span class="small">'+esc((c.f.groups||[]).length?'группы: '+c.f.groups.join(', '):'FODMAP-групп нет')+ + (rank.has(c.f.name)?' • часто употребляемый':'')+'</span></button>').join(''); +} +function addSuggested(i,g){bumpUsage(foods[i].name);let ex=meal.find(x=>x.i===i);if(ex)ex.g+=g;else meal.push({i,g});render()} 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}))} @@ -638,17 +727,21 @@ function deleteReaction(mealId,reactionId){let entries=getEntries(),e=entries.fi let openDays=JSON.parse(sessionStorage.getItem('fodmapOpenDays')||'null'); function toggleDay(k,el){openDays=openDays||{};openDays[k]=el.open;sessionStorage.setItem('fodmapOpenDays',JSON.stringify(openDays))} const foodByName=(()=>{let m=new Map();foods.forEach(f=>m.set(f.name.toLocaleLowerCase('ru'),f));return m})(); -function dayLoadPct(list){ - let total=0; - list.forEach(e=>(e.meal||[]).forEach(m=>{let f=foodByName.get((m.name||'').toLocaleLowerCase('ru'));if(f)total+=contribution(f,m.g||0)})); - return Math.round(total*100); +/* Порции групп за день. Суммируются внутри группы, между группами — никогда. */ +function dayGroupFill(list){ + let fill={}; + list.forEach(e=>(e.meal||[]).forEach(m=>{ + let f=foodByName.get((m.name||'').toLocaleLowerCase('ru'));if(!f)return; + let sh=groupShare(f,m.g||0);if(sh<=0)return; + (f.groups||[]).forEach(q=>fill[q]=(fill[q]||0)+sh); + })); + return fill; } -function loadColor(p){return p<60?'var(--green)':p<100?'var(--amber)':'var(--red)'} -function loadWord(p){return p<1?'нагрузки нет':p<60?'низкая нагрузка':p<100?'нагрузка растёт':'высокая нагрузка'} function dayReport(k,list){ let lines=['Дневник питания — '+dayTitle(k),'Сформирован: '+new Date().toLocaleString('ru-RU'),'Приёмов пищи: '+list.length]; - let p=dayLoadPct(list),s=daySummary(list); - lines.push('Совокупная нагрузка за день: '+p+'% — '+loadWord(p)); + let s=daySummary(list),gf=dayGroupFill(list); + let gl=Object.entries(gf).sort((a,b)=>b[1]-a[1]).map(([q,v])=>q+' '+v.toFixed(1)+' порц.'); + if(gl.length)lines.push('Нагрузка по группам за день: '+gl.join(', ')+' (1.0 ≈ одна порция группы на приём; группы не складываются между собой)'); if(s.groups.length)lines.push('FODMAP-группы за день: '+s.groups.join(', ')); lines.push(''); [...list].sort((a,b)=>mealDateTime(a)-mealDateTime(b)).forEach((e,i)=>{ @@ -702,11 +795,10 @@ function showHistory(){ entries.forEach(e=>{let k=dayKey(e);if(!days.has(k))days.set(k,[]);days.get(k).push(e)}); let today=localDateString(); h.innerHTML=[...days.entries()].map(([k,list])=>{ - let s=daySummary(list),p=dayLoadPct(list); + let s=daySummary(list); let open=(openDays&&k in openDays)?!!openDays[k]:k===today; return `<details class="dayfold" ${open?'open':''} ontoggle="toggleDay('${k}',this)"> - <summary><span class="dayname">${esc(dayTitle(k))}</span><span class="daychips">${s.chips}</span><span class="daymeta">${esc(s.line)}</span> - <span class="dayload"><span class="dayload-track"><span class="dayload-fill" style="width:${Math.min(100,p)}%;background:${loadColor(p)}"></span></span><span class="dayload-num" style="color:${loadColor(p)}">${p}%</span><span class="dayload-word">${esc(loadWord(p))}</span></span></summary> + <summary><span class="dayname">${esc(dayTitle(k))}</span><span class="daychips">${s.chips}</span><span class="daymeta">${esc(s.line)}</span></summary> <div class="dayactions">${s.groups.length?s.groups.map(g=>'<span class="gchip">'+esc(g)+'</span>').join(''):''}<button class="dayexport" onclick="downloadDay('${k}')">⤓ Выгрузить этот день</button></div> <div class="daybody">${list.map(entryHtml).join('')}</div> </details>`;