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; }