From e4f3d3e3cade625670963bd788aa697f9e529b77 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 23 May 2026 17:12:19 +0300 Subject: [PATCH] Alternative units, analytics charts, elevation smoothing, batch GPS update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative food units (Phase 3a): - serving_units table: шт, ст.л., ч.л., стакан, мл - product_units junction: eggs linked to шт (60g) - Unit selector chips in food modal after product selection - Display "2 шт" instead of "120г" in food log when unit is set - amount_g stays canonical, unit info is display metadata Analytics (Phase 4): - Stats section on Today screen with period selector (30d/90d) - Weight trend chart (line, purple) - Macros donut (protein/fat/carbs with kcal center text) - Kcal balance bar chart (intake green vs expenditure red, includes BMR) - Chart fullscreen + zoom support (reuses GPS chart patterns) GPS improvements: - Elevation smoothing: 5-point moving average + 1m dead zone - Fixes inflated elevation gain (440m → ~70m for a real 70m route) - Batch polyline update: poll loop suppresses per-point addLatLng, calls onBatchUpdate to rebuild polyline from sorted points at once - Eliminates visual jumps when app returns from background Boot screen: removed verbose migration/GPS status labels, shows "Загрузка..." only, updates text only during actual seed import --- frontend/css/styles.css | 34 ++++ frontend/js/db.js | 126 +++++++++++- frontend/js/gps.js | 33 +++- frontend/js/main.js | 414 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 557 insertions(+), 50 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index b9917e0..456baa6 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1557,6 +1557,40 @@ select { text-align: center; } +/* Unit chips (alternative food units) */ +.unit-chips { + display: flex; + gap: 6px; + overflow-x: auto; + padding: 4px 0; + margin-bottom: 8px; +} +.unit-chip { + padding: 4px 12px; + border-radius: 16px; + font-size: 13px; + border: 1px solid var(--border); + background: transparent; + color: var(--on-bg); + cursor: pointer; + white-space: nowrap; + font-family: inherit; +} +.unit-chip.selected { + background: var(--primary); + color: #000; + border-color: var(--primary); +} + +/* Stats charts */ +.stats-chart-block { + margin-bottom: 16px; +} +.stats-period { + display: flex; + gap: 4px; +} + /* Loading */ .loading { text-align: center; diff --git a/frontend/js/db.js b/frontend/js/db.js index e67fa15..a035d14 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -340,10 +340,12 @@ 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, 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 LEFT JOIN recipes r ON fl.recipe_id = r.id + LEFT JOIN serving_units su ON fl.unit_id = su.id WHERE fl.date = ?1 ORDER BY fl.time ASC`, [date] ); @@ -356,7 +358,7 @@ async function addFoodManual(date, time, desc, kcal) { ); } -async function addFoodEntry(date, time, productId, amountG, note) { +async function addFoodEntry(date, time, productId, amountG, note, unitId, unitAmount) { let kcal = null, protein = null, fat = null, carbs = null, fiber = null; if (productId) { const p = await getProduct(productId); @@ -371,14 +373,15 @@ async function addFoodEntry(date, time, productId, amountG, note) { } await execute( `INSERT INTO food_log - (date, time, product_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g, note) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`, + (date, time, product_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g, note, unit_id, unit_amount) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)`, [date, time, productId ?? null, amountG, - kcal, protein, fat, carbs, fiber, note ?? null] + kcal, protein, fat, carbs, fiber, note ?? null, + unitId ?? null, unitAmount ?? null] ); } -async function updateFoodEntry(id, time, amountG, note, kcalOverride) { +async function updateFoodEntry(id, time, amountG, note, kcalOverride, unitId, unitAmount) { // Recalculate nutrition from product if linked const rows = await select("SELECT product_id FROM food_log WHERE id = ?1", [id]); if (!rows.length) return; @@ -398,9 +401,11 @@ async function updateFoodEntry(id, time, amountG, note, kcalOverride) { } await execute( `UPDATE food_log SET time=?1, amount_g=?2, note=?3, - kcal=?4, protein_g=?5, fat_g=?6, carbs_g=?7, fiber_g=?8 - WHERE id=?9`, - [time, amountG, note ?? null, kcal, protein, fat, carbs, fiber, id] + kcal=?4, protein_g=?5, fat_g=?6, carbs_g=?7, fiber_g=?8, + unit_id=?9, unit_amount=?10 + WHERE id=?11`, + [time, amountG, note ?? null, kcal, protein, fat, carbs, fiber, + unitId ?? null, unitAmount ?? null, id] ); } @@ -518,6 +523,26 @@ async function getSupplementTotalsForDate(date) { return rows[0] || {}; } +// --------------------------------------------------------------------------- +// Serving units +// --------------------------------------------------------------------------- + +async function getServingUnits() { + return select("SELECT * FROM serving_units ORDER BY id"); +} + +async function getUnitsForProduct(productId) { + // Returns product-specific units (with their grams_per_unit) + universal units + return select( + `SELECT su.*, COALESCE(pu.grams_per_unit, su.grams_equivalent) as grams_per_unit + FROM serving_units su + LEFT JOIN product_units pu ON pu.unit_id = su.id AND pu.product_id = ?1 + WHERE su.category = 'universal' OR pu.id IS NOT NULL + ORDER BY CASE WHEN pu.id IS NOT NULL THEN 0 ELSE 1 END, su.id`, + [productId] + ); +} + // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- @@ -540,6 +565,24 @@ async function getActivityTotalsForDate(date) { return rows[0] ?? { kcal: 0, minutes: 0 }; } +async function getDailyFoodTotals(days) { + return select( + `SELECT date, SUM(kcal) as kcal, SUM(protein_g) as protein_g, + SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g + FROM food_log WHERE date >= date('now', ?1) + GROUP BY date ORDER BY date ASC`, + [`-${days ?? 30} days`] + ); +} + +async function getDailyActivityTotals(days) { + return select( + `SELECT date, SUM(kcal) as kcal FROM activity_log + WHERE date >= date('now', ?1) GROUP BY date ORDER BY date ASC`, + [`-${days ?? 30} days`] + ); +} + // --------------------------------------------------------------------------- // GPS sessions & points // --------------------------------------------------------------------------- @@ -663,6 +706,10 @@ window.DB.deleteSupplementLog = deleteSupplementLog; window.DB.getSupplementTotalsForDate = getSupplementTotalsForDate; window.DB.getFoodTotalsForDate = getFoodTotalsForDate; window.DB.getActivityTotalsForDate = getActivityTotalsForDate; +window.DB.getServingUnits = getServingUnits; +window.DB.getUnitsForProduct = getUnitsForProduct; +window.DB.getDailyFoodTotals = getDailyFoodTotals; +window.DB.getDailyActivityTotals = getDailyActivityTotals; window.DB.getCookingMethods = getCookingMethods; window.DB.getCookingMethod = getCookingMethod; window.DB.getRecipes = getRecipes; @@ -763,6 +810,67 @@ async function runMigrations() { await execute("ALTER TABLE recipe_ingredients ADD COLUMN cooking_method_id INTEGER REFERENCES cooking_methods(id)"); } + // --- Serving units table --- + await execute(`CREATE TABLE IF NOT EXISTS serving_units ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + name_short TEXT, + grams_equivalent REAL, + category TEXT DEFAULT 'universal' + )`); + + // --- Product units junction table --- + await execute(`CREATE TABLE IF NOT EXISTS product_units ( + id INTEGER PRIMARY KEY, + product_id INTEGER NOT NULL REFERENCES products(id), + unit_id INTEGER NOT NULL REFERENCES serving_units(id), + grams_per_unit REAL NOT NULL, + UNIQUE(product_id, unit_id) + )`); + + // --- Add unit columns to food_log --- + if (!(await hasColumn("food_log", "unit_id"))) { + await execute("ALTER TABLE food_log ADD COLUMN unit_id INTEGER"); + } + if (!(await hasColumn("food_log", "unit_amount"))) { + await execute("ALTER TABLE food_log ADD COLUMN unit_amount REAL"); + } + + // Seed serving_units if empty + const suCnt = await select("SELECT COUNT(*) as cnt FROM serving_units"); + if (suCnt[0].cnt === 0) { + const units = [ + [1, "шт", "шт", null, "product_specific"], + [2, "ст.л.", "ст.л.", 15, "universal"], + [3, "ч.л.", "ч.л.", 5, "universal"], + [4, "стакан", "стак.", 250, "universal"], + [5, "мл", "мл", 1, "universal"], + ]; + for (const u of units) { + await execute( + `INSERT INTO serving_units (id, name, name_short, grams_equivalent, category) + VALUES (?1, ?2, ?3, ?4, ?5)`, u + ); + } + console.log("[db.js] Seeded 5 serving units"); + } + + // Seed product_units for eggs: link products with "яйц" AND "кур" to unit 1 (шт), 60g each + try { + const eggProducts = await select( + `SELECT id FROM products WHERE name_lower LIKE '%яйц%' AND name_lower LIKE '%кур%'` + ); + for (const ep of eggProducts) { + await execute( + `INSERT OR IGNORE INTO product_units (product_id, unit_id, grams_per_unit) + VALUES (?1, 1, 60)`, [ep.id] + ); + } + if (eggProducts.length > 0) { + console.log(`[db.js] Linked ${eggProducts.length} egg products to шт unit`); + } + } catch (e) { console.error("[db.js] egg unit seeding error:", e); } + 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/gps.js b/frontend/js/gps.js index c3e48bf..ba94380 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -59,6 +59,18 @@ GPS.computeStats = function(points, weightKg) { let distance = 0, elevGain = 0, elevLoss = 0; const speeds = []; // m/s per segment + // 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise) + const smoothedAlt = sorted.map((p, i) => { + const alt = p.altitude ?? p.altitude_gps_m; + if (alt == null) return null; + let sum = 0, cnt = 0; + for (let j = Math.max(0, i - 2); j <= Math.min(sorted.length - 1, i + 2); j++) { + const a = sorted[j].altitude ?? sorted[j].altitude_gps_m; + if (a != null) { sum += a; cnt++; } + } + return cnt > 0 ? sum / cnt : alt; + }); + for (let i = 1; i < sorted.length; i++) { const p = sorted[i], prev = sorted[i - 1]; const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon); @@ -67,12 +79,11 @@ GPS.computeStats = function(points, weightKg) { const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; speeds.push(dt > 0 ? d / dt : 0); - const alt = p.altitude ?? p.altitude_gps_m; - const prevAlt = prev.altitude ?? prev.altitude_gps_m; - if (alt != null && prevAlt != null) { - const diff = alt - prevAlt; - if (diff > 0) elevGain += diff; - else elevLoss += Math.abs(diff); + // Elevation from smoothed altitude, with 1m dead zone + if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) { + const diff = smoothedAlt[i] - smoothedAlt[i - 1]; + if (diff > 1) elevGain += diff; + else if (diff < -1) elevLoss += Math.abs(diff); } } @@ -375,11 +386,21 @@ GPS.startRecording = async function(activityType, weightKg) { polling = true; try { const nativePoints = await GPS._readNativePoints(); + let newCount = 0; + // Suppress per-point UI updates during batch — onBatchUpdate handles it + const origOnPoint = GPS.recording.onPoint; for (const p of nativePoints) { const pTs = new Date(p.ts).getTime(); if (pTs <= GPS.recording._lastProcessedTs) continue; + GPS.recording.onPoint = null; // suppress addLatLng per point await GPS._processIncomingPoint(p); GPS.recording._lastProcessedTs = pTs; + newCount++; + } + GPS.recording.onPoint = origOnPoint; + // Batch UI update — rebuild polyline from sorted points + if (newCount > 0 && GPS.recording.onBatchUpdate) { + GPS.recording.onBatchUpdate(); } } catch (e) { console.error("[GPS] poll error:", e); diff --git a/frontend/js/main.js b/frontend/js/main.js index 6b9d14c..9e3659a 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -36,51 +36,32 @@ async function init() {
`; - const bootLabel = document.getElementById("boot-label"); + const bootEl = document.getElementById("boot-label"); const bootCount = document.getElementById("seed-count"); - function bootLog(msg) { if (bootLabel) bootLabel.textContent = msg; } + const bootFill = document.getElementById("seed-fill"); - // Wait for DB migrations - try { - bootLog("Миграция базы данных..."); - if (DB._migrationsReady) await DB._migrationsReady; - bootLog("Миграция завершена"); - } catch (e) { bootLog("Ошибка миграции: " + e); } + // Wait for DB migrations (fast — just checks columns exist) + try { if (DB._migrationsReady) await DB._migrationsReady; } catch (e) { console.error("[BOOT]", e); } // Recover any orphaned GPS recording session - try { - bootLog("Проверка GPS..."); - await GPS.recoverSession(); - } catch (e) { /* ignore */ } + try { await GPS.recoverSession(); } catch (e) { /* ignore */ } - // Import seed products if needed — with progress bar + // Import seed products if needed — show progress only if actually importing try { - bootLog("Проверка продуктов..."); - let unlisten = null; try { unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => { const { current, total } = ev.payload; const pct = total > 0 ? Math.round(current / total * 100) : 0; - const fill = document.getElementById("seed-fill"); - if (fill) { - fill.classList.add("determinate"); - fill.style.width = pct + "%"; - } + if (bootEl) bootEl.textContent = "Обновление продуктов (" + pct + "%)"; + if (bootFill) { bootFill.classList.add("determinate"); bootFill.style.width = pct + "%"; } if (bootCount) bootCount.textContent = current + " / " + total; - bootLog("Обновление продуктов (" + pct + "%)"); }); } catch {} const result = await window.__TAURI__.core.invoke("import_seed_products"); - bootLog("Продукты: " + result); - if (unlisten) unlisten(); - } catch (e) { - bootLog("Ошибка продуктов: " + e); - } - - bootLog("Готово, загрузка экрана..."); + } catch (e) { console.error("[SEED]", e); } // Apply safe area insets (Android status bar / nav bar) try { @@ -255,7 +236,9 @@ async function renderToday(container) { `
  • ${f.time ?? ""} ${esc(f.product_name ?? f.note ?? "—")} - ${f.amount_g ? `${f.amount_g}г` : ""} + ${f.unit_amount && f.unit_short + ? `${f.unit_amount} ${f.unit_short}` + : f.amount_g ? `${f.amount_g}г` : ""} ${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}
  • ` @@ -317,6 +300,17 @@ async function renderToday(container) { ${sleep ? formatSleepInfo(sleep) : `нет данных`} + +
    +
    +

    Статистика

    +
    + + +
    +
    +
    +
    `; container.querySelector("#prev-day").onclick = () => { @@ -357,6 +351,257 @@ async function renderToday(container) { container.querySelector("#add-act-btn").onclick = () => showActivityModal(); container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal(); container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep); + + // Stats charts section + const statsContainer = container.querySelector("#stats-charts"); + const statsPeriod = container.querySelector(".stats-period"); + if (statsContainer && statsPeriod) { + renderStatsCharts(statsContainer, 30, foodTotals, bmr); + statsPeriod.querySelectorAll(".type-chip").forEach(chip => { + chip.onclick = () => { + statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected")); + chip.classList.add("selected"); + renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, bmr); + }; + }); + } +} + +// --------------------------------------------------------------------------- +// Stats charts +// --------------------------------------------------------------------------- + +const statsChartInstances = []; + +async function renderStatsCharts(container, days, foodTotals, bmr) { + // Destroy old charts + statsChartInstances.forEach(c => c.destroy()); + statsChartInstances.length = 0; + + container.innerHTML = `
    Загрузка...
    `; + + let weights, dailyFood, dailyActivity; + try { + [weights, dailyFood, dailyActivity] = await Promise.all([ + DB.getRecentWeights(days), + DB.getDailyFoodTotals(days), + DB.getDailyActivityTotals(days), + ]); + } catch (e) { + container.innerHTML = `
    Ошибка загрузки статистики
    `; + return; + } + + container.innerHTML = ""; + + // --- Weight chart (line) --- + if (weights.length > 1) { + const block = document.createElement("div"); + block.className = "stats-chart-block"; + block.innerHTML = ` +
    Вес
    +
    `; + container.appendChild(block); + + const ctx = block.querySelector("#stats-weight-chart"); + const labels = weights.map(w => { + const d = w.date.split("-"); + return d[2] + "." + d[1]; + }); + const chart = new Chart(ctx, { + type: "line", + data: { + labels, + datasets: [{ + data: weights.map(w => w.weight_kg), + borderColor: "#bb86fc", + borderWidth: 1.5, + pointRadius: weights.length > 30 ? 0 : 2, + fill: false, + tension: 0.3, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + zoom: { + zoom: { + wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 }, + pinch: { enabled: true }, + mode: "x", + onZoomStart: ({chart, event}) => { + if (event && event.type === "wheel") return true; + const wrap = chart.canvas.closest(".chart-wrap"); + return wrap && wrap.classList.contains("chart-full"); + }, + }, + pan: { enabled: false }, + }, + }, + scales: { + x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, + y: { ticks: { color: "#888" }, grid: { color: "#333" } }, + }, + }, + }); + statsChartInstances.push(chart); + } + + // --- Macros donut (doughnut) --- + const protein = Math.round(foodTotals.protein_g ?? 0); + const fat = Math.round(foodTotals.fat_g ?? 0); + const carbs = Math.round(foodTotals.carbs_g ?? 0); + const totalKcal = Math.round(foodTotals.kcal ?? 0); + + if (protein + fat + carbs > 0) { + const block = document.createElement("div"); + block.className = "stats-chart-block"; + block.innerHTML = ` +
    Макронутриенты (сегодня)
    +
    `; + container.appendChild(block); + + const ctx = block.querySelector("#stats-macros-chart"); + const chart = new Chart(ctx, { + type: "doughnut", + data: { + labels: ["Белки", "Жиры", "Углеводы"], + datasets: [{ + data: [protein, fat, carbs], + backgroundColor: ["#64b5f6", "#ffb74d", "#81c784"], + borderWidth: 0, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + cutout: "60%", + plugins: { + legend: { + display: true, + position: "bottom", + labels: { color: "#888", font: { size: 12 } }, + }, + }, + }, + plugins: [{ + id: "centerText", + afterDraw(chart) { + const { ctx: c, chartArea } = chart; + const cx = (chartArea.left + chartArea.right) / 2; + const cy = (chartArea.top + chartArea.bottom) / 2; + c.save(); + c.textAlign = "center"; + c.textBaseline = "middle"; + c.font = "bold 18px system-ui"; + c.fillStyle = "#e0e0e0"; + c.fillText(totalKcal + " кк", cx, cy); + c.restore(); + }, + }], + }); + statsChartInstances.push(chart); + } + + // --- Kcal balance chart (bar) --- + if (dailyFood.length > 0) { + const block = document.createElement("div"); + block.className = "stats-chart-block"; + block.innerHTML = ` +
    Баланс ккал
    +
    `; + container.appendChild(block); + + // Build a merged date set from food and activity data + const dateSet = new Set(); + dailyFood.forEach(d => dateSet.add(d.date)); + dailyActivity.forEach(d => dateSet.add(d.date)); + const dates = Array.from(dateSet).sort(); + + const foodMap = {}; + dailyFood.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0)); + const actMap = {}; + dailyActivity.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0)); + + const labels = dates.map(d => { + const parts = d.split("-"); + return parts[2] + "." + parts[1]; + }); + const intakeData = dates.map(d => foodMap[d] ?? 0); + const expendData = dates.map(d => (bmr ?? 0) + (actMap[d] ?? 0)); + + const ctx = block.querySelector("#stats-kcal-chart"); + const chart = new Chart(ctx, { + type: "bar", + data: { + labels, + datasets: [ + { + label: "Приход", + data: intakeData, + backgroundColor: "#81c784", + }, + { + label: "Расход", + data: expendData, + backgroundColor: "#cf6679", + }, + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: "bottom", + labels: { color: "#888", font: { size: 12 } }, + }, + zoom: { + zoom: { + wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 }, + pinch: { enabled: true }, + mode: "x", + onZoomStart: ({chart, event}) => { + if (event && event.type === "wheel") return true; + const wrap = chart.canvas.closest(".chart-wrap"); + return wrap && wrap.classList.contains("chart-full"); + }, + }, + pan: { enabled: false }, + }, + }, + scales: { + x: { ticks: { color: "#888", maxTicksLimit: 10 }, grid: { color: "#333" } }, + y: { ticks: { color: "#888" }, grid: { color: "#333" } }, + }, + }, + }); + statsChartInstances.push(chart); + } + + // If no data at all + if (container.innerHTML === "") { + container.innerHTML = `
    Недостаточно данных для графиков
    `; + return; + } + + // Bind chart fullscreen toggle for stats charts + container.querySelectorAll(".chart-wrap").forEach(wrap => { + wrap.onclick = () => { + if (wrap.classList.contains("chart-full")) return; + wrap.classList.add("chart-full"); + history.pushState({ chartFull: true }, ""); + const closeBtn = document.createElement("div"); + closeBtn.className = "chart-close-btn"; + closeBtn.innerHTML = "✕"; + closeBtn.onclick = (e) => { + e.stopPropagation(); + wrap.classList.remove("chart-full"); + closeBtn.remove(); + }; + wrap.appendChild(closeBtn); + }; + }); } // --------------------------------------------------------------------------- @@ -444,11 +689,14 @@ function showWeightModal() { function showFoodModal() { let selectedProduct = null; + let selectedUnit = null; // { id, name, name_short, grams_per_unit } + let availableUnits = []; showModal(`

    Еда

    +
    @@ -460,7 +708,7 @@ function showFoodModal() {
    - +
    @@ -468,11 +716,20 @@ function showFoodModal() {
    `, async overlay => { - const time = overlay.querySelector("#m-time").value; - const amount = parseFloat(overlay.querySelector("#m-amount").value) || 100; + const time = overlay.querySelector("#m-time").value; + const rawAmount = parseFloat(overlay.querySelector("#m-amount").value) || 100; if (selectedProduct) { - await DB.addFoodEntry(state.date, time, selectedProduct.id, amount, null); + let amountG = rawAmount; + let unitId = null; + let unitAmount = null; + if (selectedUnit && selectedUnit.grams_per_unit) { + // Convert unit amount to grams: e.g. 2 шт * 60г = 120г + unitId = selectedUnit.id; + unitAmount = rawAmount; + amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10; + } + await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount); } else { const desc = overlay.querySelector("#m-desc").value.trim(); const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null; @@ -484,16 +741,70 @@ function showFoodModal() { const overlay = document.querySelector(".modal-overlay"); const selectedEl = overlay.querySelector("#m-selected"); const manualFields = overlay.querySelector("#m-manual-fields"); + const unitChipsEl = overlay.querySelector("#m-unit-chips"); + const amountLabel = overlay.querySelector("#m-amount-label"); + const amountInput = overlay.querySelector("#m-amount"); - createProductSearch(overlay.querySelector("#m-search-wrap"), (product) => { + function renderUnitChips() { + // "г" chip is always first (default), then available units + let html = ``; + for (const u of availableUnits) { + if (u.grams_per_unit == null) continue; // skip units without conversion + html += ``; + } + unitChipsEl.innerHTML = html; + unitChipsEl.style.display = ""; + + unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => { + chip.onclick = () => { + const uid = parseInt(chip.dataset.unitId); + if (uid === 0) { + selectedUnit = null; + amountLabel.textContent = "Количество (г)"; + amountInput.placeholder = "100"; + amountInput.value = "100"; + } else { + selectedUnit = availableUnits.find(u => u.id === uid); + amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`; + amountInput.placeholder = "1"; + amountInput.value = "1"; + } + unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected")); + chip.classList.add("selected"); + }; + }); + } + + createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => { selectedProduct = product; + selectedUnit = null; selectedEl.style.display = "block"; selectedEl.innerHTML = `${esc(product.name)}`; manualFields.style.display = "none"; + + // Load available units for this product + availableUnits = await DB.getUnitsForProduct(product.id); + if (availableUnits.length > 0) { + renderUnitChips(); + } else { + unitChipsEl.style.display = "none"; + } + + // Reset amount to grams mode + amountLabel.textContent = "Количество (г)"; + amountInput.placeholder = "100"; + amountInput.value = "100"; + selectedEl.onclick = () => { selectedProduct = null; + selectedUnit = null; + availableUnits = []; selectedEl.style.display = "none"; + unitChipsEl.style.display = "none"; manualFields.style.display = ""; + amountLabel.textContent = "Количество (г)"; + amountInput.placeholder = "100"; + amountInput.value = "100"; }; }); } @@ -1539,6 +1850,26 @@ function renderRecordingInline(container) { trailLine.setLatLngs(coords); map.fitBounds(trailLine.getBounds().pad(0.1)); } + + // Batch update callback — called after poll reads multiple points at once. + // Rebuilds polyline from all sorted points instead of adding one-by-one. + GPS.recording.onBatchUpdate = () => { + const sorted = GPS.recording.points.slice().sort((a, b) => { + if (a.seq != null && b.seq != null) return a.seq - b.seq; + return new Date(a.ts) - new Date(b.ts); + }); + const coords = sorted.map(p => [p.lat, p.lon]); + borderLine.setLatLngs(coords); + trailLine.setLatLngs(coords); + if (coords.length) { + const last = coords[coords.length - 1]; + map.panTo(last); + if (posMarker) map.removeLayer(posMarker); + posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map); + } + const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg); + if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); + }; }, 200); } @@ -1553,7 +1884,6 @@ function setupLiveUpdates(container) { gpsTrailLine.addLatLng([p.lat, p.lon]); gpsMap.panTo([p.lat, p.lon]); - // Update stats const pts = GPS.recording.points; const stats = GPS.computeStats(pts, GPS.recording.weightKg); if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); @@ -1563,6 +1893,20 @@ function setupLiveUpdates(container) { } }; + GPS.recording.onBatchUpdate = () => { + if (!gpsTrailLine || !gpsMap) return; + const sorted = GPS.recording.points.slice().sort((a, b) => { + if (a.seq != null && b.seq != null) return a.seq - b.seq; + return new Date(a.ts) - new Date(b.ts); + }); + const coords = sorted.map(p => [p.lat, p.lon]); + gpsTrailLine.setLatLngs(coords); + if (coords.length) gpsMap.panTo(coords[coords.length - 1]); + const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg); + if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); + if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч"; + }; + // Timer for duration display timer = setInterval(() => { if (!GPS.recording.active) { clearInterval(timer); return; }