baldav/frontend/js/main.js
4aiman daf71333ff GPX export + GPS background recording fixes
GPX export: button in session detail, standard GPX 1.1 format.

Background GPS fixes:
- Native service: batched writes (10s flush) instead of per-point
  appendText — eliminates race condition with reader
- JS reader: skip corrupt lines, sort by timestamp, track by ts
  instead of line count — handles partial writes and reordering
- Recovery: idempotent import using max DB timestamp
- Live recording: seq numbers on every point
- FileObserver watches correct path (app root, not files/dev.baldav.app)
- Removed ACCESS_BACKGROUND_LOCATION (causes throttling)

Nav: "Трекинг" button on GPS screen when recording active.
2026-05-16 10:49:55 +03:00

2034 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// BalDav main app — pure JS, no framework, no bundler.
// Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script.
const app = document.getElementById("app");
let state = {
screen: "today",
date: todayIso(),
profile: {},
latestWeight: null,
};
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
async function init() {
if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") {
app.innerHTML = `<div class="loading" style="color:#cf6679">
Ошибка: db.js не загрузился.<br>
window.DB = ${JSON.stringify(typeof window.DB)}<br>
__TAURI__ = ${JSON.stringify(typeof window.__TAURI__)}
</div>`;
return;
}
// Wait for DB migrations
if (DB._migrationsReady) await DB._migrationsReady;
// Recover any orphaned GPS recording session
await GPS.recoverSession();
// Import seed products if needed
try {
app.innerHTML = `<div class="loading">Проверка продуктов...</div>`;
const result = await window.__TAURI__.core.invoke("import_seed_products");
console.log("[SEED]", result);
if (result.startsWith("imported:")) {
const [_, count, total] = result.split(":");
console.log(`[SEED] Imported ${count}/${total} products`);
}
} catch (e) {
console.error("[SEED] error:", e);
}
// Apply safe area insets (Android status bar / nav bar)
try {
const insets = await window.__TAURI__.core.invoke("get_safe_insets");
if (insets.top || insets.bottom) {
document.documentElement.style.setProperty("--safe-area-top", insets.top + "px");
document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px");
}
} catch {}
state.profile = await DB.getFullProfile();
state.latestWeight = await DB.getLatestWeight();
if (false) {
renderScreen("settings");
} else {
renderScreen("today");
}
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function renderScreen(name) {
const prevScreen = state.screen;
state.screen = name;
// Push history so "back" navigates between screens instead of exiting app
if (prevScreen && prevScreen !== name) {
history.pushState({ screen: name }, "");
}
const screens = {
today: renderToday,
products: renderProducts,
gps: renderGpsScreen,
recording: renderRecordingInline,
supplements: renderSupplements,
settings: renderSettings,
};
app.innerHTML = "";
// Floating recording bar (visible on all screens except recording)
if (GPS.recording.active && name !== "recording") {
const bar = document.createElement("div");
bar.className = "rec-floating-bar";
bar.onclick = () => renderScreen("recording");
bar.innerHTML = `<span class="rec-bar-dot"></span><span id="rec-bar-text">Запись...</span>`;
app.appendChild(bar);
// Update bar every second
if (window._recBarTimer) clearInterval(window._recBarTimer);
window._recBarTimer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
const sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
const el = document.querySelector("#rec-bar-text");
if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`;
}, 1000);
} else if (window._recBarTimer) {
clearInterval(window._recBarTimer);
}
app.appendChild(buildNav(name));
const content = document.createElement("div");
content.className = "screen-content";
app.appendChild(content);
screens[name](content);
}
function buildNav(active) {
const nav = document.createElement("nav");
nav.className = "bottom-nav";
[
{ id: "today", label: "Сегодня" },
{ id: "products", label: "Продукты" },
{ id: "gps", label: "Трек" },
{ id: "supplements", label: "Добавки" },
{ id: "settings", label: "Настройки" },
].forEach(item => {
const btn = document.createElement("button");
btn.className = "nav-btn" + (item.id === active ? " active" : "");
btn.textContent = item.label;
btn.onclick = () => renderScreen(item.id);
nav.appendChild(btn);
});
return nav;
}
// ---------------------------------------------------------------------------
// TODAY screen
// ---------------------------------------------------------------------------
async function renderToday(container) {
container.innerHTML = `<div class="loading">Загрузка...</div>`;
const [weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals] = await Promise.all([
DB.getWeightsForDate(state.date),
DB.getFoodForDate(state.date),
DB.getActivitiesForDate(state.date),
DB.getSleepForDate(state.date),
DB.getFoodTotalsForDate(state.date),
DB.getActivityTotalsForDate(state.date),
DB.getSupplementLogForDate(state.date),
DB.getSupplementTotalsForDate(state.date),
]);
const bmr = calcBmr(weights);
const intake = Math.round(foodTotals.kcal ?? 0);
const actKcal = Math.round(actTotals.kcal ?? 0);
const expend = bmr != null ? bmr + actKcal : null;
const balance = expend != null ? intake - expend : null;
container.innerHTML = `
<div class="day-header">
<button class="day-nav" id="prev-day">&#8592;</button>
<h2>${formatDate(state.date)}</h2>
<button class="day-nav" id="next-day">&#8594;</button>
</div>
<div class="summary-row">
<div class="summary-card">
<span class="summary-val">${intake}</span>
<span class="summary-lbl">ккал приход</span>
</div>
<div class="summary-card">
<span class="summary-val">${expend ?? "—"}</span>
<span class="summary-lbl">ккал расход</span>
</div>
<div class="summary-card ${balanceClass(balance)}">
<span class="summary-val">${balance != null ? (balance >= 0 ? "+" : "") + balance : "—"}</span>
<span class="summary-lbl">баланс</span>
</div>
</div>
${buildMicroStrip(suppTotals)}
<section class="log-section">
<div class="section-header"><h3>Вес</h3>
<button class="add-btn" id="add-weight-btn">+</button></div>
<ul class="log-list">
${weights.map(w =>
`<li class="log-item">
<span class="log-time">${w.time ?? ""}</span>
<span class="log-desc">${w.weight_kg} кг</span>
<button class="del-btn" data-table="weight" data-id="${w.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Еда</h3>
<button class="add-btn" id="add-food-btn">+</button></div>
<ul class="log-list">
${food.map(f =>
`<li class="log-item food-edit-item" data-food-id="${f.id}">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${esc(f.product_name ?? f.recipe_name ?? f.note ?? "—")}
${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>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Активность</h3>
<button class="add-btn" id="add-act-btn">+</button></div>
<ul class="log-list">
${activities.map(a =>
`<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${esc(a.desc ?? "—")}
${a.minutes ? `<small>${a.minutes} мин</small>` : ""}</span>
<span class="log-kcal">${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Добавки</h3>
<button class="add-btn" id="add-supp-btn">+</button></div>
<ul class="log-list">
${suppLog.map(s =>
`<li class="log-item">
<span class="log-time">${s.time ?? ""}</span>
<span class="log-desc">${esc(s.supplement_name)}${s.dose_g ? ` <small>${s.dose_g} шт</small>` : ""}</span>
<button class="del-btn" data-table="supp" data-id="${s.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Сон</h3>
<button class="add-btn" id="add-sleep-btn">&#x270E;</button></div>
<div class="sleep-info">
${sleep ? formatSleepInfo(sleep) : `<span class="log-empty">нет данных</span>`}
</div>
</section>
`;
container.querySelector("#prev-day").onclick = () => {
state.date = shiftDate(state.date, -1);
renderScreen("today");
};
container.querySelector("#next-day").onclick = () => {
state.date = shiftDate(state.date, 1);
renderScreen("today");
};
container.querySelectorAll(".del-btn").forEach(btn => {
btn.onclick = async () => {
const { table, id } = btn.dataset;
if (table === "weight") await DB.deleteWeight(parseInt(id));
if (table === "food") await DB.deleteFoodEntry(parseInt(id));
if (table === "activity") await DB.deleteActivity(parseInt(id));
if (table === "supp") await DB.deleteSupplementLog(parseInt(id));
renderScreen("today");
};
});
container.querySelectorAll(".food-edit-item").forEach(li => {
li.onclick = e => {
if (e.target.closest(".del-btn")) return;
const fid = parseInt(li.dataset.foodId);
const entry = food.find(f => f.id === fid);
if (entry) showEditFoodModal(entry);
};
});
const strip = container.querySelector(".micro-strip");
if (strip) strip.onwheel = e => { e.preventDefault(); strip.scrollLeft += e.deltaY; };
container.querySelector("#add-weight-btn").onclick = () => showWeightModal();
container.querySelector("#add-food-btn").onclick = () => showFoodModal();
container.querySelector("#add-act-btn").onclick = () => showActivityModal();
container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal();
container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep);
}
// ---------------------------------------------------------------------------
// Modals
// ---------------------------------------------------------------------------
function showModal(html, onSubmit) {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<button class="modal-back" id="modal-back">&#8592;</button>
</div>
<div class="modal-body">${html}</div>
<div class="modal-btns">
<button id="modal-cancel" class="btn-secondary">Отмена</button>
<button id="modal-ok" class="btn-primary">Сохранить</button>
</div>
</div>`;
document.body.appendChild(overlay);
history.pushState({ modal: true }, "");
const popHandler = () => {
// If fullscreen map is open inside this modal, collapse it instead of closing modal
const fsMap = overlay.querySelector(".sd-map-full");
if (fsMap) {
fsMap.classList.remove("sd-map-full");
const expandBtn = fsMap.querySelector(".sd-map-expand-btn");
if (expandBtn) expandBtn.innerHTML = "&#x26F6;";
try { map.dragging.disable(); } catch {}
history.pushState({ modal: true }, "");
window.addEventListener("popstate", popHandler, { once: true });
return;
}
closeModal(false);
};
window.addEventListener("popstate", popHandler, { once: true });
function closeModal(goBack) {
overlay.remove();
window.removeEventListener("popstate", popHandler);
if (goBack) history.back();
// Ensure history isn't empty after modal close (prevents app exit on next back)
history.replaceState({ screen: state.screen }, "");
}
overlay.querySelector("#modal-back").onclick = () => closeModal(true);
overlay.querySelector("#modal-cancel").onclick = () => closeModal(true);
overlay.querySelector("#modal-ok").onclick = async () => {
const ok = await onSubmit(overlay);
if (ok !== false) { closeModal(true); renderScreen(state.screen); }
};
setTimeout(() => overlay.querySelector("input")?.focus(), 50);
}
function showWeightModal() {
showModal(`
<h3>Вес</h3>
<div class="form-group">
<label>кг</label>
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const kg = parseFloat(overlay.querySelector("#m-kg").value);
const time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await DB.addWeight(state.date, time, kg);
});
}
function showFoodModal() {
let selectedProduct = null;
showModal(`
<h3>Еда</h3>
<div class="form-group">
<label>Поиск продукта</label>
<input id="m-prod-search" type="search" placeholder="Название продукта..." />
<div id="m-prod-results" class="modal-search-results"></div>
</div>
<div id="m-selected" class="modal-selected" style="display:none"></div>
<div id="m-manual-fields">
<div class="form-group">
<label>Или вручную: описание</label>
<input id="m-desc" type="text" placeholder="гречка с молоком" />
</div>
<div class="form-group">
<label>ккал (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="350" />
</div>
</div>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
</div>
<div class="form-group">
<label>Время</label>
<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;
if (selectedProduct) {
await DB.addFoodEntry(state.date, time, selectedProduct.id, amount, null);
} else {
const desc = overlay.querySelector("#m-desc").value.trim();
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
if (!desc) return false;
await DB.addFoodManual(state.date, time, desc, kcal);
}
});
// Wire up product search inside the modal
const overlay = document.querySelector(".modal-overlay");
const searchInput = overlay.querySelector("#m-prod-search");
const resultsEl = overlay.querySelector("#m-prod-results");
const selectedEl = overlay.querySelector("#m-selected");
const manualFields = overlay.querySelector("#m-manual-fields");
let debounce = null;
searchInput.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const q = searchInput.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
const results = await DB.searchProducts(q, 10);
resultsEl.innerHTML = results.map(p =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
resultsEl.querySelectorAll(".modal-prod-item").forEach(item => {
item.onclick = () => {
const pid = parseInt(item.dataset.pid);
selectedProduct = results.find(r => r.id === pid);
resultsEl.innerHTML = "";
searchInput.value = "";
selectedEl.style.display = "block";
selectedEl.innerHTML = `<span>${esc(selectedProduct.name)}</span>`;
manualFields.style.display = "none";
selectedEl.onclick = () => {
selectedProduct = null;
selectedEl.style.display = "none";
manualFields.style.display = "block";
searchInput.focus();
};
};
});
}, 300);
};
}
function showEditFoodModal(entry) {
let newProduct = null; // if user picks a different product
const origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? "";
showModal(`
<h3>Редактирование</h3>
<div id="m-current" class="modal-selected">
<span>${esc(origLabel)}</span>
</div>
<div id="m-search-block" style="display:none">
<div class="form-group">
<label>Заменить на</label>
<input id="m-prod-search" type="search" placeholder="Поиск продукта..." />
<div id="m-prod-results" class="modal-search-results"></div>
</div>
</div>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" value="${entry.amount_g || 100}" />
</div>
${!entry.product_id ? `
<div class="form-group" id="m-kcal-group">
<label>ккал</label>
<input id="m-kcal" type="number" inputmode="numeric" value="${entry.kcal != null ? Math.round(entry.kcal) : ""}" />
</div>` : ""}
<div class="form-group">
<label>Заметка</label>
<input id="m-note" type="text" value="${esc(entry.note ?? "")}" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${entry.time ?? ""}" />
</div>`,
async overlay => {
const amount = parseFloat(overlay.querySelector("#m-amount").value);
const time = overlay.querySelector("#m-time").value;
const note = overlay.querySelector("#m-note")?.value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
if (newProduct) {
// Replace: delete old, add new
await DB.deleteFoodEntry(entry.id);
await DB.addFoodEntry(entry.date ?? state.date, time, newProduct.id, amount, note);
} else {
const kcalEl = overlay.querySelector("#m-kcal");
const kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null;
await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride);
}
});
const overlay = document.querySelector(".modal-overlay");
const currentEl = overlay.querySelector("#m-current");
const searchBlock = overlay.querySelector("#m-search-block");
currentEl.onclick = () => {
searchBlock.style.display = "block";
searchBlock.querySelector("#m-prod-search").focus();
};
const searchInput = overlay.querySelector("#m-prod-search");
const resultsEl = overlay.querySelector("#m-prod-results");
let debounce = null;
searchInput.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const q = searchInput.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
const results = await DB.searchProducts(q, 10);
resultsEl.innerHTML = results.map(p =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
resultsEl.querySelectorAll(".modal-prod-item").forEach(item => {
item.onclick = () => {
const pid = parseInt(item.dataset.pid);
newProduct = results.find(r => r.id === pid);
currentEl.querySelector("span").textContent = newProduct.name;
searchBlock.style.display = "none";
resultsEl.innerHTML = "";
searchInput.value = "";
// Hide manual kcal field since now it's a product
const kcalGroup = overlay.querySelector("#m-kcal-group");
if (kcalGroup) kcalGroup.style.display = "none";
};
});
}, 300);
};
}
function showActivityModal() {
showModal(`
<h3>Активность</h3>
<div class="form-group">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="прогулка" />
</div>
<div class="form-group">
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="40" />
</div>
<div class="form-group">
<label>ккал расход (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="150" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const desc = overlay.querySelector("#m-desc").value.trim();
const min = parseInt(overlay.querySelector("#m-min").value) || null;
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
const time = overlay.querySelector("#m-time").value;
if (!desc) return false;
await DB.addActivity(state.date, time, "manual", desc, min, kcal);
});
}
function showSleepModal(existing) {
showModal(`
<h3>Сон</h3>
<div class="form-group">
<label>Лёг в кровать</label>
<input id="m-bed" type="time" value="${existing?.bed_time ?? ""}" />
</div>
<div class="form-group">
<label>Уснул</label>
<input id="m-asleep" type="time" value="${existing?.asleep_time ?? ""}" />
</div>
<div class="form-group">
<label>Проснулся</label>
<input id="m-wake" type="time" value="${existing?.wake_time ?? ""}" />
</div>
<div class="form-group">
<label>Дневной сон (мин)</label>
<input id="m-nap" type="number" inputmode="numeric" value="${existing?.nap_minutes ?? 0}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="m-notes" type="text" value="${esc(existing?.notes ?? "")}" />
</div>`,
async overlay => {
await DB.saveSleep(
state.date,
overlay.querySelector("#m-bed").value || null,
overlay.querySelector("#m-asleep").value || null,
overlay.querySelector("#m-wake").value || null,
parseInt(overlay.querySelector("#m-nap").value) || 0,
overlay.querySelector("#m-notes").value.trim() || null
);
});
}
// ---------------------------------------------------------------------------
// PRODUCTS screen
// ---------------------------------------------------------------------------
async function renderProducts(container) {
let debounceTimer = null;
let expandedId = null; // currently expanded product card id
container.innerHTML = `
<div class="products-screen">
<h2>Продукты</h2>
<input id="prod-search" class="search-input" type="search"
placeholder="Название, бренд или ингредиент..." />
<div id="prod-count" class="prod-count"></div>
<div id="prod-results" class="prod-list"></div>
</div>`;
const input = container.querySelector("#prod-search");
const countEl = container.querySelector("#prod-count");
const listEl = container.querySelector("#prod-results");
let lastResults = [];
async function doSearch(resetExpanded) {
const q = input.value.trim();
if (q.length < 2) {
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
countEl.textContent = "";
lastResults = [];
return;
}
if (resetExpanded) expandedId = null;
lastResults = await DB.searchProducts(q);
countEl.textContent = lastResults.length ? `Найдено: ${lastResults.length}` : "";
if (!lastResults.length) {
listEl.innerHTML = `<div class="prod-hint">Ничего не найдено</div>`;
return;
}
renderList();
}
function renderList() {
listEl.innerHTML = lastResults.map(p => renderProdCard(p)).join("");
bindProdCards(listEl);
}
function renderProdCard(p) {
const open = p.id === expandedId;
const kcal = p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) : null;
return `
<div class="prod-card ${open ? "open" : ""}" data-pid="${p.id}">
<div class="prod-card-header">
<div class="prod-card-name">
${esc(p.name)}
${p.brand ? `<span class="prod-brand">${esc(p.brand)}</span>` : ""}
${p.weight_g ? `<span class="prod-weight">${p.weight_g}г</span>` : ""}
</div>
<div class="prod-card-kcal">${kcal != null ? kcal + " кк" : "?"}</div>
</div>
${open ? renderProdDetails(p) : ""}
</div>`;
}
function renderProdDetails(p) {
const r = v => v != null ? v : "—";
return `
<div class="prod-details">
<div class="prod-macros">
<div class="macro"><span class="macro-val">${r(p.protein_per_100g)}</span><span class="macro-lbl">Б</span></div>
<div class="macro"><span class="macro-val">${r(p.fat_per_100g)}</span><span class="macro-lbl">Ж</span></div>
<div class="macro"><span class="macro-val">${r(p.carbs_per_100g)}</span><span class="macro-lbl">У</span></div>
<div class="macro"><span class="macro-val">${r(p.fiber_per_100g)}</span><span class="macro-lbl">Кл</span></div>
</div>
${p.composition ? `<div class="prod-composition">${esc(p.composition)}</div>` : ""}
<button class="btn-primary prod-add-btn" data-pid="${p.id}">Добавить в дневник</button>
</div>`;
}
function bindProdCards(el) {
el.querySelectorAll(".prod-card-header").forEach(hdr => {
hdr.onclick = () => {
const card = hdr.closest(".prod-card");
const pid = parseInt(card.dataset.pid);
expandedId = (expandedId === pid) ? null : pid;
renderList();
};
});
el.querySelectorAll(".prod-add-btn").forEach(btn => {
btn.onclick = e => {
e.stopPropagation();
showAddFoodFromProduct(parseInt(btn.dataset.pid));
};
});
}
function showAddFoodFromProduct(productId) {
showModal(`
<h3>Добавить в дневник</h3>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>
<div class="form-group">
<label>Заметка</label>
<input id="m-note" type="text" placeholder="" />
</div>`,
async overlay => {
const amount = parseFloat(overlay.querySelector("#m-amount").value);
const time = overlay.querySelector("#m-time").value;
const note = overlay.querySelector("#m-note").value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
await DB.addFoodEntry(state.date, time, productId, amount, note);
});
}
input.oninput = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(true), 300);
};
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
input.focus();
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// GPS screen — map, recording, sessions list, GPX import
// ---------------------------------------------------------------------------
let gpsMap = null;
let gpsTrailLine = null;
let gpsMarker = null;
async function renderGpsScreen(container) {
const sessions = await DB.getGpsSessions(20);
const isRec = GPS.recording.active;
container.innerHTML = `
<div class="gps-screen">
<div class="gps-header">
<div class="gps-btns">
<select id="gps-type" class="gps-type-sel">
<option value="bike">Вело</option>
<option value="walk">Ходьба</option>
<option value="run">Бег</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Трекинг" : "Старт"}
</button>
<div class="gps-menu-wrap">
<button id="gps-menu-btn" class="gps-menu-btn">&#x22EE;</button>
<div id="gps-menu" class="gps-menu hidden">
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
</div>
</div>
</div>
</div>
<div class="gps-sessions">
<h3>История</h3>
${sessions.length ? sessions.map(s => `
<div class="gps-session-item" data-sid="${s.id}">
<div class="gps-session-head">
<span>${s.date} ${s.start_time ?? ""}</span>
<span class="gps-session-type">${s.activity_type ?? ""}</span>
</div>
<div class="gps-session-stats">
${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"}
&middot; ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
&middot; ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
${s.elevation_gain_m ? " &middot; +" + Math.round(s.elevation_gain_m) + "м" : ""}
</div>
</div>
`).join("") : `<div class="prod-hint">Нет записей</div>`}
</div>
</div>`;
// Recording button
container.querySelector("#gps-rec-btn").onclick = async () => {
if (GPS.recording.active) {
renderScreen("recording");
} else {
const type = container.querySelector("#gps-type").value;
const wkg = state.latestWeight?.weight_kg ?? 80;
await GPS.startRecording(type, wkg);
renderScreen("recording");
}
};
// Session click → detail view
container.querySelectorAll(".gps-session-item").forEach(item => {
item.onclick = () => {
const sid = parseInt(item.dataset.sid);
showSessionDetail(sid);
};
});
// Three-dot menu
const menuBtn = container.querySelector("#gps-menu-btn");
const menu = container.querySelector("#gps-menu");
menuBtn.onclick = () => menu.classList.toggle("hidden");
document.addEventListener("click", e => {
if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden");
}, { once: false });
// Download map tiles — region picker
container.querySelector("#gps-download-map").onclick = () => {
menu.classList.add("hidden");
showMapDownloadModal();
};
// Clear tile cache
container.querySelector("#gps-clear-cache").onclick = async () => {
menu.classList.add("hidden");
const count = await TileCache.count();
await TileCache.clear();
console.log("Cache cleared");
};
// Unified import — detect by extension
container.querySelector("#gps-import").onclick = () => {
menu.classList.add("hidden");
pickFile("", async (data, file) => {
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
const ext = file.name.split(".").pop().toLowerCase();
console.log("[IMPORT] ext:", ext);
const wkg = state.latestWeight?.weight_kg ?? 80;
try {
if (ext === "gpx") {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
await GPS.importGPX(text, wkg);
} else if (ext === "ftb" || ext === "zip") {
const buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
console.log("[IMPORT] FTB buf size:", buf.byteLength);
const count = await GPS.importFTB(buf, wkg);
console.log("[IMPORT] done, count:", count);
} else {
console.error("[IMPORT] Unsupported file type: " + ext);
}
renderScreen("gps");
} catch (e) {
console.error("[IMPORT] error:", e, String(e));
}
}, true);
};
}
function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
container.innerHTML = `
<div class="rec-screen">
<div id="rec-map" class="rec-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
</div>`;
setTimeout(() => {
const mapEl = document.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
map.setView([p.lat, p.lon], 16);
} else if (state === "recording") {
statusEl.textContent = "";
statusEl.style.color = "";
} else if (state === "resumed") {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Live point updates
const distEl = document.querySelector("#rec-dist");
const spdEl = document.querySelector("#rec-speed");
const timeEl = document.querySelector("#rec-time");
const elevEl = document.querySelector("#rec-elev");
const statusEl = document.querySelector("#rec-status");
// Speed buffer for median
const speedBuf = [];
GPS.recording.onPoint = (p) => {
const ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
// Stats
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
// Show current state
const recState = GPS.recording.state;
if (recState === "waiting_gps") {
statusEl.textContent = "Поиск GPS...";
statusEl.style.color = "#ffb74d";
map.setView([p.lat, p.lon], 15);
return;
}
if (recState === "ready") {
// Position marker only
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker([p.lat, p.lon], { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
return;
}
// Speed
const curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
speedBuf.push(curSpeed);
if (speedBuf.length > 5) speedBuf.shift();
const sorted = speedBuf.slice().sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] || 0;
if (spdEl) spdEl.textContent = median.toFixed(1);
// Elevation
const alt = p.altitude;
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
// Pause indicator
if (GPS.recording.pauseStart) {
statusEl.textContent = "Пауза";
statusEl.style.color = "#ffb74d";
} else {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Timer — excludes pauses, starts only when recording
const timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (!GPS.recording.startTime) return; // not started yet
const totalMs = Date.now() - GPS.recording.startTime.getTime();
const activeMs = totalMs - GPS.recording.totalPauseMs -
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
const sec = Math.max(0, Math.round(activeMs / 1000));
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
// Pause button
container.querySelector("#rec-pause").onclick = () => {
if (GPS.recording.manualPaused) {
GPS.resumeRecording();
container.querySelector("#rec-pause").textContent = "Пауза";
statusEl.textContent = "";
statusEl.style.color = "";
} else {
GPS.pauseRecording();
container.querySelector("#rec-pause").textContent = "Продолжить";
statusEl.textContent = "Пауза (ручная)";
statusEl.style.color = "#ffb74d";
}
};
// Stop button
container.querySelector("#rec-stop").onclick = async () => {
await GPS.stopRecording();
clearInterval(timer);
renderScreen("gps");
};
// Restore trail if already recording
if (GPS.recording.points.length > 0) {
const coords = GPS.recording.points.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
map.fitBounds(trailLine.getBounds().pad(0.1));
}
}, 200);
}
function setupLiveUpdates(container) {
const distEl = container.querySelector("#gs-dist");
const spdEl = container.querySelector("#gs-speed");
const timeEl = container.querySelector("#gs-time");
let timer = null;
GPS.recording.onPoint = (p) => {
if (!gpsTrailLine || !gpsMap) return;
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);
if (spdEl) {
const curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
spdEl.textContent = curSpd + " км/ч";
}
};
// Timer for duration display
timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
const sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
}
// ---------------------------------------------------------------------------
// Map download modal — pick region + zoom range
// ---------------------------------------------------------------------------
const MAP_REGIONS = [
{ name: "Текущий вид карты", bounds: null },
{ name: "--- Города ---", bounds: null, disabled: true },
{ name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] },
{ name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] },
{ name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] },
{ name: "город + окрестности", bounds: [[47.35, 39.95], [47.52, 40.22]] },
{ name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] },
{ name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] },
{ name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] },
{ name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] },
{ name: "--- Области ---", bounds: null, disabled: true },
{ name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] },
{ name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] },
{ name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] },
{ name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] },
];
function showMapDownloadModal() {
const regionOpts = MAP_REGIONS.map((r, i) =>
r.disabled
? `<div class="supp-pick-divider">${r.name.replace(/---? ?/g, "")}</div>`
: `<div class="supp-pick-item" data-idx="${i}">${r.name}</div>`
).join("");
showModal(`
<h3>Скачать карту</h3>
<div class="form-group">
<label>Регион</label>
<div id="m-region-list" class="supp-pick-list">${regionOpts}</div>
</div>
<div class="form-group">
<label>Мин. зум</label>
<input id="m-zmin" type="number" value="12" min="1" max="18" />
</div>
<div class="form-group">
<label>Макс. зум</label>
<input id="m-zmax" type="number" value="15" min="1" max="18" />
</div>
<div id="m-tile-count" style="color:var(--muted);font-size:12px;margin-top:4px"></div>`,
async overlay => {
const sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return false;
const idx = parseInt(sel.dataset.idx);
const region = MAP_REGIONS[idx];
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) {
bounds = L.latLngBounds(region.bounds);
} else if (gpsMap) {
bounds = gpsMap.getBounds();
} else return false;
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
const okBtn = overlay.querySelector("#modal-ok");
okBtn.textContent = `0 / ${total}`;
okBtn.disabled = true;
await TileCache.downloadArea(bounds, zMin, zMax, (done, tot) => {
okBtn.textContent = `${done} / ${tot}`;
});
okBtn.textContent = "Готово";
okBtn.disabled = false;
});
const overlay = document.querySelector(".modal-overlay");
const countEl = overlay.querySelector("#m-tile-count");
// Selection
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
updateTileCount();
};
});
function updateTileCount() {
const sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return;
const idx = parseInt(sel.dataset.idx);
const region = MAP_REGIONS[idx];
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) bounds = L.latLngBounds(region.bounds);
else if (gpsMap) bounds = gpsMap.getBounds();
else return;
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
countEl.textContent = `~${total} тайлов`;
}
overlay.querySelector("#m-zmin").oninput = updateTileCount;
overlay.querySelector("#m-zmax").oninput = updateTileCount;
}
// ---------------------------------------------------------------------------
// Session detail view
// ---------------------------------------------------------------------------
async function showSessionDetail(sessionId) {
try {
console.log("[SD] loading session", sessionId);
const session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
console.log("[SD] loading points");
const rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
const points = GPS.filterNoise(rawPoints, session.activity_type);
console.log("[SD] filtered to", points.length, "points");
// Use stored session stats if available (computed correctly at import time),
// fallback to recomputing from points
const storedStats = session.total_distance_m != null;
const stats = storedStats ? {
distance: Math.round(session.total_distance_m || 0),
avgSpeed: session.avg_speed_kmh || 0,
maxSpeed: session.max_speed_kmh || 0,
kcal: Math.round(session.total_kcal || 0),
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? 80);
// Compute duration from points regardless
if (points.length >= 2) {
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
}
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
const rawSpeeds = [];
for (let i = 1; i < points.length; i++) {
const devSpd = points[i].speed_ms;
if (devSpd != null && devSpd >= 0) {
rawSpeeds.push(devSpd * 3.6);
} else {
const d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
const dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
}
}
// 31-point moving average — kills GPS jitter from phone-in-pocket
const HALF_WIN = 15;
const smoothed = rawSpeeds.map((_, i) => {
let sum = 0, count = 0;
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
sum += rawSpeeds[j]; count++;
}
return Math.round(sum / count * 10) / 10;
});
// maxSpeed from smoothed graph (not raw)
const graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
// Elevation data
const elevations = points.filter(p => p.altitude_gps_m != null).map(p => p.altitude_gps_m);
const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
const t0 = points.length ? new Date(points[0].ts).getTime() : 0;
const timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
const actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
const actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
const duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
: "—";
const tempo = stats.duration > 0 && stats.distance > 0
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
: "—";
const kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
showModal(`
<h3>${actName}</h3>
<div class="sd-section">
<div class="sd-title">Время</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.date}</span><span class="sd-lbl">Дата</span></div>
<div class="sd-stat"><span class="sd-val">${duration}</span><span class="sd-lbl">Длительность</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.start_time ?? "—"}</span><span class="sd-lbl">Начало</span></div>
<div class="sd-stat"><span class="sd-val">${session.end_time ?? "—"}</span><span class="sd-lbl">Конец</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(stats.distance)}</span><span class="sd-lbl">Расстояние</span></div>
<div class="sd-stat"><span class="sd-val">${tempo}</span><span class="sd-lbl">Темп</span></div>
</div>
</div>
<div class="sd-section">
<div class="sd-title">Маршрут</div>
<div id="sd-map" class="sd-map"></div>
</div>
<div class="sd-section">
<div class="sd-title">Скорость</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas>
</div>
<div class="sd-section">
<div class="sd-title">Калории</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.total_kcal ?? stats.kcal} ккал</span><span class="sd-lbl">Всего</span></div>
<div class="sd-stat"><span class="sd-val">${kcalMin} ккал/мин</span><span class="sd-lbl">Расход</span></div>
</div>
</div>
${elevations.length > 0 ? `
<div class="sd-section">
<div class="sd-title">Высота</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${minElev} м</span><span class="sd-lbl">Мин.</span></div>
<div class="sd-stat"><span class="sd-val">${maxElev} м</span><span class="sd-lbl">Макс.</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${stats.elevGain} м</span><span class="sd-lbl">Подъём</span></div>
<div class="sd-stat"><span class="sd-val">${stats.elevLoss} м</span><span class="sd-lbl">Спуск</span></div>
</div>
<canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas>
</div>` : ""}
<button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт GPX</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
`, () => {});
// Render map
setTimeout(() => {
const mapEl = document.querySelector("#sd-map");
if (!mapEl || !points.length) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Sort by timestamp, build coords and draw in one pass
// Keep original order from DB (seq from FTB import = XML order = clean track)
const t0 = new Date(points[0].ts).getTime();
const tN = new Date(points[points.length - 1].ts).getTime();
const tRange = tN - t0 || 1;
const sortedCoords = [];
for (let i = 0; i < points.length; i++) {
sortedCoords.push([points[i].lat, points[i].lon]);
}
// Polyline first (underneath)
L.polyline(sortedCoords, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
L.polyline(sortedCoords, { color: "#bb86fc", weight: 5 }).addTo(map);
// Dots on top
for (let i = 0; i < points.length; i++) {
const p = points[i];
const frac = (new Date(p.ts).getTime() - t0) / tRange;
// blue → red → yellow → green
let cr, cg, cb;
if (frac < 0.33) { const t = frac / 0.33; cr = Math.round(t * 220); cg = 0; cb = Math.round((1 - t) * 220); }
else if (frac < 0.66) { const t = (frac - 0.33) / 0.33; cr = 220; cg = Math.round(t * 200); cb = 0; }
else { const t = (frac - 0.66) / 0.34; cr = Math.round((1 - t) * 220); cg = 180; cb = 0; }
const dotColor = `rgb(${cr},${cg},${cb})`;
L.circleMarker([p.lat, p.lon], { radius: 5, color: "#333", fillColor: "#333", fillOpacity: 1, weight: 0 }).addTo(map);
L.circleMarker([p.lat, p.lon], { radius: 3.5, color: dotColor, fillColor: dotColor, fillOpacity: 1, weight: 0 })
.bindTooltip(`#${i} ${p.ts.split("T")[1].slice(0, 12)}`, { permanent: false })
.addTo(map);
}
// Start/end
if (sortedCoords.length > 1) {
L.circleMarker(sortedCoords[0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map);
L.circleMarker(sortedCoords[sortedCoords.length - 1], { radius: 6, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map);
map.fitBounds(L.latLngBounds(sortedCoords).pad(0.1));
}
const allCoords = sortedCoords;
// Fullscreen toggle button on map
// Fullscreen map toggle
let mapIsFullscreen = false;
function collapseMap() {
if (!mapIsFullscreen) return;
mapIsFullscreen = false;
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
function toggleMapFs() {
if (mapIsFullscreen) {
collapseMap();
map.dragging.disable();
} else {
mapIsFullscreen = true;
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
map.dragging.enable();
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
}
const fsBtn = L.control({ position: "topright" });
let fsDiv;
fsBtn.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleMapFs(); };
return fsDiv;
};
fsBtn.addTo(map);
// Locate me button
const locBtn = L.control({ position: "topleft" });
locBtn.onAdd = () => {
const div = L.DomUtil.create("div", "sd-map-fs-btn");
div.innerHTML = "&#x25CE;";
div.title = "Моё местоположение";
div.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => {
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, 15);
L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map);
},
err => console.error("GPS error:", err.message),
{ enableHighAccuracy: true, timeout: 10000 }
);
};
return div;
};
locBtn.addTo(map);
// Click map to toggle fullscreen (same as button)
map.on("click", () => {
if (!mapEl.classList.contains("sd-map-full")) toggleMapFs();
});
}, 200);
// Render speed chart
setTimeout(() => {
const ctx = document.querySelector("#sd-speed-chart");
if (!ctx || !smoothed.length) return;
// Update maxSpeed display to match smoothed graph
const maxEl = document.querySelector("#sd-max-speed");
if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч";
new Chart(ctx, {
type: "line",
data: {
labels: timeLabels,
datasets: [{
data: smoothed,
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
}],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
zoom: {
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
pan: { enabled: false },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "км/ч", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}, 300);
// Render elevation chart
setTimeout(() => {
const ctx = document.querySelector("#sd-elev-chart");
if (!ctx || !elevations.length) return;
const elevLabels = points.filter(p => p.altitude_gps_m != null)
.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
new Chart(ctx, {
type: "line",
data: {
labels: elevLabels,
datasets: [{
data: elevations,
borderColor: "#ffb74d",
borderWidth: 1.5,
pointRadius: 0,
fill: true,
backgroundColor: "rgba(255,183,77,0.1)",
tension: 0.3,
}],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
zoom: {
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
pan: { enabled: false },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "м", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}, 400);
// Delete button
setTimeout(() => {
const exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
const del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("gps");
};
}, 100);
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
}
// ---------------------------------------------------------------------------
// Log supplement modal (from Today screen)
// ---------------------------------------------------------------------------
async function showLogSupplementModal() {
const supps = await DB.getSupplements();
if (supps.length === 0) {
showModal(`
<h3>Добавки</h3>
<p style="color:var(--muted)">Нет добавок. Создайте на вкладке "Добавки".</p>`,
() => {});
return;
}
showModal(`
<h3>Принял добавку</h3>
<div class="form-group">
<label>Добавка</label>
<div id="m-supp-list" class="supp-pick-list">
${supps.map(s => `<div class="supp-pick-item" data-sid="${s.id}">${esc(s.name)}${s.daily_dose_g ? ` <small>(${s.daily_dose_g}г/день)</small>` : ""}</div>`).join("")}
</div>
</div>
<div class="form-group">
<label>Количество (шт)</label>
<input id="m-qty" type="number" inputmode="decimal" value="1" step="0.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const selected = overlay.querySelector(".supp-pick-item.selected");
if (!selected) return false;
const sid = parseInt(selected.dataset.sid);
const qty = parseFloat(overlay.querySelector("#m-qty").value) || 1;
const time = overlay.querySelector("#m-time").value;
await DB.logSupplement(state.date, time, sid, qty);
});
const overlay = document.querySelector(".modal-overlay");
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
};
});
}
// ---------------------------------------------------------------------------
// SUPPLEMENTS screen — manage supplements catalog
// ---------------------------------------------------------------------------
const MICRO_FIELDS = [
{ key: "vit_a_mcg", label: "Вит A (мкг)" },
{ key: "vit_c_mg", label: "Вит C (мг)" },
{ key: "vit_d_mcg", label: "Вит D (мкг)" },
{ key: "vit_e_mg", label: "Вит E (мг)" },
{ key: "vit_k_mcg", label: "Вит K (мкг)" },
{ key: "vit_b1_mg", label: "B1 (мг)" },
{ key: "vit_b2_mg", label: "B2 (мг)" },
{ key: "vit_b3_mg", label: "B3 (мг)" },
{ key: "vit_b6_mg", label: "B6 (мг)" },
{ key: "vit_b12_mcg", label: "B12 (мкг)" },
{ key: "folate_mcg", label: "Фолат (мкг)" },
{ key: "calcium_mg", label: "Ca (мг)" },
{ key: "iron_mg", label: "Fe (мг)" },
{ key: "magnesium_mg", label: "Mg (мг)" },
{ key: "zinc_mg", label: "Zn (мг)" },
{ key: "copper_mg", label: "Cu (мг)" },
{ key: "selenium_mcg", label: "Se (мкг)" },
{ key: "potassium_mg", label: "K (мг)" },
{ key: "sodium_mg", label: "Na (мг)" },
];
async function renderSupplements(container) {
const supps = await DB.getSupplements();
function suppSummary(s) {
const parts = [];
for (const f of MICRO_FIELDS) {
if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`);
}
return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : "");
}
container.innerHTML = `
<div class="products-screen">
<div class="section-header">
<h2>Добавки</h2>
<button class="add-btn" id="add-new-supp">+</button>
</div>
<div id="supp-list" class="prod-list">
${supps.length ? supps.map(s => `
<div class="prod-card" data-sid="${s.id}">
<div class="prod-card-header">
<div class="prod-card-name">${esc(s.name)}
${s.daily_dose_g ? `<span class="prod-weight">${s.daily_dose_g} шт/день</span>` : ""}
</div>
</div>
<div class="supp-summary">${suppSummary(s)}</div>
</div>`).join("")
: `<div class="prod-hint">Нет добавок. Нажмите + чтобы добавить.</div>`}
</div>
</div>`;
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
const sid = parseInt(card.dataset.sid);
const s = await DB.getSupplement(sid);
if (s) showEditSupplementModal(s);
};
});
}
function showEditSupplementModal(existing) {
const isNew = !existing;
const s = existing || {};
const microHtml = MICRO_FIELDS.map(f =>
`<div class="form-group micro-field">
<label>${f.label}</label>
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${s[f.key] ?? ""}" placeholder="—" />
</div>`
).join("");
showModal(`
<h3>${isNew ? "Новая добавка" : "Редактирование"}</h3>
<div class="form-group">
<label>Название</label>
<input id="m-name" type="text" value="${esc(s.name ?? "")}" placeholder="Витамин D3" />
</div>
<div class="form-group">
<label>Таблеток в день</label>
<input id="m-dose" type="number" inputmode="decimal" step="0.5" value="${s.daily_dose_g ?? ""}" placeholder="1" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Содержание в 1 таблетке:</p>
<div class="micro-grid">${microHtml}</div>
${!isNew ? `<button id="m-del-supp" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить добавку</button>` : ""}`,
async overlay => {
const name = overlay.querySelector("#m-name").value.trim();
if (!name) return false;
const data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
for (const f of MICRO_FIELDS) {
data[f.key] = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
}
if (isNew) {
await DB.addSupplement(data);
} else {
// Update: delete + re-add (simple, keeps log intact via supplement_id)
// Actually need an update function. For now, delete logs referencing old id is bad.
// Just delete and re-add — log entries will be orphaned. Better to add updateSupplement.
// Quick approach: raw execute
const sets = ["name=?1", "daily_dose_g=?2"];
const vals = [name, data.daily_dose_g];
let idx = 3;
for (const f of MICRO_FIELDS) {
sets.push(`${f.key}=?${idx}`);
vals.push(data[f.key]);
idx++;
}
vals.push(existing.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
values: vals,
});
}
});
if (!isNew) {
setTimeout(() => {
const delBtn = document.querySelector("#m-del-supp");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteSupplement(existing.id);
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("supplements");
};
}
}, 100);
}
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
async function renderSettings(container) {
const p = state.profile;
const profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
<div class="settings-screen">
<h2>Настройки</h2>
<div class="settings-section">
<div class="settings-item" id="s-open-profile">
<div class="settings-item-left">
<div class="settings-item-title">Профиль</div>
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Карты</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Оффлайн режим</div>
<div class="settings-item-desc">Тайлы только из кэша</div>
</div>
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">GPS (запись треков)</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. скорость (км/ч)</div>
<div class="settings-item-desc">Ниже = пауза</div>
</div>
<input id="s-min-speed" type="number" step="0.1" value="${(GPS.config.minSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. точность (м)</div>
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
</div>
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. расстояние (м)</div>
<div class="settings-item-desc">Точки ближе = пропуск</div>
</div>
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Разрешения</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Геолокация (GPS)</div>
<div class="settings-item-desc" id="s-gps-status">Проверка...</div>
</div>
<button class="settings-toggle" id="s-gps-toggle"></button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">О приложении</div>
<div class="settings-item" id="s-about">
<div class="settings-item-left">
<div class="settings-item-title">BalDav</div>
<div class="settings-item-desc" id="s-version">...</div>
</div>
</div>
</div>
</div>`;
// Profile button → open profile modal
container.querySelector("#s-open-profile").onclick = () => showProfileModal();
// Offline toggle
const offToggle = container.querySelector("#s-offline-toggle");
offToggle.onclick = () => {
const newVal = !TileCache.offlineMode;
TileCache.setOfflineMode(newVal);
offToggle.classList.toggle("on", newVal);
};
// GPS config inputs
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist"];
for (const id of gpsInputs) {
const el = container.querySelector("#" + id);
if (el) el.onchange = () => {
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
GPS.saveConfig();
};
}
// GPS permission check — works on both desktop and mobile
const gpsStatus = container.querySelector("#s-gps-status");
const gpsToggle = container.querySelector("#s-gps-toggle");
// Check GPS availability
if (!navigator.geolocation) {
gpsStatus.textContent = "Недоступно";
} else {
// Try to actually get position to check real permission
gpsStatus.textContent = "Проверка...";
navigator.geolocation.getCurrentPosition(
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
(err) => {
if (err.code === 1) gpsStatus.textContent = "Нажмите для запроса";
else gpsStatus.textContent = "Ошибка: " + err.message;
},
{ enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 }
);
}
gpsToggle.onclick = () => {
gpsStatus.textContent = "Запрашиваю...";
navigator.geolocation.getCurrentPosition(
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
(err) => {
gpsStatus.textContent = err.code === 1 ? "Запрещено" : "Ошибка: " + err.message;
gpsToggle.classList.remove("on");
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
// App version info
try {
const info = await window.__TAURI__.core.invoke("get_app_info");
container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`;
} catch {
container.querySelector("#s-version").textContent = "dev";
}
}
function showProfileModal() {
const p = state.profile;
let selectedSex = p.sex ?? "m";
showModal(`
<h3>Профиль</h3>
<div class="form-group">
<label>Рост (см)</label>
<input id="m-height" type="number" inputmode="numeric" value="${p.height_cm ?? ""}" placeholder="178" />
</div>
<div class="form-group">
<label>Год рождения</label>
<input id="m-year" type="number" inputmode="numeric" value="${p.birth_year ?? ""}" placeholder="1988" />
</div>
<div class="form-group">
<label>Пол</label>
<div class="type-chips">
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
</div>
</div>
<div class="form-group">
<label>BMR по формуле (ккал/день)</label>
<input id="m-bmr-formula" type="number" inputmode="numeric" value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="m-bmr-est" type="number" inputmode="numeric" value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>`,
async overlay => {
const height = parseInt(overlay.querySelector("#m-height").value);
const year = parseInt(overlay.querySelector("#m-year").value);
const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null;
const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) return false;
await DB.setProfileValue("height_cm", height);
await DB.setProfileValue("birth_year", year);
await DB.setProfileValue("sex", selectedSex);
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
state.profile = { ...state.profile,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
bmr_formula: bFormula ? String(bFormula) : null,
bmr_estimated: bEst ? String(bEst) : null };
});
// Sex chip handler inside modal
setTimeout(() => {
const overlay = document.querySelector(".modal-overlay");
if (!overlay) return;
overlay.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
}, 100);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// РСП — рекомендуемая суточная потребность (взрослый мужчина)
const RDA = {
vit_a_mcg: 900, vit_c_mg: 90, vit_d_mcg: 15, vit_e_mg: 15, vit_k_mcg: 120,
vit_b1_mg: 1.2, vit_b2_mg: 1.3, vit_b3_mg: 16, vit_b6_mg: 1.7,
vit_b12_mcg: 2.4, folate_mcg: 400,
calcium_mg: 1000, iron_mg: 8, magnesium_mg: 400, zinc_mg: 11,
copper_mg: 0.9, selenium_mcg: 55, potassium_mg: 3400, sodium_mg: 1500,
};
const RDA_LABELS = {
vit_a_mcg: "A", vit_c_mg: "C", vit_d_mcg: "D", vit_e_mg: "E", vit_k_mcg: "K",
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3", vit_b6_mg: "B6",
vit_b12_mcg: "B12", folate_mcg: "Фол",
calcium_mg: "Ca", iron_mg: "Fe", magnesium_mg: "Mg", zinc_mg: "Zn",
copper_mg: "Cu", selenium_mcg: "Se", potassium_mg: "K+", sodium_mg: "Na",
};
function buildMicroStrip(totals) {
const items = [];
for (const [key, rda] of Object.entries(RDA)) {
const val = totals[key];
if (val == null || val === 0) continue;
const pct = Math.round((val / rda) * 100);
const cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low";
items.push(`<span class="micro-chip ${cls}">${RDA_LABELS[key]} ${pct}%</span>`);
}
if (items.length === 0) return "";
return `<div class="micro-strip">${items.join("")}</div>`;
}
function calcBmr(weights) {
const p = state.profile;
if (!p.height_cm || !p.birth_year) return null;
if (p.bmr_estimated) return parseInt(p.bmr_estimated);
const kg = weights.length ? weights[weights.length - 1].weight_kg
: state.latestWeight?.weight_kg;
if (!kg) return parseInt(p.bmr_formula) || null;
return BMR.calculateBmr(kg, parseInt(p.height_cm),
BMR.getAge(parseInt(p.birth_year)), p.sex ?? "m");
}
function balanceClass(balance) {
if (balance == null) return "";
return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced";
}
function formatSleepInfo(s) {
const lines = [];
if (s.asleep_time && s.wake_time) {
const a = toMin(s.asleep_time), w = toMin(s.wake_time);
const h = ((a >= 720 ? 1440 - a + w : w - a) / 60).toFixed(1);
lines.push(`Сон: ${h} ч (${s.asleep_time} – ${s.wake_time})`);
}
if (s.nap_minutes) lines.push(`Дневной: ${s.nap_minutes} мин`);
if (s.notes) lines.push(esc(s.notes));
return lines.map(l => `<span class="sleep-line">${l}</span>`).join("") ||
`<span class="log-empty">нет данных</span>`;
}
function toMin(t) {
const [h, m] = (t || "0:0").split(":").map(Number);
return h * 60 + m;
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function nowTime() {
return new Date().toTimeString().slice(0, 5);
}
function shiftDate(iso, days) {
const d = new Date(iso);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function formatDate(iso) {
return new Date(iso + "T00:00:00")
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
function addZoomDisplay(map) {
const ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {
const div = L.DomUtil.create("div", "zoom-display");
div.textContent = map.getZoom();
map.on("zoomend", () => { div.textContent = map.getZoom(); });
return div;
};
ctrl.addTo(map);
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
const input = document.createElement("input");
input.type = "file";
if (accept) input.accept = accept;
input.style.display = "none";
document.body.appendChild(input);
input.addEventListener("change", async () => {
console.log("[pickFile] change event, files:", input.files.length);
const file = input.files[0];
document.body.removeChild(input);
if (!file) return;
console.log("[pickFile] file:", file.name, file.size);
try {
if (asBinary) {
const buf = await file.arrayBuffer();
await callback(buf, file);
} else {
const text = await file.text();
await callback(text, file);
}
} catch (e) {
console.error("[pickFile] error:", e);
}
});
input.click();
}
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
// Global back navigation between screens
window.addEventListener("popstate", (e) => {
// If a modal is open, don't handle here (modal has its own handler)
if (document.querySelector(".modal-overlay")) return;
// Navigate to previous screen or stay on today
if (e.state && e.state.screen) {
renderScreen(e.state.screen);
}
});
init().catch(err => {
console.error(err);
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
});