baldav/frontend/js/main.js
4aiman fda820568e Compass map rotation, landscape layout, GPS placeholder
- leaflet-rotate plugin: map rotates by compass heading via deviceorientation
- Landscape detection: CSS viewport rotation when phone held sideways (gamma > 50)
- Bottom nav hidden in landscape mode
- GPS placeholder: pixel art overlay shown until GPS signal arrives
- invalidateSize() after map init to prevent black screen
2026-06-25 16:54:45 +03:00

5644 lines
235 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.
// 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 });
const app = document.getElementById("app");
const UPDATE_BASE_URL = "https://4aiman.top/baldav";
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;
const s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*");
if (s.includes("*")) {
const parts = s.split("*").map(Number);
if (parts.length === 2 && parts.every(n => !isNaN(n))) return parts[0] * parts[1];
return NaN;
}
if (s.includes("+")) {
const 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
const raw = await window.__TAURI__.core.invoke("get_update_info");
const 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;
const 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.
const NAV_MAX = 20;
// Restore nav stack from localStorage so back gesture works after app restart
const navStack = (() => {
try {
const 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.
// ---------------------------------------------------------------------------
const 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>`;
const bootEl = document.getElementById("boot-label");
const bootCount = document.getElementById("seed-count");
const 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 {
const 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 {
const 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) => {
const { current, total } = ev.payload;
const 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 {}
const 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 {
const insets = await window.__TAURI__.core.invoke("get_safe_insets");
if (insets.top || insets.bottom) {
document.documentElement.style.setProperty("--safe-area-top", insets.top + "px");
document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px");
}
} catch {}
state.profile = await DB.getFullProfile();
state.latestWeight = await DB.getLatestWeight();
// 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; }
const 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 }, "");
}
const 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") {
const bar = document.createElement("div");
bar.className = "rec-floating-bar";
bar.onclick = () => renderScreen("recording");
bar.innerHTML = `<span class="rec-bar-dot"></span><span id="rec-bar-text">Запись...</span>`;
app.appendChild(bar);
// Update bar every second
if (window._recBarTimer) clearInterval(window._recBarTimer);
window._recBarTimer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
const sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
const el = document.querySelector("#rec-bar-text");
if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`;
}, 1000);
} else if (window._recBarTimer) {
clearInterval(window._recBarTimer);
}
app.appendChild(buildNav(name));
const content = document.createElement("div");
content.className = "screen-content";
app.appendChild(content);
screens[name](content);
}
function buildNav(active) {
const nav = document.createElement("nav");
nav.className = "bottom-nav";
[
{ id: "today", label: "Сегодня" },
{ id: "products", label: "Продукты" },
{ id: "gps", label: "Трек" },
{ id: "supplements", label: "Добавки" },
{ id: "settings", label: "Настройки" },
].forEach(item => {
const btn = document.createElement("button");
btn.className = "nav-btn" + (item.id === active ? " active" : "");
btn.textContent = item.label;
btn.onclick = () => renderScreen(item.id);
nav.appendChild(btn);
});
return nav;
}
// ---------------------------------------------------------------------------
// TODAY screen
// ---------------------------------------------------------------------------
async function renderToday(container) {
container.innerHTML = `<div class="loading">Загрузка...</div>`;
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;
}
const recentWeightsForBmr = await DB.getRecentWeights(7);
const bmr = calcBmr(recentWeightsForBmr);
const actLevel = parseFloat(state.profile.activity_level) || 1.2;
const dailyBase = bmr ? Math.round(bmr * actLevel) : null;
const intake = Math.round(foodTotals.kcal ?? 0);
const actKcal = Math.round(actTotals.kcal ?? 0);
const expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null;
const balance = expend != null ? intake - expend : null;
container.innerHTML = `
<div class="day-header">
<button class="day-nav" id="prev-day">&#8592;</button>
<h2>${formatDate(state.date)}</h2>
<button class="day-nav" id="next-day">&#8594;</button>
</div>
<div class="summary-row">
<div class="summary-card">
<span class="summary-val">${intake}</span>
<span class="summary-lbl">ккал приход</span>
</div>
<div class="summary-card">
<span class="summary-val">${expend ?? "—"}</span>
<span class="summary-lbl">ккал расход</span>
</div>
<div class="summary-card ${balanceClass(balance)}">
<span class="summary-val">${balance != null ? (balance >= 0 ? "+" : "") + balance : "—"}</span>
<span class="summary-lbl">баланс</span>
</div>
</div>
<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 => {
const dist = s.total_distance_m ? (s.total_distance_m / 1000).toFixed(1) + " км" : "";
const kcal = s.total_kcal ? Math.round(s.total_kcal) + " кк" : "";
const 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}" 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 =>
`<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${esc(a.desc ?? "—")}
${a.minutes ? `<small>${a.minutes} мин</small>` : ""}</span>
<span class="log-kcal">${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`
).join("")}
${!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 () => {
const { table, id } = btn.dataset;
if (table === "weight") await DB.deleteWeight(parseInt(id));
if (table === "food") await DB.deleteFoodEntry(parseInt(id));
if (table === "activity") await DB.deleteActivity(parseInt(id));
if (table === "supp") await DB.deleteSupplementLog(parseInt(id));
renderScreen("today");
};
});
container.querySelectorAll(".food-edit-item").forEach(li => {
li.onclick = e => {
if (e.target.closest(".del-btn")) return;
const fid = parseInt(li.dataset.foodId);
const entry = food.find(f => f.id === fid);
if (entry) showEditFoodModal(entry);
};
});
// 7-day rolling balance trend (IDEA 6)
(async () => {
try {
const trendEl = container.querySelector("#balance-trend");
if (!trendEl || dailyBase == null) return;
const [food7, act7] = await Promise.all([
DB.getDailyFoodTotals(7),
DB.getDailyActivityTotals(7),
]);
if (food7.length === 0) return;
const foodMap = {};
food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
const actMap = {};
act7.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
// Build per-day balance for all dates that have food data
const balances = [];
for (const d of food7) {
const dayIntake = foodMap[d.date] ?? 0;
const dayAct = actMap[d.date] ?? 0;
const dayBalance = dayIntake - (dailyBase + dayAct);
balances.push(dayBalance);
}
if (balances.length === 0) return;
const avg = Math.round(balances.reduce((a, b) => a + b, 0) / balances.length);
const 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
const drinkWrap = container.querySelector("#drink-bar-wrap");
if (drinkWrap) {
(async () => {
try {
const waterMl = await DB.getDailyWaterMl(state.date);
const norm = parseInt(state.profile.daily_water_ml) || 2000;
const pct = Math.min(100, Math.round(waterMl / norm * 100));
const 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)
const dailyNutEl = container.querySelector("#daily-nutrients");
if (dailyNutEl) {
const 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)
const reservesEl = container.querySelector("#daily-reserves");
if (reservesEl) {
(async () => {
try {
// Calculate and save today's reserves
const reserves = await calcReservesForDate(state.date);
if (reserves && Object.keys(reserves).length > 0) {
await DB.saveReserves(state.date, reserves);
const RDI = BMR.RDI;
const chips = [];
for (const [key, rdi] of Object.entries(RDI)) {
const amount = reserves[key] || 0;
if (amount <= 0) continue;
const pct = Math.round(amount / rdi * 100);
if (pct < 1) continue;
const label = NUTRIENT_LABELS[key] || key;
const 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 = () => {
const sid = el.dataset.gpsSession;
renderScreen("recording");
// After rendering, trigger session detail view
setTimeout(() => {
const sessionEl = document.querySelector(`[data-session-id="${sid}"]`);
if (sessionEl) sessionEl.click();
}, 300);
};
});
// Stats charts section
const statsContainer = container.querySelector("#stats-charts");
const 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
// ---------------------------------------------------------------------------
const statsChartInstances = [];
async function renderStatsCharts(container, days, foodTotals, bmr) {
// Destroy old charts
statsChartInstances.forEach(c => c.destroy());
statsChartInstances.length = 0;
container.innerHTML = `<div class="loading">Загрузка...</div>`;
let weights, dailyFood, dailyActivity;
try {
[weights, dailyFood, dailyActivity] = await Promise.all([
DB.getRecentWeights(days),
DB.getDailyFoodTotals(days),
DB.getDailyActivityTotals(days),
]);
} catch (e) {
container.innerHTML = `<div class="log-empty">Ошибка загрузки статистики</div>`;
return;
}
container.innerHTML = "";
// --- Weight chart (line) ---
if (weights.length > 1) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Вес</div>
<div class="chart-wrap"><canvas id="stats-weight-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
const ctx = block.querySelector("#stats-weight-chart");
const labels = weights.map(w => {
const d = w.date.split("-");
return d[2] + "." + d[1];
});
const chart = new Chart(ctx, {
type: "line",
data: {
labels,
datasets: [{
data: weights.map(w => w.weight_kg),
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: weights.length > 30 ? 0 : 2,
fill: false,
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
const wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
statsChartInstances.push(chart);
}
// --- Macros donut (doughnut) ---
const protein = Math.round(foodTotals.protein_g ?? 0);
const fat = Math.round(foodTotals.fat_g ?? 0);
const carbs = Math.round(foodTotals.carbs_g ?? 0);
const totalKcal = Math.round(foodTotals.kcal ?? 0);
if (protein + fat + carbs > 0) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Макронутриенты (сегодня)</div>
<div class="chart-wrap"><canvas id="stats-macros-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
const ctx = block.querySelector("#stats-macros-chart");
const chart = new Chart(ctx, {
type: "doughnut",
data: {
labels: ["Белки", "Жиры", "Углеводы"],
datasets: [{
data: [protein, fat, carbs],
backgroundColor: ["#64b5f6", "#ffb74d", "#81c784"],
borderWidth: 0,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: "60%",
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
},
},
plugins: [{
id: "centerText",
afterDraw(chart) {
const { ctx: c, chartArea } = chart;
const cx = (chartArea.left + chartArea.right) / 2;
const cy = (chartArea.top + chartArea.bottom) / 2;
c.save();
c.textAlign = "center";
c.textBaseline = "middle";
c.font = "bold 18px system-ui";
c.fillStyle = "#e0e0e0";
c.fillText(totalKcal + " кк", cx, cy);
c.restore();
},
}],
});
statsChartInstances.push(chart);
}
// --- Kcal balance chart (bar) ---
if (dailyFood.length > 0) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Баланс ккал</div>
<div class="chart-wrap"><canvas id="stats-kcal-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
// Build a merged date set from food and activity data
const dateSet = new Set();
dailyFood.forEach(d => dateSet.add(d.date));
dailyActivity.forEach(d => dateSet.add(d.date));
const dates = Array.from(dateSet).sort();
const foodMap = {};
dailyFood.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
const actMap = {};
dailyActivity.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
const labels = dates.map(d => {
const parts = d.split("-");
return parts[2] + "." + parts[1];
});
const intakeData = dates.map(d => foodMap[d] ?? 0);
const expendData = dates.map(d => (bmr ?? 0) + (actMap[d] ?? 0));
const ctx = block.querySelector("#stats-kcal-chart");
const chart = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [
{
label: "Приход",
data: intakeData,
backgroundColor: "#81c784",
},
{
label: "Расход",
data: expendData,
backgroundColor: "#cf6679",
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
const wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 10 }, grid: { color: "#333" } },
y: { ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
statsChartInstances.push(chart);
}
// --- Weight vs 7-day rolling caloric balance (IDEA 7) ---
if (weights.length > 3 && dailyFood.length > 3) {
const 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
const wMap = {};
weights.forEach(w => wMap[w.date] = w.weight_kg);
const fMap = {};
dailyFood.forEach(d => fMap[d.date] = Math.round(d.kcal ?? 0));
const aMap = {};
dailyActivity.forEach(d => aMap[d.date] = Math.round(d.kcal ?? 0));
// Generate all dates in range
const allDates = [];
if (weights.length > 0 && dailyFood.length > 0) {
const startDate = weights[0].date < dailyFood[0].date ? weights[0].date : dailyFood[0].date;
const endDate = new Date().toISOString().slice(0, 10);
let cur = new Date(startDate + "T00:00:00");
const 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)
const weightData = [];
let lastW = null;
for (const d of allDates) {
if (wMap[d] != null) lastW = wMap[d];
weightData.push(lastW);
}
// 7-day rolling average of caloric balance
const balanceData = [];
for (let i = 0; i < allDates.length; i++) {
const window7 = [];
for (let j = Math.max(0, i - 6); j <= i; j++) {
const dDate = allDates[j];
if (fMap[dDate] != null) {
const 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);
}
const corrLabels = allDates.map(d => {
const parts = d.split("-");
return parts[2] + "." + parts[1];
});
const ctx = block.querySelector("#stats-corr-chart");
const 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;
const wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: {
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");
const 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) {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<button class="modal-back" id="modal-back">&#8592;</button>
</div>
<div class="modal-body">${html}</div>
<div class="modal-btns">
<button id="modal-cancel" class="btn-secondary">Отмена</button>
<button id="modal-ok" class="btn-primary">Сохранить</button>
</div>
</div>`;
document.body.appendChild(overlay);
const 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)
const 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 {
const ok = await onSubmit(overlay);
if (ok !== false) { closeModal(); if (ok !== "norender") renderScreen(state.screen); }
} catch (e) {
console.error("[showModal] onSubmit error:", e);
alert("Ошибка: " + e);
}
};
}
function showWeightModal() {
showModal(`
<h3>Вес</h3>
<div class="form-group">
<label>кг</label>
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const kg = parseFloat(overlay.querySelector("#m-kg").value);
const time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await DB.addWeight(state.date, time, kg);
});
}
function showFoodModal() {
let selectedProduct = null;
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="numeric" placeholder="100 или 4*30" value="100" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const time = overlay.querySelector("#m-time").value;
const 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 {
const desc = overlay.querySelector("#m-desc").value.trim();
if (!desc) return false;
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
const protein = parseFloat(overlay.querySelector("#m-protein").value) || null;
const fat = parseFloat(overlay.querySelector("#m-fat").value) || null;
const carbs = parseFloat(overlay.querySelector("#m-carbs").value) || null;
const amount = rawAmount;
if (kcal || protein || fat || carbs) {
// Create a custom product in DB, then log it
const 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);
}
}
});
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const selectedEl = overlay.querySelector("#m-selected");
const manualFields = overlay.querySelector("#m-manual-fields");
const unitChipsEl = overlay.querySelector("#m-unit-chips");
const amountLabel = overlay.querySelector("#m-amount-label");
const amountInput = overlay.querySelector("#m-amount");
function renderUnitChips() {
// "г" chip is always first (default), then available units
let html = `<button class="unit-chip${!selectedUnit ? " selected" : ""}" data-unit-id="0">г</button>`;
for (const u of availableUnits) {
if (u.grams_per_unit == null) continue; // skip units without conversion
html += `<button class="unit-chip${selectedUnit && selectedUnit.id === u.id ? " selected" : ""}" data-unit-id="${u.id}">${esc(u.name_short || u.name)}</button>`;
}
unitChipsEl.innerHTML = html;
unitChipsEl.style.display = "";
unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => {
chip.onclick = () => {
const uid = parseInt(chip.dataset.unitId);
if (uid === 0) {
selectedUnit = null;
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
} else {
selectedUnit = availableUnits.find(u => u.id === uid);
amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`;
amountInput.placeholder = "1";
amountInput.value = "1";
}
unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
};
});
}
createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => {
selectedProduct = product;
selectedUnit = null;
selectedEl.style.display = "block";
selectedEl.innerHTML = `<span>${esc(product.name)}</span>`;
manualFields.style.display = "none";
// Load available units for this product
availableUnits = await DB.getUnitsForProduct(product.id);
if (availableUnits.length > 0) {
renderUnitChips();
} else {
unitChipsEl.style.display = "none";
}
// Reset amount — show "мл" for liquids
const 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
const origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? "";
showModal(`
<h3>Редактирование</h3>
<div id="m-current" class="modal-selected">
<span>${esc(origLabel)}</span>
</div>
<div id="m-search-block" style="display:none">
<div class="form-group">
<label>Заменить на</label>
<input id="m-prod-search" type="search" placeholder="Поиск продукта..." />
<div id="m-prod-results" class="modal-search-results"></div>
</div>
</div>
<div class="form-group">
<label id="m-amount-label">Количество (${(entry.water_pct && entry.water_pct > 80) ? "мл" : "г"})</label>
<input id="m-amount" type="text" inputmode="numeric" 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 => {
const amount = parseAmount(overlay.querySelector("#m-amount").value);
const time = overlay.querySelector("#m-time").value;
const note = overlay.querySelector("#m-note")?.value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
if (newProduct) {
// Replace: delete old, add new
await DB.deleteFoodEntry(entry.id);
await DB.addFoodEntry(entry.date ?? state.date, time, newProduct.id, amount, note);
} else {
const kcalEl = overlay.querySelector("#m-kcal");
const kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null;
await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride);
}
});
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const currentEl = overlay.querySelector("#m-current");
const searchBlock = overlay.querySelector("#m-search-block");
currentEl.onclick = () => {
searchBlock.style.display = "block";
searchBlock.querySelector("#m-prod-search").focus();
};
const searchInput = overlay.querySelector("#m-prod-search");
const resultsEl = overlay.querySelector("#m-prod-results");
let debounce = null;
searchInput.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const q = searchInput.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
const results = await DB.searchProducts(q);
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 = () => {
const pid = parseInt(item.dataset.pid);
newProduct = results.find(r => r.id === pid);
currentEl.querySelector("span").textContent = newProduct.name;
searchBlock.style.display = "none";
resultsEl.innerHTML = "";
searchInput.value = "";
// Hide manual kcal field since now it's a product
const kcalGroup = overlay.querySelector("#m-kcal-group");
if (kcalGroup) kcalGroup.style.display = "none";
};
});
}, 300);
};
}
function showActivityModal() {
showModal(`
<h3>Активность</h3>
<div class="form-group">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="прогулка" />
</div>
<div class="form-group">
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="40" />
</div>
<div class="form-group">
<label>ккал расход (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="150" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const desc = overlay.querySelector("#m-desc").value.trim();
const min = parseInt(overlay.querySelector("#m-min").value) || null;
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
const time = overlay.querySelector("#m-time").value;
if (!desc) return false;
await DB.addActivity(state.date, time, "manual", desc, min, kcal);
});
}
function showSleepModal(existing) {
showModal(`
<h3>Сон</h3>
<div class="form-group">
<label>Лёг в кровать</label>
<input id="m-bed" type="time" value="${existing?.bed_time ?? ""}" />
</div>
<div class="form-group">
<label>Уснул</label>
<input id="m-asleep" type="time" value="${existing?.asleep_time ?? ""}" />
</div>
<div class="form-group">
<label>Проснулся</label>
<input id="m-wake" type="time" value="${existing?.wake_time ?? ""}" />
</div>
<div class="form-group">
<label>Дневной сон (мин)</label>
<input id="m-nap" type="number" inputmode="numeric" value="${existing?.nap_minutes ?? 0}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="m-notes" type="text" value="${esc(existing?.notes ?? "")}" />
</div>`,
async overlay => {
await DB.saveSleep(
state.date,
overlay.querySelector("#m-bed").value || null,
overlay.querySelector("#m-asleep").value || null,
overlay.querySelector("#m-wake").value || null,
parseInt(overlay.querySelector("#m-nap").value) || 0,
overlay.querySelector("#m-notes").value.trim() || null
);
});
}
// ---------------------------------------------------------------------------
// PRODUCTS screen
// ---------------------------------------------------------------------------
async function renderProducts(container) {
let debounceTimer = null;
let expandedId = null; // currently expanded product card id
container.innerHTML = `
<div class="products-screen">
<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");
const 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"));
};
});
const input = container.querySelector("#prod-search");
const countEl = container.querySelector("#prod-count");
const listEl = container.querySelector("#prod-results");
let lastResults = [];
async function doSearch(resetExpanded) {
const q = input.value.trim();
if (q.length < 2) {
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
countEl.textContent = "";
lastResults = [];
return;
}
if (resetExpanded) expandedId = null;
lastResults = await DB.searchProducts(q);
countEl.textContent = lastResults.length ? `Найдено: ${lastResults.length}` : "";
if (!lastResults.length) {
listEl.innerHTML = `<div class="prod-hint">Ничего не найдено</div>`;
return;
}
renderList();
}
function renderList() {
listEl.innerHTML = lastResults.map(p => renderProdCard(p)).join("");
bindProdCards(listEl);
}
function renderProdCard(p) {
const open = p.id === expandedId;
const kcal = p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) : null;
return `
<div class="prod-card ${open ? "open" : ""}" data-pid="${p.id}">
<div class="prod-card-header">
<div class="prod-card-name">
${esc(p.name)}
${p.brand ? `<span class="prod-brand">${esc(p.brand)}</span>` : ""}
${p.weight_g ? `<span class="prod-weight">${p.weight_g}г</span>` : ""}
${sourceBadge(p.source)}
</div>
<div class="prod-card-kcal">${kcal != null ? kcal + " кк" : "?"}</div>
</div>
${open ? renderProdDetails(p) : ""}
</div>`;
}
function renderProdDetails(p) {
const r = v => v != null ? v : "—";
// Micronutrient chips for this product (per 100g)
const RDI = BMR.RDI;
const microChips = [];
for (const [key, rdi] of Object.entries(RDI)) {
if (!p[key]) continue;
const pct = Math.round(p[key] / rdi * 100);
if (pct < 1) continue;
const label = NUTRIENT_LABELS[key] || key;
const 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 = () => {
const card = hdr.closest(".prod-card");
const pid = parseInt(card.dataset.pid);
expandedId = (expandedId === pid) ? null : pid;
renderList();
};
});
el.querySelectorAll(".prod-add-btn").forEach(btn => {
btn.onclick = e => {
e.stopPropagation();
showAddFoodFromProduct(parseInt(btn.dataset.pid));
};
});
el.querySelectorAll(".prod-edit-btn").forEach(btn => {
btn.onclick = async e => {
e.stopPropagation();
const 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();
const pid = parseInt(btn.dataset.pid);
const 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="numeric" 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 => {
const amount = parseAmount(overlay.querySelector("#m-amount").value);
const time = overlay.querySelector("#m-time").value;
const note = overlay.querySelector("#m-note").value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
await DB.addFoodEntry(state.date, time, productId, amount, note);
});
}
input.oninput = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(true), 300);
};
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
input.focus();
// Add custom product button
const 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 () => {
const manualProducts = await DB.getManualProducts();
const allRecipes = await exportAllRecipes();
const supps = await DB.getSupplements();
const 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 {
const data = JSON.parse(text);
let imported = { products: 0, recipes: 0, supplements: 0 };
if (data.products && Array.isArray(data.products)) {
for (const p of data.products) {
// Dedup by name_lower + source
const 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) {
const 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 => {
const name = overlay.querySelector("#np-name").value.trim();
if (!name) return false;
const 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 (const 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) {
const 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 => {
const name = overlay.querySelector("#ep-name").value.trim();
if (!name) return false;
const waterPct = parseFloat(overlay.querySelector("#ep-water").value) || null;
const sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5", "water_pct=?6"];
const 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 (const 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(() => {
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const expBtn = overlay?.querySelector("#ep-export");
if (expBtn) expBtn.onclick = () => {
const 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) {
const recipes = await DB.getRecipes();
// Pre-calculate nutrition for each recipe to show kcal in list
const nutritionMap = {};
for (const 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 => {
const n = nutritionMap[r.id];
const kcalStr = n ? n.kcal + " кк" : "";
const 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 () => {
const allRecipes = await exportAllRecipes();
const json = JSON.stringify(allRecipes, null, 2);
downloadFile("baldav-recipes.json", json, "application/json");
};
container.querySelector("#import-recipes-btn").onclick = () => {
pickFile(".json", async (text) => {
const data = JSON.parse(text);
await importRecipes(data);
renderRecipes(container);
});
};
container.querySelectorAll(".recipe-card").forEach(card => {
card.onclick = () => {
const rid = parseInt(card.dataset.rid);
showRecipeDetailModal(rid);
};
});
}
// ---------------------------------------------------------------------------
// Recipe detail modal — view recipe, log to food diary
// ---------------------------------------------------------------------------
async function showRecipeDetailModal(recipeId) {
const recipe = await DB.getRecipe(recipeId);
if (!recipe) return;
const ingredients = await DB.getRecipeIngredients(recipeId);
const nutr = await DB.calculateRecipeNutrition(recipeId);
const ingList = ingredients.map(ing => {
const name = ing.product_name ?? ing.custom_name ?? "?";
const 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">${(() => {
const RDI = BMR.RDI;
const chips = [];
for (const [key, rdi] of Object.entries(RDI)) {
const val = nutr[key];
if (!val) continue;
const pct = Math.round(val / rdi * 100);
if (pct < 1) continue;
const label = NUTRIENT_LABELS[key] || key;
const 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
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const logBtn = overlay.querySelector("#rd-log");
const editBtn = overlay.querySelector("#rd-edit");
const exportBtn = overlay.querySelector("#rd-export");
const delBtn = overlay.querySelector("#rd-delete");
logBtn.onclick = async () => {
const 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 () => {
const ings = await DB.getRecipeIngredients(recipeId);
const 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) {
const isNew = !recipeId;
let recipe = isNew ? { name: "", cooking_method_id: null, servings: 1, notes: "" } : await DB.getRecipe(recipeId);
if (!recipe) return;
const cookingMethods = await DB.getCookingMethods();
const 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 }
const 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,
}));
const 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) => {
const 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;
const defaultMethodId = parseInt(document.querySelector("#re-method")?.value) || 0;
for (const ing of ingredients) {
if (!ing._product) continue;
const 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
const cmId = ing.cooking_method_id || defaultMethodId;
const 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;
}
const 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() {
const el = document.querySelector("#re-totals");
if (!el) return;
const 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 => {
const name = overlay.querySelector("#re-name").value.trim();
if (!name) return false;
if (ingredients.length === 0) return false;
const methodId = parseInt(overlay.querySelector("#re-method").value) || null;
const servings = parseFloat(overlay.querySelector("#re-servings").value) || 1;
const 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 (const 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
const 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() {
const listEl = overlay.querySelector("#re-ing-list");
listEl.innerHTML = renderIngredientRows();
listEl.querySelectorAll(".recipe-ing-del").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const 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;
const 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 => {
const weight = parseFloat(overlay.querySelector("#ing-weight").value);
if (isNaN(weight) || weight <= 0) return false;
const methodId = parseInt(overlay.querySelector("#ing-method").value) || null;
const 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 {
const 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
});
const overlays = document.querySelectorAll(".modal-overlay");
const overlay = overlays[overlays.length - 1];
const selectedEl = overlay.querySelector("#ing-selected");
const customBlock = overlay.querySelector("#ing-custom-block");
const 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 => {
const selected = overlay.querySelector(".recipe-pick-item.selected");
if (!selected) return false;
const rid = parseInt(selected.dataset.rid);
const time = overlay.querySelector("#rfr-time").value;
const 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
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const listEl = overlay.querySelector("#rfr-list");
(async () => {
const recipes = await DB.getRecipes();
if (!recipes.length) {
listEl.innerHTML = `<div class="prod-hint">Нет рецептов. Создайте в Продукты &rarr; Рецепты.</div>`;
return;
}
// Calculate nutrition for display
const items = [];
for (const r of recipes) {
const 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("");
const amountInput = overlay.querySelector("#rfr-amount");
const 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");
const r = items.find(x => x.id === parseInt(item.dataset.rid));
if (r && r.nutr) {
const 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
const bleAddr = await DB.getProfileValue("ble_device_addr");
if (bleAddr) {
try {
const btState = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (btState === "off") {
showToast("Bluetooth выключен");
} else if (btState === "no_adapter") {
showToast("Bluetooth недоступен");
}
} catch {}
}
const sessions = await DB.getGpsSessions(20);
const isRec = GPS.recording.active;
container.innerHTML = `
<div class="gps-screen">
<div class="gps-header">
<div class="gps-btns">
<select id="gps-type" class="gps-type-sel">
<option value="bike"${(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}">
<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 {
const type = container.querySelector("#gps-type").value;
localStorage.setItem("baldav_gps_type", type);
const 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") {
const 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
container.querySelectorAll(".gps-session-item").forEach(item => {
item.onclick = () => {
const sid = parseInt(item.dataset.sid);
showSessionDetail(sid);
};
});
// Three-dot menu
const menuBtn = container.querySelector("#gps-menu-btn");
const menu = container.querySelector("#gps-menu");
menuBtn.onclick = () => menu.classList.toggle("hidden");
document.addEventListener("click", e => {
if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden");
}, { once: false });
// Download map tiles — region picker
container.querySelector("#gps-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");
const count = await TileCache.count();
await TileCache.clear();
console.log("Cache cleared");
};
// Unified import — detect by extension
container.querySelector("#gps-import").onclick = () => {
menu.classList.add("hidden");
pickFile("", async (data, file) => {
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
const ext = file.name.split(".").pop().toLowerCase();
console.log("[IMPORT] ext:", ext);
const wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
try {
if (ext === "gpx") {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
await GPS.importGPX(text, wkg);
} else if (ext === "ftb" || ext === "zip") {
const buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
console.log("[IMPORT] FTB buf size:", buf.byteLength);
const count = await GPS.importFTB(buf, wkg);
console.log("[IMPORT] done, count:", count);
} else {
console.error("[IMPORT] Unsupported file type: " + ext);
}
renderScreen("gps");
} catch (e) {
console.error("[IMPORT] error:", e, String(e));
}
}, true);
};
// Goals button — manage goal markers
container.querySelector("#gps-goals-btn").onclick = () => showGoalMarkersModal();
}
// ---------------------------------------------------------------------------
// Goal markers management modal (IDEA 2)
// ---------------------------------------------------------------------------
async function showGoalMarkersModal() {
const 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(() => {
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
const 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 => {
const name = overlay.querySelector("#gm-name").value.trim();
const lat = parseFloat(overlay.querySelector("#gm-lat").value);
const lon = parseFloat(overlay.querySelector("#gm-lon").value);
const 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(() => {
const mapEl = document.querySelector("#gm-map");
const coordsEl = document.querySelector("#gm-coords");
const latEl = document.querySelector("#gm-lat");
const 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) {
const pts = await DB.getGpsPoints(sessions[0].id);
if (pts.length > 0) {
const last = pts[pts.length - 1];
defaultLat = last.lat;
defaultLon = last.lon;
map.setView([defaultLat, defaultLon], 14);
}
}
}).catch(() => {});
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false }).setView([defaultLat, defaultLon], 14);
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let marker = null;
map.on("click", (e) => {
const lat = e.latlng.lat;
const 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) {
try {
// Batch up to 100 points per request
const elevations = new Array(points.length).fill(null);
for (let i = 0; i < points.length; i += 100) {
const batch = points.slice(i, i + 100);
const lats = batch.map(p => p.lat.toFixed(5)).join(",");
const lons = batch.map(p => p.lon.toFixed(5)).join(",");
const resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (!resp.ok) continue;
const data = await resp.json();
if (data.elevation) {
for (let j = 0; j < data.elevation.length; j++) elevations[i + j] = data.elevation[j];
}
}
return elevations;
} catch (e) {
console.log("[Elevation API] failed:", e.message);
return null;
}
}
function showDrawRouteScreen(container) {
const points = []; // {lat, lon}
let markers = [];
let polyline = null;
container.innerHTML = `
<div class="rec-screen">
<div id="draw-map" class="rec-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(() => {
const mapEl = document.querySelector("#draw-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
const 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: 3000, maximumAge: 60000 });
}
const distEl = document.querySelector("#draw-dist");
const 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() {
const 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 => {
const { lat, lng } = e.latlng;
points.push({ lat, lon: lng });
const 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();
const 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; }
const saveBtn = document.querySelector("#draw-save");
saveBtn.textContent = "Сохранение...";
saveBtn.disabled = true;
const activityType = document.querySelector("#draw-type").value;
// Get elevation for each point
const needApi = [];
for (const p of points) {
const alt = await DB.findNearestAltitude(p.lat, p.lon, 50);
if (alt != null) { p.altitude = alt; }
else { needApi.push(p); }
}
if (needApi.length > 0) {
const elevs = await fetchElevations(needApi);
if (elevs) needApi.forEach((p, i) => { if (elevs[i] != null) p.altitude = elevs[i]; });
}
for (const p of points) { if (p.altitude == null) p.altitude = 0; }
// Build points for DB with realistic timestamps based on activity speed
const avgSpeedMs = (activityType === "bike" ? GPS.config.bikeAvgSpeedKmh
: activityType === "run" ? GPS.config.runAvgSpeedKmh
: GPS.config.walkAvgSpeedKmh) / 3.6;
const now = Date.now();
let cumTime = 0;
const dbPoints = points.map((p, i) => {
if (i > 0) {
const 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
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
const bike = await DB.getActiveBike();
const opts = { activityType, bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type };
const stats = GPS.computeStats(dbPoints, weightKg, opts);
// Create session
const date = new Date().toISOString().slice(0, 10);
const time = new Date().toTimeString().slice(0, 5);
const 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>
<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>`;
setTimeout(() => {
const mapEl = document.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
// Use last known position from profile (reliable across sessions)
const lastPt = GPS.recording.lastMovingPoint;
const initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47;
const initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07;
const 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);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
// Compass heading + landscape detection from device orientation
let currentLandscape = null; // null | 'right' | 'left'
const recScreen = document.querySelector('.rec-screen');
function handleOrientation(e) {
// Compass heading
const heading = e.webkitCompassHeading || e.alpha || 0;
const gamma = e.gamma || 0;
// Landscape detection: |gamma| > 50 = phone held sideways
const threshold = 50;
let newLandscape = null;
if (gamma > threshold) newLandscape = 'right';
else if (gamma < -threshold) newLandscape = 'left';
if (newLandscape !== currentLandscape) {
currentLandscape = newLandscape;
if (recScreen) {
recScreen.classList.remove('landscape-right', 'landscape-left');
if (newLandscape) recScreen.classList.add('landscape-' + newLandscape);
}
// Hide/show bottom nav — it's outside rec-screen, CSS can't reach it
const nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = newLandscape ? 'none' : '';
// Leaflet needs to recalculate container size after CSS change
setTimeout(() => map.invalidateSize(), 100);
}
// Set map bearing: compass heading + compensation for CSS viewport rotation
// CSS rotate(-90deg) for landscape-right, rotate(90deg) for landscape-left
// The CSS rotation rotates the visual output, so the map bearing must compensate
let bearingOffset = 0;
if (currentLandscape === 'right') bearingOffset = 90;
else if (currentLandscape === 'left') bearingOffset = -90;
map.setBearing(heading + bearingOffset);
}
const throttledOrientation = L.Util.throttle(handleOrientation, 100);
const orientEvent = 'ondeviceorientationabsolute' in window
? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(orientEvent, throttledOrientation);
GPS.recording._compassCleanup = () => {
window.removeEventListener(orientEvent, throttledOrientation);
if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left');
const nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = '';
};
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
const 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
const distEl = document.querySelector("#rec-dist");
const spdEl = document.querySelector("#rec-speed");
const timeEl = document.querySelector("#rec-time");
const elevEl = document.querySelector("#rec-elev");
const statusEl = document.querySelector("#rec-status");
// Speed buffer for median
const speedBuf = [];
GPS.recording.onPoint = (p) => {
const ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
const 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
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
// Show current state
const recState = GPS.recording.state;
if (recState === "waiting_gps") {
statusEl.textContent = "Поиск GPS...";
statusEl.style.color = "#ffb74d";
map.setView([p.lat, p.lon], 15);
return;
}
if (recState === "ready") {
// Position marker only
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker([p.lat, p.lon], { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
return;
}
// Speed
const curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
speedBuf.push(curSpeed);
if (speedBuf.length > 5) speedBuf.shift();
const sorted = speedBuf.slice().sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] || 0;
if (spdEl) spdEl.textContent = median.toFixed(1);
// Elevation
const alt = p.altitude;
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
// Pause indicator
if (GPS.recording.pauseStart) {
statusEl.textContent = "Пауза";
statusEl.style.color = "#ffb74d";
} else {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// HR update from BLE
GPS.recording.onHrUpdate = (reading) => {
const row = document.querySelector("#rec-hr-row");
if (!row) return;
row.style.display = "flex";
const hrEl = document.querySelector("#rec-hr-val");
if (hrEl) {
hrEl.textContent = reading.hr;
hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr);
}
const spo2El = document.querySelector("#rec-spo2");
if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : "";
const 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
const timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (!GPS.recording.startTime) return; // not started yet
const totalMs = Date.now() - GPS.recording.startTime.getTime();
const activeMs = totalMs - GPS.recording.totalPauseMs -
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
const sec = Math.max(0, Math.round(activeMs / 1000));
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
// Update clock
const clockEl = document.querySelector("#rec-clock");
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
}, 1000);
// Fetch temperature
(async () => {
try {
const pos = GPS.recording.lastMovingPoint || { lat: 55.75, lon: 37.62 };
const url = `https://api.open-meteo.com/v1/forecast?latitude=${pos.lat.toFixed(2)}&longitude=${pos.lon.toFixed(2)}&current=temperature_2m`;
const resp = await window.__TAURI__.core.invoke("http_request", { url, method: "GET", body: null });
const data = JSON.parse(resp);
const tempEl = document.querySelector("#rec-temp");
if (tempEl && data.current) tempEl.textContent = Math.round(data.current.temperature_2m) + "°";
} catch (e) { console.log("[Weather]", e.message || e); }
})();
// 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) {
const sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
const 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 = () => {
const sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
const coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
if (coords.length) {
const last = coords[coords.length - 1];
map.panTo(last);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
}
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
};
}, 200);
}
function setupLiveUpdates(container) {
const distEl = container.querySelector("#gs-dist");
const spdEl = container.querySelector("#gs-speed");
const timeEl = container.querySelector("#gs-time");
let timer = null;
GPS.recording.onPoint = (p) => {
if (!gpsTrailLine || !gpsMap) return;
gpsTrailLine.addLatLng([p.lat, p.lon]);
gpsMap.panTo([p.lat, p.lon]);
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) {
const curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
spdEl.textContent = curSpd + " км/ч";
}
};
GPS.recording.onBatchUpdate = () => {
if (!gpsTrailLine || !gpsMap) return;
const sorted = GPS.recording.points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
const coords = sorted.map(p => [p.lat, p.lon]);
gpsTrailLine.setLatLngs(coords);
if (coords.length) gpsMap.panTo(coords[coords.length - 1]);
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
};
// Timer for duration display
timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
const sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
}
// ---------------------------------------------------------------------------
// Map download modal — pick region + zoom range
// ---------------------------------------------------------------------------
const MAP_REGIONS = [
{ name: "Текущий вид карты", bounds: null },
{ name: "--- Города ---", bounds: null, disabled: true },
{ name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] },
{ name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] },
{ name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] },
{ name: "город + окрестности", bounds: [[47.35, 39.95], [47.52, 40.22]] },
{ name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] },
{ name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] },
{ name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] },
{ name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] },
{ name: "--- Области ---", bounds: null, disabled: true },
{ name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] },
{ name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] },
{ name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] },
{ name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] },
];
function showMapDownloadModal() {
const regionOpts = MAP_REGIONS.map((r, i) =>
r.disabled
? `<div class="supp-pick-divider">${r.name.replace(/---? ?/g, "")}</div>`
: `<div class="supp-pick-item" data-idx="${i}">${r.name}</div>`
).join("");
showModal(`
<h3>Скачать карту</h3>
<div class="form-group">
<label>Регион</label>
<div id="m-region-list" class="supp-pick-list">${regionOpts}</div>
</div>
<div class="form-group">
<label>Мин. зум</label>
<input id="m-zmin" type="number" value="12" min="1" max="18" />
</div>
<div class="form-group">
<label>Макс. зум</label>
<input id="m-zmax" type="number" value="15" min="1" max="18" />
</div>
<div id="m-tile-count" style="color:var(--muted);font-size:12px;margin-top:4px"></div>`,
async overlay => {
const sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return false;
const idx = parseInt(sel.dataset.idx);
const region = MAP_REGIONS[idx];
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) {
bounds = L.latLngBounds(region.bounds);
} else if (gpsMap) {
bounds = gpsMap.getBounds();
} else return false;
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
const okBtn = overlay.querySelector("#modal-ok");
okBtn.textContent = `0 / ${total}`;
okBtn.disabled = true;
await TileCache.downloadArea(bounds, zMin, zMax, (done, tot) => {
okBtn.textContent = `${done} / ${tot}`;
});
okBtn.textContent = "Готово";
okBtn.disabled = false;
});
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const countEl = overlay.querySelector("#m-tile-count");
// Selection
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
updateTileCount();
};
});
function updateTileCount() {
const sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return;
const idx = parseInt(sel.dataset.idx);
const region = MAP_REGIONS[idx];
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) bounds = L.latLngBounds(region.bounds);
else if (gpsMap) bounds = gpsMap.getBounds();
else return;
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
countEl.textContent = `~${total} тайлов`;
}
overlay.querySelector("#m-zmin").oninput = updateTileCount;
overlay.querySelector("#m-zmax").oninput = updateTileCount;
}
// ---------------------------------------------------------------------------
// Session detail view
// ---------------------------------------------------------------------------
let _sessionDetailRender = 0;
async function showSessionDetail(sessionId) {
const renderToken = ++_sessionDetailRender;
try {
console.log("[SD] loading session", sessionId);
const session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
console.log("[SD] loading points");
const rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
const points = GPS.filterNoise(rawPoints, session.activity_type);
console.log("[SD] filtered to", points.length, "points");
// Use stored session stats if available (computed correctly at import time),
// fallback to recomputing from points
const storedStats = session.total_distance_m != null;
const stats = storedStats ? {
distance: Math.round(session.total_distance_m || 0),
avgSpeed: session.avg_speed_kmh || 0,
maxSpeed: session.max_speed_kmh || 0,
kcal: Math.round(session.total_kcal || 0),
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? 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) {
const 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
const rawSpeeds = [];
for (let i = 1; i < points.length; i++) {
const devSpd = points[i].speed_ms;
if (devSpd != null && devSpd >= 0) {
rawSpeeds.push(devSpd * 3.6);
} else {
const d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
const dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
}
}
// 31-point moving average — kills GPS jitter from phone-in-pocket
const HALF_WIN = 15;
const smoothed = rawSpeeds.map((_, i) => {
let sum = 0, count = 0;
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
sum += rawSpeeds[j]; count++;
}
return Math.round(sum / count * 10) / 10;
});
// maxSpeed from smoothed graph (not raw)
const graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
// Elevation data
const elevations = GPS.filterAndSmoothAlt(points.map(p => p.altitude_gps_m));
const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
const t0 = points.length ? new Date(points[0].ts).getTime() : 0;
const timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
const actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
const actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
const duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
: "—";
const tempo = stats.duration > 0 && stats.distance > 0
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
: "—";
const kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
showModal(`
<h3>${actName}</h3>
<div class="sd-section">
<div class="sd-title">Время</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.date}</span><span class="sd-lbl">Дата</span></div>
<div class="sd-stat"><span class="sd-val">${duration}</span><span class="sd-lbl">Длительность</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.start_time ?? "—"}</span><span class="sd-lbl">Начало</span></div>
<div class="sd-stat"><span class="sd-val">${session.end_time ?? "—"}</span><span class="sd-lbl">Конец</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(stats.distance)}</span><span class="sd-lbl">Расстояние</span></div>
<div class="sd-stat"><span class="sd-val">${tempo}</span><span class="sd-lbl">Темп</span></div>
</div>
</div>
<div class="sd-section">
<div class="sd-title">Маршрут</div>
<div id="sd-map" class="sd-map"></div>
</div>
<div class="sd-section">
<div class="sd-title">Скорость</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<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 => {
const icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-";
const distStr = seg.distance >= 1000 ? (seg.distance / 1000).toFixed(1) + " км" : seg.distance + " м";
const elevStr = seg.type === "climb" ? `+${seg.elevGain} м, ${seg.avgGradient}%`
: seg.type === "descent" ? `-${seg.elevLoss} м, ${seg.avgGradient}%`
: `+/-${Math.max(seg.elevGain, seg.elevLoss)} м`;
const 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-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>
`, () => {});
// Render map
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const mapEl = document.querySelector("#sd-map");
if (!mapEl || !points.length) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
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);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Sort by timestamp, build coords and draw in one pass
// Keep original order from DB (seq from FTB import = XML order = clean track)
const t0 = new Date(points[0].ts).getTime();
const tN = new Date(points[points.length - 1].ts).getTime();
const tRange = tN - t0 || 1;
// Split into segments by GPS gap threshold
// If accelerometer shows movement on both sides of gap → keep connected (don't break)
const gapSec = GPS.config.gapThresholdSec ?? 30;
const segments = []; // array of arrays of [lat, lon]
const gapLines = []; // dashed lines for accel-confirmed gaps
let curSeg = [[points[0].lat, points[0].lon]];
for (let i = 1; i < points.length; i++) {
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
if (gap > gapSec) {
const accelBefore = points[i-1].accel_rms;
const accelAfter = points[i].accel_rms;
// If both sides show movement → bridge the gap with dashed line
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
// Keep in same segment — person was moving, GPS just lost signal
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]);
}
} else {
curSeg.push([points[i].lat, points[i].lon]);
}
}
segments.push(curSeg);
// Draw each segment as separate polyline with start/end markers
const 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 (const 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++) {
const p = points[i];
const alt = p.altitude_gps_m ?? p.altitude;
const spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : "";
const time = p.ts.split("T")[1].slice(0, 8);
const hr = p.heart_rate ? `${p.heart_rate} bpm` : "";
const spo2 = p.spo2 ? `SpO2:${p.spo2}%` : "";
const bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : "";
const bleInfo = [hr, spo2, bp].filter(Boolean).join(" ");
const 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);
}
// Fit map to all points
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();
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
// Push fullscreen map onto winStack
const parentOverlay = mapEl.closest(".modal-overlay");
winPush("fsMap", () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
map.dragging.disable();
map.scrollWheelZoom.disable();
setTimeout(() => { map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds); }, 100);
});
winStack[winStack.length - 1].overlay = parentOverlay;
}
}
const fsBtn = L.control({ position: "topright" });
let fsDiv;
fsBtn.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleMapFs(); };
return fsDiv;
};
fsBtn.addTo(map);
// Locate me button
const locBtn = L.control({ position: "topleft" });
locBtn.onAdd = () => {
const div = L.DomUtil.create("div", "sd-map-fs-btn");
div.innerHTML = "&#x25CE;";
div.title = "Моё местоположение";
div.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => {
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, 15);
L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map);
},
err => console.error("GPS error:", err.message),
{ enableHighAccuracy: true, timeout: 10000 }
);
};
return div;
};
locBtn.addTo(map);
// Click map to toggle fullscreen (same as button)
map.on("click", () => {
if (!mapEl.classList.contains("sd-map-full")) toggleMapFs();
});
}, 200);
// Downsample arrays for smooth chart rendering (max ~500 points)
function downsample(arr, maxPts) {
if (arr.length <= maxPts) return arr;
const step = arr.length / maxPts;
const result = [];
for (let i = 0; i < maxPts; i++) {
const idx = Math.floor(i * step);
result.push(arr[idx]);
}
return result;
}
const MAX_CHART_PTS = 500;
const dsSmoothed = downsample(smoothed, MAX_CHART_PTS);
const dsTimeLabels = downsample(timeLabels, MAX_CHART_PTS);
const dsElevations = downsample(elevations, MAX_CHART_PTS);
const 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
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const ctx = document.querySelector("#sd-speed-chart");
if (!ctx || !smoothed.length) return;
// Update maxSpeed display to match smoothed graph
const maxEl = document.querySelector("#sd-max-speed");
if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч";
new Chart(ctx, {
type: "line",
data: {
labels: 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;
const 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" } },
},
},
});
}, 300);
// Render elevation chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const ctx = document.querySelector("#sd-elev-chart");
if (!ctx || !elevations.length) return;
const elevLabels = points.filter(p => p.altitude_gps_m != null)
.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
new Chart(ctx, {
type: "line",
data: {
labels: 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;
const 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" } },
},
},
});
}, 400);
// Render HR chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const hrData = points.map(p => p.heart_rate || null);
if (!hrData.some(v => v)) { /* no HR data — hide chart */ return; }
const wrap = document.querySelector("#sd-hr-wrap");
if (wrap) wrap.style.display = "";
const ctx = document.querySelector("#sd-hr-chart");
if (!ctx) return;
const dsHr = downsample(hrData, MAX_CHART_PTS);
const dsHrLabels = downsample(
points.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
new Chart(ctx, {
type: "line",
data: {
labels: dsHrLabels,
datasets: [{
data: dsHr,
borderWidth: 2,
pointRadius: 0,
fill: false,
tension: 0.3,
spanGaps: false,
segment: {
borderColor: function(ctx2) {
const 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;
const 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" } },
},
},
});
}, 500);
// Delete button
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const 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");
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); };
wrap.appendChild(closeBtn);
const 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 => {
const el = document.querySelector("#sd-bike-name");
if (el && b) el.textContent = b.name;
else if (el) el.textContent = "—";
});
}
// Bike selector (for bike sessions)
const bikeSelect = document.querySelector("#sd-bike-select");
if (bikeSelect) {
DB.getBikes().then(bikes => {
for (const b of bikes) {
const 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 () => {
const val = bikeSelect.value ? parseInt(bikeSelect.value) : null;
await DB.updateSessionBike(sessionId, val);
session.bike_id = val;
};
}
// Compare button
const compareBtn = document.querySelector("#sd-compare");
if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points);
// Recalculate button
const recalcBtn = document.querySelector("#sd-recalc");
if (recalcBtn) recalcBtn.onclick = async () => {
recalcBtn.textContent = "Пересчёт...";
recalcBtn.disabled = true;
const 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);
}
};
const 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");
};
}, 100);
// IDEA 2: Goal markers — show closest approach for each marker
setTimeout(async () => {
try {
const goalSection = document.querySelector("#sd-goals-section");
const goalsList = document.querySelector("#sd-goals-list");
if (!goalSection || !goalsList || points.length < 2) return;
const markers = await DB.getGoalMarkers();
if (markers.length === 0) return;
// For each marker, find closest point in this track
const results = [];
for (const marker of markers) {
let minDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < points.length; i++) {
const 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
const prevSessions = await DB.getGpsSessions(50);
let bestPrevDist = Infinity;
let bestPrevDate = null;
for (const ps of prevSessions) {
if (ps.id === sessionId) continue;
const psPts = await DB.getGpsPoints(ps.id);
if (psPts.length < 2) continue;
for (const pp of psPts) {
const 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 => {
const distStr = r.minDist >= 1000 ? (r.minDist / 1000).toFixed(1) + " км" : r.minDist + " м";
let progressHtml = "";
if (r.bestPrevDist != null) {
const 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)
const maxBar = Math.max(r.minDist, r.bestPrevDist ?? r.minDist, 100);
const 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); }
}, 500);
// IDEA 8: Food cost of workout
setTimeout(async () => {
try {
const costSection = document.querySelector("#sd-food-cost-section");
const costEl = document.querySelector("#sd-food-cost");
if (!costSection || !costEl) return;
const sessionKcal = session.total_kcal ?? stats.kcal;
if (!sessionKcal || sessionKcal <= 0) return;
const foods = await DB.getFrequentFoods(7);
if (foods.length === 0) return;
// Build "cost" descriptions: N portions of food
const parts = [];
for (const f of foods) {
if (!f.kcal || f.kcal <= 0) continue;
// kcal per one serving (as user ate it)
const servingKcal = Math.round(f.kcal);
if (servingKcal <= 0) continue;
const portions = Math.round(sessionKcal / servingKcal * 10) / 10;
const amountStr = f.amount_g ? f.amount_g + "г" : "";
parts.push({
name: f.product_name,
portions,
servingKcal,
amountStr,
});
}
if (parts.length === 0) return;
// Take first 3, build readable string
const display = parts.slice(0, 3);
const 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); }
}, 600);
} 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) {
const dist = currentSession.total_distance_m || 0;
const sessions = await DB.getGpsSessions(100);
// Filter: same activity type, similar distance (+-30%), not the same session
const similar = sessions.filter(s =>
s.id !== 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;
}
const actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
showModal(`
<h3>Сравнить с...</h3>
<div class="compare-list">
${similar.map(s => {
const d = s.total_distance_m != null ? GPS.formatDistance(Math.round(s.total_distance_m)) : "?";
const spd = s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : "";
return `<div class="compare-item" data-sid="${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>`, () => {});
setTimeout(() => {
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Hide save/cancel buttons — this is a selection modal
const btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
overlay.querySelectorAll(".compare-item").forEach(item => {
item.onclick = async () => {
const compId = parseInt(item.dataset.sid);
// Close this modal, open comparison view
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
await showComparisonView(sessionId, compId, currentSession, currentPoints);
};
});
}, 100);
}
async function showComparisonView(currentId, compareId, currentSession, currentPoints) {
const compSession = await DB.getGpsSession(compareId);
if (!compSession) return;
const 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)
const curDate = currentSession.date || "";
const 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)
const newDur = (new Date(newerPoints[newerPoints.length-1].ts) - new Date(newerPoints[0].ts)) / 1000;
const oldDur = (new Date(olderPoints[olderPoints.length-1].ts) - new Date(olderPoints[0].ts)) / 1000;
const durDiff = newDur - oldDur;
const durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково";
const newAvg = newerSession.avg_speed_kmh ?? 0;
const oldAvg = olderSession.avg_speed_kmh ?? 0;
const 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(() => {
const mapEl = document.querySelector("#compare-map");
if (!mapEl) return;
// Hide save/cancel
const btns = mapEl.closest(".modal-overlay")?.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, touchZoom: true, dragging: true });
mapEl._leafletMap = map;
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Build speed lookup for older track
const olderCoords = olderPoints.map(p => [p.lat, p.lon]);
// Pre-compute older track speeds
const 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++) {
const cp = newerPoints[i];
const cpPrev = newerPoints[i - 1];
const newSpd = (cp.speed_ms ?? 0) * 3.6;
// Find nearest older point
let nearestIdx = 0, nearestDist = Infinity;
for (let j = 0; j < olderPoints.length; j++) {
const d = GPS.distance(cp.lat, cp.lon, olderPoints[j].lat, olderPoints[j].lon);
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
}
const 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
const 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;
const fsBtn = L.control({ position: "topright" });
let fsDiv;
fsBtn.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => {
e.stopPropagation();
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() {
const supps = await DB.getSupplements();
if (supps.length === 0) {
showModal(`
<h3>Добавки</h3>
<p style="color:var(--muted)">Нет добавок. Создайте на вкладке "Добавки".</p>`,
() => {});
return;
}
showModal(`
<h3>Принял добавку</h3>
<div class="form-group">
<label>Добавка</label>
<div id="m-supp-list" class="supp-pick-list">
${supps.map(s => `<div class="supp-pick-item" data-sid="${s.id}">${esc(s.name)}${s.daily_dose_g ? ` <small>(${s.daily_dose_g}г/день)</small>` : ""}</div>`).join("")}
</div>
</div>
<div class="form-group">
<label>Количество (шт)</label>
<input id="m-qty" type="number" inputmode="decimal" value="1" step="0.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const selected = overlay.querySelector(".supp-pick-item.selected");
if (!selected) return false;
const sid = parseInt(selected.dataset.sid);
const qty = parseFloat(overlay.querySelector("#m-qty").value) || 1;
const time = overlay.querySelector("#m-time").value;
await DB.logSupplement(state.date, time, sid, qty);
});
const overlay = [...document.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
// ---------------------------------------------------------------------------
const 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
const 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) {
const supps = await DB.getSupplements();
function suppSummary(s) {
const parts = [];
for (const f of MICRO_FIELDS) {
if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`);
}
return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : "");
}
container.innerHTML = `
<div class="products-screen">
<div class="section-header">
<h2>Добавки</h2>
<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 () => {
const 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) => {
const data = JSON.parse(text);
await importSupplements(data);
renderSupplements(container);
});
};
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
const sid = parseInt(card.dataset.sid);
const s = await DB.getSupplement(sid);
if (s) showEditSupplementModal(s);
};
});
}
function showEditSupplementModal(existing) {
const isNew = !existing;
const s = existing || {};
const microHtml = MICRO_FIELDS.map(f => {
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="${(() => { const r = BMR.RDI[f.key]; const 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 => {
const name = overlay.querySelector("#m-name").value.trim();
if (!name) return false;
const data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
for (const f of MICRO_FIELDS) {
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
const sets = ["name=?1", "daily_dose_g=?2"];
const vals = [name, data.daily_dose_g];
let idx = 3;
for (const f of MICRO_FIELDS) {
sets.push(`${f.key}=?${idx}`);
vals.push(data[f.key]);
idx++;
}
vals.push(existing.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
values: vals,
});
}
});
if (!isNew) {
setTimeout(() => {
const delBtn = document.querySelector("#m-del-supp");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteSupplement(existing.id);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("supplements");
};
}
}, 100);
}
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
async function renderSettings(container) {
const p = state.profile;
const profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
<div class="settings-screen">
<h2>Настройки</h2>
<div class="settings-section">
<div class="settings-item" id="s-open-profile">
<div class="settings-item-left">
<div class="settings-item-title">Профиль</div>
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Нормы нутриентов</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Стандарт РСП</div>
<div class="settings-item-desc">Пересчитает % нутриентов</div>
</div>
<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>
<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">Хуже этого = пропуск (авто x2 при плохом сигнале)</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>
<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>
<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
const 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
const offToggle = container.querySelector("#s-offline-toggle");
offToggle.onclick = () => {
const newVal = !TileCache.offlineMode;
TileCache.setOfflineMode(newVal);
offToggle.classList.toggle("on", newVal);
};
// GPS config inputs
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "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 (const id of gpsInputs) {
const el = container.querySelector("#" + id);
if (el) el.onchange = () => {
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
GPS.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;
GPS.saveConfig();
};
}
// HR/BP norms — save on change
for (const [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"]]) {
const 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() {
const bikes = await DB.getBikes();
const 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
const gpsStatus = container.querySelector("#s-gps-status");
const gpsToggle = container.querySelector("#s-gps-toggle");
// Check GPS availability
if (!navigator.geolocation) {
gpsStatus.textContent = "Недоступно";
} else {
// Try to actually get position to check real permission
gpsStatus.textContent = "Проверка...";
navigator.geolocation.getCurrentPosition(
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
(err) => {
if (err.code === 1) gpsStatus.textContent = "Нажмите для запроса";
else gpsStatus.textContent = "Ошибка: " + err.message;
},
{ enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 }
);
}
gpsToggle.onclick = () => {
gpsStatus.textContent = "Запрашиваю...";
navigator.geolocation.getCurrentPosition(
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
(err) => {
gpsStatus.textContent = err.code === 1 ? "Запрещено" : "Ошибка: " + err.message;
gpsToggle.classList.remove("on");
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
// BLE HR device scan and bind
const 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();
};
}
const bleScanBtn = container.querySelector("#s-ble-scan");
const 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 () => {
const addr = item.dataset.bleAddr;
const type = item.dataset.bleType;
const 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
const 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;
const 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 {
const 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 () => {
const 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 {
const code = await Sync.newCode();
const el = container.querySelector("#s-sync-code-display");
el.style.display = "";
el.textContent = code;
} catch (e) { showToast("Ошибка: " + e.message); }
});
// App version info + update check
try {
const info = await window.__TAURI__.core.invoke("get_app_info");
container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`;
} catch {
container.querySelector("#s-version").textContent = "dev";
}
// Check for updates: trigger Kotlin re-check, poll until result changes
const updateArea = container.querySelector("#s-update-area");
if (updateArea) {
updateArea.innerHTML = `<small style="color:var(--muted)">проверка...</small>`;
let oldTs = 0;
try {
const 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 {
const raw = await window.__TAURI__.core.invoke("get_update_info");
const 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() {
const 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 => {
const weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
const height = parseInt(overlay.querySelector("#m-height").value);
const 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);
const activityLevel = overlay.querySelector("#m-activity-level").value;
await DB.setProfileValue("activity_level", activityLevel);
const 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(() => {
const 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
const actSelect = overlay.querySelector("#m-activity-level");
if (actSelect) actSelect.value = p.activity_level || "1.2";
// BMR display
function updateBmrDisplay() {
const w = parseFloat(overlay.querySelector("#m-weight").value) || parseFloat(p.weight_kg) || 80;
const h = parseInt(overlay.querySelector("#m-height").value) || parseInt(p.height_cm) || 178;
const y = parseInt(overlay.querySelector("#m-year").value) || parseInt(p.birth_year) || 1988;
const bmrVal = BMR.calculateBmr(w, h, BMR.getAge(y), selectedSex);
const el = overlay.querySelector("#m-bmr-display");
if (el) el.textContent = bmrVal + " ккал/день";
}
updateBmrDisplay();
// Recalculate BMR when weight/height/year change
for (const id of ["#m-weight", "#m-height", "#m-year"]) {
const el = overlay.querySelector(id);
if (el) el.oninput = updateBmrDisplay;
}
}, 100);
}
// ---------------------------------------------------------------------------
// Bike profile modal
// ---------------------------------------------------------------------------
async function showBikeModal(bikeId) {
const isNew = bikeId == null;
const bike = isNew ? {} : await DB.getBike(bikeId);
if (!isNew && !bike) return;
const surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" };
const 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 => {
const name = overlay.querySelector("#m-bike-name").value.trim();
if (!name) return false;
const 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(() => {
const 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(() => {
const 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 () => {
const mileageEl = document.querySelector("#m-bike-mileage");
const listEl = document.querySelector("#m-bike-maint-list");
const addBtn = document.querySelector("#m-bike-maint-add");
const formEl = document.querySelector("#m-bike-maint-form");
if (!listEl) return;
const totalKm = await DB.getBikeTotalMileage(bikeId);
if (mileageEl) mileageEl.textContent = `Общий пробег: ${totalKm} км`;
async function renderMaintList() {
const 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 => {
const overdue = e.next_service_km != null && totalKm >= e.next_service_km;
const nextInfo = e.next_service_km != null ? `, след. при ${e.next_service_km} км` : "";
const 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";
};
}
const saveBtn = document.querySelector("#m-maint-save");
if (saveBtn) {
saveBtn.onclick = async (e) => {
e.stopPropagation();
const date = document.querySelector("#m-maint-date").value;
const desc = document.querySelector("#m-maint-desc").value.trim();
if (!desc) return;
const nextKm = parseFloat(document.querySelector("#m-maint-next").value) || null;
const notes = document.querySelector("#m-maint-notes").value.trim() || null;
// next_service_km = current mileage + interval
const 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
const 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) {
const RDI = BMR.RDI;
const items = [];
for (const [key, rdi] of Object.entries(RDI)) {
const val = totals[key];
if (val == null || val === 0) continue;
const pct = Math.round((val / rdi) * 100);
const 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
const RDI = BMR.RDI;
const factor = (f.amount_g || 100) / 100;
const items = [];
const check = (val, rdi, label) => {
if (!val || !rdi) return;
const 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
const 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) {
const RDI = BMR.RDI;
const totals = {};
// From food: product micronutrients * (amount_g / 100)
for (const f of food) {
if (!f.product_id) continue;
const factor = (f.amount_g || 100) / 100;
for (const 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 (const f of food) {
if (!f.recipe_id || !f.cached_nutrients) continue;
try {
const rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients;
for (const key of Object.keys(RDI)) {
if (rn[key]) totals[key] = (totals[key] || 0) + rn[key];
}
} catch {}
}
// From supplements
if (suppTotals) {
for (const 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
const result = [];
for (const [key, rdi] of Object.entries(RDI)) {
const amount = totals[key] || 0;
const pct = Math.round(amount / rdi * 100);
const label = NUTRIENT_LABELS[key] || key;
const 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) {
const HL = BMR.HALFLIFE;
const RDI = BMR.RDI;
if (!HL || !Object.keys(HL).length) return null;
const yesterday = shiftDate(date, -1);
const prevReserves = await DB.getReserves(yesterday);
// Get yesterday's intake (food + supplements)
const food = await DB.getFoodForDate(yesterday);
const suppTotals = await DB.getSupplementTotalsForDate(yesterday);
const intake = calcDailyNutrientTotals(food, suppTotals);
// Apply decay: reserves = (prev_reserves + intake) * 0.5^(1/hl)
const reserves = {};
for (const key of Object.keys(RDI)) {
const hl = HL[key];
if (!hl || hl <= 0) continue;
const prev = (prevReserves[key] || 0) + (intake[key] || 0);
if (prev <= 0) continue;
const 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) {
const RDI = BMR.RDI;
const totals = {};
for (const f of food) {
if (f.product_id) {
const factor = (f.amount_g || 100) / 100;
for (const 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 {
const rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients;
for (const key of Object.keys(RDI)) {
if (rn[key]) totals[key] = (totals[key] || 0) + rn[key];
}
} catch {}
}
}
if (suppTotals) {
for (const 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() {
const existing = await DB.getAllReservesDates();
if (existing.length > 0) return; // already migrated
const days = await DB.getDaysWithFood();
if (!days.length) return;
console.log(`[Reserves] migrating ${days.length} days...`);
for (const row of days) {
const 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 "";
const header = title ? `<div class="nut-item" style="font-weight:bold;opacity:0.6;min-width:auto">${title}</div>` : "";
return header + nutrients.map(n => {
const cls = n.pct < 30 ? "nut-low" : n.pct < 80 ? "nut-mid" : "nut-ok";
const 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";
const 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);
const input = wrap.querySelector("input");
const dropdown = wrap.querySelector(".search-dropdown");
let debounce = null;
input.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const q = input.value.trim();
if (q.length < 2) { dropdown.style.display = "none"; dropdown.innerHTML = ""; if (opts.onClear) opts.onClear(); return; }
const 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 () => {
const pid = parseInt(item.dataset.pid);
const 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) {
const p = state.profile;
const height = parseInt(p.height_cm);
const 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) {
const p = state.profile;
const age = BMR.getAge(parseInt(p.birth_year) || 1988);
const maxHr = parseInt(p.hr_max) || (220 - age);
const 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) {
const 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) {
const lines = [];
if (s.asleep_time && s.wake_time) {
const a = toMin(s.asleep_time), w = toMin(s.wake_time);
const h = ((a >= 720 ? 1440 - a + w : w - a) / 60).toFixed(1);
lines.push(`Сон: ${h} ч (${s.asleep_time} – ${s.wake_time})`);
}
if (s.nap_minutes) lines.push(`Дневной: ${s.nap_minutes} мин`);
if (s.notes) lines.push(esc(s.notes));
return lines.map(l => `<span class="sleep-line">${l}</span>`).join("") ||
`<span class="log-empty">нет данных</span>`;
}
function toMin(t) {
const [h, m] = (t || "0:0").split(":").map(Number);
return h * 60 + m;
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function nowTime() {
return new Date().toTimeString().slice(0, 5);
}
function shiftDate(iso, days) {
const d = new Date(iso);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function formatDate(iso) {
return new Date(iso + "T00:00:00")
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
function addZoomDisplay(map) {
const ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {
const div = L.DomUtil.create("div", "zoom-display");
div.textContent = map.getZoom();
map.on("zoomend", () => { div.textContent = map.getZoom(); });
return div;
};
ctrl.addTo(map);
}
// ---------------------------------------------------------------------------
// 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
const blob = new Blob([content], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
async function exportAllRecipes() {
const recipes = await DB.getRecipes();
const result = [];
for (const r of recipes) {
const 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) {
const arr = Array.isArray(data) ? data : [data];
for (const r of arr) {
if (!r.name) continue;
const rid = await DB.saveRecipe(r.name, r.cooking_method_id ?? null, r.servings ?? 1, r.notes ?? null);
if (!rid) continue;
for (const ing of (r.ingredients || [])) {
// Try to find product by name
let pid = 0;
if (ing.product_name) {
const 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) {
const arr = Array.isArray(data) ? data : [data];
for (const s of arr) {
if (!s.name) continue;
// Remove id — will get new one
const clean = { ...s };
delete clean.id;
await DB.addSupplement(clean);
}
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
const input = document.createElement("input");
input.type = "file";
if (accept) input.accept = accept;
input.style.display = "none";
document.body.appendChild(input);
input.addEventListener("change", async () => {
console.log("[pickFile] change event, files:", input.files.length);
const file = input.files[0];
document.body.removeChild(input);
if (!file) return;
console.log("[pickFile] file:", file.name, file.size);
try {
if (asBinary) {
const buf = await file.arrayBuffer();
await callback(buf, file);
} else {
const text = await file.text();
await callback(text, file);
}
} catch (e) {
console.error("[pickFile] error:", e);
}
});
input.click();
}
function sourceBadge(source) {
const map = {
magnit: ["Магнит", "src-magnit"],
"5ka": ["5ка", "src-5ka"],
kdv: ["KDV", "src-kdv"],
ion: ["ИОН", "src-ion"],
manual: ["Свой", "src-manual"],
recipe_import: ["Импорт", "src-manual"],
};
const [label, cls] = map[source] || ["", ""];
if (!label) return "";
return `<span class="src-badge ${cls}">${label}</span>`;
}
function showToast(msg, duration) {
const 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");
const 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>`;
});