Alternative units, analytics charts, elevation smoothing, batch GPS update

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
This commit is contained in:
4aiman 2026-05-23 17:12:19 +03:00
commit e4f3d3e3ca
4 changed files with 557 additions and 50 deletions

View file

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

View file

@ -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.

View file

@ -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);

View file

@ -36,51 +36,32 @@ async function init() {
<div class="seed-count" id="seed-count"></div>
</div>`;
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) {
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${esc(f.product_name ?? f.note ?? "—")}
${f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</span>
${f.unit_amount && f.unit_short
? `<small>${f.unit_amount} ${f.unit_short}</small>`
: f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</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>`
@ -317,6 +300,17 @@ async function renderToday(container) {
${sleep ? formatSleepInfo(sleep) : `<span class="log-empty">нет данных</span>`}
</div>
</section>
<section class="log-section">
<div class="section-header">
<h3>Статистика</h3>
<div class="stats-period">
<button class="type-chip selected" data-days="30">30д</button>
<button class="type-chip" data-days="90">90д</button>
</div>
</div>
<div id="stats-charts"></div>
</section>
`;
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 = `<div class="loading">Загрузка...</div>`;
let weights, dailyFood, dailyActivity;
try {
[weights, dailyFood, dailyActivity] = await Promise.all([
DB.getRecentWeights(days),
DB.getDailyFoodTotals(days),
DB.getDailyActivityTotals(days),
]);
} catch (e) {
container.innerHTML = `<div class="log-empty">Ошибка загрузки статистики</div>`;
return;
}
container.innerHTML = "";
// --- Weight chart (line) ---
if (weights.length > 1) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Вес</div>
<div class="chart-wrap"><canvas id="stats-weight-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
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 = `
<div class="sd-title">Макронутриенты (сегодня)</div>
<div class="chart-wrap"><canvas id="stats-macros-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
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 = `
<div class="sd-title">Баланс ккал</div>
<div class="chart-wrap"><canvas id="stats-kcal-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
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 = `<div class="log-empty">Недостаточно данных для графиков</div>`;
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 = "&#x2715;";
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(`
<h3>Еда</h3>
<div id="m-search-wrap"></div>
<div id="m-selected" class="modal-selected" style="display:none"></div>
<div id="m-unit-chips" class="unit-chips" style="display:none"></div>
<div id="m-manual-fields">
<div class="form-group">
<label>Или вручную: описание</label>
@ -460,7 +708,7 @@ function showFoodModal() {
</div>
</div>
<div class="form-group">
<label>Количество (г)</label>
<label id="m-amount-label">Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
</div>
<div class="form-group">
@ -468,11 +716,20 @@ function showFoodModal() {
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
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 = `<button class="unit-chip${!selectedUnit ? " selected" : ""}" data-unit-id="0">г</button>`;
for (const u of availableUnits) {
if (u.grams_per_unit == null) continue; // skip units without conversion
html += `<button class="unit-chip${selectedUnit && selectedUnit.id === u.id ? " selected" : ""}" data-unit-id="${u.id}">${esc(u.name_short || u.name)}</button>`;
}
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 = `<span>${esc(product.name)}</span>`;
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; }