`;
return;
}
// Diagnostic logs stay in the source but only fire in debug builds. Rust reports
// the compiled build profile (cfg!(debug_assertions)); in a release build we
// silence console.log lines that start with a diagnostic tag ([poll]/[recover]/
// [stop]/[GPS]/[HR]/[SD]/...). console.error/warn are never touched. This means
// no adding/removing logs by hand — a tagged log auto-disappears in release.
try {
let info = await window.__TAURI__.core.invoke("get_app_info");
window.__BALDAV_DEBUG = !!info.debug;
} catch { window.__BALDAV_DEBUG = false; }
if (!window.__BALDAV_DEBUG) {
let _log = console.log.bind(console);
let DIAG = /^\[(poll|recover|stop|GPS|HR|SD|seq|native|flush|watch|import|rec)\]/;
console.log = function (...a) { if (typeof a[0] === "string" && DIAG.test(a[0])) return; _log.apply(console, a); };
}
// Show loading immediately so the user sees something
app.innerHTML = `
Загрузка...
`;
let bootEl = document.getElementById("boot-label");
let bootCount = document.getElementById("seed-count");
let bootFill = document.getElementById("seed-fill");
// 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 {
let recovered = await GPS.recoverSession();
if (recovered) showToast("Восстановлена незавершённая тренировка");
} catch (e) { console.error("[Recovery]", e); }
// Load RDI standard and half-life data from profile (default: RU)
try {
let rdiCode = (await DB.getProfileValue("rdi_standard")) || "RU";
await BMR.loadRDI(rdiCode);
await BMR.loadHalflife();
} catch (e) { /* RDI stays at fallback RU values */ }
// Import seed products if needed — show progress only if actually importing
try {
let unlisten = null;
try {
unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => {
let { current, total } = ev.payload;
let pct = total > 0 ? Math.round(current / total * 100) : 0;
if (bootEl) bootEl.textContent = "Обновление продуктов (" + pct + "%)";
if (bootFill) { bootFill.classList.add("determinate"); bootFill.style.width = pct + "%"; }
if (bootCount) bootCount.textContent = current + " / " + total;
});
} catch {}
let result = await window.__TAURI__.core.invoke("import_seed_products");
if (unlisten) unlisten();
} catch (e) { console.error("[SEED]", e); appAlert("Seed error: " + e); }
// Apply safe area insets (Android status bar / nav bar)
try {
let 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();
// Migrate reserves for historical days (one-time)
try { await migrateReserves(); } catch (e) { console.error("[Reserves]", e); }
// Seed browser history from navStack so back gesture works after restart
history.replaceState({ screen: navStack[0] }, "");
for (let i = 1; i < navStack.length; i++) {
history.pushState({ screen: navStack[i] }, "");
}
// Add padding entries so first back doesn't exit
for (let i = 0; i < 3; i++) history.pushState({ screen: navStack[navStack.length - 1] }, "");
// Restore last screen (fromPopstate=true to avoid duplicate navStack push)
renderScreen(navStack[navStack.length - 1], true);
// Initialize sync
try { await Sync.init(); if (Sync.userId) Sync.now(); } catch {}
// Check for updates in background (non-blocking)
checkForUpdate();
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function renderScreen(name, fromPopstate) {
// Release screen wake lock and compass listener when leaving recording screen
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
let prevScreen = state.screen;
state.screen = name;
// Push to nav stack and browser history (unless triggered by popstate)
if (!fromPopstate && prevScreen && prevScreen !== name) {
navStack.push(name);
if (navStack.length > NAV_MAX) navStack.shift();
persistNavStack();
history.pushState({ screen: name }, "");
}
let screens = {
today: renderToday,
products: renderProducts,
recipes: renderRecipes,
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") {
let bar = document.createElement("div");
bar.className = "rec-floating-bar";
bar.onclick = () => renderScreen("recording");
bar.innerHTML = `Запись...`;
app.appendChild(bar);
// Update bar every second
if (window._recBarTimer) clearInterval(window._recBarTimer);
window._recBarTimer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
let 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));
let content = document.createElement("div");
content.className = "screen-content";
app.appendChild(content);
screens[name](content);
}
function buildNav(active) {
let 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 => {
let 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 = `
`;
document.body.appendChild(overlay);
let stackBase = winStack.length; // remember stack depth before our push
winPush("modal", () => overlay.remove());
function closeModal() {
// Pop all entries this modal added (fullscreen children + modal itself)
let count = winStack.length - stackBase;
for (let i = 0; i < count; i++) winStack.pop().close();
// Jump history back by that many steps; skip the resulting popstate events
_skipPops = count;
if (count > 0) history.go(-count);
}
overlay.querySelector("#modal-back").onclick = () => closeModal();
overlay.querySelector("#modal-cancel").onclick = () => closeModal();
overlay.querySelector("#modal-ok").onclick = async () => {
try {
let ok = await onSubmit(overlay);
if (ok !== false) { closeModal(); if (ok !== "norender") renderScreen(state.screen); }
} catch (e) {
console.error("[showModal] onSubmit error:", e);
appAlert("Ошибка: " + e);
}
};
if (onMount) onMount(overlay);
}
// In-app confirm dialog (replaces the ugly native confirm()). Integrated with the
// winStack/history so the hardware back button cancels it. Returns Promise.
function appConfirm(message, okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
${message}
`;
document.body.appendChild(ov);
let settled = false;
let base = winStack.length;
winPush("confirm", () => { ov.remove(); if (!settled) { settled = true; resolve(false); } });
let finish = (val) => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve(val);
};
ov.querySelector("#cf-cancel").onclick = () => finish(false);
ov.querySelector("#cf-ok").onclick = () => finish(true);
});
}
// In-app text prompt (replaces native prompt()). Returns Promise.
function appPrompt(message, defaultValue = "", okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
${message}
`;
document.body.appendChild(ov);
let input = ov.querySelector("#cf-input");
let settled = false;
let base = winStack.length;
winPush("prompt", () => { ov.remove(); if (!settled) { settled = true; resolve(null); } });
let finish = (val) => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve(val);
};
ov.querySelector("#cf-cancel").onclick = () => finish(null);
ov.querySelector("#cf-ok").onclick = () => finish(input.value);
input.focus();
input.select();
});
}
// In-app info dialog (replaces native alert()). One OK button. Returns Promise.
function appAlert(message, okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
${message}
`;
document.body.appendChild(ov);
let settled = false;
let base = winStack.length;
winPush("alert", () => { ov.remove(); if (!settled) { settled = true; resolve(); } });
let finish = () => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve();
};
ov.querySelector("#cf-ok").onclick = () => finish();
});
}
// Close the currently-open track-detail modal (plus its fullscreen-map child, if any),
// balance the history stack, then reopen it fresh. Mirrors the recalc/refine pattern
// but pops down to the detail modal so it also works while the map is fullscreen —
// re-rendering in place instead of stacking a second overlay (which broke the map/charts).
function reopenDetail(sessionId) {
let count = 0;
while (winStack.length > 0) {
let top = winStack.pop();
count++;
try { top.close(); } catch {}
if (top.type === "modal") break;
}
if (count > 0) { _skipPops = count; history.go(-count); }
showSessionDetail(sessionId);
}
function showWeightModal() {
showModal(`
Вес
`,
async overlay => {
let kg = parseFloat(overlay.querySelector("#m-kg").value);
let time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await DB.addWeight(state.date, time, kg);
});
}
function showFoodModal() {
let selectedProduct = null;
let selectedUnit = null; // { id, name, name_short, grams_per_unit }
let availableUnits = [];
showModal(`
Еда
`,
async overlay => {
let time = overlay.querySelector("#m-time").value;
let rawAmount = parseAmount(overlay.querySelector("#m-amount").value) || 100;
if (selectedProduct) {
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 {
let desc = overlay.querySelector("#m-desc").value.trim();
if (!desc) return false;
let kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
let protein = parseFloat(overlay.querySelector("#m-protein").value) || null;
let fat = parseFloat(overlay.querySelector("#m-fat").value) || null;
let carbs = parseFloat(overlay.querySelector("#m-carbs").value) || null;
let amount = rawAmount;
if (kcal || protein || fat || carbs) {
// Create a custom product in DB, then log it
let pid = await DB.addProduct({
name: desc, kcal_per_100g: kcal, protein_per_100g: protein,
fat_per_100g: fat, carbs_per_100g: carbs, source: "manual",
});
await DB.addFoodEntry(state.date, time, pid, amount, null);
} else {
await DB.addFoodManual(state.date, time, desc, kcal);
}
}
});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let selectedEl = overlay.querySelector("#m-selected");
let manualFields = overlay.querySelector("#m-manual-fields");
let unitChipsEl = overlay.querySelector("#m-unit-chips");
let amountLabel = overlay.querySelector("#m-amount-label");
let amountInput = overlay.querySelector("#m-amount");
function renderUnitChips() {
// "г" chip is always first (default), then available units
let html = ``;
for (let u of availableUnits) {
if (u.grams_per_unit == null) continue; // skip units without conversion
html += ``;
}
unitChipsEl.innerHTML = html;
unitChipsEl.style.display = "";
unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => {
chip.onclick = () => {
let uid = parseInt(chip.dataset.unitId);
if (uid === 0) {
selectedUnit = null;
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
} else {
selectedUnit = availableUnits.find(u => u.id === uid);
amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`;
amountInput.placeholder = "1";
amountInput.value = "1";
}
unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
};
});
}
createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => {
selectedProduct = product;
selectedUnit = null;
selectedEl.style.display = "block";
selectedEl.innerHTML = `${esc(product.name)}`;
manualFields.style.display = "none";
// Load available units for this product
availableUnits = await DB.getUnitsForProduct(product.id);
if (availableUnits.length > 0) {
renderUnitChips();
} else {
unitChipsEl.style.display = "none";
}
// Reset amount — show "мл" for liquids
let isLiquid = product.water_pct && product.water_pct > 80;
amountLabel.textContent = `Количество (${isLiquid ? "мл" : "г"})`;
amountInput.placeholder = isLiquid ? "250" : "100";
amountInput.value = isLiquid ? "250" : "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";
};
});
}
function showEditFoodModal(entry) {
let newProduct = null; // if user picks a different product
let origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? "";
showModal(`
Редактирование
${esc(origLabel)}
${!entry.product_id && !entry.recipe_id ? `
` : ""}
`,
async overlay => {
let amount = parseAmount(overlay.querySelector("#m-amount").value);
let time = overlay.querySelector("#m-time").value;
let 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 {
let kcalEl = overlay.querySelector("#m-kcal");
let kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null;
await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride);
}
});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let currentEl = overlay.querySelector("#m-current");
let searchBlock = overlay.querySelector("#m-search-block");
currentEl.onclick = () => {
searchBlock.style.display = "block";
searchBlock.querySelector("#m-prod-search").focus();
};
let searchInput = overlay.querySelector("#m-prod-search");
let resultsEl = overlay.querySelector("#m-prod-results");
let debounce = null;
searchInput.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
let q = searchInput.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
let results = await DB.searchProducts(q);
resultsEl.innerHTML = results.map(p =>
`
`;
}
function renderProdDetails(p) {
let r = v => v != null ? v : "—";
// Micronutrient chips for this product (per 100g)
let RDI = BMR.RDI;
let microChips = [];
for (let [key, rdi] of Object.entries(RDI)) {
if (!p[key]) continue;
let pct = Math.round(p[key] / rdi * 100);
if (pct < 1) continue;
let label = NUTRIENT_LABELS[key] || key;
let cls = pct < 30 ? "nut-low" : pct < 80 ? "nut-mid" : "nut-ok";
microChips.push({ pct, html: `
`,
async overlay => {
let name = overlay.querySelector("#np-name").value.trim();
if (!name) return false;
let data = {
name,
kcal_per_100g: parseFloat(overlay.querySelector("#np-kcal").value) || null,
protein_per_100g: parseFloat(overlay.querySelector("#np-protein").value) || null,
fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null,
carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null,
water_pct: parseFloat(overlay.querySelector("#np-water").value) || null,
source: pf.source === "chzn" ? "chzn" : "manual",
};
for (let f of PRODUCT_MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#np-${f.key}`).value) || null;
if (val && f.mult) val *= f.mult;
data[f.key] = val;
}
await DB.addProduct(data);
if (onSaved) onSaved();
});
}
// Честный знак scanner: camera + photo/manual, decoded by bundled zxing-wasm
// (ZXing-C++: frontend/lib/zxing-wasm.js + zxing_reader.wasm) — fully offline,
// no Google Play Services, and far better at LOCATING a DataMatrix than the
// pure-JS port. Live frames are cropped to the central ROI and upscaled for
// speed/reliability. On decode -> ChZ.resolve -> prefill the new-product modal.
function showChznScanModal(onSaved) {
if (!ChZ.hasSession()) {
appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh).");
return;
}
let ov = document.createElement("div");
ov.className = "modal-overlay";
ov.innerHTML = `
Скан «Честный знак»
🔍
Запуск камеры...
`;
document.body.appendChild(ov);
let stream = null, scanning = false, busy = false, zoomFactor = 1;
let base = winStack.length;
let statusEl = ov.querySelector("#cz-status");
let video = ov.querySelector("#cz-video");
let canvas = document.createElement("canvas");
let cctx = canvas.getContext("2d", { willReadFrequently: true });
function stopCamera() {
scanning = false;
try {
let s = video && video.srcObject;
if (s && s.getTracks) s.getTracks().forEach(t => t.stop());
if (video) video.srcObject = null;
} catch {}
}
winPush("chzn-scan", () => { stopCamera(); ov.remove(); });
function close() {
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
}
// Close this modal AND wait for its history.go popstate(s) to flush before the
// caller opens the next modal. Opening it synchronously after close() races the
// async history.go (it would unwind the NEW modal's entry) and desyncs the back
// stack — that was the "swipe-back does nothing" bug on scan -> add-product.
function closeThen() {
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
if (count <= 0) return Promise.resolve();
_skipPops = count;
return new Promise(res => {
let remaining = count;
let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } };
window.addEventListener("popstate", h);
history.go(-count);
});
}
async function onDecoded(rawVal) {
if (busy) return;
busy = true; scanning = false;
statusEl.textContent = "Код распознан, запрашиваю КБЖУ...";
try {
let prefill = await ChZ.resolve(rawVal);
stopCamera();
await closeThen();
showAddProductModal(onSaved, prefill);
} catch (e) {
busy = false;
statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз.";
if (stream) { scanning = true; tick(); }
}
}
// Central ROI of the current video frame, upscaled to a square -> ImageData.
// Cropping to the guide box removes clutter and makes the code larger, which
// is exactly what the locator needs (full-frame search was slow/unreliable).
function grabROI() {
let vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return null;
// The square container shows the video center-cropped (object-fit:cover), so
// sample exactly that center square (WYSIWYG). zxing-wasm locates the code
// anywhere within it — no need for the user to fill a tiny box; use zoom to
// enlarge the code at a focusable distance instead.
let side = Math.min(vw, vh) / zoomFactor; // digital zoom crops tighter to center
let sx = (vw - side) / 2, sy = (vh - side) / 2;
let out = Math.min(Math.min(vw, vh), 768); // enough detail, still fast
canvas.width = out; canvas.height = out;
cctx.drawImage(video, sx, sy, side, side, 0, 0, out, out);
return cctx.getImageData(0, 0, out, out);
}
async function tick() {
if (busy || !scanning) return;
let img = grabROI();
if (img) {
try {
let raw = await ChZ.decodeImageData(img, ["DataMatrix"]);
if (raw) { onDecoded(raw); return; }
} catch (e) { /* keep scanning */ }
}
if (scanning) setTimeout(tick, 100);
}
// Wire zoom slider / torch / continuous focus from the track capabilities.
// Zoom is the real answer to "the code is too small at focus distance" — the
// user enlarges the code optically instead of moving the phone dangerously close.
let torchOn = false;
function setupCameraControls() {
let track = stream && stream.getVideoTracks && stream.getVideoTracks()[0];
if (!track || !track.getCapabilities) return;
let caps = {};
try { caps = track.getCapabilities() || {}; } catch {}
try {
if (caps.focusMode && caps.focusMode.includes("continuous")) {
track.applyConstraints({ advanced: [{ focusMode: "continuous" }] }).catch(() => {});
}
} catch {}
// Zoom slider: prefer hardware zoom; otherwise fall back to digital zoom
// (CSS-scale the preview + crop the decode ROI). Always shown so the user can
// enlarge a small code instead of moving the phone inside its focus range.
let zw = ov.querySelector("#cz-zoom-wrap");
let zi = ov.querySelector("#cz-zoom");
if (caps.zoom && caps.zoom.max > caps.zoom.min) {
zi.min = caps.zoom.min; zi.max = caps.zoom.max;
zi.step = caps.zoom.step || (caps.zoom.max - caps.zoom.min) / 100 || 0.1;
zi.value = ((track.getSettings && track.getSettings()) || {}).zoom || caps.zoom.min;
zi.oninput = () => { track.applyConstraints({ advanced: [{ zoom: parseFloat(zi.value) }] }).catch(() => {}); };
} else {
zi.min = 1; zi.max = 5; zi.step = 0.1; zi.value = 1;
zi.oninput = () => {
zoomFactor = parseFloat(zi.value) || 1;
video.style.transformOrigin = "center center";
video.style.transform = "scale(" + zoomFactor + ")";
};
}
zw.style.display = "flex";
if (caps.torch) {
let tb = ov.querySelector("#cz-torch");
tb.style.display = "block";
tb.onclick = () => {
torchOn = !torchOn;
track.applyConstraints({ advanced: [{ torch: torchOn }] }).catch(() => {});
tb.style.background = torchOn ? "rgba(255,214,10,.35)" : "";
};
}
}
async function startCamera() {
try {
statusEl.textContent = "Загрузка декодера...";
await ChZ.zxReady();
} catch (e) {
statusEl.textContent = "Декодер не загрузился: " + (e.message || e);
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" }, width: { ideal: 1920 }, height: { ideal: 1080 } },
});
video.srcObject = stream;
await video.play();
setupCameraControls();
scanning = true;
statusEl.textContent = "Наведите на код (используйте зум, если код мелкий)…";
tick();
} catch (e) {
statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код.";
}
}
ov.querySelector("#cz-photo").onclick = () => {
pickFile("image/*", async (buf) => {
try {
await ChZ.zxReady();
let bmp = await createImageBitmap(new Blob([buf]));
let c = document.createElement("canvas");
c.width = bmp.width; c.height = bmp.height;
let x = c.getContext("2d", { willReadFrequently: true });
x.drawImage(bmp, 0, 0);
let img = x.getImageData(0, 0, bmp.width, bmp.height);
let raw = await ChZ.decodeImageData(img, ["DataMatrix", "QRCode"]);
if (raw) onDecoded(raw);
else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое/крупное фото.";
} catch (e) { statusEl.textContent = "Ошибка распознавания: " + (e.message || e); }
}, true);
};
ov.querySelector("#cz-manual").onclick = async () => {
let code = await appPrompt("Содержимое кода DataMatrix (01…21…93…):", "");
if (code) onDecoded(code.trim());
};
ov.querySelector("#cz-close").onclick = () => close();
startCamera();
}
function showEditProductModal(p, onSaved) {
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
let val = p[f.key] ?? "";
if (val && f.mult) val = Math.round(val / f.mult * 1000) / 1000;
return `
`;
}).join("");
showModal(`
Редактирование продукта
Нутриенты на 100г:
${microHtml}
`,
async overlay => {
let name = overlay.querySelector("#ep-name").value.trim();
if (!name) return false;
let waterPct = parseFloat(overlay.querySelector("#ep-water").value) || null;
let sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5", "water_pct=?6"];
let vals = [
name,
parseFloat(overlay.querySelector("#ep-kcal").value) || null,
parseFloat(overlay.querySelector("#ep-protein").value) || null,
parseFloat(overlay.querySelector("#ep-fat").value) || null,
parseFloat(overlay.querySelector("#ep-carbs").value) || null,
waterPct,
];
let idx = 7;
for (let f of PRODUCT_MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#ep-${f.key}`).value) || null;
if (val && f.mult) val *= f.mult;
sets.push(`${f.key}=?${idx}`);
vals.push(val);
idx++;
}
sets.push(`user_edited=1`);
vals.push(p.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE products SET ${sets.join(", ")} WHERE id=?${idx}`,
values: vals,
});
if (onSaved) onSaved();
});
// Export single product
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let expBtn = overlay?.querySelector("#ep-export");
if (expBtn) expBtn.onclick = () => {
let clean = { ...p };
delete clean.id;
downloadFile(`baldav-product-${(p.name || "").slice(0, 30)}.json`, JSON.stringify({ products: [clean] }, null, 2), "application/json");
};
}, 100);
}
// ---------------------------------------------------------------------------
// RECIPES screen
// ---------------------------------------------------------------------------
async function renderRecipes(container) {
let recipes = await DB.getRecipes();
// Pre-calculate nutrition for each recipe to show kcal in list
let nutritionMap = {};
for (let r of recipes) {
try {
nutritionMap[r.id] = await DB.calculateRecipeNutrition(r.id);
} catch (e) {
nutritionMap[r.id] = null;
}
}
container.innerHTML = `
Рецепты
${recipes.length ? recipes.map(r => {
let n = nutritionMap[r.id];
let kcalStr = n ? n.kcal + " кк" : "";
let macroStr = n ? `Б${n.protein} Ж${n.fat} У${n.carbs}` : "";
return `
`, async overlay => {
let name = overlay.querySelector("#re-name").value.trim();
if (!name) return false;
if (ingredients.length === 0) return false;
let methodId = parseInt(overlay.querySelector("#re-method").value) || null;
let servings = parseFloat(overlay.querySelector("#re-servings").value) || 1;
let notes = overlay.querySelector("#re-notes").value.trim() || null;
let rid;
if (isNew) {
rid = await DB.saveRecipe(name, methodId, servings, notes);
} else {
rid = recipeId;
await DB.updateRecipe(rid, name, methodId, servings, notes);
await DB.clearRecipeIngredients(rid);
}
for (let ing of ingredients) {
await DB.addRecipeIngredient(
rid, ing.product_id, ing.custom_name, ing.weight_g, ing.cooking_method_id
);
}
// Cache computed nutrients on the recipe for daily tracking
await DB.cacheRecipeNutrients(rid);
renderScreen("recipes");
});
// Set initial method value
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (recipe.cooking_method_id) {
overlay.querySelector("#re-method").value = recipe.cooking_method_id;
}
// Servings or method change recalculates totals
overlay.querySelector("#re-servings").oninput = updateTotalsDisplay;
overlay.querySelector("#re-method").onchange = updateTotalsDisplay;
// Bind delete buttons and add ingredient button
function rebindIngredients() {
let listEl = overlay.querySelector("#re-ing-list");
listEl.innerHTML = renderIngredientRows();
listEl.querySelectorAll(".recipe-ing-del").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
let idx = parseInt(btn.dataset.idx);
ingredients.splice(idx, 1);
rebindIngredients();
updateTotalsDisplay();
};
});
}
rebindIngredients();
// Add ingredient button — shows inline search
overlay.querySelector("#re-add-ing").onclick = () => {
showAddIngredientModal(cookingMethods, (newIng) => {
ingredients.push(newIng);
rebindIngredients();
updateTotalsDisplay();
});
};
// Load product data for existing ingredients (for nutrition calc)
for (let i = 0; i < ingredients.length; i++) {
if (ingredients[i].product_id) {
ingredients[i]._product = await DB.getProduct(ingredients[i].product_id);
}
}
updateTotalsDisplay();
}
// ---------------------------------------------------------------------------
// Add ingredient sub-modal — search product, set weight, optional cooking method
// ---------------------------------------------------------------------------
function showAddIngredientModal(cookingMethods, onAdd) {
let selectedProduct = null;
let cmOptions = cookingMethods.map(cm =>
``
).join("");
showModal(`
Добавить ингредиент
`, async overlay => {
let weight = parseFloat(overlay.querySelector("#ing-weight").value);
if (isNaN(weight) || weight <= 0) return false;
let methodId = parseInt(overlay.querySelector("#ing-method").value) || null;
let methodName = methodId
? cookingMethods.find(cm => cm.id === methodId)?.name ?? null
: null;
if (selectedProduct) {
onAdd({
product_id: selectedProduct.id,
product_name: selectedProduct.name,
custom_name: null,
weight_g: weight,
cooking_method_id: methodId,
cooking_method_name: methodName,
_product: selectedProduct,
});
} else {
let custom = overlay.querySelector("#ing-custom").value.trim();
if (!custom) return false;
onAdd({
product_id: null,
product_name: null,
custom_name: custom,
weight_g: weight,
cooking_method_id: methodId,
cooking_method_name: methodName,
_product: null,
});
}
return "norender"; // sub-modal: close without re-rendering parent screen
});
let overlays = document.querySelectorAll(".modal-overlay");
let overlay = overlays[overlays.length - 1];
let selectedEl = overlay.querySelector("#ing-selected");
let customBlock = overlay.querySelector("#ing-custom-block");
let search = createProductSearch(overlay.querySelector("#ing-search-wrap"), (product) => {
selectedProduct = product;
selectedEl.style.display = "block";
selectedEl.innerHTML = `${esc(product.name)}`;
customBlock.style.display = "none";
selectedEl.onclick = () => {
selectedProduct = null;
selectedEl.style.display = "none";
customBlock.style.display = "";
};
});
search.focus();
}
// ---------------------------------------------------------------------------
// Food modal: add recipe option
// ---------------------------------------------------------------------------
function showFoodFromRecipeModal() {
// Show a list of saved recipes to quickly log one
showModal(`
Записать рецепт
Загрузка...
`, async overlay => {
let selected = overlay.querySelector(".recipe-pick-item.selected");
if (!selected) return false;
let rid = parseInt(selected.dataset.rid);
let time = overlay.querySelector("#rfr-time").value;
let amountG = parseAmount(overlay.querySelector("#rfr-amount").value);
if (isNaN(amountG) || amountG <= 0) return false;
await DB.addFoodFromRecipe(state.date, time, rid, amountG);
});
// Load recipes into the list
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let listEl = overlay.querySelector("#rfr-list");
(async () => {
let recipes = await DB.getRecipes();
if (!recipes.length) {
listEl.innerHTML = `
Нет рецептов. Создайте в Продукты → Рецепты.
`;
return;
}
// Calculate nutrition for display
let items = [];
for (let r of recipes) {
let n = await DB.calculateRecipeNutrition(r.id);
items.push({ ...r, nutr: n });
}
listEl.innerHTML = items.map(r =>
`
`;
// Recording button
// Save activity type on change
container.querySelector("#gps-type").onchange = (e) => {
localStorage.setItem("baldav_gps_type", e.target.value);
};
container.querySelector("#gps-rec-btn").onclick = async () => {
if (GPS.recording.active) {
renderScreen("recording");
} else {
let type = container.querySelector("#gps-type").value;
localStorage.setItem("baldav_gps_type", type);
// A fresh start shows the selected route by default (overriding a prior toggle-off).
if (localStorage.getItem("baldav_active_route")) localStorage.setItem("baldav_route_shown", "1");
let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
// Get active bike for bike sessions — store bike data in recording state
if (type === "bike") {
let bike = await DB.getActiveBike();
if (bike) {
GPS.recording.bikeId = bike.id;
GPS.recording.bikeWeightKg = bike.weight_kg ?? 0;
GPS.recording.surfaceCrr = ({asphalt: 0.004, mixed: 0.006, gravel: 0.008})[bike.surface_type] ?? 0.006;
GPS.recording.tirePressureBar = bike.tire_pressure_bar ?? 3.0;
GPS.recording.chainringTeeth = bike.chainring_teeth;
GPS.recording.cogTeeth = bike.cog_teeth;
GPS.recording.wheelDiameterInch = bike.wheel_diameter_inch;
GPS.recording.gearCount = bike.gear_count;
}
}
await GPS.startRecording(type, wkg);
renderScreen("recording");
}
};
// Session click → detail view. Keep sid as a STRING (numeric id from the
// SQL plugin is corrupted off-by-1 for large i64 values).
let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); };
container.querySelectorAll(".gps-session-item").forEach(bindSessionClick);
// Pagination: "Показать ещё" loads the next page from the DB and appends it.
let sessOffset = sessions.length;
let moreBtn = container.querySelector("#gps-more");
if (moreBtn) moreBtn.onclick = async () => {
moreBtn.disabled = true;
let label = moreBtn.textContent;
moreBtn.textContent = "Загрузка...";
let next = await DB.getGpsSessions(PAGE_SIZE, sessOffset);
let listEl = container.querySelector("#gps-session-list");
next.forEach(s => {
let tmp = document.createElement("div");
tmp.innerHTML = sessionCard(s);
let item = tmp.firstElementChild;
bindSessionClick(item);
listEl.appendChild(item);
});
sessOffset += next.length;
moreBtn.disabled = false;
moreBtn.textContent = label;
if (next.length < PAGE_SIZE) moreBtn.style.display = "none";
};
// Three-dot menu
let menuBtn = container.querySelector("#gps-menu-btn");
let 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-draw-route").onclick = () => showDrawRouteScreen(container);
container.querySelector("#gps-routes").onclick = () => {
menu.classList.add("hidden");
showRoutesScreen(container);
};
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");
let count = await TileCache.count();
await TileCache.clear();
console.log("Cache cleared");
};
// Recover points from native files — rebuilds sessions whose DB rows fell behind
// the service's JSONL (e.g. screen-off import glitch). Works fully on-device.
container.querySelector("#gps-recover").onclick = async () => {
menu.classList.add("hidden");
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
// Keep the screen awake — recovery runs in the WebView and stalls if it sleeps.
let wl = null;
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
// Visible progress bar (fixed at the bottom) so it's clear it's working.
let prog = document.createElement("div");
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
prog.textContent = "Восстановление: подготовка...";
document.body.appendChild(prog);
try {
let res = await GPS.recoverFromFiles((i, n) => { prog.textContent = `Восстановление: файл ${i}/${n}`; });
if (res.error === "recording") { prog.remove(); showToast("Нельзя во время записи"); return; }
let list = res.results || [];
let totalPts = list.reduce((a, r) => a + (r.imported || 0), 0);
let sessions = list.filter(r => r.imported > 0).length;
let unmatched = list.filter(r => r.unmatched).length;
let removed = res.removed || 0;
// Show the result IN the bar — a toast gets overwritten by the Bluetooth
// check when the screen re-renders. Tap the bar to close and refresh.
prog.textContent = `Готово: файлов ${list.length}, вставлено ${totalPts} в ${sessions} сессий, удалено ${removed}, сирот ${unmatched}. Нажмите, чтобы закрыть`;
prog.style.cursor = "pointer";
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
} catch (e) {
console.error("[recover] failed", e);
prog.textContent = "Ошибка восстановления: " + (e.message || e) + " — нажмите, чтобы закрыть";
prog.onclick = () => prog.remove();
} finally {
if (wl) { try { await wl.release(); } catch {} }
}
};
// Recompute stored elevation/kcal for every recorded session (formula fix).
// Stats-only: never touches points. Same progress-bar UX as recovery.
container.querySelector("#gps-recalc-stats").onclick = async () => {
menu.classList.add("hidden");
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
let wl = null;
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
let prog = document.createElement("div");
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
prog.textContent = "Пересчёт: подготовка...";
document.body.appendChild(prog);
try {
let res = await GPS.recomputeAllStats((i, n) => { prog.textContent = `Пересчёт: ${i}/${n}`; });
if (res.error) { prog.textContent = "Ошибка: " + res.error + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); return; }
prog.textContent = `Готово: пересчитано ${res.updated}, пропущено ${res.skipped}. Нажмите, чтобы закрыть`;
prog.style.cursor = "pointer";
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
} catch (e) {
console.error("[recalc-all] failed", e);
prog.textContent = "Ошибка пересчёта: " + (e.message || e) + " — нажмите, чтобы закрыть";
prog.onclick = () => prog.remove();
} finally {
if (wl) { try { await wl.release(); } catch {} }
}
};
// 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);
let ext = file.name.split(".").pop().toLowerCase();
console.log("[IMPORT] ext:", ext);
let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
try {
if (ext === "gpx") {
let text = typeof data === "string" ? data : new TextDecoder().decode(data);
await GPS.importGPX(text, wkg);
} else if (ext === "ftb" || ext === "zip") {
let buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
console.log("[IMPORT] FTB buf size:", buf.byteLength);
let 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);
};
// Goals button — manage goal markers
container.querySelector("#gps-goals-btn").onclick = () => showGoalMarkersModal();
}
// ---------------------------------------------------------------------------
// Goal markers management modal (IDEA 2)
// ---------------------------------------------------------------------------
async function showGoalMarkersModal() {
let markers = await DB.getGoalMarkers();
let html = `
Цели (маркеры)
${markers.length ? markers.map(m => `
${esc(m.name)}
${m.lat.toFixed(5)}, ${m.lon.toFixed(5)}
${m.notes ? `
${esc(m.notes)}
` : ""}
`).join("") : `
Нет маркеров. Нажмите "Добавить" для создания.
`}
`;
showModal(html, () => {});
// Hide save/cancel since this is a management modal
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
let btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
// Delete buttons
overlay.querySelectorAll(".gm-del").forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteGoalMarker(parseInt(btn.dataset.mid));
// Close and reopen modal
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showGoalMarkersModal();
};
});
// Add button
overlay.querySelector("#gm-add").onclick = () => {
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showAddGoalMarkerModal();
};
}, 100);
}
async function showAddGoalMarkerModal() {
showModal(`
Новый маркер
Нажмите на карту для выбора точки
`, async overlay => {
let name = overlay.querySelector("#gm-name").value.trim();
let lat = parseFloat(overlay.querySelector("#gm-lat").value);
let lon = parseFloat(overlay.querySelector("#gm-lon").value);
let notes = overlay.querySelector("#gm-notes").value.trim() || null;
if (!name) return false;
if (isNaN(lat) || isNaN(lon)) return false;
await DB.addGoalMarker(name, lat, lon, notes);
// Return to goals list
showGoalMarkersModal();
});
// Initialize map after DOM is ready
setTimeout(() => {
let mapEl = document.querySelector("#gm-map");
let coordsEl = document.querySelector("#gm-coords");
let latEl = document.querySelector("#gm-lat");
let lonEl = document.querySelector("#gm-lon");
if (!mapEl) return;
// Center on user's last known position or default
let defaultLat = 55.75, defaultLon = 37.62;
// Try to get last GPS point from recent sessions
DB.getGpsSessions(1).then(async sessions => {
if (sessions.length > 0) {
let pts = await DB.getGpsPoints(sessions[0].id_str ?? sessions[0].id);
if (pts.length > 0) {
let last = pts[pts.length - 1];
defaultLat = last.lat;
defaultLon = last.lon;
map.setView([defaultLat, defaultLon], 14);
}
}
}).catch(() => {});
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([defaultLat, defaultLon], 14);
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let marker = null;
map.on("click", (e) => {
let lat = e.latlng.lat;
let lon = e.latlng.lng;
latEl.value = lat;
lonEl.value = lon;
coordsEl.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
if (marker) map.removeLayer(marker);
marker = L.marker([lat, lon]).addTo(map);
});
// Also try current position
navigator.geolocation?.getCurrentPosition(
pos => {
if (!latEl.value) {
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
}
},
() => {},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
);
}, 200);
}
// ---------------------------------------------------------------------------
// Draw manual route on map
// ---------------------------------------------------------------------------
async function fetchElevations(points) {
// Terrain elevation, tried in order until one answers a batch:
// 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m).
// 2. open-elevation.com (SRTM, reachable in RU).
// 3. open-meteo (often blocked by RU carriers, kept in case).
// 4. OpenTopoData public (free, no key, srtm30m) — last-resort keyless analog.
// Batches of 100 (also OpenTopoData's max locations/request). The GPXZ key lives
// only in GPS.config (localStorage), never in git.
let elevations = new Array(points.length).fill(null);
let gpxzKey = GPS.config.gpxzKey && GPS.config.gpxzKey.trim();
for (let i = 0; i < points.length; i += 100) {
let batch = points.slice(i, i + 100);
let got = null;
if (gpxzKey) {
try {
let latlons = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");
let resp = await fetch("https://api.gpxz.io/v1/elevation/points?latlons=" + encodeURIComponent(latlons),
{ headers: { "x-api-key": gpxzKey } });
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
} else { console.log("[gpxz] http", resp.status); }
} catch (e) { console.log("[gpxz] batch fail:", e.message); }
}
if (got) { for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; continue; }
try {
let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }),
});
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
}
} catch (e) { console.log("[open-elevation] batch fail:", e.message); }
if (!got) {
try {
let lats = batch.map(p => p.lat.toFixed(5)).join(",");
let lons = batch.map(p => p.lon.toFixed(5)).join(",");
let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; }
} catch (e) { console.log("[open-meteo] batch fail:", e.message); }
}
if (!got) {
try {
let locs = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");
let resp = await fetch("https://api.opentopodata.org/v1/srtm30m?locations=" + encodeURIComponent(locs));
if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); }
} catch (e) { console.log("[opentopodata] batch fail:", e.message); }
}
if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j];
}
return elevations.some(x => x != null) ? elevations : null;
}
// Colour for the roads overlay by activity type (road_points.atype holds the full
// activity_type: "walk" / "bike" / "run"). Configurable in GPS settings.
function roadColor(atype) {
// get_road_points (Rust) returns atype as a single letter: "w" walk, "r" run, "b" bike/other.
let c = GPS.config;
if (atype === "w") return c.roadColorWalk || "#8B5E3C";
if (atype === "r") return c.roadColorRun || "#e67e22";
return c.roadColorBike || "#2266aa";
}
// Resolve once the Leaflet map has finished its initial layout, so layers added
// after this actually render (adding a polyline before the map is loaded/sized
// leaves it invisible until the next redraw). Awaits the real 'load' event — the
// single event→await bridge — then forces a size recalc.
async function whenMapReady(map) {
if (!map._loaded) await new Promise(res => map.once("load", res));
map.invalidateSize();
}
// Draw the active saved route (localStorage baldav_active_route) as a colored line
// on a Leaflet map. Removes `prev` first. Returns the new layer, or null if none.
// Hypsometric tint (like old topo maps): low = green, mid = yellow, high = brown.
// t in [0,1] (normalized elevation).
function routeElevColor(t) {
t = Math.max(0, Math.min(1, t));
let lerp = (a, b, f) => Math.round(a + (b - a) * f);
let c1, c2, f;
if (t < 0.5) { c1 = [46, 125, 50]; c2 = [253, 216, 53]; f = t / 0.5; } // green -> yellow
else { c1 = [253, 216, 53]; c2 = [109, 76, 65]; f = (t - 0.5) / 0.5; } // yellow -> brown
return `rgb(${lerp(c1[0], c2[0], f)},${lerp(c1[1], c2[1], f)},${lerp(c1[2], c2[2], f)})`;
}
async function drawActiveRouteOverlay(map, prev) {
if (prev) { try { map.removeLayer(prev); } catch {} }
let id = localStorage.getItem("baldav_active_route");
if (!id) return null;
let route = await DB.getRoute(id);
if (!route || !route.points || route.points.length < 2) return null;
return drawRoutePolyline(map, route.points, route.color);
}
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
// kept — one colour per segment): green -> yellow -> red -> brown -> black.
// <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude.
let ROUTE_SLOPE_BANDS = [
"#2e7d32", "#7cb342", "#aeea00", "#ffeb3b", "#ffc107",
"#ff9800", "#ff5722", "#e53935", "#b71c1c", "#6d4c41", "#000000",
];
function routeSlopeColor(gradePct) {
let idx = Math.min(10, Math.floor(Math.abs(gradePct || 0)));
return ROUTE_SLOPE_BANDS[idx];
}
// Compact colour strip for the slope legend (every 1% band, 0 → 10%+).
function slopeLegendHtml() {
let cells = ROUTE_SLOPE_BANDS
.map(c => ``)
.join("");
return `уклон 0% ${cells} 10%+`;
}
// Draw a route as a feature group: a wide colored casing (per segment — by slope
// or by elevation, per baldav_route_color_mode) UNDER a thin identity-color line,
// when the route has elevations; otherwise just the plain colored line. The casing
// lives in a dedicated pane below the overlay pane, so a recorded track always
// draws ON TOP of the route. Removable via map.removeLayer.
function drawRoutePolyline(map, pts, color, mode) {
mode = mode || localStorage.getItem("baldav_route_color_mode") || "slope";
// On a rotating map (leaflet-rotate) tile/overlay panes live INSIDE _rotatePane;
// a pane created at the default mapPane level wouldn't rotate/zoom with the map
// (route flashes then vanishes). Parent our pane to _rotatePane when it exists.
if (!map.getPane("routePane")) {
map.createPane("routePane", map._rotatePane || undefined);
map.getPane("routePane").style.zIndex = 250; // between tilePane(200) and overlayPane(400)
}
let popts = { pane: "routePane", interactive: false };
let latlngs = pts.map(p => [p.lat, p.lon]);
let group = L.featureGroup();
let st = GPS.routeElevStats(pts);
if (st.hasEle) {
let valid = st.smoothed.filter(e => e != null);
let lo = Math.min(...valid), hi = Math.max(...valid), span = (hi - lo) || 1;
for (let i = 0; i < pts.length - 1; i++) {
let col;
if (mode === "elev") {
let e = st.smoothed[i] ?? st.smoothed[i+1];
if (e == null) continue;
col = routeElevColor((e - lo) / span);
} else {
col = routeSlopeColor(st.grades[i]);
}
L.polyline([latlngs[i], latlngs[i+1]], { ...popts, color: col, weight: 14, opacity: 0.95 }).addTo(group);
}
}
L.polyline(latlngs, { ...popts, color: color || "#e53935", weight: 3, opacity: 0.95 }).addTo(group);
group.addTo(map);
return group;
}
// Saved routes management page (import GPX, rename, color, delete, pick active).
// Replaces the GPS container like showDrawRouteScreen; back returns to the GPS screen.
async function showRoutesScreen(container) {
let active = localStorage.getItem("baldav_active_route") || "";
async function render() {
let routes = await DB.getRoutes();
let rows = routes.map(r => `
Высоты ещё не загружены — нажмите кнопку ниже (нужен интернет).
`}
`;
container.querySelector("#rd-back").onclick = () => showRoutesScreen(container);
container.querySelector("#rd-edit").onclick = () => showRouteEditor(container, routeId);
container.querySelector("#rd-fetch").onclick = async (e) => {
let btn = e.currentTarget, orig = btn.textContent;
btn.disabled = true; btn.textContent = "Загрузка высот...";
let eles = null;
try { eles = await fetchElevations(pts); } catch {}
if (!eles) { showToast("Сервис высот недоступен"); btn.disabled = false; btn.textContent = orig; return; }
let merged = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p));
await DB.updateRoutePoints(routeId, merged);
showToast("Высоты загружены");
showRouteDetail(container, routeId);
};
let mapEl = container.querySelector("#rd-map");
if (mapEl) {
void mapEl.offsetHeight; // force layout so Leaflet reads the real size
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false, zoomControl: false });
map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing)
new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map);
let routeLayer = drawRoutePolyline(map, pts, route.color, mode);
// Tappable vertices → altitude + grade to next point
for (let i = 0; i < pts.length; i++) {
let e = st.smoothed[i], g = st.grades[i];
let info = `#${i}${e != null ? " | " + Math.round(e) + " м" : ""}${i < pts.length - 1 ? " | " + (g >= 0 ? "+" : "") + g.toFixed(1) + "%" : ""}`;
L.circleMarker([pts[i].lat, pts[i].lon], { radius: 4, color: "#fff", weight: 1, fillColor: "#000", fillOpacity: 0.5, interactive: true })
.bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
let bounds = L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.1);
// On-map color-mode toggle (slope <-> elevation) — redraws the route in place.
if (st.hasEle) {
let colorCtl = L.control({ position: "topleft" });
colorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = mode === "slope" ? "∡" : "⛰";
d.title = "Раскраска: уклон / высота";
d.onclick = (e) => {
e.stopPropagation();
mode = mode === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", mode);
map.removeLayer(routeLayer);
routeLayer = drawRoutePolyline(map, pts, route.color, mode);
d.innerHTML = mode === "slope" ? "∡" : "⛰";
let leg = container.querySelector("#rd-legend");
if (leg) leg.innerHTML = mode === "slope"
? slopeLegendHtml()
: `▬ низко → ▬ высоко`;
};
return d;
};
colorCtl.addTo(map);
}
// Fullscreen expand/collapse — reuses .sd-map-full + winStack, like the track map.
let fsDiv;
function toggleFs() {
if (mapEl.classList.contains("sd-map-full")) { history.back(); return; }
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "✕";
map.invalidateSize(); map.fitBounds(bounds);
winPush("fsRouteMap", () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "⛶";
map.invalidateSize(); map.fitBounds(bounds);
});
}
let fsCtl = L.control({ position: "topright" });
fsCtl.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "⛶";
fsDiv.title = "На весь экран";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleFs(); };
return fsDiv;
};
fsCtl.addTo(map);
map.invalidateSize();
map.fitBounds(bounds);
}
if (st.hasEle) {
let ctx = container.querySelector("#rd-elev-chart");
if (ctx) new Chart(ctx, {
type: "line",
data: {
labels: st.cumKm.map(k => k.toFixed(1)),
datasets: [{
data: st.smoothed,
borderColor: "#ffb74d", borderWidth: 2, pointRadius: 0, fill: true,
backgroundColor: "rgba(255,183,77,0.12)", tension: 0.3,
// Steep (>8%) segments turn red — the sections that hurt on a singlespeed.
segment: { borderColor: c => Math.abs(st.grades[c.p0DataIndex] || 0) > 8 ? "#e53935" : "#ffb74d" },
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: {
title: items => items[0].label + " км",
label: it => Math.round(it.raw) + " м, уклон " + ((st.grades[it.dataIndex] || 0) >= 0 ? "+" : "") + (st.grades[it.dataIndex] || 0).toFixed(1) + "%",
} },
},
scales: {
x: { 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" } },
},
},
});
}
}
// Draw a NEW saved route, or edit an existing one's geometry, right in the app —
// tap to add points, undo the last, save. Elevations are fetched afterwards from
// the preview screen (geometry-only here). New -> prompts a name; edit -> replaces
// points_json (drops stale elevations — geometry changed) keeping name/color.
async function showRouteEditor(container, routeId) {
let editing = routeId != null;
let route = editing ? await DB.getRoute(routeId) : null;
let points = (editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : [];
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
let markers = [];
let polyline = null;
// BRouter "along roads" mode: waypoints stay in `points`; when on, the line between
// them (and the saved geometry) follows roads via the offline engine. routeGeom = the
// dense routed [{lat,lon}] (null = straight through waypoints).
let brouterMode = false, routeGeom = null, routeTimer = null;
container.innerHTML = `
0 мДистанция
0Точек
тап карты — точка · тап линии — вставить · тащи — двигать · тап точки — удалить
`;
setTimeout(() => {
let mapEl = document.querySelector("#re-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map);
let distEl = document.querySelector("#re-dist");
let ptsEl = document.querySelector("#re-pts");
let dotIcon = L.divIcon({
className: "re-dot-wrap",
html: ``,
iconSize: [16, 16], iconAnchor: [8, 8],
});
function currentPts() { return (brouterMode && routeGeom) ? routeGeom : points; }
function updateStats() {
let g = currentPts(), dist = 0;
for (let i = 1; i < g.length; i++) dist += GPS.distance(g[i-1].lat, g[i-1].lon, g[i].lat, g[i].lon);
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
function updatePolyline() {
let latlngs = currentPts().map(p => [p.lat, p.lon]);
if (polyline) map.removeLayer(polyline);
if (latlngs.length >= 2) {
polyline = L.polyline(latlngs, { color, weight: 4, interactive: true }).addTo(map);
// Tap the LINE to insert a point at that spot — a distinct target from
// markers/empty-map, so it doesn't clash with add-at-end / drag / delete.
polyline.on("click", (e) => { L.DomEvent.stop(e); insertAt(e.latlng); });
}
}
// Re-route the waypoints along roads (debounced). On success set routeGeom + redraw.
async function doBrouter() {
if (!brouterMode || points.length < 2) { routeGeom = null; updatePolyline(); updateStats(); return; }
try {
let gpx = await BRouter.route(points.map(p => [p.lon, p.lat]), "trekking");
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
routeGeom = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon })) : null;
} catch (e) { routeGeom = null; showToast("BRouter: " + (e.message || e)); }
// Draw the routed geometry directly. NOT changed() — that nulls routeGeom and
// re-schedules doBrouter, looping forever and never showing the road line.
updatePolyline(); updateStats();
}
function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(doBrouter, 450); }
// A waypoint changed: straight placeholder now, then re-route (if on) shortly after.
function changed() { routeGeom = null; updatePolyline(); updateStats(); if (brouterMode && points.length >= 2) scheduleRoute(); }
// Draggable + deletable point marker. Markers stay parallel to `points` (same
// index) — drag updates points[idx], click (delete) splices both. Returns the
// marker; caller pushes (end) or splices (mid-insert) into `markers`.
function makeMarker(p) {
let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon });
m.on("drag", () => {
let idx = markers.indexOf(m);
if (idx >= 0) { points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; routeGeom = null; updatePolyline(); }
});
m.on("dragend", changed);
m.on("click", (e) => {
L.DomEvent.stop(e); // don't fall through to a map-click (which would add a point)
let idx = markers.indexOf(m);
if (idx < 0) return;
points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m);
changed();
});
m.addTo(map);
return m;
}
// Insert a point where the line was tapped: pick the nearest segment [i,i+1]
// (planar lat/lon distance — fine at city scale) and splice at i+1.
function insertAt(latlng) {
if (points.length < 2) return;
let best = -1, bestD = Infinity;
for (let i = 0; i < points.length - 1; i++) {
let a = points[i], b = points[i + 1];
let dx = b.lon - a.lon, dy = b.lat - a.lat;
let t = (dx || dy) ? ((latlng.lng - a.lon) * dx + (latlng.lat - a.lat) * dy) / (dx * dx + dy * dy) : 0;
t = Math.max(0, Math.min(1, t));
let cx = a.lon + t * dx, cy = a.lat + t * dy;
let d = Math.hypot(latlng.lng - cx, latlng.lat - cy);
if (d < bestD) { bestD = d; best = i; }
}
if (best < 0) return;
let np = { lat: latlng.lat, lon: latlng.lng };
points.splice(best + 1, 0, np);
markers.splice(best + 1, 0, makeMarker(np));
changed();
}
if (points.length) {
points.forEach(p => markers.push(makeMarker(p)));
changed();
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
map.on("click", e => {
let p = { lat: e.latlng.lat, lon: e.latlng.lng };
points.push(p); markers.push(makeMarker(p));
changed();
});
document.querySelector("#re-undo").onclick = () => {
if (!points.length) return;
points.pop();
let m = markers.pop(); if (m) map.removeLayer(m);
changed();
};
document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container);
// "Along roads" toggle: route the waypoints via BRouter and draw/save the road path.
document.querySelector("#re-brouter").onclick = () => {
brouterMode = !brouterMode;
document.querySelector("#re-brouter").classList.toggle("active", brouterMode);
changed();
};
document.querySelector("#re-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let btn = document.querySelector("#re-save");
btn.disabled = true; btn.textContent = "Сохранение...";
// Save the routed geometry when "along roads" is on, else the raw waypoints.
let savePts = (brouterMode && routeGeom && routeGeom.length >= 2) ? routeGeom : points;
if (editing) {
await DB.updateRoutePoints(routeId, savePts);
showToast("Маршрут обновлён — загрузите высоты заново");
showRouteDetail(container, routeId);
} else {
let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить");
if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; }
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", savePts);
showToast("Маршрут создан");
showRouteDetail(container, id);
}
};
}, 200);
}
function showDrawRouteScreen(container) {
let points = []; // {lat, lon}
let markers = [];
let polyline = null;
container.innerHTML = `
0 мДистанция
0Точек
`;
setTimeout(() => {
let mapEl = document.querySelector("#draw-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Try to center on last known position
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
}, () => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
let distEl = document.querySelector("#draw-dist");
let ptsEl = document.querySelector("#draw-pts");
function updateStats() {
let dist = 0;
for (let i = 1; i < points.length; i++) {
dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
}
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
function updatePolyline() {
let latlngs = points.map(p => [p.lat, p.lon]);
if (polyline) map.removeLayer(polyline);
if (latlngs.length >= 2) {
polyline = L.polyline(latlngs, { color: GPS.config.trackColor || "#bb86fc", weight: 4 }).addTo(map);
}
}
// Map click → add point
map.on("click", e => {
let { lat, lng } = e.latlng;
points.push({ lat, lon: lng });
let marker = L.circleMarker([lat, lng], {
radius: 6, color: GPS.config.trackColor || "#bb86fc", fillColor: GPS.config.trackColor || "#bb86fc", fillOpacity: 1, weight: 2
}).addTo(map);
markers.push(marker);
updatePolyline();
updateStats();
});
// Undo
document.querySelector("#draw-undo").onclick = () => {
if (points.length === 0) return;
points.pop();
let m = markers.pop();
if (m) map.removeLayer(m);
updatePolyline();
updateStats();
};
// Cancel
document.querySelector("#draw-cancel").onclick = () => renderScreen("gps");
// Save
document.querySelector("#draw-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let saveBtn = document.querySelector("#draw-save");
saveBtn.textContent = "Сохранение...";
saveBtn.disabled = true;
let activityType = document.querySelector("#draw-type").value;
// Get elevation for each point
let needApi = [];
for (let p of points) {
let alt = await DB.findNearestAltitude(p.lat, p.lon, 50);
if (alt != null) { p.altitude = alt; }
else { needApi.push(p); }
}
if (needApi.length > 0) {
let elevs = await fetchElevations(needApi);
if (elevs) needApi.forEach((p, i) => { if (elevs[i] != null) p.altitude = elevs[i]; });
}
for (let p of points) { if (p.altitude == null) p.altitude = 0; }
// Build points for DB with realistic timestamps based on activity speed
let avgSpeedMs = (activityType === "bike" ? GPS.config.bikeAvgSpeedKmh
: activityType === "run" ? GPS.config.runAvgSpeedKmh
: GPS.config.walkAvgSpeedKmh) / 3.6;
let now = Date.now();
let cumTime = 0;
let dbPoints = points.map((p, i) => {
if (i > 0) {
let d = GPS.distance(points[i-1].lat, points[i-1].lon, p.lat, p.lon);
cumTime += d / avgSpeedMs; // seconds to cover this segment
}
return {
lat: p.lat, lon: p.lon,
altitude_gps_m: p.altitude,
speed_ms: avgSpeedMs,
ts: new Date(now + cumTime * 1000).toISOString(),
seq: i + 1,
};
});
// Compute stats
let latestW = await DB.getLatestWeight();
let profile = await DB.getFullProfile();
let weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
let bike = await DB.getActiveBike();
let opts = { activityType, bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type };
let stats = GPS.computeStats(dbPoints, weightKg, opts);
// Create session
let date = new Date().toISOString().slice(0, 10);
let time = new Date().toTimeString().slice(0, 5);
let session = {
id: now, date, start_time: time, end_time: time,
activity_type: activityType, source: "manual",
total_distance_m: Math.round(stats.distance),
total_kcal: Math.round(stats.kcal),
avg_speed_kmh: stats.avgSpeed,
max_speed_kmh: stats.maxSpeed,
elevation_gain_m: Math.round(stats.elevGain),
elevation_loss_m: Math.round(stats.elevLoss),
bike_id: bike?.id ?? null,
};
await DB.importGpxSession(session, dbPoints);
showToast(`Маршрут сохранён: ${GPS.formatDistance(stats.distance)}, ${Math.round(stats.kcal)} ккал`);
renderScreen("gps");
};
}, 200);
}
// Darken a #rrggbb colour by factor f (0.8 = 20% darker) — for the position puck fill.
function darkenHex(hex, f) {
let m = /^#?([0-9a-fA-F]{6})$/.exec(hex || "");
if (!m) return hex || "#bb86fc";
let n = parseInt(m[1], 16);
let r = Math.round(((n >> 16) & 255) * f), g = Math.round(((n >> 8) & 255) * f), b = Math.round((n & 255) * f);
return "#" + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
}
// SVG "navigation puck": a position circle (track colour, white ring, 1px black
// outer ring so it never blends with the map) + an optional direction triangle
// above it. Circle is centred in the icon so it stays put while the icon rotates
// (leaflet-rotate rotates the icon by heading around the anchor).
function posPuckIcon(fill, withTriangle) {
let tri = withTriangle
? ``
: "";
return L.divIcon({
className: "rec-puck",
html: ``,
iconSize: [56, 56],
iconAnchor: [28, 28], // circle centre = geo position; rotation pivots here
});
}
async function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
// Keep screen on while recording is visible
if (navigator.wakeLock) {
navigator.wakeLock.request("screen").then(lock => {
window._screenLock = lock;
}).catch(() => {});
}
container.innerHTML = `
`;
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
container.querySelector("#export-supps-btn").onclick = async () => {
let supps = await DB.getSupplements();
downloadFile("baldav-supplements.json", JSON.stringify(supps, null, 2), "application/json");
};
container.querySelector("#import-supps-btn").onclick = () => {
pickFile(".json", async (text) => {
let data = JSON.parse(text);
await importSupplements(data);
renderSupplements(container);
});
};
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
let sid = parseInt(card.dataset.sid);
let s = await DB.getSupplement(sid);
if (s) showEditSupplementModal(s);
};
});
}
function showEditSupplementModal(existing) {
let isNew = !existing;
let s = existing || {};
let microHtml = MICRO_FIELDS.map(f => {
let displayVal = s[f.key] ?? "";
if (displayVal && f.mult) displayVal = Math.round(displayVal / f.mult * 1000) / 1000; // mcg→mg for display
return `
`;
}
).join("");
showModal(`
${isNew ? "Новая добавка" : "Редактирование"}
Содержание в 1 таблетке:
${microHtml}
${!isNew ? `` : ""}`,
async overlay => {
let name = overlay.querySelector("#m-name").value.trim();
if (!name) return false;
let data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
for (let f of MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
if (val && f.mult) val *= f.mult; // convert mg→mcg where needed
data[f.key] = val;
}
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
let sets = ["name=?1", "daily_dose_g=?2"];
let vals = [name, data.daily_dose_g];
let idx = 3;
for (let 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(() => {
let delBtn = document.querySelector("#m-del-supp");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteSupplement(existing.id);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("supplements");
};
}
}, 100);
}
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
// Offline maps live on the router under /baldav/tiles/. The list is read from the
// directory listing, so a new .db uploaded there appears without an app rebuild.
let TILES_BASE_URL = "http://192.168.1.1:88/baldav/tiles/";
// Regions the packing tools know about. Shown even when the router has no .db for
// them yet (greyed out) so nothing silently disappears from the list.
let KNOWN_REGIONS = {
novocherkassk: "Мой регион",
rostov_city: "Ростов-на-Дону",
rostov_oblast: "Ростовская обл.",
moscow_city: "Москва",
moscow_oblast: "Московская обл.",
spb: "Санкт-Петербург",
krasnodar_city: "Краснодар",
krasnodar_krai: "Краснодарский край",
sochi: "Сочи (+Адлер, Кр. Поляна)",
kazan: "Казань",
novosibirsk: "Новосибирск",
ekaterinburg: "Екатеринбург",
};
// Fetch [{ file, name, sizeMB }] from the router's tiles directory listing.
async function fetchRouterTiles() {
let html = await window.__TAURI__.core.invoke("http_request", {
url: TILES_BASE_URL, method: "GET", body: null,
});
let out = [];
let re = /(tiles_[A-Za-z0-9_-]+\.db)<\/a>[\s\S]*?-\s*([\d.]+)\s*kbyte/g;
let m;
while ((m = re.exec(html)) !== null) {
out.push({ file: m[1], name: m[1].replace(/^tiles_|\.db$/g, ""), sizeMB: (parseFloat(m[2]) / 1024).toFixed(1) });
}
if (!out.length) {
for (let f of [...new Set(html.match(/tiles_[A-Za-z0-9_-]+\.db/g) || [])]) {
out.push({ file: f, name: f.replace(/^tiles_|\.db$/g, ""), sizeMB: null });
}
}
return out;
}
// Open the Android SAF file picker and return the path of the picked file.
// A content:// URI has no path, so Kotlin copies the file into the app dir and
// writes the path to open_file_result.txt, which we poll here. Returns null if
// the user cancelled. Desktop has no picker — callers fall back to a path prompt.
async function pickLocalFile(mime) {
let invoke = window.__TAURI__.core.invoke;
await invoke("pick_file_open", { mime: mime || "*/*" });
for (let i = 0; i < 600; i++) { // poll up to ~3 min while the picker is open
await new Promise(r => setTimeout(r, 300));
let res = await invoke("get_picked_file");
if (!res) continue;
if (res === "CANCELLED") return null;
if (res.startsWith("ERROR:")) throw new Error(res.slice(6));
return res;
}
throw new Error("Таймаут выбора файла");
}
// Download a tile DB by URL into the app dir, then import it into the tile cache.
async function importTilesFromUrl(url, label, btn, container) {
let invoke = window.__TAURI__.core.invoke;
let origText = btn.textContent;
btn.disabled = true;
btn.textContent = "Скачивание...";
try {
let appDir = await window.__TAURI__.path.appDataDir();
let dest = appDir + "/" + (label || "tiles_download.db");
let bytes = await invoke("download_file", { url, dest });
btn.textContent = `Импорт (${(parseInt(bytes) / 1048576).toFixed(1)} MB)...`;
let count = await TileCache.importFromSqlite(dest, (done, total) => {
btn.textContent = `${Math.round(done / total * 100)}% (${done}/${total})`;
});
showToast(`${label}: ${count} тайлов импортировано`);
TileCache.count().then(n => {
let el = container.querySelector("#s-tile-count");
if (el) el.textContent = `${n} тайлов в кэше`;
});
} catch (e) {
showToast("Ошибка: " + String(e));
}
btn.disabled = false;
btn.textContent = origText;
}
async function renderSettings(container) {
let p = state.profile;
let profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
Настройки
Профиль
${profileSummary || "Не заполнен"}
›
Нормы нутриентов
Стандарт РСП
Пересчитает % нутриентов
Честный знак
Сессия
${(() => {
let s = ChZ.getSession();
if (!s) return "Не импортирована";
let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0));
return `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`;
})()}
Обновить сессию
Продлить sid через refresh (проверка связи)
Карты
Оффлайн режим
Тайлы только из кэша
Импорт тайлов
Загрузить .db файл с тайлами региона
Очистить продукты без магазина
Удалить неиспользованные продукты без метки источника (health-diet, фастфуд). Съеденные и в рецептах остаются.
Скачать с роутера
LAN: 192.168.1.1
Кэш тайлов
...
Просмотр дорог
Все проезженные маршруты на карте
GPS (запись треков)
Мин. скорость (км/ч)
Ниже = пауза
Макс. погрешность GPS (м)
Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно. Применяется и при пересчёте трека — снизьте и нажмите «Пересчитать», чтобы отбросить неточные точки (обратимо: поднимите обратно).
Мин. расстояние (м)
Точки ближе = пропуск
Макс. скорость (км/ч)
Выше = глитч GPS, игнор
Макс. пауза GPS (сек)
Пробел длиннее = новый сегмент
Аэросопротивление
Город 0.6, спорт 0.4
Плотность воздуха (кг/м3)
Море 1.225, горы 1.0
КПД педалирования (%)
Человек ~25%
Сглаживание высоты
Полуокно (5=11 точек, 7=15)
Ходьба / Бег
Макс. скорость (км/ч)
Выше = глитч GPS
Мин. скорость (км/ч)
Ниже = стоит
Цвет текущего трека
Линия записи/трека (чтобы не путать с дорогами)
Цвет дорог: вело
Цвет дорог: ходьба
Цвет дорог: бег
Fused-локация (Google Play)
Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.
GPXZ API ключ (высоты маршрутов)
Точные высоты Copernicus GLO-30 (лучше SRTM для уклонов). Пусто = open-elevation. Ключ хранится только на устройстве, не в git.
Сохранить настройки GPS
Велосипеды
+ Добавить велосипед
Разрешения
Геолокация (GPS)
Проверка...
Фоновая геолокация
Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».
Нормы ЧСС и давления
ЧСС покой (мин / макс)
ЧСС макс
По умолчанию 220 - возраст
Давление норма (сис / диа)
Пульсометр (BLE)
${state.profile.ble_device_name || "Не привязан"}
${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}