diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 8c683bd..c511f76 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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 { diff --git a/frontend/js/bmr.js b/frontend/js/bmr.js index c7c8a78..902d840 100644 --- a/frontend/js/bmr.js +++ b/frontend/js/bmr.js @@ -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 }; diff --git a/frontend/js/db.js b/frontend/js/db.js index a78e309..fd12329 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -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. diff --git a/frontend/js/main.js b/frontend/js/main.js index b06067d..f248f58 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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 = `
@@ -240,7 +238,7 @@ async function renderToday(container) {
- ${intake}${hasGuessed ? "*" : ""} + ${intake} ккал приход
@@ -282,7 +280,8 @@ async function renderToday(container) { ${esc(f.product_name ?? f.note ?? "—")} ${f.unit_amount && f.unit_short ? `${f.unit_amount} ${f.unit_short}` - : f.amount_g ? `${f.amount_g}г` : ""} + : f.amount_g ? `${f.amount_g}г` : ""} + ${foodNutrientLine(f)} ${f.kcal != null ? Math.round(f.kcal) + " кк" : ""} ` @@ -337,6 +336,11 @@ async function renderToday(container) { +
+

Нутриенты

+
+
+

Сон

@@ -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) + || `
нет данных о нутриентах
`; + } + 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) {
${nutr.carbs}У
${nutr.fiber}Кл
+
+
+
Микронутриенты (на порцию, % РСП)
+
${(() => { + 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: '
' + label + " " + pct + "%
" }); + } + chips.sort((a, b) => a.pct - b.pct); + return chips.map(c => c.html).join(""); + })()}
` : ""} @@ -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(`${RDA_LABELS[key]} ${pct}%`); + items.push(`${NUTRIENT_LABELS[key] || key} ${pct}%`); } if (items.length === 0) return ""; return `
${items.join("")}
`; } +// 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 `
${items.join(", ")}
`; +} + +// 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 `
${n.label} ${n.pct}%
`; + }).join(""); +} + // --------------------------------------------------------------------------- // Reusable product search dropdown component // parentEl: element to insert into (must have position:relative or will be set)