RDI table, nutrient % in food/recipes, daily deficiency strip

RDI (bmr.js):
- 27 nutrients per TR TS 022/2011 Russian daily intake standards
- Exposed as window.BMR.RDI

Supplement columns (db.js):
- 12 new columns: B5, B7(biotin), D, K, Cr, Mn, Mo, I, P, Si, V, lutein, lycopene, carotene
- getSupplementTotalsForDate expanded to sum all new columns

Food nutrient display (main.js):
- Removed asterisk (*) from daily kcal (guessed tint stays)
- getFoodForDate now JOINs all micronutrient columns from products
- foodNutrientLine() shows "C 12%, Ca 5%, Fe 8%..." under each food item
- calculateRecipeNutrition expanded to 19 micronutrients with cooking loss

Daily nutrients section (main.js):
- "Нутриенты" section on Today screen between Добавки and Сон
- calcDailyNutrients() sums food (on-the-fly from products) + supplements
- Sorted by % RDI ascending — most deficient first
- Color coded: red < 30%, orange 30-80%, green >= 80%

Recipe detail:
- "Микронутриенты (на порцию, % РСП)" chips section
This commit is contained in:
4aiman 2026-05-29 13:38:14 +03:00
commit 885ef27f36
4 changed files with 248 additions and 40 deletions

View file

@ -548,6 +548,7 @@ select {
.food-edit-item { cursor: pointer; }
.food-edit-item:active { background: rgba(255,255,255,0.05); }
.food-edit-item .log-desc { white-space: normal; }
/* Sleep info */
.sleep-info {
@ -1437,6 +1438,32 @@ select {
font-size: 14px;
}
/* Per-food-item nutrient line */
.food-nutrients {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
white-space: normal;
}
/* Daily nutrient totals strip — chips sorted by deficiency */
.nutrient-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 14px;
}
.nut-item {
font-size: 12px;
padding: 3px 8px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
}
.nut-low { color: #f44336; border-color: #f44336; }
.nut-mid { color: #ffb74d; border-color: #ffb74d; }
.nut-ok { color: #81c784; border-color: #81c784; }
/* Recipe components */
/* Guessed nutrients indicator — slight tint on food items with estimated micronutrients */
.log-item.guessed {

View file

@ -27,4 +27,35 @@ function estimateRealBmr(weights, foodTotals) {
return realExpend > 500 ? Math.round(realExpend) : null; // sanity: < 500 kcal/day is wrong data
}
window.BMR = { calculateBmr, getAge, estimateRealBmr };
// Russian daily recommended intake (РСП) per ТР ТС 022/2011
const RDI = {
vit_a_mcg: 800, // ретиноловый эквивалент
vit_c_mg: 60,
vit_e_mg: 10, // токофероловый эквивалент
vit_b1_mg: 1.4,
vit_b2_mg: 1.6,
vit_b3_mg: 18, // ниацин
vit_b5_mg: 6, // пантотеновая
vit_b6_mg: 2,
vit_b7_mcg: 50, // биотин
folate_mcg: 200, // фолиевая (= 0.2 мг = 200 мкг), в БД как folate_mcg
vit_b12_mcg: 1,
vit_d_mcg: 5,
vit_k_mcg: 120,
calcium_mg: 1000,
magnesium_mg: 400,
zinc_mg: 15,
iron_mg: 14,
iodine_mg: 0.15,
selenium_mcg: 70,
molybdenum_mcg: 70,
manganese_mg: 2,
copper_mg: 1,
chromium_mg: 0.05,
phosphorus_mg: 800,
potassium_mg: 3500,
sodium_mg: 2400,
carotene_mcg: 5000, // бета-каротин (нет официальной нормы, ~5мг)
};
window.BMR = { calculateBmr, getAge, estimateRealBmr, RDI };

View file

@ -192,8 +192,12 @@ async function getRecipeIngredients(recipeId) {
return select(
`SELECT ri.*, p.name as product_name, p.kcal_per_100g,
p.protein_per_100g, p.fat_per_100g, p.carbs_per_100g,
p.fiber_per_100g, p.vit_a_mcg, p.vit_c_mg, p.vit_b1_mg,
p.vit_b2_mg,
p.fiber_per_100g,
p.vit_a_mcg, p.vit_c_mg, p.vit_d_mcg, p.vit_e_mg, p.vit_k_mcg,
p.vit_b1_mg, p.vit_b2_mg, p.vit_b3_mg, p.vit_b6_mg,
p.vit_b12_mcg, p.folate_mcg,
p.calcium_mg, p.iron_mg, p.magnesium_mg, p.zinc_mg, p.copper_mg,
p.selenium_mcg, p.potassium_mg, p.sodium_mg,
cm.name as cooking_method_name
FROM recipe_ingredients ri
LEFT JOIN products p ON ri.product_id = p.id AND ri.product_id > 0
@ -263,10 +267,17 @@ async function calculateRecipeNutrition(recipeId) {
return cmCache[cmId];
}
const totals = {
kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0,
vit_a_mcg: 0, vit_c_mg: 0, vit_b1_mg: 0, vit_b2_mg: 0,
};
// All micronutrient keys tracked in products table
const microKeys = [
"vit_a_mcg", "vit_c_mg", "vit_d_mcg", "vit_e_mg", "vit_k_mcg",
"vit_b1_mg", "vit_b2_mg", "vit_b3_mg", "vit_b6_mg",
"vit_b12_mcg", "folate_mcg",
"calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg", "copper_mg",
"selenium_mcg", "potassium_mg", "sodium_mg",
];
const totals = { kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0 };
for (const mk of microKeys) totals[mk] = 0;
for (const ing of ingredients) {
if (!ing.product_id || ing.product_id === 0) continue; // skip custom ingredients without product data
@ -279,11 +290,8 @@ async function calculateRecipeNutrition(recipeId) {
fat: (ing.fat_per_100g ?? 0) * factor,
carbs: (ing.carbs_per_100g ?? 0) * factor,
fiber: (ing.fiber_per_100g ?? 0) * factor,
vit_a_mcg: (ing.vit_a_mcg ?? 0) * factor,
vit_c_mg: (ing.vit_c_mg ?? 0) * factor,
vit_b1_mg: (ing.vit_b1_mg ?? 0) * factor,
vit_b2_mg: (ing.vit_b2_mg ?? 0) * factor,
};
for (const mk of microKeys) raw[mk] = (ing[mk] ?? 0) * factor;
// Apply cooking loss: use ingredient's method, or recipe's default
const cmId = ing.cooking_method_id || recipe.cooking_method_id;
@ -295,6 +303,7 @@ async function calculateRecipeNutrition(recipeId) {
raw.fat *= (1 - (cm.fat_loss_pct ?? 0) / 100);
raw.carbs *= (1 - (cm.carbs_loss_pct ?? 0) / 100);
raw.fiber *= (1 - (cm.fiber_loss_pct ?? 0) / 100);
// Vitamin losses: cooking_methods table only has columns for a, c, b1, b2
raw.vit_a_mcg *= (1 - (cm.vit_a_loss_pct ?? 0) / 100);
raw.vit_c_mg *= (1 - (cm.vit_c_loss_pct ?? 0) / 100);
raw.vit_b1_mg *= (1 - (cm.vit_b1_loss_pct ?? 0) / 100);
@ -307,19 +316,20 @@ async function calculateRecipeNutrition(recipeId) {
}
// Per serving
return {
const result = {
kcal: Math.round(totals.kcal / servings),
protein: Math.round(totals.protein / servings * 10) / 10,
fat: Math.round(totals.fat / servings * 10) / 10,
carbs: Math.round(totals.carbs / servings * 10) / 10,
fiber: Math.round(totals.fiber / servings * 10) / 10,
vit_a_mcg: Math.round(totals.vit_a_mcg / servings * 10) / 10,
vit_c_mg: Math.round(totals.vit_c_mg / servings * 10) / 10,
vit_b1_mg: Math.round(totals.vit_b1_mg / servings * 1000) / 1000,
vit_b2_mg: Math.round(totals.vit_b2_mg / servings * 1000) / 1000,
servings: servings,
totalWeight: ingredients.reduce((s, i) => s + (i.amount_g || 0), 0),
};
// Add all micronutrients per serving (rounded)
for (const mk of microKeys) {
result[mk] = Math.round(totals[mk] / servings * 100) / 100;
}
return result;
}
// Log a recipe as a food entry — stores calculated nutrition per serving
@ -342,7 +352,13 @@ async function addFoodFromRecipe(date, time, recipeId) {
async function getFoodForDate(date) {
return select(
`SELECT fl.*, p.name as product_name, p.brand as product_brand,
COALESCE(p.nutrients_guessed, 0) as nutrients_guessed, r.name as recipe_name,
COALESCE(p.nutrients_guessed, 0) as nutrients_guessed,
p.vit_a_mcg, p.vit_c_mg, p.vit_d_mcg, p.vit_e_mg, p.vit_k_mcg,
p.vit_b1_mg, p.vit_b2_mg, p.vit_b3_mg, p.vit_b6_mg,
p.vit_b12_mcg, p.folate_mcg,
p.calcium_mg, p.iron_mg, p.magnesium_mg, p.zinc_mg, p.copper_mg,
p.selenium_mcg, p.potassium_mg, p.sodium_mg,
r.name as recipe_name,
su.name as unit_name, su.name_short as unit_short
FROM food_log fl
LEFT JOIN products p ON fl.product_id = p.id
@ -506,7 +522,9 @@ async function getSupplementTotalsForDate(date) {
SUM(COALESCE(sl.dose_g,1) * s.vit_b1_mg) as vit_b1_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b2_mg) as vit_b2_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b3_mg) as vit_b3_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b5_mg) as vit_b5_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b6_mg) as vit_b6_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b7_mcg) as vit_b7_mcg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b12_mcg) as vit_b12_mcg,
SUM(COALESCE(sl.dose_g,1) * s.folate_mcg) as folate_mcg,
SUM(COALESCE(sl.dose_g,1) * s.calcium_mg) as calcium_mg,
@ -516,7 +534,13 @@ async function getSupplementTotalsForDate(date) {
SUM(COALESCE(sl.dose_g,1) * s.copper_mg) as copper_mg,
SUM(COALESCE(sl.dose_g,1) * s.selenium_mcg) as selenium_mcg,
SUM(COALESCE(sl.dose_g,1) * s.potassium_mg) as potassium_mg,
SUM(COALESCE(sl.dose_g,1) * s.sodium_mg) as sodium_mg
SUM(COALESCE(sl.dose_g,1) * s.sodium_mg) as sodium_mg,
SUM(COALESCE(sl.dose_g,1) * s.chromium_mg) as chromium_mg,
SUM(COALESCE(sl.dose_g,1) * s.manganese_mg) as manganese_mg,
SUM(COALESCE(sl.dose_g,1) * s.molybdenum_mcg) as molybdenum_mcg,
SUM(COALESCE(sl.dose_g,1) * s.iodine_mg) as iodine_mg,
SUM(COALESCE(sl.dose_g,1) * s.phosphorus_mg) as phosphorus_mg,
SUM(COALESCE(sl.dose_g,1) * s.carotene_mcg) as carotene_mcg
FROM supplement_log sl
JOIN supplements s ON sl.supplement_id = s.id
WHERE sl.date = ?1`,
@ -1081,6 +1105,22 @@ async function runMigrations() {
created_at TEXT DEFAULT (datetime('now'))
)`);
// --- Supplement columns migration: add missing micronutrient columns ---
const suppCols = (await select("PRAGMA table_info(supplements)")).map(r => r.name);
const addSuppCol = async (col, type) => { if (!suppCols.includes(col)) await execute(`ALTER TABLE supplements ADD COLUMN ${col} ${type}`); };
await addSuppCol("vit_b5_mg", "REAL");
await addSuppCol("vit_b7_mcg", "REAL"); // biotin
await addSuppCol("chromium_mg", "REAL");
await addSuppCol("manganese_mg", "REAL");
await addSuppCol("molybdenum_mcg", "REAL");
await addSuppCol("iodine_mg", "REAL");
await addSuppCol("phosphorus_mg", "REAL");
await addSuppCol("silicon_mg", "REAL"); // кремний — no RDI
await addSuppCol("vanadium_mg", "REAL"); // ванадий — no RDI
await addSuppCol("lutein_mg", "REAL"); // лютеин — no RDI
await addSuppCol("lycopene_mg", "REAL"); // ликопин — no RDI
await addSuppCol("carotene_mcg", "REAL"); // бета-каротин
console.log("[db.js] recipe migrations done, checking seq repair...");
// Repair old tracks: assign seq to points where it's NULL, ordered by timestamp.

View file

@ -229,8 +229,6 @@ async function renderToday(container) {
// Show expenditure even without BMR if there are activities
const expend = bmr != null ? bmr + actKcal : actKcal > 0 ? actKcal : null;
const balance = expend != null ? intake - expend : null;
const hasGuessed = food.some(f => f.nutrients_guessed);
container.innerHTML = `
<div class="day-header">
<button class="day-nav" id="prev-day">&#8592;</button>
@ -240,7 +238,7 @@ async function renderToday(container) {
<div class="summary-row">
<div class="summary-card">
<span class="summary-val">${intake}${hasGuessed ? "*" : ""}</span>
<span class="summary-val">${intake}</span>
<span class="summary-lbl">ккал приход</span>
</div>
<div class="summary-card">
@ -282,7 +280,8 @@ async function renderToday(container) {
<span class="log-desc">${esc(f.product_name ?? f.note ?? "—")}
${f.unit_amount && f.unit_short
? `<small>${f.unit_amount} ${f.unit_short}</small>`
: f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</span>
: f.amount_g ? `<small>${f.amount_g}г</small>` : ""}
${foodNutrientLine(f)}</span>
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="food" data-id="${f.id}">&#x2715;</button>
</li>`
@ -337,6 +336,11 @@ async function renderToday(container) {
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Нутриенты</h3></div>
<div class="nutrient-strip" id="daily-nutrients"></div>
</section>
<section class="log-section">
<div class="section-header"><h3>Сон</h3>
<button class="add-btn" id="add-sleep-btn">&#x270E;</button></div>
@ -420,6 +424,14 @@ async function renderToday(container) {
const strip = container.querySelector(".micro-strip");
if (strip) strip.onwheel = e => { e.preventDefault(); strip.scrollLeft += e.deltaY; };
// Fill daily nutrient totals section (sorted by deficiency, lowest first)
const dailyNutEl = container.querySelector("#daily-nutrients");
if (dailyNutEl) {
const nutrients = calcDailyNutrients(food, suppTotals);
dailyNutEl.innerHTML = renderNutrientChips(nutrients)
|| `<div class="log-empty" style="padding:6px 14px">нет данных о нутриентах</div>`;
}
container.querySelector("#add-weight-btn").onclick = () => showWeightModal();
container.querySelector("#add-food-btn").onclick = () => showFoodModal();
container.querySelector("#add-recipe-food-btn").onclick = () => showFoodFromRecipeModal();
@ -1387,6 +1399,24 @@ async function showRecipeDetailModal(recipeId) {
<div class="macro"><span class="macro-val">${nutr.carbs}</span><span class="macro-lbl">У</span></div>
<div class="macro"><span class="macro-val">${nutr.fiber}</span><span class="macro-lbl">Кл</span></div>
</div>
</div>
<div class="sd-section" style="margin-top:8px">
<div class="sd-title">Микронутриенты (на порцию, % РСП)</div>
<div class="nutrient-strip">${(() => {
const RDI = BMR.RDI;
const chips = [];
for (const [key, rdi] of Object.entries(RDI)) {
const val = nutr[key];
if (!val) continue;
const pct = Math.round(val / rdi * 100);
if (pct < 1) continue;
const label = NUTRIENT_LABELS[key] || key;
const cls = pct < 30 ? "nut-low" : pct < 80 ? "nut-mid" : "nut-ok";
chips.push({ pct, html: '<div class="nut-item ' + cls + '">' + label + " " + pct + "%</div>" });
}
chips.sort((a, b) => a.pct - b.pct);
return chips.map(c => c.html).join("");
})()}</div>
</div>` : ""}
<button id="rd-log" class="btn-primary full-width" style="margin-top:16px">Записать в дневник</button>
@ -3832,36 +3862,116 @@ async function showBikeModal(bikeId) {
// Helpers
// ---------------------------------------------------------------------------
// РСП — рекомендуемая суточная потребность (взрослый мужчина)
const RDA = {
vit_a_mcg: 900, vit_c_mg: 90, vit_d_mcg: 15, vit_e_mg: 15, vit_k_mcg: 120,
vit_b1_mg: 1.2, vit_b2_mg: 1.3, vit_b3_mg: 16, vit_b6_mg: 1.7,
vit_b12_mcg: 2.4, folate_mcg: 400,
calcium_mg: 1000, iron_mg: 8, magnesium_mg: 400, zinc_mg: 11,
copper_mg: 0.9, selenium_mcg: 55, potassium_mg: 3400, sodium_mg: 1500,
};
const RDA_LABELS = {
vit_a_mcg: "A", vit_c_mg: "C", vit_d_mcg: "D", vit_e_mg: "E", vit_k_mcg: "K",
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3", vit_b6_mg: "B6",
vit_b12_mcg: "B12", folate_mcg: "Фол",
calcium_mg: "Ca", iron_mg: "Fe", magnesium_mg: "Mg", zinc_mg: "Zn",
copper_mg: "Cu", selenium_mcg: "Se", potassium_mg: "K+", sodium_mg: "Na",
// Nutrient labels for display — maps DB column names to short UI labels
const NUTRIENT_LABELS = {
vit_a_mcg: "A", vit_c_mg: "C", vit_e_mg: "E",
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3",
vit_b5_mg: "B5", vit_b6_mg: "B6", vit_b7_mcg: "B7",
folate_mcg: "B9", vit_b12_mcg: "B12",
vit_d_mcg: "D", vit_k_mcg: "K",
calcium_mg: "Ca", magnesium_mg: "Mg", zinc_mg: "Zn",
iron_mg: "Fe", iodine_mg: "I", selenium_mcg: "Se",
molybdenum_mcg: "Mo", manganese_mg: "Mn", copper_mg: "Cu",
chromium_mg: "Cr", phosphorus_mg: "P", potassium_mg: "K+",
sodium_mg: "Na", carotene_mcg: "b-car",
};
// Build supplement micronutrient strip (existing horizontal chips on Today screen)
function buildMicroStrip(totals) {
const RDI = BMR.RDI;
const items = [];
for (const [key, rda] of Object.entries(RDA)) {
for (const [key, rdi] of Object.entries(RDI)) {
const val = totals[key];
if (val == null || val === 0) continue;
const pct = Math.round((val / rda) * 100);
const pct = Math.round((val / rdi) * 100);
const cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low";
items.push(`<span class="micro-chip ${cls}">${RDA_LABELS[key]} ${pct}%</span>`);
items.push(`<span class="micro-chip ${cls}">${NUTRIENT_LABELS[key] || key} ${pct}%</span>`);
}
if (items.length === 0) return "";
return `<div class="micro-strip">${items.join("")}</div>`;
}
// Per-food-item nutrient line: shows non-zero nutrients as %RDI chips
function foodNutrientLine(f) {
if (!f.product_id) return ""; // manual entry or recipe — no per-product data
const RDI = BMR.RDI;
const factor = (f.amount_g || 100) / 100;
const items = [];
const check = (val, rdi, label) => {
if (!val || !rdi) return;
const pct = Math.round(val * factor / rdi * 100);
if (pct >= 1) items.push(`${label} ${pct}%`);
};
check(f.vit_c_mg, RDI.vit_c_mg, "C");
check(f.vit_a_mcg, RDI.vit_a_mcg, "A");
check(f.vit_b1_mg, RDI.vit_b1_mg, "B1");
check(f.vit_b2_mg, RDI.vit_b2_mg, "B2");
check(f.vit_b3_mg, RDI.vit_b3_mg, "B3");
check(f.vit_b6_mg, RDI.vit_b6_mg, "B6");
check(f.vit_b12_mcg, RDI.vit_b12_mcg, "B12");
check(f.folate_mcg, RDI.folate_mcg, "B9");
check(f.vit_d_mcg, RDI.vit_d_mcg, "D");
check(f.vit_e_mg, RDI.vit_e_mg, "E");
check(f.vit_k_mcg, RDI.vit_k_mcg, "K");
check(f.calcium_mg, RDI.calcium_mg, "Ca");
check(f.iron_mg, RDI.iron_mg, "Fe");
check(f.magnesium_mg, RDI.magnesium_mg, "Mg");
check(f.potassium_mg, RDI.potassium_mg, "K+");
check(f.zinc_mg, RDI.zinc_mg, "Zn");
check(f.copper_mg, RDI.copper_mg, "Cu");
check(f.selenium_mcg, RDI.selenium_mcg, "Se");
check(f.sodium_mg, RDI.sodium_mg, "Na");
if (items.length === 0) return "";
return `<div class="food-nutrients">${items.join(", ")}</div>`;
}
// Calculate daily nutrient totals from food (product micronutrients) + supplements
// Returns array sorted by %RDI ascending (most deficient first)
function calcDailyNutrients(food, suppTotals) {
const RDI = BMR.RDI;
const totals = {};
// From food: product micronutrients * (amount_g / 100)
for (const f of food) {
if (!f.product_id) continue;
const factor = (f.amount_g || 100) / 100;
for (const key of Object.keys(RDI)) {
if (f[key]) {
totals[key] = (totals[key] || 0) + f[key] * factor;
}
}
}
// From supplements
if (suppTotals) {
for (const key of Object.keys(RDI)) {
if (suppTotals[key]) {
totals[key] = (totals[key] || 0) + suppTotals[key];
}
}
}
// Build array sorted by %RDI ascending
const result = [];
for (const [key, rdi] of Object.entries(RDI)) {
const amount = totals[key] || 0;
const pct = Math.round(amount / rdi * 100);
const label = NUTRIENT_LABELS[key] || key;
result.push({ key, label, amount, rdi, pct });
}
result.sort((a, b) => a.pct - b.pct);
return result;
}
// Render nutrient chips from an array of {label, pct} objects
function renderNutrientChips(nutrients) {
if (!nutrients || nutrients.length === 0) return "";
return nutrients.map(n => {
const cls = n.pct < 30 ? "nut-low" : n.pct < 80 ? "nut-mid" : "nut-ok";
return `<div class="nut-item ${cls}">${n.label} ${n.pct}%</div>`;
}).join("");
}
// ---------------------------------------------------------------------------
// Reusable product search dropdown component
// parentEl: element to insert into (must have position:relative or will be set)