baldav/frontend/js/main.js
4aiman f395e8ec07 Fix sub-modal (ingredient) closing parent modal (recipe editor)
Problem: ingredient modal's closeModal called history.back(), which
triggered recipe editor's popHandler → closed recipe editor too.

Fix: modal suspend/resume system.
- When sub-modal opens, parent modal's popHandler is suspended
- When sub-modal closes, parent is resumed
- Parent popHandler ignores popstate events while suspended
- closeModal resumes parent before calling history.back()

Also: closeModal now pops exactly 1 history entry (not _historyDepth).
The _historyDepth counter tracks fullscreen entries within the modal,
which are handled by popHandler's decrements. closeModal only needs
to pop the modal's own base entry.
2026-05-27 22:55:43 +03:00

4067 lines
163 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// BalDav main app — pure JS, no framework, no bundler.
// Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script.
const app = document.getElementById("app");
let state = {
screen: "today",
date: todayIso(),
profile: {},
latestWeight: null,
};
// 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 {}
}
// Flag: modal popHandler already handled this popstate, global handler should skip
let _modalPopHandled = false;
// ---------------------------------------------------------------------------
// 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 { await GPS.recoverSession(); } catch (e) { /* ignore */ }
// 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); }
// 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();
// 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);
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function renderScreen(name, fromPopstate) {
// Release screen wake lock when leaving recording screen
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = 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 = {};
try {
[weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals] = await Promise.all([
DB.getWeightsForDate(state.date),
DB.getFoodForDate(state.date),
DB.getActivitiesForDate(state.date),
DB.getSleepForDate(state.date),
DB.getFoodTotalsForDate(state.date),
DB.getActivityTotalsForDate(state.date),
DB.getSupplementLogForDate(state.date),
DB.getSupplementTotalsForDate(state.date),
]);
} catch (e) {
console.error("[renderToday] DB error:", e);
container.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка загрузки: ${esc(String(e))}</div>`;
return;
}
const bmr = calcBmr(weights);
const intake = Math.round(foodTotals.kcal ?? 0);
const actKcal = Math.round(actTotals.kcal ?? 0);
const expend = bmr != null ? bmr + actKcal : null;
const balance = expend != null ? intake - expend : null;
const hasGuessed = food.some(f => f.nutrients_guessed);
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}${hasGuessed ? "*" : ""}</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>
${buildMicroStrip(suppTotals)}
<section class="log-section">
<div class="section-header"><h3>Вес</h3>
<button class="add-btn" id="add-weight-btn">+</button></div>
<ul class="log-list">
${weights.map(w =>
`<li class="log-item">
<span class="log-time">${w.time ?? ""}</span>
<span class="log-desc">${w.weight_kg} кг</span>
<button class="del-btn" data-table="weight" data-id="${w.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Еда: Продукты</h3>
<button class="add-btn" id="add-food-btn">+</button>
</div>
<ul class="log-list">
${food.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 ?? "—")}
${f.unit_amount && f.unit_short
? `<small>${f.unit_amount} ${f.unit_short}</small>`
: f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</span>
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="food" data-id="${f.id}">&#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">
${activities.map(a =>
`<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${esc(a.desc ?? "—")}
${a.minutes ? `<small>${a.minutes} мин</small>` : ""}</span>
<span class="log-kcal">${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Добавки</h3>
<button class="add-btn" id="add-supp-btn">+</button></div>
<ul class="log-list">
${suppLog.map(s =>
`<li class="log-item">
<span class="log-time">${s.time ?? ""}</span>
<span class="log-desc">${esc(s.supplement_name)}${s.dose_g ? ` <small>${s.dose_g} шт</small>` : ""}</span>
<button class="del-btn" data-table="supp" data-id="${s.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Сон</h3>
<button class="add-btn" id="add-sleep-btn">&#x270E;</button></div>
<div class="sleep-info">
${sleep ? formatSleepInfo(sleep) : `<span class="log-empty">нет данных</span>`}
</div>
</section>
<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 || bmr == 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 - (bmr + 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); }
})();
const strip = container.querySelector(".micro-strip");
if (strip) strip.onwheel = e => { e.preventDefault(); strip.scrollLeft += e.deltaY; };
container.querySelector("#add-weight-btn").onclick = () => showWeightModal();
container.querySelector("#add-food-btn").onclick = () => showFoodModal();
container.querySelector("#add-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);
// Stats charts section
const statsContainer = container.querySelector("#stats-charts");
const statsPeriod = container.querySelector(".stats-period");
if (statsContainer && statsPeriod) {
renderStatsCharts(statsContainer, 30, foodTotals, bmr);
statsPeriod.querySelectorAll(".type-chip").forEach(chip => {
chip.onclick = () => {
statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, bmr);
};
});
}
}
// ---------------------------------------------------------------------------
// Stats charts
// ---------------------------------------------------------------------------
const statsChartInstances = [];
async function renderStatsCharts(container, days, foodTotals, bmr) {
// Destroy old charts
statsChartInstances.forEach(c => c.destroy());
statsChartInstances.length = 0;
container.innerHTML = `<div class="loading">Загрузка...</div>`;
let weights, dailyFood, dailyActivity;
try {
[weights, dailyFood, dailyActivity] = await Promise.all([
DB.getRecentWeights(days),
DB.getDailyFoodTotals(days),
DB.getDailyActivityTotals(days),
]);
} catch (e) {
container.innerHTML = `<div class="log-empty">Ошибка загрузки статистики</div>`;
return;
}
container.innerHTML = "";
// --- Weight chart (line) ---
if (weights.length > 1) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Вес</div>
<div class="chart-wrap"><canvas id="stats-weight-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
const ctx = block.querySelector("#stats-weight-chart");
const labels = weights.map(w => {
const d = w.date.split("-");
return d[2] + "." + d[1];
});
const chart = new Chart(ctx, {
type: "line",
data: {
labels,
datasets: [{
data: weights.map(w => w.weight_kg),
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: weights.length > 30 ? 0 : 2,
fill: false,
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
const wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
statsChartInstances.push(chart);
}
// --- Macros donut (doughnut) ---
const protein = Math.round(foodTotals.protein_g ?? 0);
const fat = Math.round(foodTotals.fat_g ?? 0);
const carbs = Math.round(foodTotals.carbs_g ?? 0);
const totalKcal = Math.round(foodTotals.kcal ?? 0);
if (protein + fat + carbs > 0) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Макронутриенты (сегодня)</div>
<div class="chart-wrap"><canvas id="stats-macros-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
const ctx = block.querySelector("#stats-macros-chart");
const chart = new Chart(ctx, {
type: "doughnut",
data: {
labels: ["Белки", "Жиры", "Углеводы"],
datasets: [{
data: [protein, fat, carbs],
backgroundColor: ["#64b5f6", "#ffb74d", "#81c784"],
borderWidth: 0,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: "60%",
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
},
},
plugins: [{
id: "centerText",
afterDraw(chart) {
const { ctx: c, chartArea } = chart;
const cx = (chartArea.left + chartArea.right) / 2;
const cy = (chartArea.top + chartArea.bottom) / 2;
c.save();
c.textAlign = "center";
c.textBaseline = "middle";
c.font = "bold 18px system-ui";
c.fillStyle = "#e0e0e0";
c.fillText(totalKcal + " кк", cx, cy);
c.restore();
},
}],
});
statsChartInstances.push(chart);
}
// --- Kcal balance chart (bar) ---
if (dailyFood.length > 0) {
const block = document.createElement("div");
block.className = "stats-chart-block";
block.innerHTML = `
<div class="sd-title">Баланс ккал</div>
<div class="chart-wrap"><canvas id="stats-kcal-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
container.appendChild(block);
// Build a merged date set from food and activity data
const dateSet = new Set();
dailyFood.forEach(d => dateSet.add(d.date));
dailyActivity.forEach(d => dateSet.add(d.date));
const dates = Array.from(dateSet).sort();
const foodMap = {};
dailyFood.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
const actMap = {};
dailyActivity.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
const labels = dates.map(d => {
const parts = d.split("-");
return parts[2] + "." + parts[1];
});
const intakeData = dates.map(d => foodMap[d] ?? 0);
const expendData = dates.map(d => (bmr ?? 0) + (actMap[d] ?? 0));
const ctx = block.querySelector("#stats-kcal-chart");
const chart = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [
{
label: "Приход",
data: intakeData,
backgroundColor: "#81c784",
},
{
label: "Расход",
data: expendData,
backgroundColor: "#cf6679",
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
const wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 10 }, grid: { color: "#333" } },
y: { ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
statsChartInstances.push(chart);
}
// --- 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.02 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
const wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: {
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
container.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
history.pushState({ chartFull: true }, "");
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
function collapseChart() {
wrap.classList.remove("chart-full");
closeBtn.remove();
window.removeEventListener("popstate", chartPopHandler);
}
closeBtn.onclick = (e) => { e.stopPropagation(); collapseChart(); history.back(); };
const chartPopHandler = () => { collapseChart(); };
window.addEventListener("popstate", chartPopHandler, { once: true });
wrap.appendChild(closeBtn);
};
});
}
// ---------------------------------------------------------------------------
// 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>`;
// Suspend parent modal if sub-modal opening on top
const parentOverlay = document.querySelector(".modal-overlay");
if (parentOverlay && parentOverlay._suspend) parentOverlay._suspend();
document.body.appendChild(overlay);
// Simple history stack: each open = pushState, each back = popState pops it.
// Modal = 1 entry. Fullscreen map/chart inside modal = 1 more entry each.
// Back always pops the top entry. No re-pushing, no flags.
let _historyDepth = 1; // how many entries this modal owns
let _suspended = false; // true when sub-modal is open on top
history.pushState({ modal: true }, "");
const popHandler = () => {
if (_suspended) return; // sub-modal handles this popstate
_historyDepth--;
// Check what's on top and collapse it
const fsMap = overlay.querySelector(".sd-map-full");
if (fsMap) {
fsMap.classList.remove("sd-map-full");
const expandBtn = fsMap.querySelector(".sd-map-expand-btn");
if (expandBtn) expandBtn.innerHTML = "&#x26F6;";
try {
const mapObj = fsMap._leafletMap;
if (mapObj) {
mapObj.dragging.disable();
mapObj.scrollWheelZoom.disable();
setTimeout(() => { mapObj.invalidateSize(); if (fsMap._bounds) mapObj.fitBounds(fsMap._bounds); }, 100);
}
} catch {}
return; // stay in modal, depth already decremented
}
const fsChart = overlay.querySelector(".chart-full");
if (fsChart) {
fsChart.classList.remove("chart-full");
const btn = fsChart.querySelector(".chart-close-btn");
if (btn) btn.remove();
return; // stay in modal
}
// Nothing fullscreen — close modal
_modalPopHandled = true;
window.removeEventListener("popstate", popHandler);
overlay.remove();
};
window.addEventListener("popstate", popHandler);
// Called when fullscreen opens inside this modal — push 1 entry
overlay._pushFullscreen = function() {
_historyDepth++;
history.pushState({ modalFs: true }, "");
};
// Suspend/resume this modal's popHandler when a sub-modal opens on top
overlay._suspend = function() { _suspended = true; };
overlay._resume = function() { _suspended = false; };
function closeModal() {
window.removeEventListener("popstate", popHandler);
overlay.remove();
// Resume parent modal if this was a sub-modal
const parent = document.querySelector(".modal-overlay");
if (parent && parent._resume) parent._resume();
// Pop this modal's history entry
_modalPopHandled = true;
history.back();
}
overlay.querySelector("#modal-back").onclick = () => closeModal();
overlay.querySelector("#modal-cancel").onclick = () => closeModal();
overlay.querySelector("#modal-ok").onclick = async () => {
const ok = await onSubmit(overlay);
if (ok !== false) { closeModal(); if (ok !== "norender") renderScreen(state.screen); }
};
setTimeout(() => overlay.querySelector("input")?.focus(), 50);
}
function showWeightModal() {
showModal(`
<h3>Вес</h3>
<div class="form-group">
<label>кг</label>
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const kg = parseFloat(overlay.querySelector("#m-kg").value);
const time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await DB.addWeight(state.date, time, kg);
});
}
function showFoodModal() {
let selectedProduct = null;
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>ккал (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="350" />
</div>
</div>
<div class="form-group">
<label id="m-amount-label">Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const time = overlay.querySelector("#m-time").value;
const rawAmount = parseFloat(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();
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
if (!desc) return false;
await DB.addFoodManual(state.date, time, desc, kcal);
}
});
const overlay = document.querySelector(".modal-overlay");
const selectedEl = overlay.querySelector("#m-selected");
const manualFields = overlay.querySelector("#m-manual-fields");
const unitChipsEl = overlay.querySelector("#m-unit-chips");
const amountLabel = overlay.querySelector("#m-amount-label");
const amountInput = overlay.querySelector("#m-amount");
function renderUnitChips() {
// "г" chip is always first (default), then available units
let html = `<button class="unit-chip${!selectedUnit ? " selected" : ""}" data-unit-id="0">г</button>`;
for (const u of availableUnits) {
if (u.grams_per_unit == null) continue; // skip units without conversion
html += `<button class="unit-chip${selectedUnit && selectedUnit.id === u.id ? " selected" : ""}" data-unit-id="${u.id}">${esc(u.name_short || u.name)}</button>`;
}
unitChipsEl.innerHTML = html;
unitChipsEl.style.display = "";
unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => {
chip.onclick = () => {
const uid = parseInt(chip.dataset.unitId);
if (uid === 0) {
selectedUnit = null;
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
} else {
selectedUnit = availableUnits.find(u => u.id === uid);
amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`;
amountInput.placeholder = "1";
amountInput.value = "1";
}
unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
};
});
}
createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => {
selectedProduct = product;
selectedUnit = null;
selectedEl.style.display = "block";
selectedEl.innerHTML = `<span>${esc(product.name)}</span>`;
manualFields.style.display = "none";
// Load available units for this product
availableUnits = await DB.getUnitsForProduct(product.id);
if (availableUnits.length > 0) {
renderUnitChips();
} else {
unitChipsEl.style.display = "none";
}
// Reset amount to grams mode
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
selectedEl.onclick = () => {
selectedProduct = null;
selectedUnit = null;
availableUnits = [];
selectedEl.style.display = "none";
unitChipsEl.style.display = "none";
manualFields.style.display = "";
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
};
});
}
function showEditFoodModal(entry) {
let newProduct = null; // if user picks a different product
const origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? "";
showModal(`
<h3>Редактирование</h3>
<div id="m-current" class="modal-selected">
<span>${esc(origLabel)}</span>
</div>
<div id="m-search-block" style="display:none">
<div class="form-group">
<label>Заменить на</label>
<input id="m-prod-search" type="search" placeholder="Поиск продукта..." />
<div id="m-prod-results" class="modal-search-results"></div>
</div>
</div>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" value="${entry.amount_g || 100}" />
</div>
${!entry.product_id ? `
<div class="form-group" id="m-kcal-group">
<label>ккал</label>
<input id="m-kcal" type="number" inputmode="numeric" value="${entry.kcal != null ? Math.round(entry.kcal) : ""}" />
</div>` : ""}
<div class="form-group">
<label>Заметка</label>
<input id="m-note" type="text" value="${esc(entry.note ?? "")}" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${entry.time ?? ""}" />
</div>`,
async overlay => {
const amount = parseFloat(overlay.querySelector("#m-amount").value);
const time = overlay.querySelector("#m-time").value;
const note = overlay.querySelector("#m-note")?.value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
if (newProduct) {
// Replace: delete old, add new
await DB.deleteFoodEntry(entry.id);
await DB.addFoodEntry(entry.date ?? state.date, time, newProduct.id, amount, note);
} else {
const kcalEl = overlay.querySelector("#m-kcal");
const kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null;
await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride);
}
});
const overlay = document.querySelector(".modal-overlay");
const currentEl = overlay.querySelector("#m-current");
const searchBlock = overlay.querySelector("#m-search-block");
currentEl.onclick = () => {
searchBlock.style.display = "block";
searchBlock.querySelector("#m-prod-search").focus();
};
const searchInput = overlay.querySelector("#m-prod-search");
const resultsEl = overlay.querySelector("#m-prod-results");
let debounce = null;
searchInput.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const q = searchInput.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
const results = await DB.searchProducts(q);
resultsEl.innerHTML = results.map(p =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
resultsEl.querySelectorAll(".modal-prod-item").forEach(item => {
item.onclick = () => {
const pid = parseInt(item.dataset.pid);
newProduct = results.find(r => r.id === pid);
currentEl.querySelector("span").textContent = newProduct.name;
searchBlock.style.display = "none";
resultsEl.innerHTML = "";
searchInput.value = "";
// Hide manual kcal field since now it's a product
const kcalGroup = overlay.querySelector("#m-kcal-group");
if (kcalGroup) kcalGroup.style.display = "none";
};
});
}, 300);
};
}
function showActivityModal() {
showModal(`
<h3>Активность</h3>
<div class="form-group">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="прогулка" />
</div>
<div class="form-group">
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="40" />
</div>
<div class="form-group">
<label>ккал расход (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="150" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const desc = overlay.querySelector("#m-desc").value.trim();
const min = parseInt(overlay.querySelector("#m-min").value) || null;
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
const time = overlay.querySelector("#m-time").value;
if (!desc) return false;
await DB.addActivity(state.date, time, "manual", desc, min, kcal);
});
}
function showSleepModal(existing) {
showModal(`
<h3>Сон</h3>
<div class="form-group">
<label>Лёг в кровать</label>
<input id="m-bed" type="time" value="${existing?.bed_time ?? ""}" />
</div>
<div class="form-group">
<label>Уснул</label>
<input id="m-asleep" type="time" value="${existing?.asleep_time ?? ""}" />
</div>
<div class="form-group">
<label>Проснулся</label>
<input id="m-wake" type="time" value="${existing?.wake_time ?? ""}" />
</div>
<div class="form-group">
<label>Дневной сон (мин)</label>
<input id="m-nap" type="number" inputmode="numeric" value="${existing?.nap_minutes ?? 0}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="m-notes" type="text" value="${esc(existing?.notes ?? "")}" />
</div>`,
async overlay => {
await DB.saveSleep(
state.date,
overlay.querySelector("#m-bed").value || null,
overlay.querySelector("#m-asleep").value || null,
overlay.querySelector("#m-wake").value || null,
parseInt(overlay.querySelector("#m-nap").value) || 0,
overlay.querySelector("#m-notes").value.trim() || null
);
});
}
// ---------------------------------------------------------------------------
// PRODUCTS screen
// ---------------------------------------------------------------------------
async function renderProducts(container) {
let debounceTimer = null;
let expandedId = null; // currently expanded product card id
container.innerHTML = `
<div class="products-screen">
<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">
<input id="prod-search" class="search-input" type="search"
placeholder="Название, бренд или ингредиент..." />
<div id="prod-count" class="prod-count"></div>
<div id="prod-results" class="prod-list"></div>
</div>
<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>` : ""}
</div>
<div class="prod-card-kcal">${kcal != null ? kcal + " кк" : "?"}</div>
</div>
${open ? renderProdDetails(p) : ""}
</div>`;
}
function renderProdDetails(p) {
const r = v => v != null ? v : "—";
return `
<div class="prod-details">
<div class="prod-macros">
<div class="macro"><span class="macro-val">${r(p.protein_per_100g)}</span><span class="macro-lbl">Б</span></div>
<div class="macro"><span class="macro-val">${r(p.fat_per_100g)}</span><span class="macro-lbl">Ж</span></div>
<div class="macro"><span class="macro-val">${r(p.carbs_per_100g)}</span><span class="macro-lbl">У</span></div>
<div class="macro"><span class="macro-val">${r(p.fiber_per_100g)}</span><span class="macro-lbl">Кл</span></div>
</div>
${p.composition ? `<div class="prod-composition">${esc(p.composition)}</div>` : ""}
<button class="btn-primary prod-add-btn" data-pid="${p.id}">Добавить в дневник</button>
</div>`;
}
function bindProdCards(el) {
el.querySelectorAll(".prod-card-header").forEach(hdr => {
hdr.onclick = () => {
const card = hdr.closest(".prod-card");
const pid = parseInt(card.dataset.pid);
expandedId = (expandedId === pid) ? null : pid;
renderList();
};
});
el.querySelectorAll(".prod-add-btn").forEach(btn => {
btn.onclick = e => {
e.stopPropagation();
showAddFoodFromProduct(parseInt(btn.dataset.pid));
};
});
}
function showAddFoodFromProduct(productId) {
showModal(`
<h3>Добавить в дневник</h3>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>
<div class="form-group">
<label>Заметка</label>
<input id="m-note" type="text" placeholder="" />
</div>`,
async overlay => {
const amount = parseFloat(overlay.querySelector("#m-amount").value);
const time = overlay.querySelector("#m-time").value;
const note = overlay.querySelector("#m-note").value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
await DB.addFoodEntry(state.date, time, productId, amount, note);
});
}
input.oninput = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(true), 300);
};
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
input.focus();
}
// ---------------------------------------------------------------------------
// 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>
<button class="add-btn" id="add-recipe-btn">+</button>
</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.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>` : ""}
<button id="rd-log" class="btn-primary full-width" style="margin-top:16px">Записать в дневник</button>
<button id="rd-edit" 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.querySelector(".modal-overlay");
const logBtn = overlay.querySelector("#rd-log");
const editBtn = overlay.querySelector("#rd-edit");
const delBtn = overlay.querySelector("#rd-delete");
logBtn.onclick = async () => {
await DB.addFoodFromRecipe(state.date, nowTime(), recipeId);
history.back(); // popHandler closes modal
renderScreen(state.screen);
};
editBtn.onclick = () => {
history.back(); // popHandler closes modal
setTimeout(() => showRecipeEditorModal(recipeId), 50);
};
delBtn.onclick = async () => {
await DB.deleteRecipe(recipeId);
history.back(); // popHandler closes modal
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
);
}
renderScreen("recipes");
});
// Set initial method value
const overlay = document.querySelector(".modal-overlay");
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-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;
await DB.addFoodFromRecipe(state.date, time, rid);
});
// Load recipes into the list
const overlay = document.querySelector(".modal-overlay");
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("");
listEl.querySelectorAll(".recipe-pick-item").forEach(item => {
item.onclick = () => {
listEl.querySelectorAll(".recipe-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
};
});
})();
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// GPS screen — map, recording, sessions list, GPX import
// ---------------------------------------------------------------------------
let gpsMap = null;
let gpsTrailLine = null;
let gpsMarker = null;
async function renderGpsScreen(container) {
const sessions = await DB.getGpsSessions(20);
const isRec = GPS.recording.active;
container.innerHTML = `
<div class="gps-screen">
<div class="gps-header">
<div class="gps-btns">
<select id="gps-type" class="gps-type-sel">
<option value="bike"${(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-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-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.querySelector(".modal-overlay");
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));
// Refresh modal
overlay.remove();
history.back();
showGoalMarkersModal();
};
});
// Add button
overlay.querySelector("#gm-add").onclick = () => {
overlay.remove();
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);
}
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 id="rec-map" class="rec-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
</div>`;
setTimeout(() => {
const mapEl = document.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
map.setView([p.lat, p.lon], 16);
} else if (state === "recording") {
statusEl.textContent = "";
statusEl.style.color = "";
} else if (state === "resumed") {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Live point updates
const distEl = document.querySelector("#rec-dist");
const spdEl = document.querySelector("#rec-speed");
const timeEl = document.querySelector("#rec-time");
const elevEl = document.querySelector("#rec-elev");
const statusEl = document.querySelector("#rec-status");
// Speed buffer for median
const speedBuf = [];
GPS.recording.onPoint = (p) => {
const ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
// Stats
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg, 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 = "";
}
};
// Timer — excludes pauses, starts only when recording
const timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (!GPS.recording.startTime) return; // not started yet
const totalMs = Date.now() - GPS.recording.startTime.getTime();
const activeMs = totalMs - GPS.recording.totalPauseMs -
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
const sec = Math.max(0, Math.round(activeMs / 1000));
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
// Pause button
container.querySelector("#rec-pause").onclick = () => {
if (GPS.recording.manualPaused) {
GPS.resumeRecording();
container.querySelector("#rec-pause").textContent = "Пауза";
statusEl.textContent = "";
statusEl.style.color = "";
} else {
GPS.pauseRecording();
container.querySelector("#rec-pause").textContent = "Продолжить";
statusEl.textContent = "Пауза (ручная)";
statusEl.style.color = "#ffb74d";
}
};
// Stop button
container.querySelector("#rec-stop").onclick = async () => {
await GPS.stopRecording();
clearInterval(timer);
renderScreen("gps");
};
// Restore trail if already recording — 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.querySelector(".modal-overlay");
const countEl = overlay.querySelector("#m-tile-count");
// Selection
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
updateTileCount();
};
});
function updateTileCount() {
const sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return;
const idx = parseInt(sel.dataset.idx);
const region = MAP_REGIONS[idx];
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) bounds = L.latLngBounds(region.bounds);
else if (gpsMap) bounds = gpsMap.getBounds();
else return;
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
countEl.textContent = `~${total} тайлов`;
}
overlay.querySelector("#m-zmin").oninput = updateTileCount;
overlay.querySelector("#m-zmax").oninput = updateTileCount;
}
// ---------------------------------------------------------------------------
// Session detail view
// ---------------------------------------------------------------------------
async function showSessionDetail(sessionId) {
try {
console.log("[SD] loading session", sessionId);
const session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
console.log("[SD] loading points");
const rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
const points = GPS.filterNoise(rawPoints, session.activity_type);
console.log("[SD] filtered to", points.length, "points");
// Use stored session stats if available (computed correctly at import time),
// fallback to recomputing from points
const storedStats = session.total_distance_m != null;
const stats = storedStats ? {
distance: Math.round(session.total_distance_m || 0),
avgSpeed: session.avg_speed_kmh || 0,
maxSpeed: session.max_speed_kmh || 0,
kcal: Math.round(session.total_kcal || 0),
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? 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 = points.filter(p => p.altitude_gps_m != null).map(p => p.altitude_gps_m);
const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
const t0 = points.length ? new Date(points[0].ts).getTime() : 0;
const timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
const actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
const actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
const duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
: "—";
const tempo = stats.duration > 0 && stats.distance > 0
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
: "—";
const kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
showModal(`
<h3>${actName}</h3>
<div class="sd-section">
<div class="sd-title">Время</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.date}</span><span class="sd-lbl">Дата</span></div>
<div class="sd-stat"><span class="sd-val">${duration}</span><span class="sd-lbl">Длительность</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${session.start_time ?? "—"}</span><span class="sd-lbl">Начало</span></div>
<div class="sd-stat"><span class="sd-val">${session.end_time ?? "—"}</span><span class="sd-lbl">Конец</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(stats.distance)}</span><span class="sd-lbl">Расстояние</span></div>
<div class="sd-stat"><span class="sd-val">${tempo}</span><span class="sd-lbl">Темп</span></div>
</div>
</div>
<div class="sd-section">
<div class="sd-title">Маршрут</div>
<div id="sd-map" class="sd-map"></div>
</div>
<div class="sd-section">
<div class="sd-title">Скорость</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<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>` : ""}
${(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(() => {
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 for popHandler map 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
const gapSec = GPS.config.gapThresholdSec ?? 30;
const segments = []; // array of arrays of [lat, lon]
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) {
segments.push(curSeg);
curSeg = [];
}
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);
});
// 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 info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}`;
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() — popHandler will handle the 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 entry — back will pop it
const modal = mapEl.closest(".modal-overlay");
if (modal && modal._pushFullscreen) modal._pushFullscreen();
}
}
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(() => {
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.02 },
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: false },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "км/ч", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}, 300);
// Render elevation chart
setTimeout(() => {
const ctx = document.querySelector("#sd-elev-chart");
if (!ctx || !elevations.length) return;
const elevLabels = points.filter(p => p.altitude_gps_m != null)
.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
new Chart(ctx, {
type: "line",
data: {
labels: 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.02 },
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: false },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "м", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}, 400);
// Delete button
setTimeout(() => {
const exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
// Chart fullscreen toggle — tap to expand, tap X or back to collapse
// Chart fullscreen in modal — pushState so back collapses it
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
const modal = wrap.closest(".modal-overlay");
if (modal && modal._pushFullscreen) modal._pushFullscreen();
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => {
e.stopPropagation();
history.back(); // popHandler will collapse
};
wrap.appendChild(closeBtn);
};
});
// 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
document.querySelector(".modal-overlay")?.remove();
history.back();
showSessionDetail(sessionId);
}
};
const del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
document.querySelector(".modal-overlay")?.remove();
// Remove modal popstate handler, go to gps screen
history.replaceState({ screen: "gps" }, "");
renderScreen("gps", true);
};
}, 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} x ${esc(p.name)}${p.amountStr ? " (" + 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.querySelector(".modal-overlay");
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
overlay.remove();
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;
// Compute duration diff
const curDur = currentPoints.length >= 2
? (new Date(currentPoints[currentPoints.length-1].ts) - new Date(currentPoints[0].ts)) / 1000 : 0;
const compDur = compPoints.length >= 2
? (new Date(compPoints[compPoints.length-1].ts) - new Date(compPoints[0].ts)) / 1000 : 0;
const durDiff = curDur - compDur;
const durDiffStr = (durDiff <= 0 ? "" : "+") + GPS.formatDuration(Math.abs(Math.round(durDiff)));
const durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково";
const curAvg = currentSession.avg_speed_kmh ?? 0;
const compAvg = compSession.avg_speed_kmh ?? 0;
const spdDiff = Math.round((curAvg - compAvg) * 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">${curAvg} км/ч</span><span class="sd-lbl">Текущая средняя</span></div>
<div class="sd-stat"><span class="sd-val">${compAvg} км/ч</span><span class="sd-lbl">Сравнение</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);
// Draw comparison track (thin dashed cyan)
const compCoords = compPoints.map(p => [p.lat, p.lon]);
L.polyline(compCoords, { color: "#4dd0e1", weight: 3, dashArray: "8,6", opacity: 0.7 }).addTo(map);
// Build speed comparison: for each point on current track, find nearest on comparison track
// Pre-compute comparison speeds for quick nearest-neighbor lookup
const compSpeeds = [];
for (let i = 0; i < compPoints.length; i++) {
const spd = compPoints[i].speed_ms != null ? compPoints[i].speed_ms * 3.6 : 0;
compSpeeds.push(spd);
}
// For each current segment, find nearest comparison point and compare speeds
// Draw colored polyline segments
for (let i = 1; i < currentPoints.length; i++) {
const cp = currentPoints[i];
const cpPrev = currentPoints[i - 1];
const curSpd = cp.speed_ms != null ? cp.speed_ms * 3.6 : 0;
// Find nearest comparison point (simple brute force — OK for <2000 points)
let nearestIdx = 0, nearestDist = Infinity;
for (let j = 0; j < compPoints.length; j++) {
const d = GPS.distance(cp.lat, cp.lon, compPoints[j].lat, compPoints[j].lon);
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
}
const compSpd = compSpeeds[nearestIdx];
// Color: green if faster, red if slower, white if similar (within 1 km/h)
let segColor;
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare — use default
else if (curSpd > compSpd + 1) segColor = "#4caf50"; // faster
else if (curSpd < compSpd - 1) segColor = "#f44336"; // slower
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 = currentPoints.map(p => [p.lat, p.lon]).concat(compCoords);
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.querySelector(".modal-overlay");
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
};
});
}
// ---------------------------------------------------------------------------
// SUPPLEMENTS screen — manage supplements catalog
// ---------------------------------------------------------------------------
const MICRO_FIELDS = [
{ key: "vit_a_mcg", label: "Вит A (мкг)" },
{ key: "vit_c_mg", label: "Вит C (мг)" },
{ key: "vit_d_mcg", label: "Вит D (мкг)" },
{ key: "vit_e_mg", label: "Вит E (мг)" },
{ key: "vit_k_mcg", label: "Вит K (мкг)" },
{ key: "vit_b1_mg", label: "B1 (мг)" },
{ key: "vit_b2_mg", label: "B2 (мг)" },
{ key: "vit_b3_mg", label: "B3 (мг)" },
{ key: "vit_b6_mg", label: "B6 (мг)" },
{ key: "vit_b12_mcg", label: "B12 (мкг)" },
{ key: "folate_mcg", label: "Фолат (мкг)" },
{ key: "calcium_mg", label: "Ca (мг)" },
{ key: "iron_mg", label: "Fe (мг)" },
{ key: "magnesium_mg", label: "Mg (мг)" },
{ key: "zinc_mg", label: "Zn (мг)" },
{ key: "copper_mg", label: "Cu (мг)" },
{ key: "selenium_mcg", label: "Se (мкг)" },
{ key: "potassium_mg", label: "K (мг)" },
{ key: "sodium_mg", label: "Na (мг)" },
];
async function renderSupplements(container) {
const supps = await DB.getSupplements();
function suppSummary(s) {
const parts = [];
for (const f of MICRO_FIELDS) {
if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`);
}
return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : "");
}
container.innerHTML = `
<div class="products-screen">
<div class="section-header">
<h2>Добавки</h2>
<button class="add-btn" id="add-new-supp">+</button>
</div>
<div id="supp-list" class="prod-list">
${supps.length ? supps.map(s => `
<div class="prod-card" data-sid="${s.id}">
<div class="prod-card-header">
<div class="prod-card-name">${esc(s.name)}
${s.daily_dose_g ? `<span class="prod-weight">${s.daily_dose_g} шт/день</span>` : ""}
</div>
</div>
<div class="supp-summary">${suppSummary(s)}</div>
</div>`).join("")
: `<div class="prod-hint">Нет добавок. Нажмите + чтобы добавить.</div>`}
</div>
</div>`;
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
const sid = parseInt(card.dataset.sid);
const s = await DB.getSupplement(sid);
if (s) showEditSupplementModal(s);
};
});
}
function showEditSupplementModal(existing) {
const isNew = !existing;
const s = existing || {};
const microHtml = MICRO_FIELDS.map(f =>
`<div class="form-group micro-field">
<label>${f.label}</label>
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${s[f.key] ?? ""}" placeholder="—" />
</div>`
).join("");
showModal(`
<h3>${isNew ? "Новая добавка" : "Редактирование"}</h3>
<div class="form-group">
<label>Название</label>
<input id="m-name" type="text" value="${esc(s.name ?? "")}" placeholder="Витамин D3" />
</div>
<div class="form-group">
<label>Таблеток в день</label>
<input id="m-dose" type="number" inputmode="decimal" step="0.5" value="${s.daily_dose_g ?? ""}" placeholder="1" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Содержание в 1 таблетке:</p>
<div class="micro-grid">${microHtml}</div>
${!isNew ? `<button id="m-del-supp" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить добавку</button>` : ""}`,
async overlay => {
const name = overlay.querySelector("#m-name").value.trim();
if (!name) return false;
const data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
for (const f of MICRO_FIELDS) {
data[f.key] = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
}
if (isNew) {
await DB.addSupplement(data);
} else {
// Update: delete + re-add (simple, keeps log intact via supplement_id)
// Actually need an update function. For now, delete logs referencing old id is bad.
// Just delete and re-add — log entries will be orphaned. Better to add updateSupplement.
// Quick approach: raw execute
const sets = ["name=?1", "daily_dose_g=?2"];
const vals = [name, data.daily_dose_g];
let idx = 3;
for (const f of MICRO_FIELDS) {
sets.push(`${f.key}=?${idx}`);
vals.push(data[f.key]);
idx++;
}
vals.push(existing.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
values: vals,
});
}
});
if (!isNew) {
setTimeout(() => {
const delBtn = document.querySelector("#m-del-supp");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteSupplement(existing.id);
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("supplements");
};
}
}, 100);
}
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
async function renderSettings(container) {
const p = state.profile;
const profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
<div class="settings-screen">
<h2>Настройки</h2>
<div class="settings-section">
<div class="settings-item" id="s-open-profile">
<div class="settings-item-left">
<div class="settings-item-title">Профиль</div>
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Карты</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Оффлайн режим</div>
<div class="settings-item-desc">Тайлы только из кэша</div>
</div>
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">GPS (запись треков)</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. скорость (км/ч)</div>
<div class="settings-item-desc">Ниже = пауза</div>
</div>
<input id="s-min-speed" type="number" step="0.1" value="${(GPS.config.minSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. точность (м)</div>
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
</div>
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. расстояние (м)</div>
<div class="settings-item-desc">Точки ближе = пропуск</div>
</div>
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div 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>
<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" id="s-about">
<div class="settings-item-left">
<div class="settings-item-title">BalDav</div>
<div class="settings-item-desc" id="s-version">...</div>
</div>
</div>
</div>
</div>`;
// Profile button → open profile modal
container.querySelector("#s-open-profile").onclick = () => showProfileModal();
// Offline toggle
const offToggle = container.querySelector("#s-offline-toggle");
offToggle.onclick = () => {
const newVal = !TileCache.offlineMode;
TileCache.setOfflineMode(newVal);
offToggle.classList.toggle("on", newVal);
};
// GPS config inputs
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff"];
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.saveConfig();
};
}
// 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 }
);
};
// App version info
try {
const info = await window.__TAURI__.core.invoke("get_app_info");
container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`;
} catch {
container.querySelector("#s-version").textContent = "dev";
}
}
function showProfileModal() {
const p = state.profile;
let selectedSex = p.sex ?? "m";
showModal(`
<h3>Профиль</h3>
<div class="form-group">
<label>Вес (кг)</label>
<input id="m-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 по формуле (ккал/день)</label>
<input id="m-bmr-formula" type="number" inputmode="numeric" value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="m-bmr-est" type="number" inputmode="numeric" value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>`,
async overlay => {
const weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
const height = parseInt(overlay.querySelector("#m-height").value);
const year = parseInt(overlay.querySelector("#m-year").value);
const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null;
const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) return false;
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);
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
state.profile = { ...state.profile,
weight_kg: weight ? weight : state.profile.weight_kg,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
bmr_formula: bFormula ? String(bFormula) : null,
bmr_estimated: bEst ? String(bEst) : null };
});
// Sex chip handler inside modal
setTimeout(() => {
const overlay = document.querySelector(".modal-overlay");
if (!overlay) return;
overlay.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
}, 100);
}
// ---------------------------------------------------------------------------
// 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.querySelector(".modal-overlay");
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);
document.querySelector(".modal-overlay")?.remove();
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
// ---------------------------------------------------------------------------
// РСП — рекомендуемая суточная потребность (взрослый мужчина)
const RDA = {
vit_a_mcg: 900, vit_c_mg: 90, vit_d_mcg: 15, vit_e_mg: 15, vit_k_mcg: 120,
vit_b1_mg: 1.2, vit_b2_mg: 1.3, vit_b3_mg: 16, vit_b6_mg: 1.7,
vit_b12_mcg: 2.4, folate_mcg: 400,
calcium_mg: 1000, iron_mg: 8, magnesium_mg: 400, zinc_mg: 11,
copper_mg: 0.9, selenium_mcg: 55, potassium_mg: 3400, sodium_mg: 1500,
};
const RDA_LABELS = {
vit_a_mcg: "A", vit_c_mg: "C", vit_d_mcg: "D", vit_e_mg: "E", vit_k_mcg: "K",
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3", vit_b6_mg: "B6",
vit_b12_mcg: "B12", folate_mcg: "Фол",
calcium_mg: "Ca", iron_mg: "Fe", magnesium_mg: "Mg", zinc_mg: "Zn",
copper_mg: "Cu", selenium_mcg: "Se", potassium_mg: "K+", sodium_mg: "Na",
};
function buildMicroStrip(totals) {
const items = [];
for (const [key, rda] of Object.entries(RDA)) {
const val = totals[key];
if (val == null || val === 0) continue;
const pct = Math.round((val / rda) * 100);
const cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low";
items.push(`<span class="micro-chip ${cls}">${RDA_LABELS[key]} ${pct}%</span>`);
}
if (items.length === 0) return "";
return `<div class="micro-strip">${items.join("")}</div>`;
}
// ---------------------------------------------------------------------------
// 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)}</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(weights) {
const p = state.profile;
if (!p.height_cm || !p.birth_year) return null;
if (p.bmr_estimated) return parseInt(p.bmr_estimated);
const kg = weights.length ? weights[weights.length - 1].weight_kg
: state.latestWeight?.weight_kg;
if (!kg) return parseInt(p.bmr_formula) || null;
return BMR.calculateBmr(kg, parseInt(p.height_cm),
BMR.getAge(parseInt(p.birth_year)), p.sex ?? "m");
}
function balanceClass(balance) {
if (balance == null) return "";
return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced";
}
function formatSleepInfo(s) {
const lines = [];
if (s.asleep_time && s.wake_time) {
const a = toMin(s.asleep_time), w = toMin(s.wake_time);
const h = ((a >= 720 ? 1440 - a + w : w - a) / 60).toFixed(1);
lines.push(`Сон: ${h} ч (${s.asleep_time} – ${s.wake_time})`);
}
if (s.nap_minutes) lines.push(`Дневной: ${s.nap_minutes} мин`);
if (s.notes) lines.push(esc(s.notes));
return lines.map(l => `<span class="sleep-line">${l}</span>`).join("") ||
`<span class="log-empty">нет данных</span>`;
}
function toMin(t) {
const [h, m] = (t || "0:0").split(":").map(Number);
return h * 60 + m;
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function nowTime() {
return new Date().toTimeString().slice(0, 5);
}
function shiftDate(iso, days) {
const d = new Date(iso);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function formatDate(iso) {
return new Date(iso + "T00:00:00")
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
function addZoomDisplay(map) {
const ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {
const div = L.DomUtil.create("div", "zoom-display");
div.textContent = map.getZoom();
map.on("zoomend", () => { div.textContent = map.getZoom(); });
return div;
};
ctrl.addTo(map);
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
const input = document.createElement("input");
input.type = "file";
if (accept) input.accept = accept;
input.style.display = "none";
document.body.appendChild(input);
input.addEventListener("change", async () => {
console.log("[pickFile] change event, files:", input.files.length);
const file = input.files[0];
document.body.removeChild(input);
if (!file) return;
console.log("[pickFile] file:", file.name, file.size);
try {
if (asBinary) {
const buf = await file.arrayBuffer();
await callback(buf, file);
} else {
const text = await file.text();
await callback(text, file);
}
} catch (e) {
console.error("[pickFile] error:", e);
}
});
input.click();
}
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
// Global back navigation between screens
window.addEventListener("popstate", (e) => {
// Modal popHandler already handled this event — skip
if (_modalPopHandled) { _modalPopHandled = false; return; }
// If a modal is open, don't handle here (modal has its own handler)
if (document.querySelector(".modal-overlay")) return;
// Chart fullscreen on screen (not in modal) — collapse it
const fsChart = document.querySelector(".chart-full");
if (fsChart) {
fsChart.classList.remove("chart-full");
const btn = fsChart.querySelector(".chart-close-btn");
if (btn) btn.remove();
return;
}
// Stale modal/chart entries left after app restart — skip them
if (e.state && (e.state.modal || e.state.chartFull)) {
history.back();
return;
}
// Pop our nav stack and navigate to previous screen
if (navStack.length > 1) {
navStack.pop();
persistNavStack();
const prev = navStack[navStack.length - 1];
renderScreen(prev, 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>`;
});