Add day/week FODMAP category chart
AI Just committed 2026-08-18 18:23:08 +0000

Analyzing diff...

index.html+56 −4
index 240081a..a8ce1e3 100644
--- a/index.html
+++ b/index.html
@@ -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}
+: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}.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}
 .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}
@@ -41,6 +41,9 @@
 <div class="small">Сначала сохраните состав еды. Реакцию можно добавить позже — через 30 минут, несколько часов или на следующий день.</div>
 <div class="export" data-feature="journal-export-v1"><b>Экспорт дневника</b><div class="small" style="margin:4px 0 9px">Скопируйте читаемый отчёт за выбранный период или сохраните полную резервную копию для будущего импорта.</div>
 <div class="export-controls"><label><span class="small">Период отчёта</span><select id="exportPeriod" style="width:100%"><option value="today">Сегодня</option><option value="week">Последние 7 дней</option><option value="all">Весь дневник</option></select></label><button onclick="copyJournalReport()">Скопировать отчёт</button><button class="secondary" onclick="downloadJournalBackup()">Скачать резервную копию</button></div><div id="exportStatus" class="small export-status" role="status" aria-live="polite"></div></div>
+<div class="daychart" data-feature="day-chart-v1"><b>Итоги дня</b><div class="small" style="margin:4px 0 9px">Какие FODMAP-группы и в каком количестве были за период, и из каких продуктов они пришли.</div>
+<div class="chart-controls"><label><span class="small">Период</span><select id="chartPeriod" onchange="renderDayChart()" style="width:100%"><option value="today">Сегодня</option><option value="week">Последние 7 дней</option></select></label><label><span class="small">Показывать</span><select id="chartMetric" onchange="renderDayChart()" style="width:100%"><option value="grams">Граммы</option><option value="items">Количество продуктов</option></select></label></div>
+<div id="dayChart" class="chart-body"></div><div id="chartDetail" class="chart-detail small"></div></div>
 <div id="history" class="history"></div>
 <div id="analytics" class="analytics"></div>
 </div>
@@ -280,7 +283,7 @@ function saveMeal(){
  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('Состав и время приёма пищи сохранены. Реакцию можно добавить позже.')
+ entries.sort((a,b)=>mealDateTime(b)-mealDateTime(a));setEntries(entries.slice(0,200));showHistory();render();renderDayChart();alert('Состав и время приёма пищи сохранены. Реакцию можно добавить позже.')
 }
 function newMeal(){meal=[];editingMealId=null;document.getElementById('mealDate').value=localDateString();document.getElementById('mealTime').value=localTimeString();render()}
 function editMeal(id){
@@ -306,7 +309,7 @@ function showHistory(){
  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)
+ renderAnalytics(entries);if(document.getElementById('dayChart'))renderDayChart()
 }
 function renderAnalytics(entries){
  let names=[...new Set(entries.flatMap(e=>(e.meal||[]).map(m=>m.name)))],profiles=names.map(n=>({n,p:personalProfile(n)}));
@@ -326,6 +329,55 @@ function saveCustomFood(){
  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();
+
+const CHART_GROUPS=['фруктоза','лактоза','фруктаны','GOS','сорбитол','маннитол','полиолы'];
+const CHART_COLORS={'фруктоза':'#d8a23d','лактоза':'#7569a8','фруктаны':'#cf615a','GOS':'#5c9b6a','сорбитол':'#3f8fa8','маннитол':'#b0743c','полиолы':'#9a7fbc','без FODMAP-групп':'#b6b1a7'};
+function chartEntries(period){
+ let entries=getEntries(),now=new Date(),from;
+ if(period==='week'){from=new Date(now.getFullYear(),now.getMonth(),now.getDate()-6)}
+ else{from=new Date(now.getFullYear(),now.getMonth(),now.getDate())}
+ return entries.filter(e=>mealDateTime(e)>=from);
+}
+function chartData(period,metric){
+ let groups={},levels={green:0,amber:0,red:0,unknown:0},total=0,items=0;
+ chartEntries(period).forEach(e=>(e.meal||[]).forEach(m=>{
+  let val=metric==='items'?1:(+m.g||0);
+  items++;total+=val;
+  let lv=m.level==='g'?'green':m.level==='a'?'amber':m.level==='r'?'red':'unknown';levels[lv]++;
+  let gs=(m.groups&&m.groups.length)?m.groups:['без FODMAP-групп'];
+  gs.forEach(q=>{let k=CHART_GROUPS.includes(q)?q:(q==='без FODMAP-групп'?q:q);
+   groups[k]=groups[k]||{value:0,foods:{}};groups[k].value+=val;groups[k].foods[m.name]=(groups[k].foods[m.name]||0)+(+m.g||0)});
+ }));
+ return {groups,levels,total,items};
+}
+function arcPath(cx,cy,r,ri,a0,a1){
+ let p=(a,rad)=>[cx+rad*Math.cos(a),cy+rad*Math.sin(a)],big=(a1-a0)>Math.PI?1:0,
+ [x0,y0]=p(a0,r),[x1,y1]=p(a1,r),[x2,y2]=p(a1,ri),[x3,y3]=p(a0,ri);
+ return `M${x0} ${y0}A${r} ${r} 0 ${big} 1 ${x1} ${y1}L${x2} ${y2}A${ri} ${ri} 0 ${big} 0 ${x3} ${y3}Z`;
+}
+function renderDayChart(){
+ let box=document.getElementById('dayChart');if(!box)return;
+ let period=document.getElementById('chartPeriod').value,metric=document.getElementById('chartMetric').value,
+ d=chartData(period,metric),unit=metric==='items'?'':' г',
+ keys=Object.keys(d.groups).sort((a,b)=>d.groups[b].value-d.groups[a].value);
+ document.getElementById('chartDetail').innerHTML='';
+ if(!d.items){box.innerHTML='<div class="small">За выбранный период сохранённых приёмов пищи нет.</div>';return}
+ let sum=keys.reduce((s,k)=>s+d.groups[k].value,0)||1,a=-Math.PI/2,slices='';
+ keys.forEach(k=>{let frac=d.groups[k].value/sum,a1=a+frac*Math.PI*2;
+  slices+=`<path class="chart-slice" d="${arcPath(90,90,84,50,a,Math.min(a1,a+Math.PI*2-0.0001))}" fill="${CHART_COLORS[k]||'#9a958c'}" onclick="showChartDetail('${encodeURIComponent(k)}')"><title>${esc(k)}: ${Math.round(d.groups[k].value)}${unit}</title></path>`;a=a1});
+ let donut=`<svg viewBox="0 0 180 180" width="180" height="180" role="img" aria-label="FODMAP-группы за период">${slices}<text x="90" y="86" text-anchor="middle" font-size="13" fill="#77736a">приёмов</text><text x="90" y="108" text-anchor="middle" font-size="22" font-weight="700" fill="#282723">${chartEntries(period).length}</text></svg>`;
+ let legend='<div class="chart-legend">'+keys.map(k=>`<div onclick="showChartDetail('${encodeURIComponent(k)}')"><i style="background:${CHART_COLORS[k]||'#9a958c'}"></i><span>${esc(k)}</span><b style="margin-left:auto">${Math.round(d.groups[k].value)}${unit}</b></div>`).join('')+'</div>';
+ let lv=[['Зелёные','green','var(--green)'],['Жёлтые','amber','var(--amber)'],['Красные','red','var(--red)'],['Без данных','unknown','#9a958c']],mx=Math.max(...lv.map(x=>d.levels[x[1]]))||1;
+ let bars='<div class="chart-bars"><b style="font-size:13px">Порции по светофору</b>'+lv.map(([label,key,color])=>`<div>${label} — <b>${d.levels[key]}</b><div class="bar-track"><div class="bar-fill" style="width:${Math.round(d.levels[key]/mx*100)}%;background:${color}"></div></div></div>`).join('')+'</div>';
+ box.innerHTML=donut+legend+bars;
+}
+function showChartDetail(k){
+ let key=decodeURIComponent(k),period=document.getElementById('chartPeriod').value,metric=document.getElementById('chartMetric').value,
+ g=chartData(period,metric).groups[key];if(!g)return;
+ let foods=Object.entries(g.foods).sort((a,b)=>b[1]-a[1]).map(([n,v])=>`${esc(n)} — ${Math.round(v)} г`).join('; ');
+ document.getElementById('chartDetail').innerHTML=`<b>${esc(key)}</b>: ${foods||'нет данных'}`;
+}
+
+showHistory();render();renderDayChart();
 
 </script></body></html>