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:
parent
c5bf6da79e
commit
e4f3d3e3ca
4 changed files with 557 additions and 50 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}">✕</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 = "✕";
|
||||
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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue