baldav/frontend/js/main.js
4aiman 84a2b786ca GPS: course-up map rotation in landscape + robust screen-off import
- applyBearing: use h, not h - rotOffset. The .rec-screen (map + panel) is
  CSS-rotated by rotOffset in landscape and setBearing adds another rotate on
  the map pane; subtracting rotOffset cancelled the screen rotation for the
  map only, gluing course to the portrait top edge while the panel turned.
  Using h lets the map turn with the screen -> course points to physical top.
- Poll: guard the requestAnimationFrame yield with !document.hidden. rAF never
  fires while the screen is off, so awaiting it there hung the import loop and
  froze point import mid-walk (regression from the progressive-draw change).
- recoverSession: import missing native points by the SET of existing
  timestamps instead of ts > maxDbTs. Sparse screen-gated points can have a
  late max ts, so the old check imported nothing and left the track full of
  gaps; also pass sensorData so bearing/accel/gyro are stored.
2026-07-08 22:11:17 +03:00

6225 lines
262 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.
// let DB = window.DB;
// let GPS = window.GPS;
// let BMR = window.BMR;
// let TileCache = window.TileCache;
// Disable leaflet-rotate's compass control on all maps by default;
// only the recording map enables rotate: true explicitly.
if (L && L.Map) L.Map.mergeOptions({ rotateControl: false });
let app = document.getElementById("app");
let UPDATE_BASE_URL = "https://4aiman.top/baldav";
let ACTIVITY_MET = { squats: 6.0, jumprope: 12.3, stretch: 2.3, swim: 8.0, manual: 3.0 };
let ACTIVITY_NAMES = { squats: "Приседания", jumprope: "Скакалка", stretch: "Растяжки", swim: "Бассейн", manual: "Другое" };
function activityKcal(type, minutes, weightKg) {
let met = ACTIVITY_MET[type] || 3.0;
return Math.round(met * 3.5 * weightKg * minutes / 200);
}
let state = {
screen: "today",
date: todayIso(),
profile: {},
latestWeight: null,
updateAvailable: null, // { remoteBuild: number } or null
};
// Parse amount string: supports "4*30" (=120), "2*150" (=300), "300+50" (=350), plain "250"
function parseAmount(str) {
if (!str) return NaN;
let s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*");
if (s.includes("*")) {
let parts = s.split("*").map(Number);
if (parts.length === 2 && parts.every(n => !isNaN(n))) return parts[0] * parts[1];
return NaN;
}
if (s.includes("+")) {
let parts = s.split("+").map(Number);
if (parts.every(n => !isNaN(n))) return parts.reduce((a, b) => a + b, 0);
return NaN;
}
return parseFloat(s);
}
// Check for app updates by comparing BUILD_NUMBER with remote
async function checkForUpdate() {
try {
// Kotlin checks BUILD_NUMBER via native HTTP (works through VPN/proxy)
// and writes result to update_info.json, Rust reads it
let raw = await window.__TAURI__.core.invoke("get_update_info");
let info = JSON.parse(raw);
if (info.remote && info.local && info.remote > info.local) {
state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local, url: info.url || null };
console.log(`[Update] available: ${info.local} -> ${info.remote} url=${info.url}`);
} else {
state.updateAvailable = null;
}
} catch (e) {
console.log("[Update] check failed:", e.message || e);
}
}
async function startUpdate() {
if (!state.updateAvailable) return;
let url = state.updateAvailable.url || (UPDATE_BASE_URL + "/baldav.apk");
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "open_url:" + url });
} catch (e) {
console.error("[Update] open browser failed:", e);
}
}
// Navigation history stack — survives in-memory across screen changes.
// When app is restored from background, we replay the stack into browser history.
let NAV_MAX = 20;
// Restore nav stack from localStorage so back gesture works after app restart
let navStack = (() => {
try {
let saved = JSON.parse(localStorage.getItem("baldav_nav_stack"));
if (Array.isArray(saved) && saved.length > 0) return saved;
} catch {}
return ["today"];
})();
function persistNavStack() {
try { localStorage.setItem("baldav_nav_stack", JSON.stringify(navStack)); } catch {}
}
// ---------------------------------------------------------------------------
// Window stack — single global stack for ALL overlays (modals, fullscreen).
// Every open = push. Every back = pop top. One popstate handler.
// ---------------------------------------------------------------------------
let winStack = []; // { type, close() }
let _skipPops = 0; // counter: how many popstate events to skip (from closeModal's history.go)
function winPush(type, closeFn) {
winStack.push({ type, close: closeFn });
history.pushState({ win: type }, "");
}
function winPopTop() {
if (winStack.length === 0) return false;
winStack.pop().close();
return true;
}
// ---------------------------------------------------------------------------
// 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;
}
// Show loading immediately so the user sees something
app.innerHTML = `<div class="seed-progress">
<div class="seed-label" id="boot-label">Загрузка...</div>
<div class="seed-bar"><div class="seed-bar-fill" id="seed-fill"></div></div>
<div class="seed-count" id="seed-count"></div>
</div>`;
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); alert("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 = `<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; }
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 = `<div class="loading">Загрузка...</div>`;
let weights = [], food = [], activities = [], sleep = [], foodTotals = {}, actTotals = {}, suppLog = [], suppTotals = {}, gpsSessions = [];
try {
[weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals, gpsSessions] = 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),
DB.getGpsSessionsForDate(state.date),
]);
} catch (e) {
console.error("[renderToday] DB error:", e);
container.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка загрузки: ${esc(String(e))}</div>`;
return;
}
let recentWeightsForBmr = await DB.getRecentWeights(7);
let bmr = calcBmr(recentWeightsForBmr);
let actLevel = parseFloat(state.profile.activity_level) || 1.2;
let dailyBase = bmr ? Math.round(bmr * actLevel) : null;
let intake = Math.round(foodTotals.kcal ?? 0);
let actKcal = Math.round(actTotals.kcal ?? 0);
let expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null;
let 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>
<div class="balance-trend" id="balance-trend"></div>
<div class="drink-bar-wrap" id="drink-bar-wrap"></div>
<div class="nutrient-strip ${localStorage.getItem("nut_intake_open") ? "" : "nutrient-collapsed"}" id="daily-nutrients"></div>
<div class="nutrient-strip ${localStorage.getItem("nut_reserves_open") ? "" : "nutrient-collapsed"}" id="daily-reserves" style="margin-top:2px"></div>
<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.filter(f => !f.recipe_id).map(f =>
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${esc(f.product_name ?? f.note ?? "—")}
${sourceBadge(f.product_source)}
${f.unit_amount && f.unit_short
? `<small>${f.unit_amount} ${f.unit_short}</small>`
: f.amount_g ? `<small>${f.amount_g}г</small>` : ""}
${foodNutrientLine(f)}</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-recipe-food-btn" title="Из рецепта">+</button>
</div>
<ul class="log-list">
${food.filter(f => f.recipe_id).map(f =>
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${esc(f.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">
${gpsSessions.map(s => {
let dist = s.total_distance_m ? (s.total_distance_m / 1000).toFixed(1) + " км" : "";
let kcal = s.total_kcal ? Math.round(s.total_kcal) + " кк" : "";
let dur = s.end_time && s.start_time ? s.start_time + "–" + s.end_time : s.start_time || "";
return `<li class="log-item" data-gps-session="${s.id_str ?? s.id}" style="cursor:pointer">
<span class="log-time">${dur}</span>
<span class="log-desc">${dist}${dist && kcal ? " / " : ""}${kcal}
${s.elevation_gain_m ? `<small>+${Math.round(s.elevation_gain_m)}м</small>` : ""}</span>
<span class="log-kcal" style="color:var(--primary)">${s.avg_speed_kmh ? s.avg_speed_kmh.toFixed(1) + " км/ч" : ""}</span>
</li>`;
}).join("")}
${activities.map(a => {
let at = a.activity_type || "manual";
let name = ACTIVITY_NAMES[at] || a.desc || "—";
let detail = "";
if (at === "squats" && a.sets && a.reps) detail = `${a.sets}x${a.reps}, `;
if (at === "swim" && a.distance_m) detail = `${a.distance_m} м, `;
if (a.minutes) detail += `${a.minutes} мин`;
if (at === "manual" && a.desc) detail = esc(a.desc) + (a.minutes ? `, ${a.minutes} мин` : "");
return `<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${at !== "manual" ? name : ""}${detail ? ` <small>${detail}</small>` : ""}</span>
<span class="log-kcal">${a.kcal && !isNaN(a.kcal) ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`;
}).join("")}
${!gpsSessions.length && !activities.length ? `<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>
<!-- nutrient strip moved to top (after balance trend) -->
<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>
<section class="log-section">
<div class="section-header">
<h3>Статистика</h3>
<div class="stats-period">
<button class="type-chip selected" data-days="30">30д</button>
<button class="type-chip" data-days="90">90д</button>
</div>
</div>
<div id="stats-charts"></div>
</section>
`;
container.querySelector("#prev-day").onclick = () => {
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 () => {
let { 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;
let fid = parseInt(li.dataset.foodId);
let entry = food.find(f => f.id === fid);
if (entry) showEditFoodModal(entry);
};
});
// 7-day rolling balance trend (IDEA 6)
(async () => {
try {
let trendEl = container.querySelector("#balance-trend");
if (!trendEl || dailyBase == null) return;
let [food7, act7] = await Promise.all([
DB.getDailyFoodTotals(7),
DB.getDailyActivityTotals(7),
]);
if (food7.length === 0) return;
let foodMap = {};
food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
let actMap = {};
act7.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
// Build per-day balance for all dates that have food data
let balances = [];
for (let d of food7) {
let dayIntake = foodMap[d.date] ?? 0;
let dayAct = actMap[d.date] ?? 0;
let dayBalance = dayIntake - (dailyBase + dayAct);
balances.push(dayBalance);
}
if (balances.length === 0) return;
let avg = Math.round(balances.reduce((a, b) => a + b, 0) / balances.length);
let cls = avg <= 0 ? "trend-deficit" : "trend-surplus";
trendEl.innerHTML = `
<span class="balance-trend-label">Среднее за ${balances.length} дн.:</span>
<span class="balance-trend-val ${cls}">${avg >= 0 ? "+" : ""}${avg} ккал/день</span>`;
} catch (e) { console.error("[7d trend]", e); }
})();
// Fill drink bar
let drinkWrap = container.querySelector("#drink-bar-wrap");
if (drinkWrap) {
(async () => {
try {
let waterMl = await DB.getDailyWaterMl(state.date);
let norm = parseInt(state.profile.daily_water_ml) || 2000;
let pct = Math.min(100, Math.round(waterMl / norm * 100));
let cls = pct < 30 ? "low" : pct < 70 ? "mid" : "ok";
drinkWrap.innerHTML = `
<span class="drink-label">${waterMl} / ${norm} мл</span>
<div class="drink-bar"><div class="drink-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
} catch (e) { console.error("[DrinkBar]", e); }
})();
}
// Fill nutrient strip (top of page, sorted by deficiency)
let dailyNutEl = container.querySelector("#daily-nutrients");
if (dailyNutEl) {
let nutrients = calcDailyNutrients(food, suppTotals);
dailyNutEl.innerHTML = renderNutrientChips(nutrients, "Приход")
|| `<div class="log-empty" style="padding:6px 14px">нет данных о нутриентах</div>`;
dailyNutEl.onclick = () => {
dailyNutEl.classList.toggle("nutrient-collapsed");
if (dailyNutEl.classList.contains("nutrient-collapsed")) localStorage.removeItem("nut_intake_open");
else localStorage.setItem("nut_intake_open", "1");
};
}
// Fill reserves strip (accumulated body stores from previous days)
let reservesEl = container.querySelector("#daily-reserves");
if (reservesEl) {
(async () => {
try {
// Calculate and save today's reserves
let reserves = await calcReservesForDate(state.date);
if (reserves && Object.keys(reserves).length > 0) {
await DB.saveReserves(state.date, reserves);
let RDI = BMR.RDI;
let chips = [];
for (let [key, rdi] of Object.entries(RDI)) {
let amount = reserves[key] || 0;
if (amount <= 0) continue;
let pct = Math.round(amount / rdi * 100);
if (pct < 1) continue;
let label = NUTRIENT_LABELS[key] || key;
let normType = BMR.RDI_TYPES[key] || "rdi";
chips.push({ pct, label, normType });
}
chips.sort((a, b) => a.pct - b.pct);
reservesEl.innerHTML = renderNutrientChips(chips, "Запасы") || "";
} else {
reservesEl.innerHTML = "";
}
} catch (e) { console.error("[Reserves] render:", e); }
})();
reservesEl.onclick = () => {
reservesEl.classList.toggle("nutrient-collapsed");
if (reservesEl.classList.contains("nutrient-collapsed")) localStorage.removeItem("nut_reserves_open");
else localStorage.setItem("nut_reserves_open", "1");
};
}
container.querySelector("#add-weight-btn").onclick = () => showWeightModal();
container.querySelector("#add-food-btn").onclick = () => showFoodModal();
container.querySelector("#add-recipe-food-btn").onclick = () => showFoodFromRecipeModal();
container.querySelector("#add-act-btn").onclick = () => showActivityModal();
container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal();
container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep);
// GPS session clicks → open track details
container.querySelectorAll("[data-gps-session]").forEach(el => {
el.onclick = () => showSessionDetail(el.dataset.gpsSession);
});
// Stats charts section
let statsContainer = container.querySelector("#stats-charts");
let statsPeriod = container.querySelector(".stats-period");
if (statsContainer && statsPeriod) {
renderStatsCharts(statsContainer, 30, foodTotals, dailyBase);
statsPeriod.querySelectorAll(".type-chip").forEach(chip => {
chip.onclick = () => {
statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase);
};
});
}
}
// ---------------------------------------------------------------------------
// Stats charts
// ---------------------------------------------------------------------------
let statsChartInstances = [];
async function renderStatsCharts(container, days, foodTotals, bmr) {
// Destroy old charts
statsChartInstances.forEach(c => c.destroy());
statsChartInstances.length = 0;
container.innerHTML = `<div class="loading">Загрузка...</div>`;
let weights, dailyFood, dailyActivity;
try {
[weights, dailyFood, dailyActivity] = await Promise.all([
DB.getRecentWeights(days),
DB.getDailyFoodTotals(days),
DB.getDailyActivityTotals(days),
]);
} catch (e) {
container.innerHTML = `<div class="log-empty">Ошибка загрузки статистики</div>`;
return;
}
container.innerHTML = "";
// --- Weight chart (line) ---
if (weights.length > 1) {
let block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Вес</div>
<div class="chart-wrap"><canvas id="stats-weight-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
let ctx = block.querySelector("#stats-weight-chart");
let labels = weights.map(w => {
let d = w.date.split("-");
return d[2] + "." + d[1];
});
let chart = new Chart(ctx, {
type: "line",
data: {
labels,
datasets: [{
data: weights.map(w => w.weight_kg),
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: weights.length > 30 ? 0 : 2,
fill: false,
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
statsChartInstances.push(chart);
}
// --- Macros donut (doughnut) ---
let protein = Math.round(foodTotals.protein_g ?? 0);
let fat = Math.round(foodTotals.fat_g ?? 0);
let carbs = Math.round(foodTotals.carbs_g ?? 0);
let totalKcal = Math.round(foodTotals.kcal ?? 0);
if (protein + fat + carbs > 0) {
let block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Макронутриенты (сегодня)</div>
<div class="chart-wrap"><canvas id="stats-macros-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
let ctx = block.querySelector("#stats-macros-chart");
let chart = new Chart(ctx, {
type: "doughnut",
data: {
labels: ["Белки", "Жиры", "Углеводы"],
datasets: [{
data: [protein, fat, carbs],
backgroundColor: ["#64b5f6", "#ffb74d", "#81c784"],
borderWidth: 0,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: "60%",
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
},
},
plugins: [{
id: "centerText",
afterDraw(chart) {
let { ctx: c, chartArea } = chart;
let cx = (chartArea.left + chartArea.right) / 2;
let cy = (chartArea.top + chartArea.bottom) / 2;
c.save();
c.textAlign = "center";
c.textBaseline = "middle";
c.font = "bold 18px system-ui";
c.fillStyle = "#e0e0e0";
c.fillText(totalKcal + " кк", cx, cy);
c.restore();
},
}],
});
statsChartInstances.push(chart);
}
// --- Kcal balance chart (bar) ---
if (dailyFood.length > 0) {
let block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Баланс ккал</div>
<div class="chart-wrap"><canvas id="stats-kcal-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
// Build a merged date set from food and activity data
let dateSet = new Set();
dailyFood.forEach(d => dateSet.add(d.date));
dailyActivity.forEach(d => dateSet.add(d.date));
let dates = Array.from(dateSet).sort();
let foodMap = {};
dailyFood.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
let actMap = {};
dailyActivity.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
let labels = dates.map(d => {
let parts = d.split("-");
return parts[2] + "." + parts[1];
});
let intakeData = dates.map(d => foodMap[d] ?? 0);
let expendData = dates.map(d => (bmr ?? 0) + (actMap[d] ?? 0));
let ctx = block.querySelector("#stats-kcal-chart");
let chart = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [
{
label: "Приход",
data: intakeData,
backgroundColor: "#81c784",
},
{
label: "Расход",
data: expendData,
backgroundColor: "#cf6679",
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 10 }, grid: { color: "#333" } },
y: { ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
statsChartInstances.push(chart);
}
// --- Weight vs 7-day rolling caloric balance (IDEA 7) ---
if (weights.length > 3 && dailyFood.length > 3) {
let block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Вес и баланс калорий</div>
<div class="chart-wrap"><canvas id="stats-corr-chart" height="200" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
// Build date-indexed maps
let wMap = {};
weights.forEach(w => wMap[w.date] = w.weight_kg);
let fMap = {};
dailyFood.forEach(d => fMap[d.date] = Math.round(d.kcal ?? 0));
let aMap = {};
dailyActivity.forEach(d => aMap[d.date] = Math.round(d.kcal ?? 0));
// Generate all dates in range
let allDates = [];
if (weights.length > 0 && dailyFood.length > 0) {
let startDate = weights[0].date < dailyFood[0].date ? weights[0].date : dailyFood[0].date;
let endDate = new Date().toISOString().slice(0, 10);
let cur = new Date(startDate + "T00:00:00");
let end = new Date(endDate + "T00:00:00");
while (cur <= end) {
allDates.push(cur.toISOString().slice(0, 10));
cur.setDate(cur.getDate() + 1);
}
}
// Interpolate weight (fill gaps with last known value)
let weightData = [];
let lastW = null;
for (let d of allDates) {
if (wMap[d] != null) lastW = wMap[d];
weightData.push(lastW);
}
// 7-day rolling average of caloric balance
let balanceData = [];
for (let i = 0; i < allDates.length; i++) {
let window7 = [];
for (let j = Math.max(0, i - 6); j <= i; j++) {
let dDate = allDates[j];
if (fMap[dDate] != null) {
let dayBalance = fMap[dDate] - (bmr ?? 0) - (aMap[dDate] ?? 0);
window7.push(dayBalance);
}
}
balanceData.push(window7.length > 0 ? Math.round(window7.reduce((a, b) => a + b, 0) / window7.length) : null);
}
let corrLabels = allDates.map(d => {
let parts = d.split("-");
return parts[2] + "." + parts[1];
});
let ctx = block.querySelector("#stats-corr-chart");
let chart = new Chart(ctx, {
type: "line",
data: {
labels: corrLabels,
datasets: [
{
label: "Вес",
data: weightData,
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
yAxisID: "y",
spanGaps: true,
},
{
label: "Баланс 7д",
data: balanceData,
borderColor: "#81c784",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
yAxisID: "y1",
spanGaps: true,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: {
position: "left",
title: { display: true, text: "кг", color: "#bb86fc" },
ticks: { color: "#bb86fc" },
grid: { color: "#333" },
},
y1: {
position: "right",
title: { display: true, text: "ккал", color: "#81c784" },
ticks: { color: "#81c784" },
grid: { drawOnChartArea: false },
},
},
},
});
statsChartInstances.push(chart);
}
// If no data at all
if (container.innerHTML === "") {
container.innerHTML = `<div class="log-empty">Недостаточно данных для графиков</div>`;
return;
}
// Bind chart fullscreen toggle for stats charts (Today screen, no modal)
container.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
let closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); };
wrap.appendChild(closeBtn);
winPush("fsChart", () => { wrap.classList.remove("chart-full"); closeBtn.remove(); });
};
});
}
// ---------------------------------------------------------------------------
// Modals
// ---------------------------------------------------------------------------
function showModal(html, onSubmit, onMount) {
let 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);
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);
alert("Ошибка: " + e);
}
};
if (onMount) onMount(overlay);
}
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 => {
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(`
<h3>Еда</h3>
<div id="m-search-wrap"></div>
<div id="m-selected" class="modal-selected" style="display:none"></div>
<div id="m-unit-chips" class="unit-chips" style="display:none"></div>
<div id="m-manual-fields">
<div class="form-group">
<label>Или вручную: описание</label>
<input id="m-desc" type="text" placeholder="Тесто для чебуреков" />
</div>
<div class="form-group">
<label>ккал на 100г</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="260" />
</div>
<div style="display:flex;gap:8px">
<div class="form-group" style="flex:1">
<label>Б</label>
<input id="m-protein" type="number" inputmode="decimal" step="0.1" placeholder="7" />
</div>
<div class="form-group" style="flex:1">
<label>Ж</label>
<input id="m-fat" type="number" inputmode="decimal" step="0.1" placeholder="4" />
</div>
<div class="form-group" style="flex:1">
<label>У</label>
<input id="m-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
</div>
</div>
</div>
<div class="form-group">
<label id="m-amount-label">Количество (г)</label>
<input id="m-amount" type="text" inputmode="text" placeholder="100 или 4*30" value="100" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
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 = `<button class="unit-chip${!selectedUnit ? " selected" : ""}" data-unit-id="0">г</button>`;
for (let u of availableUnits) {
if (u.grams_per_unit == null) continue; // skip units without conversion
html += `<button class="unit-chip${selectedUnit && selectedUnit.id === u.id ? " selected" : ""}" data-unit-id="${u.id}">${esc(u.name_short || u.name)}</button>`;
}
unitChipsEl.innerHTML = html;
unitChipsEl.style.display = "";
unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => {
chip.onclick = () => {
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 = `<span>${esc(product.name)}</span>`;
manualFields.style.display = "none";
// Load available units for this product
availableUnits = await DB.getUnitsForProduct(product.id);
if (availableUnits.length > 0) {
renderUnitChips();
} else {
unitChipsEl.style.display = "none";
}
// Reset amount — 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(`
<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 id="m-amount-label">Количество (${(entry.water_pct && entry.water_pct > 80) ? "мл" : "г"})</label>
<input id="m-amount" type="text" inputmode="text" value="${entry.amount_g || 100}" />
</div>
${!entry.product_id && !entry.recipe_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 => {
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 =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)} ${sourceBadge(p.source)}</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 = () => {
let 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
let kcalGroup = overlay.querySelector("#m-kcal-group");
if (kcalGroup) kcalGroup.style.display = "none";
};
});
}, 300);
};
}
function showActivityModal() {
let types = Object.entries(ACTIVITY_NAMES);
showModal(`
<h3>Активность</h3>
<div class="form-group">
<label>Тип</label>
<select id="m-atype" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
${types.map(([k,v]) => `<option value="${k}">${v}</option>`).join("")}
</select>
</div>
<div class="form-group" id="m-desc-row">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="прогулка" />
</div>
<div class="form-group" id="m-sets-row" style="display:none">
<label>Подходы x Повторения</label>
<div style="display:flex;gap:8px">
<input id="m-sets" type="number" inputmode="numeric" placeholder="4" style="width:50%" />
<input id="m-reps" type="number" inputmode="numeric" placeholder="15" style="width:50%" />
</div>
</div>
<div class="form-group" id="m-dist-row" style="display:none">
<label>Дистанция (м)</label>
<input id="m-dist" type="number" inputmode="numeric" placeholder="800" />
</div>
<div class="form-group">
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="20" />
</div>
<div class="form-group">
<label>ккал расход</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="авто" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
let atype = overlay.querySelector("#m-atype").value;
let desc = overlay.querySelector("#m-desc").value.trim();
let min = parseInt(overlay.querySelector("#m-min").value) || null;
let sets = parseInt(overlay.querySelector("#m-sets").value) || null;
let reps = parseInt(overlay.querySelector("#m-reps").value) || null;
let dist = parseFloat(overlay.querySelector("#m-dist").value) || null;
let time = overlay.querySelector("#m-time").value;
let kcalRaw = overlay.querySelector("#m-kcal").value.trim();
let kcal = kcalRaw ? parseFloat(kcalRaw) : null;
if (kcal === null || isNaN(kcal)) {
if (min && atype !== "manual") {
kcal = activityKcal(atype, min, parseFloat(state.latestWeight?.weight_kg) || 75);
} else {
kcal = null;
}
}
let label = atype === "manual" ? desc : ACTIVITY_NAMES[atype];
if (!label) return false;
await DB.addActivity(state.date, time, "manual", label, min, kcal,
{ activity_type: atype, sets, reps, distance_m: dist });
}, overlay => {
// Dynamic field visibility based on type
let sel = overlay.querySelector("#m-atype");
let update = () => {
let t = sel.value;
overlay.querySelector("#m-desc-row").style.display = t === "manual" ? "" : "none";
overlay.querySelector("#m-sets-row").style.display = t === "squats" ? "" : "none";
overlay.querySelector("#m-dist-row").style.display = t === "swim" ? "" : "none";
// Auto-calc kcal when minutes change
let minEl = overlay.querySelector("#m-min");
let kcalEl = overlay.querySelector("#m-kcal");
if (t !== "manual" && minEl.value && !kcalEl.value) {
let w = parseFloat(state.latestWeight?.weight_kg) || 75;
kcalEl.placeholder = activityKcal(t, parseInt(minEl.value) || 0, w) || "авто";
}
};
sel.onchange = update;
overlay.querySelector("#m-min").oninput = () => {
let t = sel.value;
if (t !== "manual") {
let w = parseFloat(state.latestWeight?.weight_kg) || 75;
let m = parseInt(overlay.querySelector("#m-min").value) || 0;
overlay.querySelector("#m-kcal").placeholder = m ? activityKcal(t, m, w) : "авто";
}
};
update();
});
}
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">
<div class="sub-tabs">
<button class="sub-tab active" data-tab="products">Продукты</button>
<button class="sub-tab" data-tab="recipes">Рецепты</button>
</div>
<div id="products-tab">
<div style="display:flex;gap:8px;align-items:center">
<input id="prod-search" class="search-input" type="search" style="flex:1"
placeholder="Название, бренд или ингредиент..." />
<button class="add-btn" id="export-all-custom" style="font-size:12px" title="Экспорт своих продуктов и рецептов">&#x21E9;</button>
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">&#x21E7;</button>
<button class="add-btn" id="add-custom-product" title="Свой продукт">+</button>
</div>
<div id="prod-count" class="prod-count"></div>
<div id="prod-results" class="prod-list"></div>
</div>
<div id="recipes-tab" style="display:none"></div>
</div>`;
// Sub-tab switching
container.querySelectorAll(".sub-tab").forEach(btn => {
btn.onclick = () => {
container.querySelectorAll(".sub-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
let tab = btn.dataset.tab;
container.querySelector("#products-tab").style.display = tab === "products" ? "" : "none";
container.querySelector("#recipes-tab").style.display = tab === "recipes" ? "" : "none";
if (tab === "recipes") renderRecipes(container.querySelector("#recipes-tab"));
};
});
let input = container.querySelector("#prod-search");
let countEl = container.querySelector("#prod-count");
let listEl = container.querySelector("#prod-results");
let lastResults = [];
async function doSearch(resetExpanded) {
let 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) {
let open = p.id === expandedId;
let 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>` : ""}
${sourceBadge(p.source)}
</div>
<div class="prod-card-kcal">${kcal != null ? kcal + " кк" : "?"}</div>
</div>
${open ? renderProdDetails(p) : ""}
</div>`;
}
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: `<div class="nut-item ${cls}">${label} ${pct}%</div>` });
}
microChips.sort((a, b) => a.pct - b.pct);
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>
${microChips.length ? `<div class="nutrient-strip" style="padding:4px 0">${microChips.map(c => c.html).join("")}</div>` : ""}
${p.composition ? `<div class="prod-composition">${esc(p.composition)}</div>` : ""}
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn-primary prod-add-btn" data-pid="${p.id}" style="flex:1">Добавить в дневник</button>
<button class="btn-secondary prod-edit-btn" data-pid="${p.id}" style="flex:0">Ред.</button>
<button class="btn-secondary prod-del-btn" data-pid="${p.id}" style="flex:0;color:#cf6679">Уд.</button>
</div>
</div>`;
}
function bindProdCards(el) {
el.querySelectorAll(".prod-card-header").forEach(hdr => {
hdr.onclick = () => {
let card = hdr.closest(".prod-card");
let 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));
};
});
el.querySelectorAll(".prod-edit-btn").forEach(btn => {
btn.onclick = async e => {
e.stopPropagation();
let p = await DB.getProduct(parseInt(btn.dataset.pid));
if (p) showEditProductModal(p, () => doSearch(false));
};
});
el.querySelectorAll(".prod-del-btn").forEach(btn => {
btn.onclick = async e => {
e.stopPropagation();
let pid = parseInt(btn.dataset.pid);
let refs = await DB.getProductRefs(pid);
if (refs.food > 0 || refs.recipes > 0) {
alert(`Нельзя удалить: продукт используется в ${refs.food ? refs.food + " записях еды" : ""}${refs.food && refs.recipes ? " и " : ""}${refs.recipes ? refs.recipes + " рецептах" : ""}`);
return;
}
await DB.deleteProduct(pid);
doSearch(false);
};
});
}
function showAddFoodFromProduct(productId) {
showModal(`
<h3>Добавить в дневник</h3>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="text" inputmode="text" placeholder="100 или 4*30" 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 => {
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;
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();
// Add custom product button
let addCustomBtn = container.querySelector("#add-custom-product");
if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true));
// Export all custom data (manual products + recipes + supplements)
container.querySelector("#export-all-custom")?.addEventListener("click", async () => {
let manualProducts = await DB.getManualProducts();
let allRecipes = await exportAllRecipes();
let supps = await DB.getSupplements();
let bundle = { products: manualProducts, recipes: allRecipes, supplements: supps };
downloadFile("baldav-custom-data.json", JSON.stringify(bundle, null, 2), "application/json");
showToast(`Экспорт: ${manualProducts.length} продуктов, ${allRecipes.length} рецептов, ${supps.length} добавок`);
});
// Import custom data
container.querySelector("#import-all-custom")?.addEventListener("click", () => {
pickFile(".json", async (text) => {
try {
let data = JSON.parse(text);
let imported = { products: 0, recipes: 0, supplements: 0 };
if (data.products && Array.isArray(data.products)) {
for (let p of data.products) {
// Dedup by name_lower + source
let existing = await DB.searchProducts(p.name_lower || p.name?.toLowerCase() || "", 1);
if (existing.some(e => e.name_lower === (p.name_lower || p.name?.toLowerCase()) && e.source === (p.source || "manual"))) continue;
delete p.id;
await DB.addProduct(p);
imported.products++;
}
}
if (data.recipes && Array.isArray(data.recipes)) {
await importRecipes(data.recipes);
imported.recipes = data.recipes.length;
}
if (data.supplements && Array.isArray(data.supplements)) {
await importSupplements(data.supplements);
imported.supplements = data.supplements.length;
}
showToast(`Импорт: ${imported.products} продуктов, ${imported.recipes} рецептов, ${imported.supplements} добавок`);
doSearch(true);
} catch (e) {
showToast("Ошибка импорта: " + e.message);
}
});
});
}
function showAddProductModal(onSaved) {
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
return `<div class="form-group micro-field">
<label>${f.label}</label>
<input id="np-${f.key}" type="number" inputmode="decimal" step="any" placeholder="—" />
</div>`;
}).join("");
showModal(`
<h3>Новый продукт</h3>
<div class="form-group">
<label>Название</label>
<input id="np-name" type="text" placeholder="Тесто для чебуреков" />
</div>
<div style="display:flex;gap:8px">
<div class="form-group" style="flex:1">
<label>ккал/100г</label>
<input id="np-kcal" type="number" inputmode="decimal" placeholder="260" />
</div>
<div class="form-group" style="flex:1">
<label>Б</label>
<input id="np-protein" type="number" inputmode="decimal" step="0.1" placeholder="7" />
</div>
<div class="form-group" style="flex:1">
<label>Ж</label>
<input id="np-fat" type="number" inputmode="decimal" step="0.1" placeholder="4" />
</div>
<div class="form-group" style="flex:1">
<label>У</label>
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
</div>
</div>
<div class="form-group" style="margin-top:8px">
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" placeholder="" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г (опционально):</p>
<div class="micro-grid">${microHtml}</div>`,
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: "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();
});
}
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 `<div class="form-group micro-field">
<label>${f.label}</label>
<input id="ep-${f.key}" type="number" inputmode="decimal" step="any" value="${val}" placeholder="—" />
</div>`;
}).join("");
showModal(`
<h3>Редактирование продукта</h3>
<div class="form-group">
<label>Название</label>
<input id="ep-name" type="text" value="${esc(p.name)}" />
</div>
<div style="display:flex;gap:8px">
<div class="form-group" style="flex:1">
<label>ккал/100г</label>
<input id="ep-kcal" type="number" inputmode="decimal" value="${p.kcal_per_100g ?? ""}" />
</div>
<div class="form-group" style="flex:1">
<label>Б</label>
<input id="ep-protein" type="number" inputmode="decimal" step="0.1" value="${p.protein_per_100g ?? ""}" />
</div>
<div class="form-group" style="flex:1">
<label>Ж</label>
<input id="ep-fat" type="number" inputmode="decimal" step="0.1" value="${p.fat_per_100g ?? ""}" />
</div>
<div class="form-group" style="flex:1">
<label>У</label>
<input id="ep-carbs" type="number" inputmode="decimal" step="0.1" value="${p.carbs_per_100g ?? ""}" />
</div>
</div>
<div class="form-group" style="margin-top:8px">
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
<input id="ep-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${p.water_pct ?? ""}" placeholder="" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г:</p>
<div class="micro-grid">${microHtml}</div>
<button id="ep-export" class="btn-secondary full-width" style="margin-top:12px;font-size:13px">Экспорт продукта</button>`,
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 = `
<div class="products-screen">
<div class="section-header">
<h2>Рецепты</h2>
<span style="display:flex;gap:6px">
<button class="add-btn" id="import-recipes-btn" style="font-size:12px">Импорт</button>
<button class="add-btn" id="export-recipes-btn" style="font-size:12px">Экспорт</button>
<button class="add-btn" id="add-recipe-btn">+</button>
</span>
</div>
<div id="recipe-list" class="prod-list">
${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 `
<div class="prod-card recipe-card" data-rid="${r.id}">
<div class="prod-card-header">
<div class="prod-card-name">
${esc(r.name)}
${r.cooking_method_name ? `<span class="prod-brand">${esc(r.cooking_method_name)}</span>` : ""}
${r.servings > 1 ? `<span class="prod-weight">${r.servings} порц.</span>` : ""}
</div>
<div class="prod-card-kcal">${kcalStr}</div>
</div>
${macroStr ? `<div class="recipe-macros-hint">${macroStr}</div>` : ""}
</div>`;
}).join("") : `<div class="prod-hint">Нет рецептов. Нажмите + чтобы создать.</div>`}
</div>
</div>`;
container.querySelector("#add-recipe-btn").onclick = () => showRecipeEditorModal(null);
container.querySelector("#export-recipes-btn").onclick = async () => {
let allRecipes = await exportAllRecipes();
let json = JSON.stringify(allRecipes, null, 2);
downloadFile("baldav-recipes.json", json, "application/json");
};
container.querySelector("#import-recipes-btn").onclick = () => {
pickFile(".json", async (text) => {
let data = JSON.parse(text);
await importRecipes(data);
renderRecipes(container);
});
};
container.querySelectorAll(".recipe-card").forEach(card => {
card.onclick = () => {
let rid = parseInt(card.dataset.rid);
showRecipeDetailModal(rid);
};
});
}
// ---------------------------------------------------------------------------
// Recipe detail modal — view recipe, log to food diary
// ---------------------------------------------------------------------------
async function showRecipeDetailModal(recipeId) {
let recipe = await DB.getRecipe(recipeId);
if (!recipe) return;
let ingredients = await DB.getRecipeIngredients(recipeId);
let nutr = await DB.calculateRecipeNutrition(recipeId);
let ingList = ingredients.map(ing => {
let name = ing.product_name ?? ing.custom_name ?? "?";
let cm = ing.cooking_method_name ? ` (${ing.cooking_method_name})` : "";
return `<li class="log-item">
<span class="log-desc">${esc(name)}${cm}</span>
<span class="log-kcal">${ing.amount_g} г</span>
</li>`;
}).join("");
showModal(`
<h3>${esc(recipe.name)}</h3>
${recipe.cooking_method_name ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Способ: ${esc(recipe.cooking_method_name)}</p>` : ""}
${recipe.servings > 1 ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Порций: ${recipe.servings}</p>` : ""}
${recipe.notes ? `<p style="color:var(--muted);font-size:13px;margin-bottom:12px">${esc(recipe.notes)}</p>` : ""}
<div class="sd-section">
<div class="sd-title">Ингредиенты</div>
<ul class="log-list">${ingList || `<li class="log-empty">нет ингредиентов</li>`}</ul>
</div>
${nutr ? `
<div class="sd-section" style="margin-top:12px">
<div class="sd-title">Пищевая ценность (на порцию)</div>
<div class="prod-macros">
<div class="macro"><span class="macro-val">${nutr.kcal}</span><span class="macro-lbl">ккал</span></div>
<div class="macro"><span class="macro-val">${nutr.protein}</span><span class="macro-lbl">Б</span></div>
<div class="macro"><span class="macro-val">${nutr.fat}</span><span class="macro-lbl">Ж</span></div>
<div class="macro"><span class="macro-val">${nutr.carbs}</span><span class="macro-lbl">У</span></div>
<div class="macro"><span class="macro-val">${nutr.fiber}</span><span class="macro-lbl">Кл</span></div>
</div>
</div>
<div class="sd-section" style="margin-top:8px">
<div class="sd-title">Микронутриенты (на порцию, % РСП)</div>
<div class="nutrient-strip">${(() => {
let RDI = BMR.RDI;
let chips = [];
for (let [key, rdi] of Object.entries(RDI)) {
let val = nutr[key];
if (!val) continue;
let pct = Math.round(val / rdi * 100);
if (pct < 1) continue;
let label = NUTRIENT_LABELS[key] || key;
let cls = pct < 30 ? "nut-low" : pct < 80 ? "nut-mid" : "nut-ok";
chips.push({ pct, html: '<div class="nut-item ' + cls + '">' + label + " " + pct + "%</div>" });
}
chips.sort((a, b) => a.pct - b.pct);
return chips.map(c => c.html).join("");
})()}</div>
</div>` : ""}
<div class="form-group" style="margin-top:16px">
<label>Сколько съел (г)</label>
<input id="rd-amount" type="text" inputmode="numeric" placeholder="${nutr ? Math.round(nutr.totalWeight / (recipe.servings || 1)) : 100} или 2*${nutr ? Math.round(nutr.totalWeight / (recipe.servings || 1)) : 100}" value="${nutr ? Math.round(nutr.totalWeight / (recipe.servings || 1)) : 100}" />
${nutr ? `<small style="color:var(--muted)">1 порция = ${Math.round(nutr.totalWeight / (recipe.servings || 1))} г</small>` : ""}
</div>
<button id="rd-log" class="btn-primary full-width" style="margin-top:8px">Записать в дневник</button>
<button id="rd-edit" class="btn-secondary full-width" style="margin-top:8px">Редактировать</button>
<button id="rd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт рецепта</button>
<button id="rd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить рецепт</button>
`, () => {});
// Wire up buttons after DOM is ready
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let logBtn = overlay.querySelector("#rd-log");
let editBtn = overlay.querySelector("#rd-edit");
let exportBtn = overlay.querySelector("#rd-export");
let delBtn = overlay.querySelector("#rd-delete");
logBtn.onclick = async () => {
let amountG = parseAmount(overlay.querySelector("#rd-amount").value);
if (isNaN(amountG) || amountG <= 0) return;
await DB.addFoodFromRecipe(state.date, nowTime(), recipeId, amountG);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen(state.screen);
};
exportBtn.onclick = async () => {
let ings = await DB.getRecipeIngredients(recipeId);
let exported = {
name: recipe.name, cooking_method_id: recipe.cooking_method_id,
servings: recipe.servings, notes: recipe.notes,
ingredients: ings.map(i => ({
product_name: i.product_name, custom_name: i.custom_name,
amount_g: i.amount_g, cooking_method_id: i.cooking_method_id,
kcal_per_100g: i.kcal_per_100g, protein_per_100g: i.protein_per_100g,
fat_per_100g: i.fat_per_100g, carbs_per_100g: i.carbs_per_100g,
})),
};
downloadFile(`${recipe.name}.json`, JSON.stringify(exported, null, 2), "application/json");
};
editBtn.onclick = () => {
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showRecipeEditorModal(recipeId);
};
delBtn.onclick = async () => {
await DB.deleteRecipe(recipeId);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("recipes");
};
}
// ---------------------------------------------------------------------------
// Recipe editor modal — create / edit recipe with ingredient list
// ---------------------------------------------------------------------------
async function showRecipeEditorModal(recipeId) {
let isNew = !recipeId;
let recipe = isNew ? { name: "", cooking_method_id: null, servings: 1, notes: "" } : await DB.getRecipe(recipeId);
if (!recipe) return;
let cookingMethods = await DB.getCookingMethods();
let existingIngredients = isNew ? [] : await DB.getRecipeIngredients(recipeId);
// Local in-memory ingredient list for editing
// Each item: { product_id, product_name, custom_name, weight_g, cooking_method_id, cooking_method_name }
let ingredients = existingIngredients.map(ing => ({
product_id: ing.product_id,
product_name: ing.product_name,
custom_name: ing.custom_name,
weight_g: ing.amount_g,
cooking_method_id: ing.cooking_method_id,
cooking_method_name: ing.cooking_method_name,
}));
let cmOptions = cookingMethods.map(cm =>
`<option value="${cm.id}">${esc(cm.name)}</option>`
).join("");
function renderIngredientRows() {
if (ingredients.length === 0) return `<div class="log-empty">Добавьте ингредиенты</div>`;
return ingredients.map((ing, idx) => {
let name = ing.product_name ?? ing.custom_name ?? "?";
return `<div class="recipe-ing-row" data-idx="${idx}">
<div class="recipe-ing-info">
<span class="recipe-ing-name">${esc(name)}</span>
<span class="recipe-ing-weight">${ing.weight_g} г</span>
${ing.cooking_method_name ? `<span class="recipe-ing-cm">${esc(ing.cooking_method_name)}</span>` : ""}
</div>
<button class="del-btn recipe-ing-del" data-idx="${idx}">&#x2715;</button>
</div>`;
}).join("");
}
// Calculate totals from current in-memory ingredients, with cooking loss
function calcTotals() {
let kcal = 0, protein = 0, fat = 0, carbs = 0;
let defaultMethodId = parseInt(document.querySelector("#re-method")?.value) || 0;
for (let ing of ingredients) {
if (!ing._product) continue;
let f = (ing.weight_g || 0) / 100;
let ik = (ing._product.kcal_per_100g ?? 0) * f;
let ip = (ing._product.protein_per_100g ?? 0) * f;
let ifat = (ing._product.fat_per_100g ?? 0) * f;
let ic = (ing._product.carbs_per_100g ?? 0) * f;
// Apply cooking loss
let cmId = ing.cooking_method_id || defaultMethodId;
let cm = cmId ? cookingMethods.find(m => m.id === cmId) : null;
if (cm) {
ik *= (1 - (cm.kcal_loss_pct ?? 0) / 100);
ip *= (1 - (cm.protein_loss_pct ?? 0) / 100);
ifat *= (1 - (cm.fat_loss_pct ?? 0) / 100);
ic *= (1 - (cm.carbs_loss_pct ?? 0) / 100);
}
kcal += ik; protein += ip; fat += ifat; carbs += ic;
}
let servings = parseFloat(document.querySelector("#re-servings")?.value) || 1;
return {
kcal: Math.round(kcal / servings),
protein: Math.round(protein / servings * 10) / 10,
fat: Math.round(fat / servings * 10) / 10,
carbs: Math.round(carbs / servings * 10) / 10,
};
}
function updateTotalsDisplay() {
let el = document.querySelector("#re-totals");
if (!el) return;
let t = calcTotals();
el.innerHTML = `${t.kcal} кк | Б${t.protein} Ж${t.fat} У${t.carbs}`;
}
showModal(`
<h3>${isNew ? "Новый рецепт" : "Редактирование рецепта"}</h3>
<div class="form-group">
<label>Название</label>
<input id="re-name" type="text" value="${esc(recipe.name)}" placeholder="Борщ, Оливье..." />
</div>
<div class="form-group">
<label>Способ приготовления</label>
<select id="re-method">
<option value="">-- нет --</option>
${cmOptions}
</select>
</div>
<div class="form-group">
<label>Порций</label>
<input id="re-servings" type="number" inputmode="decimal" step="0.5" min="0.5"
value="${recipe.servings ?? 1}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="re-notes" type="text" value="${esc(recipe.notes ?? "")}" placeholder="Бабушкин рецепт..." />
</div>
<div class="sd-section" style="margin-top:12px">
<div class="section-header" style="padding-left:0">
<div class="sd-title">Ингредиенты</div>
<button class="add-btn" id="re-add-ing">+</button>
</div>
<div id="re-ing-list">${renderIngredientRows()}</div>
</div>
<div id="re-totals" class="recipe-totals"></div>
`, 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 =>
`<option value="${cm.id}">${esc(cm.name)}</option>`
).join("");
showModal(`
<h3>Добавить ингредиент</h3>
<div id="ing-search-wrap"></div>
<div id="ing-selected" class="modal-selected" style="display:none"></div>
<div id="ing-custom-block">
<div class="form-group">
<label>Или вручную: название</label>
<input id="ing-custom" type="text" placeholder="масло оливковое" />
</div>
</div>
<div class="form-group">
<label>Вес (г)</label>
<input id="ing-weight" type="number" inputmode="decimal" value="100" placeholder="100" />
</div>
<div class="form-group">
<label>Обработка (для этого ингредиента)</label>
<select id="ing-method">
<option value="">-- как рецепт --</option>
${cmOptions}
</select>
</div>
`, 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 = `<span>${esc(product.name)}</span>`;
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(`
<h3>Записать рецепт</h3>
<div id="rfr-list" class="prod-list"><div class="loading">Загрузка...</div></div>
<div class="form-group" style="margin-top:12px">
<label>Сколько съел (г)</label>
<input id="rfr-amount" type="text" inputmode="numeric" placeholder="100 или 2*150" value="" />
<small id="rfr-hint" style="color:var(--muted)"></small>
</div>
<div class="form-group" style="margin-top:8px">
<label>Время</label>
<input id="rfr-time" type="time" value="${nowTime()}" />
</div>
`, 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 = `<div class="prod-hint">Нет рецептов. Создайте в Продукты &rarr; Рецепты.</div>`;
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 =>
`<div class="supp-pick-item recipe-pick-item" data-rid="${r.id}">
${esc(r.name)}
${r.nutr ? `<small style="color:var(--muted);margin-left:6px">${r.nutr.kcal} кк</small>` : ""}
</div>`
).join("");
let amountInput = overlay.querySelector("#rfr-amount");
let hintEl = overlay.querySelector("#rfr-hint");
listEl.querySelectorAll(".recipe-pick-item").forEach(item => {
item.onclick = () => {
listEl.querySelectorAll(".recipe-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
let r = items.find(x => x.id === parseInt(item.dataset.rid));
if (r && r.nutr) {
let servingG = Math.round(r.nutr.totalWeight / (r.servings || 1));
amountInput.value = servingG;
hintEl.textContent = `1 порция = ${servingG} г, ${r.nutr.kcal} кк`;
}
};
});
})();
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// GPS screen — map, recording, sessions list, GPX import
// ---------------------------------------------------------------------------
let gpsMap = null;
let gpsTrailLine = null;
let gpsMarker = null;
async function renderGpsScreen(container) {
// Check Bluetooth state if BLE device is paired
let bleAddr = await DB.getProfileValue("ble_device_addr");
if (bleAddr) {
try {
let btState = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (btState === "off") {
showToast("Bluetooth выключен");
} else if (btState === "no_adapter") {
showToast("Bluetooth недоступен");
}
} catch {}
}
let sessions = await DB.getGpsSessions(20);
let 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"${(localStorage.getItem("baldav_gps_type")||"bike")==="bike"?" selected":""}>Вело</option>
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Трекинг" : "Старт"}
</button>
<button id="gps-goals-btn" class="btn-secondary" style="padding:8px 12px;font-size:13px">Цели</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-draw-route">Нарисовать маршрут</div>
<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_str ?? 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
// 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);
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;
}
}
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).
container.querySelectorAll(".gps-session-item").forEach(item => {
item.onclick = () => showSessionDetail(item.dataset.sid);
});
// 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-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");
};
// 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 = `
<h3>Цели (маркеры)</h3>
<div id="gm-list" class="goal-markers-list">
${markers.length ? markers.map(m => `
<div class="goal-marker-card" data-mid="${m.id}">
<div class="goal-marker-info">
<div class="goal-marker-name">${esc(m.name)}</div>
<div class="goal-marker-coords">${m.lat.toFixed(5)}, ${m.lon.toFixed(5)}</div>
${m.notes ? `<div class="goal-marker-notes">${esc(m.notes)}</div>` : ""}
</div>
<button class="del-btn gm-del" data-mid="${m.id}">&#x2715;</button>
</div>
`).join("") : `<div class="prod-hint">Нет маркеров. Нажмите "Добавить" для создания.</div>`}
</div>
<button id="gm-add" class="btn-primary full-width" style="margin-top:12px">Добавить маркер</button>`;
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(`
<h3>Новый маркер</h3>
<div class="form-group">
<label>Название</label>
<input id="gm-name" type="text" placeholder="Вершина холма, парк..." />
</div>
<div class="form-group">
<label>Заметка</label>
<input id="gm-notes" type="text" placeholder="" />
</div>
<div id="gm-map-wrap" class="gm-map-wrap">
<div id="gm-map" class="gm-map"></div>
<div id="gm-coords" class="gm-coords">Нажмите на карту для выбора точки</div>
</div>
<input id="gm-lat" type="hidden" />
<input id="gm-lon" type="hidden" />
`, 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. Primary: open-elevation.com (reachable in RU). Fallback: open-meteo
// (blocked by RU carriers on this device, kept in case it opens up). Batches of 100.
let elevations = new Array(points.length).fill(null);
for (let i = 0; i < points.length; i += 100) {
let batch = points.slice(i, i + 100);
let got = null;
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) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j];
}
return elevations.some(x => x != null) ? elevations : null;
}
function showDrawRouteScreen(container) {
let points = []; // {lat, lon}
let markers = [];
let polyline = null;
container.innerHTML = `
<div class="draw-screen">
<div id="draw-map" class="draw-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="draw-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="draw-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
</div>
<div class="rec-bottom">
<select id="draw-type" style="padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
<option value="bike">Вело</option>
<option value="walk">Ходьба</option>
<option value="run">Бег</option>
</select>
<button id="draw-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
<button id="draw-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
<button id="draw-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
</div>
</div>`;
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: "#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: "#bb86fc", fillColor: "#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);
}
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 = `
<div class="rec-screen">
<div class="rec-map-wrap">
<div id="rec-map" class="rec-map"></div>
${GPS.recording.state === "waiting_gps" ? '<div id="gps-placeholder" class="gps-placeholder"><div class="gps-placeholder-text">Поиск GPS...</div></div>' : ''}
</div>
<div class="rec-panel">
<div class="rec-hr-row" id="rec-hr-row" style="display:none">
<span id="rec-hr-val" class="rec-hr-val">--</span>
<span class="rec-hr-unit">bpm</span>
<span id="rec-spo2" class="rec-hr-extra"></span>
<span id="rec-bp" class="rec-hr-extra"></span>
</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 class="rec-stat"><span id="rec-clock" class="rec-val">${new Date().toTimeString().slice(0,5)}</span><span class="rec-lbl">Часы</span></div>
<div class="rec-stat"><span id="rec-temp" class="rec-val">—</span><span class="rec-lbl">t&deg;C</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>
</div>`;
let mapEl = container.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
// Use last known position from profile (reliable across sessions)
let lastPt = GPS.recording.lastMovingPoint;
let initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47;
let initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07;
let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], 15);
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
let trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
// Map orientation: a mode button picks the source — magnetic compass or GPS course.
// Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show
// BOTH directions (orange = compass, green = GPS course) for visual comparison.
let rotOffset = 0; // 0/90/180/270 — whole-screen rotation
let compassFrozen = false; // X button: freeze current heading
let frozenHeading = 0; // heading captured at the moment of freezing
let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW)
let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW)
let prevCoursePt = null; // for the computed-bearing fallback
let lastPos = null; // last position, for drawing the direction arrows
let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course"
// Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing
// north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha).
let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0;
let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap
let recScreen = container.querySelector(".rec-screen");
let btnWrap = mapEl.parentElement;
// Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north.
let compassEl = document.createElement("div");
compassEl.className = "rec-compass";
compassEl.innerHTML = '<div class="rec-compass-arrow"></div>';
compassEl.title = "Тап = выставить север по текущему направлению";
compassEl.onclick = () => {
northOffset = lastRawHeading;
localStorage.setItem("baldav_north_offset2", String(northOffset));
smoothedHeading = 0; // snap to north-up immediately
applyBearing();
if (typeof showToast === "function") showToast("Север откалиброван");
};
btnWrap.appendChild(compassEl);
let compassArrow = compassEl.firstChild;
function updateNorthArrow(bearing) {
compassArrow.style.transform = `rotate(${-bearing}deg)`;
}
// Mode button: compass ("К") ↔ GPS course ("GPS").
let modeBtn = document.createElement("button");
modeBtn.className = "rec-map-btn rec-mode-btn";
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
modeBtn.title = "Поворот карты: компас / GPS-курс";
modeBtn.onclick = () => {
orientMode = orientMode === "compass" ? "course" : "compass";
localStorage.setItem("baldav_orient_mode", orientMode);
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
applyBearing();
};
btnWrap.appendChild(modeBtn);
// Two direction arrows drawn on the map from the current position (always visible):
// orange = compass, green = GPS course. Geo-anchored, so they turn with the map.
let compassDirLine = L.polyline([], { color: "#ff9800", weight: 4, opacity: 0.9, dashArray: "6,6" }).addTo(map);
let compassDirTip = L.circleMarker([0, 0], { radius: 5, color: "#ff9800", fillColor: "#ff9800", fillOpacity: 1, weight: 0 }).addTo(map);
let courseDirLine = L.polyline([], { color: "#4caf50", weight: 5, opacity: 0.95 }).addTo(map);
let courseDirTip = L.circleMarker([0, 0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1, weight: 0 }).addTo(map);
function updateArrows() {
if (!lastPos) return;
// Both arrows point the TRUE geographic direction (CW from north).
if (smoothedHeading != null) {
let end = GPS.destination(lastPos.lat, lastPos.lon, smoothedHeading, 40);
compassDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]);
compassDirTip.setLatLng(end);
}
if (courseHeading != null) {
let end = GPS.destination(lastPos.lat, lastPos.lon, courseHeading, 70); // travel direction (GPS)
courseDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]);
courseDirTip.setLatLng(end);
}
}
// Base heading = chosen source. GPS mode rotates by course when we have it, else falls
// back to the compass so the map ALWAYS rotates to a known-good heading (never static).
function baseHeading() {
// Course-up: setBearing puts geo-dir (360-theta) up and net rotation = h, so to put
// courseHeading at the top we need h = 360 - courseHeading (derived from leaflet-rotate).
if (orientMode === "course" && courseHeading != null) return (360 - courseHeading + 360) % 360;
return smoothedHeading != null ? smoothedHeading : 0;
}
function applyBearing() {
let h = compassFrozen ? frozenHeading : baseHeading();
// Do NOT subtract rotOffset. The whole .rec-screen (map + panel) is CSS-rotated
// by rotOffset in landscape; setBearing adds another CSS rotate on the map's
// rotatePane. Subtracting rotOffset cancelled the screen rotation FOR THE MAP,
// so the map stayed portrait-oriented (course glued to the narrow top edge)
// while the panel rotated. Using h lets the map turn with the screen like the
// panel → course points to the physical top. Portrait (rotOffset=0): h-0=h.
let bearing = h;
map.setBearing(bearing);
updateNorthArrow(bearing);
}
// Update GPS course from a point. Prefer the chip's course-over-ground (p.bearing);
// fall back to bearing between positions ≥15 m apart (long baseline = less jitter).
function updateCourse(p) {
if (!p) return;
lastPos = { lat: p.lat, lon: p.lon };
let b = null;
if (p.bearing != null && (p.speed_ms == null || p.speed_ms > 1.0)) {
b = p.bearing;
} else if (prevCoursePt && GPS.distance(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon) >= 15) {
b = GPS.bearing(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon);
prevCoursePt = { lat: p.lat, lon: p.lon };
}
if (!prevCoursePt) prevCoursePt = { lat: p.lat, lon: p.lon };
if (b != null) {
if (courseHeading === null) courseHeading = b;
else {
let diff = b - courseHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
courseHeading = ((courseHeading + diff * 0.4) % 360 + 360) % 360;
}
if (orientMode === "course" && !compassFrozen) applyBearing();
}
updateArrows();
}
function applyScreenRotation() {
if (recScreen) {
recScreen.classList.remove("rot-90", "rot-270");
if (rotOffset) recScreen.classList.add("rot-" + rotOffset);
}
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = rotOffset ? "none" : "";
map.invalidateSize();
}
// Rotation button — 4 states, each tap +90°. Arrow shows chosen "up".
let rotBtn = document.createElement("button");
rotBtn.className = "rec-map-btn rec-rotate-btn";
rotBtn.innerHTML = "&#8593;"; // ↑
rotBtn.title = "Повернуть карту на 90°";
rotBtn.onclick = () => {
// 3 readable states: portrait + both landscapes. 180° is skipped —
// it would flip the panel text upside down ("низ" would end up on top).
rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0);
applyScreenRotation();
applyBearing();
};
btnWrap.appendChild(rotBtn);
// Freeze button — X: lock/unlock compass following (does NOT rotate)
let freezeBtn = document.createElement("button");
freezeBtn.className = "rec-map-btn rec-freeze-btn";
freezeBtn.textContent = "X";
freezeBtn.title = "Заморозить поворот карты";
freezeBtn.onclick = () => {
compassFrozen = !compassFrozen;
if (compassFrozen) frozenHeading = baseHeading();
freezeBtn.classList.toggle("active", compassFrozen);
applyBearing();
};
btnWrap.appendChild(freezeBtn);
// Roads toggle button
let roadsBtn = document.createElement("button");
roadsBtn.className = "rec-map-btn rec-roads-toggle";
roadsBtn.textContent = "R";
let roadLayers = [];
roadsBtn.onclick = async () => {
if (roadLayers.length > 0) {
roadLayers.forEach(l => map.removeLayer(l));
roadLayers = [];
roadsBtn.classList.remove("active");
localStorage.setItem("baldav_show_roads", "0");
return;
}
roadsBtn.classList.add("active");
localStorage.setItem("baldav_show_roads", "1");
let pts = await DB.getRoadPoints();
let seg = [];
for (let i = 0; i < pts.length; i++) {
if (seg.length > 0) {
let prev = seg[seg.length - 1];
let dlat = (pts[i].lat - prev.lat) * 111000;
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) {
if (seg.length >= 2) {
let color = seg[0].atype === "w" ? "#8B5E3C" : "#2266aa";
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6 }).addTo(map));
}
seg = [];
}
}
seg.push(pts[i]);
}
if (seg.length >= 2) {
let color = seg[0].atype === "w" ? "#8B5E3C" : "#2266aa";
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6 }).addTo(map));
}
};
btnWrap.appendChild(roadsBtn);
// Auto-load roads if was enabled
if (localStorage.getItem("baldav_show_roads") === "1") roadsBtn.click();
function handleOrientation(e) {
let raw;
if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading
else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion
else return;
lastRawHeading = raw; // for the calibration tap
let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset
// EMA smoothing to reduce needle jitter (factor 0.3).
if (smoothedHeading === null) smoothedHeading = heading;
else {
let diff = heading - smoothedHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360;
}
// DEBUG overlay: raw sensor values on screen (to verify calibration).
let dbg = document.getElementById("orient-dbg");
if (!dbg) {
dbg = document.createElement("div");
dbg.id = "orient-dbg";
dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;";
mapEl.parentElement.appendChild(dbg);
}
dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading);
updateArrows(); // keep the orange compass arrow live even in GPS mode
if (!compassFrozen) applyBearing();
}
let throttledOrientation = L.Util.throttle(handleOrientation, 100);
let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(orientEvent, throttledOrientation);
applyBearing(); // initial: north-up until first compass reading
GPS.recording._compassCleanup = () => {
window.removeEventListener(orientEvent, throttledOrientation);
if (recScreen) recScreen.classList.remove("rot-90", "rot-270");
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = "";
};
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
let ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
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
let distEl = document.querySelector("#rec-dist");
let spdEl = document.querySelector("#rec-speed");
let timeEl = document.querySelector("#rec-time");
let elevEl = document.querySelector("#rec-elev");
let statusEl = document.querySelector("#rec-status");
// Speed buffer for median
let speedBuf = [];
GPS.recording.onPoint = (p) => {
let ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
// Save position for next session start (avoid black screen)
DB.setProfileValue("last_lat", String(p.lat));
DB.setProfileValue("last_lon", String(p.lon));
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
// Stats
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
// Show current state
let 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;
}
// Orient map by direction of movement (GPS course) + update direction arrows
updateCourse(p);
// Speed
let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
speedBuf.push(curSpeed);
if (speedBuf.length > 5) speedBuf.shift();
let sorted = speedBuf.slice().sort((a, b) => a - b);
let median = sorted[Math.floor(sorted.length / 2)] || 0;
if (spdEl) spdEl.textContent = median.toFixed(1);
// Elevation
let 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 = "";
}
};
// HR update from BLE
GPS.recording.onHrUpdate = (reading) => {
let row = document.querySelector("#rec-hr-row");
if (!row) return;
row.style.display = "flex";
let hrEl = document.querySelector("#rec-hr-val");
if (hrEl) {
hrEl.textContent = reading.hr;
hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr);
}
let spo2El = document.querySelector("#rec-spo2");
if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : "";
let bpEl = document.querySelector("#rec-bp");
if (bpEl) bpEl.textContent = (reading.bp_sys && reading.bp_dia) ? `${reading.bp_sys}/${reading.bp_dia}` : "";
};
// Timer — excludes pauses, starts only when recording
let timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (!GPS.recording.startTime) return; // not started yet
let totalMs = Date.now() - GPS.recording.startTime.getTime();
let activeMs = totalMs - GPS.recording.totalPauseMs -
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
let sec = Math.max(0, Math.round(activeMs / 1000));
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
// Update clock
let clockEl = document.querySelector("#rec-clock");
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
}, 1000);
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
async function fetchTemperature() {
let pos = GPS.recording.lastMovingPoint || { lat: parseFloat(state.profile.last_lat) || 47.42, lon: parseFloat(state.profile.last_lon) || 40.08 };
let lat = pos.lat.toFixed(2), lon = pos.lon.toFixed(2);
let invoke = window.__TAURI__.core.invoke;
let apis = [
// Yandex Pogoda — works on Russian mobile internet, scrape HTML
{ url: `https://yandex.ru/pogoda/?lat=${lat}&lon=${lon}`,
parse: r => { let m = r.match(/FactTemperature_value[^>]*>(-?\d+)/); return m ? parseInt(m[1]) : null; } },
// open-meteo — free API, may be blocked on mobile
{ url: `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m`,
parse: r => { try { return JSON.parse(r).current?.temperature_2m; } catch { return null; } } },
// wttr.in — free, may be blocked on mobile
{ url: `https://wttr.in/${lat},${lon}?format=%t`,
parse: r => { let n = parseFloat(r); return isNaN(n) ? null : n; } },
];
for (let api of apis) {
try {
let resp = await invoke("http_request", { url: api.url, method: "GET", body: null });
let temp = api.parse(resp);
if (temp != null && !isNaN(temp)) {
let el = document.querySelector("#rec-temp");
if (el) el.textContent = Math.round(temp) + "°";
return;
}
} catch {}
}
}
fetchTemperature();
let weatherTimer = setInterval(fetchTemperature, 15 * 60 * 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 () => {
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
clearInterval(timer);
renderScreen("gps");
};
// Restore trail if already recording — sort by seq/ts to avoid jumps
if (GPS.recording.points.length > 0) {
let sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
let coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
map.fitBounds(trailLine.getBounds().pad(0.1));
}
// Batch update callback — called after poll reads multiple points at once.
// Rebuilds polyline from all sorted points instead of adding one-by-one.
GPS.recording.onBatchUpdate = () => {
let sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
let coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
if (coords.length) {
let last = coords[coords.length - 1];
map.panTo(last);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing)
}
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
};
// Lightweight per-point draw for progressive import (screen-wake catch-up):
// only extends the polyline so the track visibly grows. Heavy work (stats,
// marker, course) is done periodically + at the end by onBatchUpdate.
GPS.recording.onImportPoint = (p) => {
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
};
}
function setupLiveUpdates(container) {
let distEl = container.querySelector("#gs-dist");
let spdEl = container.querySelector("#gs-speed");
let 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]);
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) {
let curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
spdEl.textContent = curSpd + " км/ч";
}
};
GPS.recording.onBatchUpdate = () => {
if (!gpsTrailLine || !gpsMap) return;
let sorted = GPS.recording.points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let coords = sorted.map(p => [p.lat, p.lon]);
gpsTrailLine.setLatLngs(coords);
if (coords.length) gpsMap.panTo(coords[coords.length - 1]);
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
};
// Lightweight per-point draw for progressive import (see recording screen).
GPS.recording.onImportPoint = (p) => {
if (!gpsTrailLine) return;
gpsTrailLine.addLatLng([p.lat, p.lon]);
};
// Timer for duration display + auto-finish watchdog for a forgotten session
timer = setInterval(async () => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (GPS.shouldAutoFinish()) {
clearInterval(timer);
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
showToast("Тренировка завершена автоматически — нет движения");
renderScreen("gps");
return;
}
let 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
// ---------------------------------------------------------------------------
let 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() {
let 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 => {
let sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return false;
let idx = parseInt(sel.dataset.idx);
let region = MAP_REGIONS[idx];
let zMin = parseInt(overlay.querySelector("#m-zmin").value);
let 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;
let total = TileCache.countTilesForArea(bounds, zMin, zMax);
let 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;
});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let 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() {
let sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return;
let idx = parseInt(sel.dataset.idx);
let region = MAP_REGIONS[idx];
let zMin = parseInt(overlay.querySelector("#m-zmin").value);
let 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;
let total = TileCache.countTilesForArea(bounds, zMin, zMax);
countEl.textContent = `~${total} тайлов`;
}
overlay.querySelector("#m-zmin").oninput = updateTileCount;
overlay.querySelector("#m-zmax").oninput = updateTileCount;
}
// ---------------------------------------------------------------------------
// Session detail view
// ---------------------------------------------------------------------------
let _sessionDetailRender = 0;
async function showSessionDetail(sessionId) {
let renderToken = ++_sessionDetailRender;
try {
console.log("[SD] loading session", sessionId);
let session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
let 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
let storedStats = session.total_distance_m != null;
let 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 ?? state.profile?.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);
}
// Always compute segments from points (needed for the Segments section)
if (!stats.segments && points.length >= 3) {
let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
stats.segments = fullStats.segments;
}
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
let rawSpeeds = [];
for (let i = 1; i < points.length; i++) {
let devSpd = points[i].speed_ms;
if (devSpd != null && devSpd >= 0) {
rawSpeeds.push(devSpd * 3.6);
} else {
let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
let 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
let HALF_WIN = 15;
let 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)
let graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
// Elevation data
let elevations = GPS.filterAndSmoothAlt(points.map(p => p.altitude_gps_m));
let minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
let maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
let duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
: "—";
let tempo = stats.duration > 0 && stats.distance > 0
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
: "—";
let 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>
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
</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>
<div class="chart-wrap" id="sd-elev-wrap"><canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div>
<div class="chart-wrap" id="sd-hr-wrap" style="display:none"><canvas id="sd-hr-chart" height="150" style="touch-action:pan-y"></canvas></div>
</div>` : ""}
${(stats.segments && stats.segments.length > 1) ? `
<div class="sd-section">
<div class="sd-title">Сегменты</div>
<div class="climb-segments">
${stats.segments.map(seg => {
let icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-";
let distStr = seg.distance >= 1000 ? (seg.distance / 1000).toFixed(1) + " км" : seg.distance + " м";
let elevStr = seg.type === "climb" ? `+${seg.elevGain} м, ${seg.avgGradient}%`
: seg.type === "descent" ? `-${seg.elevLoss} м, ${seg.avgGradient}%`
: `+/-${Math.max(seg.elevGain, seg.elevLoss)} м`;
let durStr = GPS.formatDuration(seg.duration);
return `<div class="segment-item ${seg.type}">
<span class="seg-icon">${icon}</span>
<span class="seg-info">${distStr}, ${elevStr}</span>
<span class="seg-stats">${durStr} | ${seg.avgSpeed} км/ч</span>
</div>`;
}).join("")}
</div>
</div>` : ""}
<div class="sd-section" id="sd-goals-section" style="display:none">
<div class="sd-title">Цели</div>
<div id="sd-goals-list"></div>
</div>
<div class="sd-section" id="sd-food-cost-section" style="display:none">
<div class="sd-title">Стоимость тренировки</div>
<div id="sd-food-cost" class="food-cost"></div>
</div>
${session.bike_id ? `<div class="sd-section"><div class="sd-title">Велосипед</div><div class="sd-row"><div class="sd-stat"><span class="sd-val" id="sd-bike-name">...</span><span class="sd-lbl">Название</span></div></div></div>` : ''}
${session.activity_type === 'bike' ? `<div class="sd-section"><div class="sd-row"><div class="form-group" style="width:100%;margin:0"><label>Велосипед</label><select id="sd-bike-select" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)"><option value="">-- Нет --</option></select></div></div></div>` : ''}
<button id="sd-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
`, () => {});
if (renderToken !== _sessionDetailRender) return;
// Render map
let mapEl = document.querySelector("#sd-map");
if (mapEl && points.length) {
void mapEl.offsetHeight; // force layout so Leaflet reads the real container size
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
mapEl._leafletMap = map; // save ref for winStack collapse
// 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);
let 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)
let t0 = new Date(points[0].ts).getTime();
let tN = new Date(points[points.length - 1].ts).getTime();
let tRange = tN - t0 || 1;
// Split into segments by GPS gap threshold
// Manual routes: no splitting — points are connected as-is (synthetic timestamps have large gaps)
let isManualRoute = session.source === "manual";
let gapSec = GPS.config.gapThresholdSec ?? 30;
let maxGapConnectSec = 120; // beyond this, never draw a connecting line — split instead
let segments = []; // array of arrays of [lat, lon]
let gapLines = []; // dashed lines for accel-confirmed gaps
let curSeg = [[points[0].lat, points[0].lon]];
for (let i = 1; i < points.length; i++) {
if (!isManualRoute) {
let gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
if (gap > gapSec) {
let accelBefore = points[i-1].accel_rms;
let accelAfter = points[i].accel_rms;
// Only bridge a SHORT gap (movement confirmed by accel) with a dashed line.
// A long gap is not real travel — split with no connecting line, so we never
// draw a straight line across half the city.
if (gap <= maxGapConnectSec && accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
curSeg.push([points[i].lat, points[i].lon]);
gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]);
} else {
segments.push(curSeg);
curSeg = [];
curSeg.push([points[i].lat, points[i].lon]);
}
continue;
}
}
curSeg.push([points[i].lat, points[i].lon]);
}
segments.push(curSeg);
// Draw each segment as separate polyline with start/end markers
let allCoords = [];
segments.forEach(seg => {
if (seg.length < 2) { allCoords.push(...seg); return; }
L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
L.polyline(seg, { color: "#bb86fc", weight: 5 }).addTo(map);
L.circleMarker(seg[0], { radius: 5, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map);
L.circleMarker(seg[seg.length - 1], { radius: 5, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map);
allCoords.push(...seg);
});
// Draw dashed lines for accel-confirmed gaps (GPS lost signal but person was moving)
for (let gl of gapLines) {
L.polyline(gl, { color: "#bb86fc", weight: 3, opacity: 0.5, dashArray: "8, 8" }).addTo(map);
}
// Invisible but tappable hit-targets on top of track
for (let i = 0; i < points.length; i++) {
let p = points[i];
let alt = p.altitude_gps_m ?? p.altitude;
let spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : "";
let time = p.ts.split("T")[1].slice(0, 8);
let hr = p.heart_rate ? `${p.heart_rate} bpm` : "";
let spo2 = p.spo2 ? `SpO2:${p.spo2}%` : "";
let bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : "";
let bleInfo = [hr, spo2, bp].filter(Boolean).join(" ");
let info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`;
L.circleMarker([p.lat, p.lon], {
radius: 8, weight: 0, fillOpacity: 0, interactive: true,
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
// Ensure Leaflet has correct container size, then fit
map.invalidateSize();
if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
}
mapEl._bounds = allCoords.length > 1 ? L.latLngBounds(allCoords).pad(0.1) : null;
// Fullscreen toggle button on map
// Fullscreen map toggle
function toggleMapFs() {
if (mapEl.classList.contains("sd-map-full")) {
// Collapse via history.back() — winStack close handler will reset CSS
history.back();
} else {
// Expand — push to history stack
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
map.dragging.enable();
map.scrollWheelZoom.enable();
map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
// Push fullscreen map onto winStack
let parentOverlay = mapEl.closest(".modal-overlay");
winPush("fsMap", () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
map.dragging.disable();
map.scrollWheelZoom.disable();
map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds);
});
winStack[winStack.length - 1].overlay = parentOverlay;
}
}
let 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
let locBtn = L.control({ position: "topleft" });
locBtn.onAdd = () => {
let div = L.DomUtil.create("div", "sd-map-fs-btn");
div.innerHTML = "&#x25CE;";
div.title = "Моё местоположение";
div.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => {
let 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();
});
}
// Downsample arrays for smooth chart rendering (max ~500 points)
function downsample(arr, maxPts) {
if (arr.length <= maxPts) return arr;
let step = arr.length / maxPts;
let result = [];
for (let i = 0; i < maxPts; i++) {
let idx = Math.floor(i * step);
result.push(arr[idx]);
}
return result;
}
let MAX_CHART_PTS = 500;
let dsSmoothed = downsample(smoothed, MAX_CHART_PTS);
let dsTimeLabels = downsample(timeLabels, MAX_CHART_PTS);
let dsElevations = downsample(elevations, MAX_CHART_PTS);
let dsElevLabels = downsample(
points.filter(p => p.altitude_gps_m != null).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
// Render speed chart
let speedCtx = document.querySelector("#sd-speed-chart");
if (speedCtx && smoothed.length) {
let maxEl = document.querySelector("#sd-max-speed");
if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч";
new Chart(speedCtx, {
type: "line",
data: {
labels: dsTimeLabels,
datasets: [{
data: dsSmoothed,
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
limits: { x: { minRange: 0.5 } },
onZoomStart: ({chart, event}) => {
// Allow wheel zoom (Ctrl+scroll) always; pinch only in fullscreen
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: true, mode: "x" },
},
},
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" } },
},
},
});
}
// Render elevation chart
let elevCtx = document.querySelector("#sd-elev-chart");
if (elevCtx && elevations.length) {
new Chart(elevCtx, {
type: "line",
data: {
labels: dsElevLabels,
datasets: [{
data: dsElevations,
borderColor: "#ffb74d",
borderWidth: 1.5,
pointRadius: 0,
fill: true,
backgroundColor: "rgba(255,183,77,0.1)",
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
limits: { x: { minRange: 0.5 } },
onZoomStart: ({chart, event}) => {
// Allow wheel zoom (Ctrl+scroll) always; pinch only in fullscreen
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: true, mode: "x" },
},
},
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" } },
},
},
});
}
// Render HR chart
let hrData = points.map(p => p.heart_rate || null);
let hrCtx = document.querySelector("#sd-hr-chart");
if (hrData.some(v => v) && hrCtx) {
let wrap = document.querySelector("#sd-hr-wrap");
if (wrap) wrap.style.display = "";
let dsHr = downsample(hrData, MAX_CHART_PTS);
let dsHrLabels = downsample(
points.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
new Chart(hrCtx, {
type: "line",
data: {
labels: dsHrLabels,
datasets: [{
data: dsHr,
borderWidth: 2,
pointRadius: 0,
fill: false,
tension: 0.3,
spanGaps: false,
segment: {
borderColor: function(ctx2) {
let v = ctx2.p1.parsed.y;
if (v == null || v === 0) return "rgba(100,181,246,0.3)";
return hrZoneColor(v);
}
},
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
let w = chart.canvas.closest(".chart-wrap");
return w && w.classList.contains("chart-full");
},
},
pan: { enabled: true, mode: "x" },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "bpm", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}
// Buttons + bike selector
{
let exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
// Chart fullscreen in modal — push to winStack, back collapses
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
let closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); };
wrap.appendChild(closeBtn);
let parentOverlay = wrap.closest(".modal-overlay");
winPush("fsChart", () => {
wrap.classList.remove("chart-full");
closeBtn.remove();
});
winStack[winStack.length - 1].overlay = parentOverlay;
};
});
// Bike name display
if (session.bike_id) {
DB.getBike(session.bike_id).then(b => {
let el = document.querySelector("#sd-bike-name");
if (el && b) el.textContent = b.name;
else if (el) el.textContent = "—";
});
}
// Bike selector (for bike sessions)
let bikeSelect = document.querySelector("#sd-bike-select");
if (bikeSelect) {
DB.getBikes().then(bikes => {
for (let b of bikes) {
let opt = document.createElement("option");
opt.value = b.id;
opt.textContent = b.name + (b.is_active ? " [активный]" : "");
if (session.bike_id && session.bike_id === b.id) opt.selected = true;
bikeSelect.appendChild(opt);
}
});
bikeSelect.onchange = async () => {
let val = bikeSelect.value ? parseInt(bikeSelect.value) : null;
await DB.updateSessionBike(sessionId, val);
session.bike_id = val;
};
}
// Compare button
let compareBtn = document.querySelector("#sd-compare");
if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points);
// Recalculate button
let recalcBtn = document.querySelector("#sd-recalc");
if (recalcBtn) recalcBtn.onclick = async () => {
recalcBtn.textContent = "Пересчёт...";
recalcBtn.disabled = true;
let newStats = await GPS.recalculateSession(sessionId);
recalcBtn.textContent = "Пересчитать";
recalcBtn.disabled = false;
if (newStats) {
// Close modal and reopen with fresh data
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showSessionDetail(sessionId);
}
};
let refineBtn = document.querySelector("#sd-refine");
if (refineBtn) refineBtn.onclick = async () => {
let reset = (t) => { refineBtn.textContent = t; refineBtn.disabled = false; };
refineBtn.disabled = true;
refineBtn.textContent = "Загрузка высот...";
try {
let pts = await DB.getGpsPoints(sessionId);
if (pts.length < 2) return reset("Уточнить высоты (онлайн)");
// Sample to ~300 points to limit API load; terrain is smooth → interpolate the rest.
let step = Math.max(1, Math.ceil(pts.length / 300));
let sampleIdx = [];
for (let i = 0; i < pts.length; i += step) sampleIdx.push(i);
if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1);
let terr = await fetchElevations(sampleIdx.map(i => pts[i]));
if (!terr || !terr.some(x => x != null)) {
if (typeof showToast === "function") showToast("Сервис высот недоступен");
return reset("Уточнить высоты (онлайн)");
}
// Interpolate terrain to every point (linear by index between samples).
let alt = new Array(pts.length).fill(null);
for (let s = 0; s < sampleIdx.length - 1; s++) {
let i0 = sampleIdx[s], i1 = sampleIdx[s + 1], a0 = terr[s], a1 = terr[s + 1];
if (a0 == null || a1 == null) continue;
for (let i = i0; i <= i1; i++) alt[i] = a0 + (a1 - a0) * (i1 > i0 ? (i - i0) / (i1 - i0) : 0);
}
let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null);
refineBtn.textContent = "Сохранение...";
await DB.updatePointsSrtm(rows);
await GPS.recalculateSession(sessionId);
if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам");
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showSessionDetail(sessionId);
} catch (e) {
console.log("[refine] fail:", e.message);
if (typeof showToast === "function") showToast("Ошибка высот: " + e.message);
reset("Уточнить высоты (онлайн)");
}
};
let del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("gps");
};
}
// Goal markers — closest approach for each marker
try {
let goalSection = document.querySelector("#sd-goals-section");
let goalsList = document.querySelector("#sd-goals-list");
let markers = (goalSection && goalsList && points.length >= 2) ? await DB.getGoalMarkers() : [];
if (markers.length > 0) {
// For each marker, find closest point in this track
let results = [];
for (let marker of markers) {
let minDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < points.length; i++) {
let d = GPS.distance(points[i].lat, points[i].lon, marker.lat, marker.lon);
if (d < minDist) { minDist = d; closestIdx = i; }
}
// Check previous sessions for progress comparison
let prevSessions = await DB.getGpsSessions(50);
let bestPrevDist = Infinity;
let bestPrevDate = null;
for (let ps of prevSessions) {
let psId = ps.id_str ?? String(ps.id);
if (psId === String(sessionId)) continue;
let psPts = await DB.getGpsPoints(psId);
if (psPts.length < 2) continue;
for (let pp of psPts) {
let d = GPS.distance(pp.lat, pp.lon, marker.lat, marker.lon);
if (d < bestPrevDist) {
bestPrevDist = d;
bestPrevDate = ps.date;
}
}
}
results.push({
name: marker.name,
minDist: Math.round(minDist),
closestIdx,
bestPrevDist: bestPrevDist < Infinity ? Math.round(bestPrevDist) : null,
bestPrevDate,
});
}
goalSection.style.display = "";
goalsList.innerHTML = results.map(r => {
let distStr = r.minDist >= 1000 ? (r.minDist / 1000).toFixed(1) + " км" : r.minDist + " м";
let progressHtml = "";
if (r.bestPrevDist != null) {
let diff = r.bestPrevDist - r.minDist;
if (diff > 0) {
progressHtml = `<span class="goal-progress goal-better">На ${diff >= 1000 ? (diff / 1000).toFixed(1) + " км" : diff + " м"} ближе (было ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"}, ${r.bestPrevDate})</span>`;
} else if (diff < 0) {
progressHtml = `<span class="goal-progress goal-worse">Рекорд: ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"} (${r.bestPrevDate})</span>`;
} else {
progressHtml = `<span class="goal-progress">Повторение рекорда</span>`;
}
}
// Progress bar: ratio of current distance to a reasonable max (e.g. 5000m)
let maxBar = Math.max(r.minDist, r.bestPrevDist ?? r.minDist, 100);
let pct = Math.max(0, Math.min(100, 100 - (r.minDist / maxBar * 100)));
return `<div class="goal-marker-item">
<div class="goal-marker-name">${esc(r.name)}</div>
<div class="goal-marker-dist">Ближайшая точка: ${distStr} (точка #${r.closestIdx})</div>
<div class="goal-bar-wrap"><div class="goal-bar-fill" style="width:${pct}%"></div></div>
${progressHtml}
</div>`;
}).join("");
}
} catch (e) { console.error("[goals]", e); }
// Food cost of workout
try {
let costSection = document.querySelector("#sd-food-cost-section");
let costEl = document.querySelector("#sd-food-cost");
let sessionKcal = session.total_kcal ?? stats.kcal;
let foods = (costSection && costEl && sessionKcal > 0) ? await DB.getFrequentFoods(7) : [];
// Build "cost" descriptions: N portions of food
let parts = [];
for (let f of foods) {
if (!f.kcal || f.kcal <= 0) continue;
// kcal per one serving (as user ate it)
let servingKcal = Math.round(f.kcal);
if (servingKcal <= 0) continue;
let portions = Math.round(sessionKcal / servingKcal * 10) / 10;
let amountStr = f.amount_g ? f.amount_g + "г" : "";
parts.push({
name: f.product_name,
portions,
servingKcal,
amountStr,
});
}
if (parts.length > 0) {
// Take first 3, build readable string
let display = parts.slice(0, 3);
let lines = display.map(p =>
`${p.portions} порц. ${esc(p.name)} (по ${p.amountStr || "?"}, ${p.servingKcal} кк/порц.)`
);
costSection.style.display = "";
costEl.innerHTML = `<div class="food-cost-total">${Math.round(sessionKcal)} ккал — это как:</div>
<div class="food-cost-items">${lines.map(l => `<div class="food-cost-line">${l}</div>`).join('<div class="food-cost-or">или</div>')}</div>`;
}
} catch (e) { console.error("[food-cost]", e); }
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
}
// ---------------------------------------------------------------------------
// Lap comparison — overlay two tracks on the same map
// ---------------------------------------------------------------------------
async function showCompareModal(sessionId, currentSession, currentPoints) {
let dist = currentSession.total_distance_m || 0;
let sessions = await DB.getGpsSessions(100);
// Filter: same activity type, similar distance (+-30%), not the same session
let similar = sessions.filter(s =>
(s.id_str ?? String(s.id)) !== String(sessionId) &&
s.activity_type === currentSession.activity_type &&
s.total_distance_m != null &&
s.total_distance_m > dist * 0.7 && s.total_distance_m < dist * 1.3
);
if (similar.length === 0) {
showModal(`<h3>Сравнение</h3><p style="color:var(--muted)">Нет похожих тренировок (та же активность, расстояние +-30%).</p>`, () => {});
return;
}
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
showModal(`
<h3>Сравнить с...</h3>
<div class="compare-list">
${similar.map(s => {
let d = s.total_distance_m != null ? GPS.formatDistance(Math.round(s.total_distance_m)) : "?";
let spd = s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : "";
return `<div class="compare-item" data-sid="${s.id_str ?? s.id}">
<div style="font-weight:600">${s.date} ${s.start_time ?? ""}</div>
<div style="font-size:12px;color:var(--muted)">${d} | ${spd}</div>
</div>`;
}).join("")}
</div>`, () => {});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Hide save/cancel buttons — this is a selection modal
let btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
overlay.querySelectorAll(".compare-item").forEach(item => {
item.onclick = async () => {
let compId = item.dataset.sid; // keep as string (large i64 id)
// Close this modal, open comparison view
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
await showComparisonView(sessionId, compId, currentSession, currentPoints);
};
});
}
async function showComparisonView(currentId, compareId, currentSession, currentPoints) {
let compSession = await DB.getGpsSession(compareId);
if (!compSession) return;
let compPoints = GPS.filterNoise(await DB.getGpsPoints(compareId), compSession.activity_type);
if (compPoints.length < 2 || currentPoints.length < 2) return;
// Determine which is newer — newer is always "current" for color coding
// Red = newer is slower (regression), Green = newer is faster (progress)
let curDate = currentSession.date || "";
let compDate = compSession.date || "";
let newerPoints, olderPoints, newerSession, olderSession;
if (curDate >= compDate) {
newerPoints = currentPoints; olderPoints = compPoints;
newerSession = currentSession; olderSession = compSession;
} else {
newerPoints = compPoints; olderPoints = currentPoints;
newerSession = compSession; olderSession = currentSession;
}
// Compute duration diff (newer - older)
let newDur = (new Date(newerPoints[newerPoints.length-1].ts) - new Date(newerPoints[0].ts)) / 1000;
let oldDur = (new Date(olderPoints[olderPoints.length-1].ts) - new Date(olderPoints[0].ts)) / 1000;
let durDiff = newDur - oldDur;
let durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково";
let newAvg = newerSession.avg_speed_kmh ?? 0;
let oldAvg = olderSession.avg_speed_kmh ?? 0;
let spdDiff = Math.round((newAvg - oldAvg) * 10) / 10;
showModal(`
<h3>Сравнение треков</h3>
<div class="sd-section">
<div class="sd-title">Результат</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val" style="color:${durDiff <= 0 ? "#4caf50" : "#f44336"}">${durLabel}</span><span class="sd-lbl">Время</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${newAvg} км/ч</span><span class="sd-lbl">Новый (${newerSession.date})</span></div>
<div class="sd-stat"><span class="sd-val">${oldAvg} км/ч</span><span class="sd-lbl">Старый (${olderSession.date})</span></div>
<div class="sd-stat"><span class="sd-val" style="color:${spdDiff >= 0 ? "#4caf50" : "#f44336"}">${spdDiff > 0 ? "+" : ""}${spdDiff}</span><span class="sd-lbl">Разница</span></div>
</div>
</div>
<div class="sd-section">
<div class="sd-title">Карта</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Зелёные участки — прогресс (новый быстрее). Красные — регресс (новый медленнее).</div>
<div id="compare-map" class="sd-map" style="height:300px"></div>
</div>
`, () => {});
setTimeout(() => {
let mapEl = document.querySelector("#compare-map");
if (!mapEl) return;
// Hide save/cancel
let btns = mapEl.closest(".modal-overlay")?.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false, zoomControl: false, touchZoom: true, dragging: true });
mapEl._leafletMap = map;
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Build speed lookup for older track
let olderCoords = olderPoints.map(p => [p.lat, p.lon]);
// Pre-compute older track speeds
let olderSpeeds = olderPoints.map(p => (p.speed_ms ?? 0) * 3.6);
// Draw newer track colored by speed difference vs older
// Green = newer faster (progress), Red = newer slower (regression)
for (let i = 1; i < newerPoints.length; i++) {
let cp = newerPoints[i];
let cpPrev = newerPoints[i - 1];
let newSpd = (cp.speed_ms ?? 0) * 3.6;
// Find nearest older point
let nearestIdx = 0, nearestDist = Infinity;
for (let j = 0; j < olderPoints.length; j++) {
let d = GPS.distance(cp.lat, cp.lon, olderPoints[j].lat, olderPoints[j].lon);
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
}
let oldSpd = olderSpeeds[nearestIdx];
let segColor;
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare
else if (newSpd > oldSpd + 1) segColor = "#4caf50"; // progress
else if (newSpd < oldSpd - 1) segColor = "#f44336"; // regression
else segColor = "#bb86fc"; // similar
L.polyline(
[[cpPrev.lat, cpPrev.lon], [cp.lat, cp.lon]],
{ color: segColor, weight: 5, opacity: 0.9 }
).addTo(map);
}
// Fit to both tracks
let allCoords = newerPoints.map(p => [p.lat, p.lon]).concat(olderCoords);
if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
}
// Fullscreen toggle
let fsState = false;
let 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();
if (fsState) {
fsState = false;
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
} else {
fsState = true;
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
};
return fsDiv;
};
fsBtn.addTo(map);
}, 300);
}
// ---------------------------------------------------------------------------
// Log supplement modal (from Today screen)
// ---------------------------------------------------------------------------
async function showLogSupplementModal() {
let 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 => {
let selected = overlay.querySelector(".supp-pick-item.selected");
if (!selected) return false;
let sid = parseInt(selected.dataset.sid);
let qty = parseFloat(overlay.querySelector("#m-qty").value) || 1;
let time = overlay.querySelector("#m-time").value;
await DB.logSupplement(state.date, time, sid, qty);
});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
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
// ---------------------------------------------------------------------------
let MICRO_FIELDS = [
{ key: "vit_a_mcg", label: "A / ретинол (мкг РЭ)" },
{ key: "carotene_mcg", label: "Бета-каротин (мкг)" },
{ key: "vit_c_mg", label: "C / аскорбинка (мг)" },
{ key: "vit_d_mcg", label: "D3 / холекальциферол (мкг)" },
{ 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 / PP / ниацин (мг)" },
{ key: "vit_b5_mg", label: "B5 / пантотенат (мг)" },
{ key: "vit_b6_mg", label: "B6 / пиридоксин (мг)" },
{ key: "vit_b7_mcg", label: "B7 / биотин (мкг)" },
{ key: "folate_mcg", label: "B9 / фолиевая (мкг)" },
{ key: "vit_b12_mcg", label: "B12 / цианокобаламин (мкг)" },
{ key: "calcium_mg", label: "Кальций / Ca (мг)" },
{ key: "magnesium_mg", label: "Магний / Mg (мг)" },
{ key: "iron_mg", label: "Железо / Fe (мг)" },
{ 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 (мг)" },
{ key: "phosphorus_mg", label: "Фосфор / P (мг)" },
{ key: "iodine_mg", label: "Йод / I (мкг)", mult: 0.001 },
{ key: "chromium_mg", label: "Хром / Cr (мкг)", mult: 0.001 },
{ key: "manganese_mg", label: "Марганец / Mn (мг)" },
{ key: "molybdenum_mcg", label: "Молибден / Mo (мкг)" },
{ key: "fish_oil_mg", label: "Рыбий жир (мг)" },
{ key: "omega3_mg", label: "-- Омега-3 ПНЖК (мг)" },
{ key: "epa_mg", label: "-- EPA / эйкозапентаеновая (мг)" },
{ key: "dha_mg", label: "-- DHA / докозагексаеновая (мг)" },
{ key: "omega6_mg", label: "Омега-6 (мг)" },
{ key: "omega9_mg", label: "Омега-9 (мг)" },
{ key: "silicon_mg", label: "Кремний / Si (мг)" },
{ key: "vanadium_mg", label: "Ванадий / V (мг)" },
{ key: "lutein_mg", label: "Лютеин (мг)" },
{ key: "lycopene_mg", label: "Ликопин (мг)" },
{ key: "ginseng_mg", label: "Женьшень / Panax (мг)" },
{ key: "resveratrol_mg", label: "Ресвератрол (мг)" },
{ key: "coq10_mg", label: "Коэнзим Q10 (мг)" },
];
// Subset of MICRO_FIELDS that exist as columns in the products table
let PRODUCT_MICRO_FIELDS = MICRO_FIELDS.filter(f => [
"vit_a_mcg", "vit_c_mg", "vit_d_mcg", "vit_e_mg", "vit_k_mcg",
"vit_b1_mg", "vit_b2_mg", "vit_b3_mg", "vit_b6_mg", "vit_b12_mcg", "folate_mcg",
"calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg", "copper_mg",
"selenium_mcg", "potassium_mg", "sodium_mg", "phosphorus_mg", "carotene_mcg",
"ginseng_mg", "resveratrol_mg", "coq10_mg",
].includes(f.key));
async function renderSupplements(container) {
let supps = await DB.getSupplements();
function suppSummary(s) {
let parts = [];
for (let 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>
<span style="display:flex;gap:6px">
<button class="add-btn" id="import-supps-btn" style="font-size:12px">Импорт</button>
<button class="add-btn" id="export-supps-btn" style="font-size:12px">Экспорт</button>
<button class="add-btn" id="add-new-supp">+</button>
</span>
</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.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 `<div class="form-group micro-field">
<label>${f.label}</label>
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${displayVal}" placeholder="${(() => { let r = BMR.RDI[f.key]; let t = BMR.RDI_TYPES[f.key]; return r ? r + (t === 'aup' ? '*' : '') : '—'; })()}" />
</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 => {
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
// ---------------------------------------------------------------------------
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 = `
<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>
<select id="s-rdi-standard" style="width:80px;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
<option value="RU">РФ</option>
<option value="EU">ЕС</option>
<option value="US">США</option>
</select>
</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 class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Импорт тайлов</div>
<div class="settings-item-desc">Загрузить .db файл с тайлами региона</div>
</div>
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Скачать с роутера</div>
<div class="settings-item-desc">LAN: 192.168.1.1</div>
</div>
<button class="btn-secondary" id="s-download-tiles" style="padding:6px 12px;font-size:13px;">Регионы</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Кэш тайлов</div>
<div class="settings-item-desc" id="s-tile-count">...</div>
</div>
</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="btn-secondary" id="s-roads-map" style="padding:6px 12px;font-size:13px;">Открыть</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">Макс. погрешность GPS (м)</div>
<div class="settings-item-desc">Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно.</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 class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. скорость (км/ч)</div>
<div class="settings-item-desc">Выше = глитч GPS, игнор</div>
</div>
<input id="s-max-speed" type="number" step="1" value="${GPS.config.maxSpeedKmh}" 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">Макс. пауза GPS (сек)</div>
<div class="settings-item-desc">Пробел длиннее = новый сегмент</div>
</div>
<input id="s-gap-threshold" type="number" step="5" value="${GPS.config.gapThresholdSec}" 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">Город 0.6, спорт 0.4</div>
</div>
<input id="s-air-drag" type="number" step="0.05" value="${GPS.config.airDrag}" 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">Плотность воздуха (кг/м3)</div>
<div class="settings-item-desc">Море 1.225, горы 1.0</div>
</div>
<input id="s-air-density" type="number" step="0.01" value="${GPS.config.airDensity}" 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">Человек ~25%</div>
</div>
<input id="s-pedal-eff" type="number" step="1" value="${GPS.config.pedalEfficiency}" 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">Полуокно (5=11 точек, 7=15)</div>
</div>
<input id="s-alt-smooth" type="number" step="1" value="${GPS.config.altSmoothHalf}" 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">Макс. скорость (км/ч)</div>
<div class="settings-item-desc">Выше = глитч GPS</div>
</div>
<input id="s-walk-max-speed" type="number" step="1" value="${GPS.config.walkMaxSpeedKmh}" 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-walk-min-speed" type="number" step="0.1" value="${(GPS.config.walkMinSpeedMs * 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">Fused-локация (Google Play)</div>
<div class="settings-item-desc">Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.</div>
</div>
<input id="s-use-fused" type="checkbox" ${GPS.config.useFusedLocation ? "checked" : ""} style="width:20px;height:20px;">
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Велосипеды</div>
<div id="s-bikes-list"></div>
<div class="settings-item" id="s-add-bike" style="cursor:pointer">
<div class="settings-item-left">
<div class="settings-item-title" style="color:var(--primary)">+ Добавить велосипед</div>
</div>
</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 class="settings-item" id="s-bgloc-item" style="cursor:pointer">
<div class="settings-item-left">
<div class="settings-item-title">Фоновая геолокация</div>
<div class="settings-item-desc">Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».</div>
</div>
<button class="btn-secondary" id="s-bgloc-btn">Разрешить</button>
</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>
<div style="display:flex;gap:4px">
<input id="s-hr-rest-min" type="number" value="${state.profile.hr_rest_min || 60}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
<input id="s-hr-rest-max" type="number" value="${state.profile.hr_rest_max || 100}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
</div>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">ЧСС макс</div>
<div class="settings-item-desc">По умолчанию 220 - возраст</div>
</div>
<input id="s-hr-max" type="number" value="${state.profile.hr_max || (220 - BMR.getAge(parseInt(state.profile.birth_year) || 1988))}" 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>
<div style="display:flex;gap:4px">
<input id="s-bp-sys" type="number" value="${state.profile.bp_sys_norm || 120}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
<input id="s-bp-dia" type="number" value="${state.profile.bp_dia_norm || 80}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
</div>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Пульсометр (BLE)</div>
<div class="settings-item" id="s-ble-device">
<div class="settings-item-left">
<div class="settings-item-title" id="s-ble-name">${state.profile.ble_device_name || "Не привязан"}</div>
<div class="settings-item-desc" id="s-ble-status">${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}</div>
</div>
<div style="display:flex;gap:6px">
${state.profile.ble_device_addr ? `<button id="s-ble-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
</div>
</div>
<div id="s-ble-results" style="padding:0 14px"></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" id="s-sync-status">${Sync.userId ? "Подключено" : "Не настроено"}</div>
<div class="settings-item-desc" id="s-sync-desc">${Sync.userId ? (state.profile.sync_last_time || "Не синхронизировано") : "Привяжите устройство"}</div>
</div>
<button class="btn-primary" id="s-sync-now" style="padding:6px 12px;font-size:13px">${Sync.userId ? "Синхр." : "Настроить"}</button>
</div>
<div id="s-sync-setup" style="padding:0 14px;display:${Sync.userId ? "none" : ""}">
<div class="form-group" style="margin-top:8px">
<label>Код привязки (с другого устройства)</label>
<div style="display:flex;gap:6px">
<input id="s-sync-code" type="text" maxlength="6" placeholder="ABC123" style="flex:1;text-transform:uppercase" />
<button id="s-sync-pair" class="btn-primary" style="padding:6px 12px;font-size:13px">Привязать</button>
</div>
</div>
<button id="s-sync-register" class="btn-secondary full-width" style="margin-top:8px;font-size:13px">Создать новый аккаунт</button>
</div>
<div id="s-sync-info" style="padding:0 14px;display:${Sync.userId ? "" : "none"}">
<button id="s-sync-show-code" class="btn-secondary full-width" style="margin-top:8px;font-size:13px">Показать код для привязки</button>
<div id="s-sync-code-display" style="display:none;margin-top:8px;text-align:center;font-size:28px;font-weight:bold;letter-spacing:4px;color:var(--primary)"></div>
</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 id="s-update-area"></div>
</div>
</div>
</div>`;
// Profile button → open profile modal
container.querySelector("#s-open-profile").onclick = () => showProfileModal();
// RDI standard selector
let rdiSel = container.querySelector("#s-rdi-standard");
if (rdiSel) {
rdiSel.value = state.profile.rdi_standard || "RU";
rdiSel.onchange = async () => {
await DB.setProfileValue("rdi_standard", rdiSel.value);
state.profile.rdi_standard = rdiSel.value;
await BMR.loadRDI(rdiSel.value);
};
}
// Offline toggle
let offToggle = container.querySelector("#s-offline-toggle");
offToggle.onclick = () => {
let newVal = !TileCache.offlineMode;
TileCache.setOfflineMode(newVal);
offToggle.classList.toggle("on", newVal);
};
// Tile cache count
TileCache.count().then(n => {
let el = container.querySelector("#s-tile-count");
if (el) el.textContent = `${n} тайлов в кэше`;
});
// Import tiles from SQLite file
// User pushes .db to app dir: adb push tiles_region.db /data/user/0/dev.baldav.app/files/
container.querySelector("#s-import-tiles").onclick = async () => {
let btn = container.querySelector("#s-import-tiles");
// Prompt for path — user can type full path or just filename in app files dir
let filePath = prompt("Путь к файлу тайлов (.db).\nНа Android: скопируйте файл через\nadb push file.db /data/user/0/dev.baldav.app/files/\nи введите имя файла:");
if (!filePath) return;
// If just a filename, resolve relative to app files dir
if (!filePath.includes("/")) {
try {
let appDir = await window.__TAURI__.path.appDataDir();
// appDataDir on Android = /data/user/0/pkg/files/dev.baldav.app/
// files dir = /data/user/0/pkg/files/
// Try both locations
let candidates = [
appDir + "/" + filePath,
appDir + "/../../files/" + filePath,
appDir + "/../" + filePath,
];
filePath = candidates[0]; // try appDataDir first
} catch {
// desktop: use as-is
}
}
let origText = btn.textContent;
btn.disabled = true;
btn.textContent = "0%...";
try {
let count = await TileCache.importFromSqlite(filePath, (done, total) => {
let pct = Math.round(done / total * 100);
btn.textContent = `${pct}% (${done}/${total})`;
});
btn.textContent = origText;
btn.disabled = false;
showToast(`Импортировано ${count} тайлов`);
let el = container.querySelector("#s-tile-count");
TileCache.count().then(n => { if (el) el.textContent = `${n} тайлов в кэше`; });
} catch (e) {
btn.textContent = origText;
btn.disabled = false;
showToast("Ошибка: " + String(e));
}
};
// Download tiles from router
container.querySelector("#s-download-tiles").onclick = async () => {
let regions = [
["novocherkassk", "region"],
["rostov_city", "Rostov-on-Don"],
["rostov_oblast", "Rostov Oblast"],
["moscow_city", "Moscow"],
["moscow_oblast", "Moscow Oblast"],
["spb", "Saint Petersburg"],
["krasnodar_city", "Krasnodar"],
["krasnodar_krai", "Krasnodar Krai"],
["kazan", "Kazan"],
["novosibirsk", "Novosibirsk"],
["ekaterinburg", "Ekaterinburg"],
];
let list = regions.map((r, i) => `${i + 1}. ${r[1]}`).join("\n");
let choice = prompt("Выберите регион (номер):\n" + list);
if (!choice) return;
let idx = parseInt(choice) - 1;
if (idx < 0 || idx >= regions.length) { showToast("Неверный номер"); return; }
let regionKey = regions[idx][0];
let regionName = regions[idx][1];
let url = `http://192.168.1.1:88/baldav/tiles/tiles_${regionKey}.db`;
let invoke = window.__TAURI__.core.invoke;
let btn = container.querySelector("#s-download-tiles");
btn.disabled = true;
btn.textContent = "Скачивание...";
try {
let appDir = await window.__TAURI__.path.appDataDir();
let dest = appDir + `/tiles_${regionKey}.db`;
let bytes = await invoke("download_file", { url, dest });
let sizeMB = (parseInt(bytes) / 1024 / 1024).toFixed(1);
btn.textContent = `Импорт (${sizeMB} MB)...`;
let count = await TileCache.importFromSqlite(dest, (done, total) => {
let pct = Math.round(done / total * 100);
btn.textContent = `${pct}% (${done}/${total})`;
});
btn.textContent = "Регионы";
btn.disabled = false;
showToast(`${regionName}: ${count} тайлов импортировано`);
TileCache.count().then(n => {
let el = container.querySelector("#s-tile-count");
if (el) el.textContent = `${n} тайлов в кэше`;
});
} catch (e) {
btn.textContent = "Регионы";
btn.disabled = false;
showToast("Ошибка: " + String(e));
}
};
// Roads map
container.querySelector("#s-roads-map").onclick = () => showRoadsMap();
// GPS config inputs
let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"];
for (let id of gpsInputs) {
let 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.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60;
GPS.config.gapThresholdSec = parseFloat(container.querySelector("#s-gap-threshold").value) || 30;
GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6;
GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225;
GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25;
GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5;
GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12;
GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6;
let fusedEl = container.querySelector("#s-use-fused");
if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked;
GPS.saveConfig();
};
}
// HR/BP norms — save on change
for (let [id, key] of [["s-hr-rest-min","hr_rest_min"],["s-hr-rest-max","hr_rest_max"],["s-hr-max","hr_max"],["s-bp-sys","bp_sys_norm"],["s-bp-dia","bp_dia_norm"]]) {
let el = container.querySelector("#" + id);
if (el) el.onchange = async () => {
await DB.setProfileValue(key, el.value);
state.profile[key] = el.value;
};
}
// Bikes list
async function loadBikesList() {
let bikes = await DB.getBikes();
let listEl = container.querySelector("#s-bikes-list");
if (!listEl) return;
listEl.innerHTML = bikes.map(b => `
<div class="settings-item" data-bike-id="${b.id}" style="cursor:pointer">
<div class="settings-item-left">
<div class="settings-item-title">${esc(b.name)}${b.is_active ? ' <span style="color:var(--primary);font-size:12px">[активный]</span>' : ''}</div>
<div class="settings-item-desc">${b.weight_kg ? b.weight_kg + ' кг' : ''}${b.surface_type ? ' / ' + ({asphalt:'Асфальт',mixed:'Смешанный',gravel:'Грунт'}[b.surface_type] || b.surface_type) : ''}</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>`).join("");
listEl.querySelectorAll("[data-bike-id]").forEach(item => {
item.onclick = () => showBikeModal(parseInt(item.dataset.bikeId));
});
}
loadBikesList();
container.querySelector("#s-add-bike").onclick = () => showBikeModal(null);
// GPS permission check — works on both desktop and mobile
let gpsStatus = container.querySelector("#s-gps-status");
let 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 }
);
};
let bgLocBtn = container.querySelector("#s-bgloc-btn");
if (bgLocBtn) bgLocBtn.onclick = async () => {
await GPS.requestBackgroundLocation();
showToast("Настройки → Разрешения → Геоданные → «Разрешать всегда»");
};
// BLE HR device scan and bind
let bleUnbindBtn = container.querySelector("#s-ble-unbind");
if (bleUnbindBtn) {
bleUnbindBtn.onclick = async () => {
await DB.setProfileValue("ble_device_addr", "");
await DB.setProfileValue("ble_device_type", "");
await DB.setProfileValue("ble_device_name", "");
state.profile.ble_device_addr = "";
state.profile.ble_device_type = "";
state.profile.ble_device_name = "";
try { await window.__TAURI__.core.invoke("ble_save_device", { address: "", deviceType: "" }); } catch {}
try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {}
container.querySelector("#s-ble-name").textContent = "Не привязан";
container.querySelector("#s-ble-status").textContent = "Нажмите для поиска";
bleUnbindBtn.remove();
};
}
let bleScanBtn = container.querySelector("#s-ble-scan");
let bleResults = container.querySelector("#s-ble-results");
if (bleScanBtn) {
bleScanBtn.onclick = async () => {
bleScanBtn.textContent = "Поиск...";
bleScanBtn.disabled = true;
bleResults.innerHTML = `<div style="padding:8px 0;color:var(--muted);font-size:13px">Сканирование 5 сек...</div>`;
try {
// Trigger scan (Android returns empty immediately, Kotlin scans in background)
await window.__TAURI__.core.invoke("ble_scan", { durationSecs: 5 });
// Wait for Kotlin scan to complete, then read results
await new Promise(r => setTimeout(r, 6000));
let devices = await window.__TAURI__.core.invoke("ble_scan_results");
if (!devices || !devices.length) {
bleResults.innerHTML = `<div style="padding:8px 0;color:var(--muted);font-size:13px">Устройства не найдены</div>`;
} else {
bleResults.innerHTML = devices.map(d =>
`<div class="settings-item" style="cursor:pointer" data-ble-addr="${d.address}" data-ble-type="${d.device_type}" data-ble-name="${esc(d.name)}">
<div class="settings-item-left">
<div class="settings-item-title">${esc(d.name)}</div>
<div class="settings-item-desc">${d.address} (${d.device_type}) ${d.rssi}dBm</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>`
).join("");
bleResults.querySelectorAll("[data-ble-addr]").forEach(item => {
item.onclick = async () => {
let addr = item.dataset.bleAddr;
let type = item.dataset.bleType;
let name = item.dataset.bleName;
item.querySelector(".settings-item-desc").textContent = "Подключение...";
try {
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
await DB.setProfileValue("ble_device_addr", addr);
await DB.setProfileValue("ble_device_type", type);
await DB.setProfileValue("ble_device_name", name);
// Write file for Kotlin to read at GPS start
try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {}
state.profile.ble_device_addr = addr;
state.profile.ble_device_type = type;
state.profile.ble_device_name = name;
container.querySelector("#s-ble-name").textContent = name;
container.querySelector("#s-ble-status").textContent = addr + " (подключён)";
bleResults.innerHTML = "";
} catch (e) {
item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e;
}
};
});
}
} catch (e) {
bleResults.innerHTML = `<div style="padding:8px 0;color:#cf6679;font-size:13px">${e}</div>`;
}
bleScanBtn.textContent = "Поиск";
bleScanBtn.disabled = false;
};
}
// Sync handlers
let syncNowBtn = container.querySelector("#s-sync-now");
if (syncNowBtn) {
syncNowBtn.onclick = async () => {
if (!Sync.userId) {
// Show setup
container.querySelector("#s-sync-setup").style.display = "";
return;
}
syncNowBtn.textContent = "...";
syncNowBtn.disabled = true;
let r = await Sync.now();
container.querySelector("#s-sync-desc").textContent = `Отправлено: ${r.pushed}, получено: ${r.pulled}`;
syncNowBtn.textContent = "Синхр.";
syncNowBtn.disabled = false;
};
}
container.querySelector("#s-sync-register")?.addEventListener("click", async () => {
try {
let code = await Sync.register();
container.querySelector("#s-sync-status").textContent = "Подключено";
container.querySelector("#s-sync-setup").style.display = "none";
container.querySelector("#s-sync-info").style.display = "";
container.querySelector("#s-sync-code-display").style.display = "";
container.querySelector("#s-sync-code-display").textContent = code;
syncNowBtn.textContent = "Синхр.";
showToast("Аккаунт создан. Код: " + code);
} catch (e) { showToast("Ошибка: " + e.message); }
});
container.querySelector("#s-sync-pair")?.addEventListener("click", async () => {
let code = container.querySelector("#s-sync-code").value.trim().toUpperCase();
if (code.length !== 6) { showToast("Введите 6-символьный код"); return; }
try {
await Sync.pair(code);
container.querySelector("#s-sync-status").textContent = "Подключено";
container.querySelector("#s-sync-setup").style.display = "none";
container.querySelector("#s-sync-info").style.display = "";
syncNowBtn.textContent = "Синхр.";
showToast("Устройство привязано!");
await Sync.now();
} catch (e) { showToast("Ошибка: " + e.message); }
});
container.querySelector("#s-sync-show-code")?.addEventListener("click", async () => {
try {
let code = await Sync.newCode();
let el = container.querySelector("#s-sync-code-display");
el.style.display = "";
el.textContent = code;
} catch (e) { showToast("Ошибка: " + e.message); }
});
// App version info + update check
try {
let 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";
}
// Check for updates: trigger Kotlin re-check, poll until result changes
let updateArea = container.querySelector("#s-update-area");
if (updateArea) {
updateArea.innerHTML = `<small style="color:var(--muted)">проверка...</small>`;
let oldTs = 0;
try {
let old = JSON.parse(await window.__TAURI__.core.invoke("get_update_info"));
oldTs = old.ts || 0;
} catch {}
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_update" });
} catch {}
// Poll until Kotlin writes new result (ts changes)
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 200));
try {
let raw = await window.__TAURI__.core.invoke("get_update_info");
let info = JSON.parse(raw);
if (info.ts && info.ts !== oldTs) {
await checkForUpdate();
break;
}
} catch {}
}
if (state.updateAvailable) {
showUpdateButton(updateArea);
} else {
updateArea.innerHTML = "";
}
}
}
function showUpdateButton(container) {
container.innerHTML = `<button id="s-update-btn" class="btn-primary" style="padding:6px 14px;font-size:13px">
Обновить (${state.updateAvailable.remoteBuild})
</button>`;
container.querySelector("#s-update-btn").onclick = () => startUpdate();
}
function showProfileModal() {
let p = state.profile;
let selectedSex = p.sex ?? "m";
showModal(`
<h3>Профиль</h3>
<div class="form-group">
<label>Вес (кг)</label>
<input id="m-weight" type="number" inputmode="decimal" step="0.1" value="${p.weight_kg ?? ""}" placeholder="80" />
</div>
<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 (Mifflin-St Jeor)</label>
<div style="padding:6px 0;color:var(--muted);font-size:14px" id="m-bmr-display">...</div>
</div>
<div class="form-group">
<label>Образ жизни (без учёта тренировок)</label>
<select id="m-activity-level" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
<option value="1.2">Сидячий (офис, минимум движения)</option>
<option value="1.375">Лёгкая активность (ходьба, быт)</option>
<option value="1.55">Умеренная (стоячая работа, прогулки)</option>
<option value="1.725">Высокая (физический труд)</option>
</select>
</div>
<div class="form-group">
<label>Норма воды (мл/день)</label>
<input id="m-water-norm" type="number" inputmode="numeric" value="${p.daily_water_ml ?? "2000"}" placeholder="2000" />
</div>`,
async overlay => {
let weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
let height = parseInt(overlay.querySelector("#m-height").value);
let year = parseInt(overlay.querySelector("#m-year").value);
if (isNaN(height) || isNaN(year)) return false;
if (weight) await DB.setProfileValue("weight_kg", weight);
await DB.setProfileValue("height_cm", height);
await DB.setProfileValue("birth_year", year);
await DB.setProfileValue("sex", selectedSex);
let activityLevel = overlay.querySelector("#m-activity-level").value;
await DB.setProfileValue("activity_level", activityLevel);
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
await DB.setProfileValue("daily_water_ml", waterNorm);
state.profile = { ...state.profile,
weight_kg: weight ? weight : state.profile.weight_kg,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
activity_level: activityLevel };
});
// Initialize modal controls
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Sex chips
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");
updateBmrDisplay();
};
});
// Activity level dropdown
let actSelect = overlay.querySelector("#m-activity-level");
if (actSelect) actSelect.value = p.activity_level || "1.2";
// BMR display
function updateBmrDisplay() {
let w = parseFloat(overlay.querySelector("#m-weight").value) || parseFloat(p.weight_kg) || 80;
let h = parseInt(overlay.querySelector("#m-height").value) || parseInt(p.height_cm) || 178;
let y = parseInt(overlay.querySelector("#m-year").value) || parseInt(p.birth_year) || 1988;
let bmrVal = BMR.calculateBmr(w, h, BMR.getAge(y), selectedSex);
let el = overlay.querySelector("#m-bmr-display");
if (el) el.textContent = bmrVal + " ккал/день";
}
updateBmrDisplay();
// Recalculate BMR when weight/height/year change
for (let id of ["#m-weight", "#m-height", "#m-year"]) {
let el = overlay.querySelector(id);
if (el) el.oninput = updateBmrDisplay;
}
}, 100);
}
// ---------------------------------------------------------------------------
// Bike profile modal
// ---------------------------------------------------------------------------
async function showBikeModal(bikeId) {
let isNew = bikeId == null;
let bike = isNew ? {} : await DB.getBike(bikeId);
if (!isNew && !bike) return;
let surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" };
let surfaceVal = bike.surface_type ?? "mixed";
showModal(`
<h3>${isNew ? "Новый велосипед" : "Редактирование"}</h3>
<div class="form-group">
<label>Название</label>
<input id="m-bike-name" type="text" value="${esc(bike.name ?? "")}" placeholder="Мой велосипед" />
</div>
<div class="form-group">
<label>Вес велосипеда (кг)</label>
<input id="m-bike-weight" type="number" inputmode="decimal" step="0.1" value="${bike.weight_kg ?? ""}" placeholder="12" />
</div>
<div class="form-group">
<label>Диаметр колеса (дюймы)</label>
<input id="m-bike-wheel" type="number" inputmode="decimal" step="0.5" value="${bike.wheel_diameter_inch ?? 26}" placeholder="26" />
</div>
<div class="form-group">
<label>Передняя звезда (зубцы)</label>
<input id="m-bike-chainring" type="number" inputmode="numeric" value="${bike.chainring_teeth ?? ""}" placeholder="42" />
</div>
<div class="form-group">
<label>Задняя звезда (зубцы)</label>
<input id="m-bike-cog" type="number" inputmode="numeric" value="${bike.cog_teeth ?? ""}" placeholder="18" />
</div>
<div class="form-group">
<label>Покрытие</label>
<select id="m-bike-surface" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
<option value="asphalt"${surfaceVal === "asphalt" ? " selected" : ""}>Асфальт</option>
<option value="mixed"${surfaceVal === "mixed" ? " selected" : ""}>Смешанный</option>
<option value="gravel"${surfaceVal === "gravel" ? " selected" : ""}>Грунт</option>
</select>
</div>
<div class="form-group">
<label>Давление в шинах (бар)</label>
<input id="m-bike-pressure" type="number" inputmode="decimal" step="0.1" value="${bike.tire_pressure_bar ?? 3.0}" placeholder="3.0" />
</div>
<div class="form-group">
<label>Активный велосипед</label>
<div class="type-chips">
<button class="type-chip ${bike.is_active ? "selected" : ""}" id="m-bike-active-yes" data-active="1">Да</button>
<button class="type-chip ${!bike.is_active ? "selected" : ""}" id="m-bike-active-no" data-active="0">Нет</button>
</div>
</div>
${!isNew ? `
<div class="sd-section" style="margin-top:20px">
<div class="sd-title">Обслуживание</div>
<div id="m-bike-mileage" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
<div id="m-bike-maint-list"></div>
<button id="m-bike-maint-add" class="btn-secondary full-width" style="margin-top:8px">+ Добавить</button>
<div id="m-bike-maint-form" style="display:none;margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
<div class="form-group"><label>Дата</label><input id="m-maint-date" type="date" value="${new Date().toISOString().slice(0,10)}" /></div>
<div class="form-group"><label>Описание</label><input id="m-maint-desc" type="text" placeholder="Смазка цепи, подкачка шин..." /></div>
<div class="form-group"><label>След. обслуживание через (км)</label><input id="m-maint-next" type="number" inputmode="decimal" step="1" placeholder="100" /></div>
<div class="form-group"><label>Заметки</label><input id="m-maint-notes" type="text" placeholder="" /></div>
<button id="m-maint-save" class="btn-primary full-width">Сохранить запись</button>
</div>
</div>
` : ""}
${!isNew ? `<button id="m-del-bike" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить велосипед</button>` : ""}`,
async overlay => {
let name = overlay.querySelector("#m-bike-name").value.trim();
if (!name) return false;
let data = {
name,
weight_kg: parseFloat(overlay.querySelector("#m-bike-weight").value) || null,
wheel_diameter_inch: parseFloat(overlay.querySelector("#m-bike-wheel").value) || 26,
chainring_teeth: parseInt(overlay.querySelector("#m-bike-chainring").value) || null,
cog_teeth: parseInt(overlay.querySelector("#m-bike-cog").value) || null,
surface_type: overlay.querySelector("#m-bike-surface").value,
tire_pressure_bar: parseFloat(overlay.querySelector("#m-bike-pressure").value) || null,
is_active: overlay.querySelector("#m-bike-active-yes").classList.contains("selected"),
};
if (isNew) {
await DB.saveBike(data);
} else {
await DB.updateBike(bikeId, data);
}
// Refresh the settings screen to show updated list
renderScreen("settings");
});
// Active toggle chips
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
overlay.querySelectorAll("#m-bike-active-yes, #m-bike-active-no").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
overlay.querySelector("#m-bike-active-yes").classList.toggle("selected", btn.dataset.active === "1");
overlay.querySelector("#m-bike-active-no").classList.toggle("selected", btn.dataset.active === "0");
};
});
}, 50);
if (!isNew) {
setTimeout(() => {
let delBtn = document.querySelector("#m-del-bike");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteBike(bikeId);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("settings");
};
}
}, 100);
// Bike maintenance section
setTimeout(async () => {
let mileageEl = document.querySelector("#m-bike-mileage");
let listEl = document.querySelector("#m-bike-maint-list");
let addBtn = document.querySelector("#m-bike-maint-add");
let formEl = document.querySelector("#m-bike-maint-form");
if (!listEl) return;
let totalKm = await DB.getBikeTotalMileage(bikeId);
if (mileageEl) mileageEl.textContent = `Общий пробег: ${totalKm} км`;
async function renderMaintList() {
let entries = await DB.getBikeMaintenanceLog(bikeId);
if (entries.length === 0) {
listEl.innerHTML = '<div style="font-size:13px;color:var(--muted);padding:8px 0">Нет записей</div>';
return;
}
listEl.innerHTML = entries.map(e => {
let overdue = e.next_service_km != null && totalKm >= e.next_service_km;
let nextInfo = e.next_service_km != null ? `, след. при ${e.next_service_km} км` : "";
let dateShort = e.date.slice(5).replace("-", ".");
return `<div class="maint-item${overdue ? " maint-overdue" : ""}" data-mid="${e.id}">
<div class="maint-info">
<span class="maint-date">${dateShort}</span>
<span class="maint-desc">${esc(e.description)}</span>
${e.mileage_km != null ? `<span class="maint-km">(${e.mileage_km} км${nextInfo})</span>` : ""}
${overdue ? '<span class="maint-warn">[!]</span>' : ""}
</div>
<button class="maint-del" data-mid="${e.id}">&times;</button>
</div>`;
}).join("");
listEl.querySelectorAll(".maint-del").forEach(btn => {
btn.onclick = async (ev) => {
ev.stopPropagation();
await DB.deleteBikeMaintenance(parseInt(btn.dataset.mid));
await renderMaintList();
};
});
}
await renderMaintList();
if (addBtn) {
addBtn.onclick = (e) => {
e.stopPropagation();
formEl.style.display = formEl.style.display === "none" ? "block" : "none";
};
}
let saveBtn = document.querySelector("#m-maint-save");
if (saveBtn) {
saveBtn.onclick = async (e) => {
e.stopPropagation();
let date = document.querySelector("#m-maint-date").value;
let desc = document.querySelector("#m-maint-desc").value.trim();
if (!desc) return;
let nextKm = parseFloat(document.querySelector("#m-maint-next").value) || null;
let notes = document.querySelector("#m-maint-notes").value.trim() || null;
// next_service_km = current mileage + interval
let nextServiceKm = nextKm != null ? Math.round((totalKm + nextKm) * 10) / 10 : null;
await DB.addBikeMaintenance(bikeId, date, desc, totalKm, nextServiceKm, notes);
document.querySelector("#m-maint-desc").value = "";
document.querySelector("#m-maint-next").value = "";
document.querySelector("#m-maint-notes").value = "";
formEl.style.display = "none";
await renderMaintList();
};
}
}, 150);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Nutrient labels for display — maps DB column names to short UI labels
let NUTRIENT_LABELS = {
vit_a_mcg: "A", vit_c_mg: "C", vit_e_mg: "E",
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3",
vit_b5_mg: "B5", vit_b6_mg: "B6", vit_b7_mcg: "B7",
folate_mcg: "B9", vit_b12_mcg: "B12",
vit_d_mcg: "D", vit_k_mcg: "K",
calcium_mg: "Ca", magnesium_mg: "Mg", zinc_mg: "Zn",
iron_mg: "Fe", iodine_mg: "I", selenium_mcg: "Se",
molybdenum_mcg: "Mo", manganese_mg: "Mn", copper_mg: "Cu",
chromium_mg: "Cr", phosphorus_mg: "P", potassium_mg: "K+",
sodium_mg: "Na",
omega3_mg: "O-3", epa_mg: "EPA", dha_mg: "DHA",
lutein_mg: "Lut", lycopene_mg: "Lik", silicon_mg: "Si", vanadium_mg: "V",
ginseng_mg: "Ginseng", resveratrol_mg: "Resv", coq10_mg: "CoQ10",
// carotene_mcg not shown separately — auto-converted to vit A
};
// Build supplement micronutrient strip (existing horizontal chips on Today screen)
function buildMicroStrip(totals) {
let RDI = BMR.RDI;
let items = [];
for (let [key, rdi] of Object.entries(RDI)) {
let val = totals[key];
if (val == null || val === 0) continue;
let pct = Math.round((val / rdi) * 100);
let cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low";
items.push(`<span class="micro-chip ${cls}">${NUTRIENT_LABELS[key] || key} ${pct}%</span>`);
}
if (items.length === 0) return "";
return `<div class="micro-strip">${items.join("")}</div>`;
}
// Per-food-item nutrient line: shows non-zero nutrients as %RDI chips
function foodNutrientLine(f) {
if (!f.product_id) return ""; // manual entry or recipe — no per-product data
let RDI = BMR.RDI;
let factor = (f.amount_g || 100) / 100;
let items = [];
let check = (val, rdi, label) => {
if (!val || !rdi) return;
let pct = Math.round(val * factor / rdi * 100);
if (pct >= 1) items.push(`${label} ${pct}%`);
};
check(f.vit_c_mg, RDI.vit_c_mg, "C");
// Vitamin A = retinol + carotene/12
let vitA = (f.vit_a_mcg || 0) + (f.carotene_mcg || 0) / 12;
check(vitA, RDI.vit_a_mcg, "A");
check(f.vit_b1_mg, RDI.vit_b1_mg, "B1");
check(f.vit_b2_mg, RDI.vit_b2_mg, "B2");
check(f.vit_b3_mg, RDI.vit_b3_mg, "B3");
check(f.vit_b6_mg, RDI.vit_b6_mg, "B6");
check(f.vit_b12_mcg, RDI.vit_b12_mcg, "B12");
check(f.folate_mcg, RDI.folate_mcg, "B9");
check(f.vit_d_mcg, RDI.vit_d_mcg, "D");
check(f.vit_e_mg, RDI.vit_e_mg, "E");
check(f.vit_k_mcg, RDI.vit_k_mcg, "K");
check(f.calcium_mg, RDI.calcium_mg, "Ca");
check(f.iron_mg, RDI.iron_mg, "Fe");
check(f.magnesium_mg, RDI.magnesium_mg, "Mg");
check(f.potassium_mg, RDI.potassium_mg, "K+");
check(f.zinc_mg, RDI.zinc_mg, "Zn");
check(f.copper_mg, RDI.copper_mg, "Cu");
check(f.selenium_mcg, RDI.selenium_mcg, "Se");
check(f.sodium_mg, RDI.sodium_mg, "Na");
if (items.length === 0) return "";
return `<div class="food-nutrients">${items.join(", ")}</div>`;
}
// Calculate daily nutrient totals from food (product micronutrients) + supplements
// Returns array sorted by %RDI ascending (most deficient first)
function calcDailyNutrients(food, suppTotals) {
let RDI = BMR.RDI;
let totals = {};
// From food: product micronutrients * (amount_g / 100)
for (let f of food) {
if (!f.product_id) continue;
let factor = (f.amount_g || 100) / 100;
for (let key of Object.keys(RDI)) {
if (f[key]) {
totals[key] = (totals[key] || 0) + f[key] * factor;
}
}
// Convert beta-carotene → vitamin A (12 mcg carotene = 1 mcg retinol equivalent)
if (f.carotene_mcg) {
totals.vit_a_mcg = (totals.vit_a_mcg || 0) + (f.carotene_mcg * factor) / 12;
}
}
// From recipes (cached_nutrients JSON in recipes table)
for (let f of food) {
if (!f.recipe_id || !f.cached_nutrients) continue;
try {
let rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients;
for (let key of Object.keys(RDI)) {
if (rn[key]) totals[key] = (totals[key] || 0) + rn[key];
}
} catch {}
}
// From supplements
if (suppTotals) {
for (let key of Object.keys(RDI)) {
if (suppTotals[key]) {
totals[key] = (totals[key] || 0) + suppTotals[key];
}
}
// Convert supplement beta-carotene → vitamin A
if (suppTotals.carotene_mcg) {
totals.vit_a_mcg = (totals.vit_a_mcg || 0) + suppTotals.carotene_mcg / 12;
}
}
// Build array sorted by %RDI ascending
let result = [];
for (let [key, rdi] of Object.entries(RDI)) {
let amount = totals[key] || 0;
let pct = Math.round(amount / rdi * 100);
let label = NUTRIENT_LABELS[key] || key;
let normType = BMR.RDI_TYPES[key] || "rdi";
result.push({ key, label, amount, rdi, pct, normType });
}
result.sort((a, b) => a.pct - b.pct);
return result;
}
// Calculate today's reserves = (yesterday_reserves + yesterday_intake) * decay
// decay = 0.5^(1/halflife_days) per nutrient
async function calcReservesForDate(date) {
let HL = BMR.HALFLIFE;
let RDI = BMR.RDI;
if (!HL || !Object.keys(HL).length) return null;
let yesterday = shiftDate(date, -1);
let prevReserves = await DB.getReserves(yesterday);
// Get yesterday's intake (food + supplements)
let food = await DB.getFoodForDate(yesterday);
let suppTotals = await DB.getSupplementTotalsForDate(yesterday);
let intake = calcDailyNutrientTotals(food, suppTotals);
// Apply decay: reserves = (prev_reserves + intake) * 0.5^(1/hl)
let reserves = {};
for (let key of Object.keys(RDI)) {
let hl = HL[key];
if (!hl || hl <= 0) continue;
let prev = (prevReserves[key] || 0) + (intake[key] || 0);
if (prev <= 0) continue;
let decayed = prev * Math.pow(0.5, 1 / hl);
if (decayed >= 0.001) reserves[key] = Math.round(decayed * 1000) / 1000;
}
return reserves;
}
// Raw totals (no RDI %, just amounts) — reused by reserves calc
function calcDailyNutrientTotals(food, suppTotals) {
let RDI = BMR.RDI;
let totals = {};
for (let f of food) {
if (f.product_id) {
let factor = (f.amount_g || 100) / 100;
for (let key of Object.keys(RDI)) {
if (f[key]) totals[key] = (totals[key] || 0) + f[key] * factor;
}
if (f.carotene_mcg) totals.vit_a_mcg = (totals.vit_a_mcg || 0) + (f.carotene_mcg * factor) / 12;
}
if (f.recipe_id && f.cached_nutrients) {
try {
let rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients;
for (let key of Object.keys(RDI)) {
if (rn[key]) totals[key] = (totals[key] || 0) + rn[key];
}
} catch {}
}
}
if (suppTotals) {
for (let key of Object.keys(RDI)) {
if (suppTotals[key]) totals[key] = (totals[key] || 0) + suppTotals[key];
}
if (suppTotals.carotene_mcg) totals.vit_a_mcg = (totals.vit_a_mcg || 0) + suppTotals.carotene_mcg / 12;
}
return totals;
}
// One-time migration: fill nutrient_reserves for all historical days
async function migrateReserves() {
let existing = await DB.getAllReservesDates();
if (existing.length > 0) return; // already migrated
let days = await DB.getDaysWithFood();
if (!days.length) return;
console.log(`[Reserves] migrating ${days.length} days...`);
for (let row of days) {
let reserves = await calcReservesForDate(row.date);
if (reserves && Object.keys(reserves).length > 0) {
await DB.saveReserves(row.date, reserves);
}
}
console.log("[Reserves] migration done");
}
// Render nutrient chips from an array of {label, pct} objects
function renderNutrientChips(nutrients, title) {
if (!nutrients || nutrients.length === 0) return "";
let header = title ? `<div class="nut-item" style="font-weight:bold;opacity:0.6;min-width:auto">${title}</div>` : "";
return header + nutrients.map(n => {
let cls = n.pct < 30 ? "nut-low" : n.pct < 80 ? "nut-mid" : "nut-ok";
let star = n.normType === "aup" ? "*" : "";
return `<div class="nut-item ${cls}">${n.label}${star} ${n.pct}%</div>`;
}).join("");
}
// ---------------------------------------------------------------------------
// Reusable product search dropdown component
// parentEl: element to insert into (must have position:relative or will be set)
// onSelect(product): called when user picks a product from results
// opts.placeholder, opts.onClear (called when input cleared after selection)
// Returns { el, focus() }
// ---------------------------------------------------------------------------
function createProductSearch(parentEl, onSelect, opts) {
opts = opts || {};
parentEl.style.position = "relative";
let wrap = document.createElement("div");
wrap.innerHTML = `
<input type="search" class="search-input" placeholder="${esc(opts.placeholder || "Поиск продукта...")}" />
<div class="search-dropdown" style="display:none"></div>`;
parentEl.appendChild(wrap);
let input = wrap.querySelector("input");
let dropdown = wrap.querySelector(".search-dropdown");
let debounce = null;
input.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
let q = input.value.trim();
if (q.length < 2) { dropdown.style.display = "none"; dropdown.innerHTML = ""; if (opts.onClear) opts.onClear(); return; }
let results = await DB.searchProducts(q);
dropdown.style.display = "";
dropdown.innerHTML = results.map(p =>
`<div class="sd-item" data-pid="${p.id}">
<span>${esc(p.name)} ${sourceBadge(p.source)}</span>
<span class="sd-item-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
dropdown.querySelectorAll(".sd-item").forEach(item => {
item.onclick = async () => {
let pid = parseInt(item.dataset.pid);
let product = results.find(r => r.id === pid) || await DB.getProduct(pid);
dropdown.style.display = "none";
dropdown.innerHTML = "";
input.value = product.name;
onSelect(product);
};
});
}, 300);
};
return { el: wrap, input, focus() { input.focus(); } };
}
function calcBmr(recentWeights) {
let p = state.profile;
let height = parseInt(p.height_cm);
let birthYear = parseInt(p.birth_year);
if (!height || !birthYear) return null;
// Moving average weight over recent days (smooths daily fluctuations)
let kg;
if (recentWeights && recentWeights.length >= 2) {
kg = recentWeights.reduce((s, w) => s + parseFloat(w.weight_kg), 0) / recentWeights.length;
} else if (recentWeights && recentWeights.length === 1) {
kg = parseFloat(recentWeights[0].weight_kg);
} else {
kg = parseFloat(state.latestWeight?.weight_kg ?? p.weight_kg);
}
if (!kg) return null;
return BMR.calculateBmr(kg, height, BMR.getAge(birthYear), p.sex ?? "m");
}
function hrZoneClass(hr) {
let p = state.profile;
let age = BMR.getAge(parseInt(p.birth_year) || 1988);
let maxHr = parseInt(p.hr_max) || (220 - age);
let restMax = parseInt(p.hr_rest_max) || 100;
if (hr <= restMax) return "hr-zone-green";
if (hr <= 140) return "hr-zone-yellow";
if (hr <= maxHr * 0.85) return "hr-zone-orange";
return "hr-zone-red";
}
function hrZoneColor(hr) {
let cls = hrZoneClass(hr);
if (cls === "hr-zone-green") return "#81c784";
if (cls === "hr-zone-yellow") return "#ffb74d";
if (cls === "hr-zone-orange") return "#ff8a65";
return "#ef5350";
}
function balanceClass(balance) {
if (balance == null) return "";
return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced";
}
function formatSleepInfo(s) {
let lines = [];
if (s.asleep_time && s.wake_time) {
let a = toMin(s.asleep_time), w = toMin(s.wake_time);
let 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) {
let [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) {
let 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" });
}
async function showRoadsMap() {
let container = document.querySelector(".screen-content");
container.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;">
<div style="display:flex;align-items:center;padding:8px 12px;padding-top:calc(8px + var(--safe-area-top));background:var(--surface);border-bottom:1px solid var(--border);gap:8px;">
<button id="roads-back" class="btn-secondary" style="padding:6px 12px;">Назад</button>
<span style="flex:1;font-weight:600;">Дороги</span>
<span id="roads-info" style="font-size:12px;color:var(--muted);"></span>
<button id="roads-rebuild" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Перестроить</button>
</div>
<div id="roads-map" style="flex:1;"></div>
</div>`;
document.querySelector("#roads-back").onclick = () => renderScreen("settings");
let mapEl = document.querySelector("#roads-map");
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([47.42, 40.08], 13);
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let infoEl = document.querySelector("#roads-info");
infoEl.textContent = "Загрузка...";
// Rebuild button — always visible
let rebuildBtn = document.querySelector("#roads-rebuild");
async function doRebuild() {
rebuildBtn.disabled = true;
rebuildBtn.textContent = "...";
infoEl.textContent = "Построение (может занять минуту)...";
try {
let count = await DB.rebuildRoads();
infoEl.textContent = `${count} точек`;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
showRoadsMap();
} catch (e) {
infoEl.textContent = "Ошибка: " + e;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
}
}
rebuildBtn.onclick = doRebuild;
let pts;
try {
pts = await DB.getRoadPoints();
} catch (e) {
// Table missing or schema mismatch — need rebuild
infoEl.textContent = "Нужно построить дороги";
return;
}
if (pts.length === 0) {
infoEl.textContent = "Нет дорог — нажмите Перестроить";
return;
}
// Split points into segments by gap (>100m) and activity type
let segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
let seg = [];
let segType = pts[0]?.atype || "b";
for (let i = 0; i < pts.length; i++) {
let newType = pts[i].atype || "b";
if (seg.length > 0) {
let prev = seg[seg.length - 1];
let dlat = (pts[i].lat - prev[0]) * 111000;
let dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
seg = [];
segType = newType;
}
}
seg.push([pts[i].lat, pts[i].lon]);
}
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
// Draw: bike = blue, walk = brown
let allCoords = [];
for (let s of segments) {
let color = s.atype === "w" ? "#8B5E3C" : "#2266aa";
L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map);
allCoords.push(...s.coords);
}
if (allCoords.length > 0) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
}
let bikeSegs = segments.filter(s => s.atype !== "w").length;
let walkSegs = segments.filter(s => s.atype === "w").length;
infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`;
}
function addZoomDisplay(map) {
let ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {
let div = L.DomUtil.create("div", "zoom-display");
div.textContent = map.getZoom();
map.on("zoomend", () => { div.textContent = map.getZoom(); });
return div;
};
ctrl.addTo(map);
}
// ---------------------------------------------------------------------------
// Export/Import: recipes, supplements
// ---------------------------------------------------------------------------
async function downloadFile(filename, content) {
// Use Tauri save_file (works on Android via SAF)
try {
if (window.__TAURI__) {
await window.__TAURI__.core.invoke("save_file", { filename, content });
return;
}
} catch (e) {
console.error("[download] Tauri save error:", e);
}
// Fallback for desktop browsers
let blob = new Blob([content], { type: "application/json" });
let a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
async function exportAllRecipes() {
let recipes = await DB.getRecipes();
let result = [];
for (let r of recipes) {
let ings = await DB.getRecipeIngredients(r.id);
result.push({
name: r.name,
cooking_method_id: r.cooking_method_id,
cooking_method_name: r.cooking_method_name,
servings: r.servings,
notes: r.notes,
ingredients: ings.map(i => ({
product_name: i.product_name,
custom_name: i.custom_name,
amount_g: i.amount_g,
cooking_method_id: i.cooking_method_id,
cooking_method_name: i.cooking_method_name,
// Store product nutrients so recipient can create product if missing
kcal_per_100g: i.kcal_per_100g,
protein_per_100g: i.protein_per_100g,
fat_per_100g: i.fat_per_100g,
carbs_per_100g: i.carbs_per_100g,
})),
});
}
return result;
}
async function importRecipes(data) {
let arr = Array.isArray(data) ? data : [data];
for (let r of arr) {
if (!r.name) continue;
let rid = await DB.saveRecipe(r.name, r.cooking_method_id ?? null, r.servings ?? 1, r.notes ?? null);
if (!rid) continue;
for (let ing of (r.ingredients || [])) {
// Try to find product by name
let pid = 0;
if (ing.product_name) {
let found = await DB.searchProducts(ing.product_name, 1);
if (found.length) {
pid = found[0].id;
} else if (ing.kcal_per_100g != null) {
// Create product from exported data
pid = await DB.addProduct({
name: ing.product_name,
kcal_per_100g: ing.kcal_per_100g,
protein_per_100g: ing.protein_per_100g,
fat_per_100g: ing.fat_per_100g,
carbs_per_100g: ing.carbs_per_100g,
source: "recipe_import",
});
}
}
await DB.addRecipeIngredient(rid, pid, ing.custom_name ?? null, ing.amount_g ?? 100, ing.cooking_method_id ?? null);
}
}
}
async function importSupplements(data) {
let arr = Array.isArray(data) ? data : [data];
for (let s of arr) {
if (!s.name) continue;
// Remove id — will get new one
let clean = { ...s };
delete clean.id;
await DB.addSupplement(clean);
}
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
let 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);
let file = input.files[0];
document.body.removeChild(input);
if (!file) return;
console.log("[pickFile] file:", file.name, file.size);
try {
if (asBinary) {
let buf = await file.arrayBuffer();
await callback(buf, file);
} else {
let text = await file.text();
await callback(text, file);
}
} catch (e) {
console.error("[pickFile] error:", e);
}
});
input.click();
}
function sourceBadge(source) {
let map = {
magnit: ["Магнит", "src-magnit"],
"5ka": ["5ка", "src-5ka"],
kdv: ["KDV", "src-kdv"],
ion: ["ИОН", "src-ion"],
manual: ["Свой", "src-manual"],
recipe_import: ["Импорт", "src-manual"],
};
let [label, cls] = map[source] || ["", ""];
if (!label) return "";
return `<span class="src-badge ${cls}">${label}</span>`;
}
function showToast(msg, duration) {
let el = document.createElement("div");
el.textContent = msg;
el.style.cssText = "position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:99999;opacity:0;transition:opacity 0.3s";
document.body.appendChild(el);
requestAnimationFrame(() => el.style.opacity = "1");
let hide = () => { el.style.opacity = "0"; setTimeout(() => el.remove(), 300); };
el.onclick = hide;
setTimeout(hide, duration || 6000);
}
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
// Single global popstate handler
window.addEventListener("popstate", (e) => {
// closeModal used history.go(-N) — skip N popstate events
if (_skipPops > 0) { _skipPops--; return; }
// winStack has entries — pop the top one (close modal/fullscreen)
if (winPopTop()) return;
// Stale entries from previous app session — skip
if (e.state && (e.state.modal || e.state.chartFull || e.state.modalFs || e.state.win)) {
history.back();
return;
}
// Navigate between screens via navStack
if (navStack.length > 1) {
navStack.pop();
persistNavStack();
renderScreen(navStack[navStack.length - 1], true);
} else if (e.state && e.state.screen) {
renderScreen(e.state.screen, true);
}
});
init().catch(err => {
console.error(err);
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
});