baldav/frontend/js/main.js
4aiman 4740bdbe11 Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions

Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only

Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)

Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00

3204 lines
126 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;
const navStack = ["today"]; // always starts with "today" as root
// ---------------------------------------------------------------------------
// 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 with a few entries so back gesture doesn't immediately close app
history.replaceState({ screen: "today" }, "");
for (let i = 0; i < 5; i++) history.pushState({ screen: "today" }, "");
if (false) {
renderScreen("settings");
} else {
renderScreen("today");
}
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function renderScreen(name, fromPopstate) {
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();
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>
${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);
};
});
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);
}
// 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;";
closeBtn.onclick = (e) => {
e.stopPropagation();
wrap.classList.remove("chart-full");
closeBtn.remove();
};
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>`;
document.body.appendChild(overlay);
history.pushState({ modal: true }, "");
const popHandler = () => {
// Fullscreen map inside modal? Collapse it first.
const fsMap = overlay.querySelector(".sd-map-full");
if (fsMap) {
fsMap.classList.remove("sd-map-full");
const expandBtn = fsMap.querySelector(".sd-map-expand-btn");
if (expandBtn) expandBtn.innerHTML = "&#x26F6;";
try { map.dragging.disable(); map.scrollWheelZoom.disable(); } catch {}
history.pushState({ modal: true }, "");
return;
}
// Fullscreen chart inside modal? Collapse it first.
const fsChart = document.querySelector(".chart-full");
if (fsChart) {
fsChart.classList.remove("chart-full");
const btn = fsChart.querySelector(".chart-close-btn");
if (btn) btn.remove();
history.pushState({ modal: true }, "");
return;
}
// Nothing fullscreen — close modal
window.removeEventListener("popstate", popHandler);
closeModal(false);
};
window.addEventListener("popstate", popHandler);
function closeModal(goBack) {
overlay.remove();
window.removeEventListener("popstate", popHandler);
if (goBack) history.back();
// Ensure history isn't empty after modal close (prevents app exit on next back)
history.replaceState({ screen: state.screen }, "");
}
overlay.querySelector("#modal-back").onclick = () => closeModal(true);
overlay.querySelector("#modal-cancel").onclick = () => closeModal(true);
overlay.querySelector("#modal-ok").onclick = async () => {
const ok = await onSubmit(overlay);
if (ok !== false) { closeModal(true); renderScreen(state.screen); }
};
setTimeout(() => overlay.querySelector("input")?.focus(), 50);
}
function showWeightModal() {
showModal(`
<h3>Вес</h3>
<div class="form-group">
<label>кг</label>
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const kg = parseFloat(overlay.querySelector("#m-kg").value);
const time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await DB.addWeight(state.date, time, kg);
});
}
function showFoodModal() {
let selectedProduct = null;
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);
overlay.remove();
history.back();
renderScreen(state.screen);
};
editBtn.onclick = () => {
overlay.remove();
history.back();
showRecipeEditorModal(recipeId);
};
delBtn.onclick = async () => {
await DB.deleteRecipe(recipeId);
overlay.remove();
history.back();
renderScreen("recipes");
};
}
// ---------------------------------------------------------------------------
// Recipe editor modal — create / edit recipe with ingredient list
// ---------------------------------------------------------------------------
async function showRecipeEditorModal(recipeId) {
const isNew = !recipeId;
let recipe = isNew ? { name: "", cooking_method_id: null, servings: 1, notes: "" } : await DB.getRecipe(recipeId);
if (!recipe) return;
const cookingMethods = await DB.getCookingMethods();
const existingIngredients = isNew ? [] : await DB.getRecipeIngredients(recipeId);
// Local in-memory ingredient list for editing
// Each item: { product_id, product_name, custom_name, weight_g, cooking_method_id, cooking_method_name }
const ingredients = existingIngredients.map(ing => ({
product_id: ing.product_id,
product_name: ing.product_name,
custom_name: ing.custom_name,
weight_g: ing.amount_g,
cooking_method_id: ing.cooking_method_id,
cooking_method_name: ing.cooking_method_name,
}));
const cmOptions = cookingMethods.map(cm =>
`<option value="${cm.id}">${esc(cm.name)}</option>`
).join("");
function renderIngredientRows() {
if (ingredients.length === 0) return `<div class="log-empty">Добавьте ингредиенты</div>`;
return ingredients.map((ing, idx) => {
const name = ing.product_name ?? ing.custom_name ?? "?";
return `<div class="recipe-ing-row" data-idx="${idx}">
<div class="recipe-ing-info">
<span class="recipe-ing-name">${esc(name)}</span>
<span class="recipe-ing-weight">${ing.weight_g} г</span>
${ing.cooking_method_name ? `<span class="recipe-ing-cm">${esc(ing.cooking_method_name)}</span>` : ""}
</div>
<button class="del-btn recipe-ing-del" data-idx="${idx}">&#x2715;</button>
</div>`;
}).join("");
}
// Calculate totals from current in-memory ingredients, with cooking loss
function calcTotals() {
let kcal = 0, protein = 0, fat = 0, carbs = 0;
const defaultMethodId = parseInt(document.querySelector("#re-method")?.value) || 0;
for (const ing of ingredients) {
if (!ing._product) continue;
const f = (ing.weight_g || 0) / 100;
let ik = (ing._product.kcal_per_100g ?? 0) * f;
let ip = (ing._product.protein_per_100g ?? 0) * f;
let ifat = (ing._product.fat_per_100g ?? 0) * f;
let ic = (ing._product.carbs_per_100g ?? 0) * f;
// Apply cooking loss
const cmId = ing.cooking_method_id || defaultMethodId;
const cm = cmId ? cookingMethods.find(m => m.id === cmId) : null;
if (cm) {
ik *= (1 - (cm.kcal_loss_pct ?? 0) / 100);
ip *= (1 - (cm.protein_loss_pct ?? 0) / 100);
ifat *= (1 - (cm.fat_loss_pct ?? 0) / 100);
ic *= (1 - (cm.carbs_loss_pct ?? 0) / 100);
}
kcal += ik; protein += ip; fat += ifat; carbs += ic;
}
const servings = parseFloat(document.querySelector("#re-servings")?.value) || 1;
return {
kcal: Math.round(kcal / servings),
protein: Math.round(protein / servings * 10) / 10,
fat: Math.round(fat / servings * 10) / 10,
carbs: Math.round(carbs / servings * 10) / 10,
};
}
function updateTotalsDisplay() {
const el = document.querySelector("#re-totals");
if (!el) return;
const t = calcTotals();
el.innerHTML = `${t.kcal} кк | Б${t.protein} Ж${t.fat} У${t.carbs}`;
}
showModal(`
<h3>${isNew ? "Новый рецепт" : "Редактирование рецепта"}</h3>
<div class="form-group">
<label>Название</label>
<input id="re-name" type="text" value="${esc(recipe.name)}" placeholder="Борщ, Оливье..." />
</div>
<div class="form-group">
<label>Способ приготовления</label>
<select id="re-method">
<option value="">-- нет --</option>
${cmOptions}
</select>
</div>
<div class="form-group">
<label>Порций</label>
<input id="re-servings" type="number" inputmode="decimal" step="0.5" min="0.5"
value="${recipe.servings ?? 1}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="re-notes" type="text" value="${esc(recipe.notes ?? "")}" placeholder="Бабушкин рецепт..." />
</div>
<div class="sd-section" style="margin-top:12px">
<div class="section-header" style="padding-left:0">
<div class="sd-title">Ингредиенты</div>
<button class="add-btn" id="re-add-ing">+</button>
</div>
<div id="re-ing-list">${renderIngredientRows()}</div>
</div>
<div id="re-totals" class="recipe-totals"></div>
`, async overlay => {
const name = overlay.querySelector("#re-name").value.trim();
if (!name) return false;
if (ingredients.length === 0) return false;
const methodId = parseInt(overlay.querySelector("#re-method").value) || null;
const servings = parseFloat(overlay.querySelector("#re-servings").value) || 1;
const notes = overlay.querySelector("#re-notes").value.trim() || null;
let rid;
if (isNew) {
rid = await DB.saveRecipe(name, methodId, servings, notes);
} else {
rid = recipeId;
await DB.updateRecipe(rid, name, methodId, servings, notes);
await DB.clearRecipeIngredients(rid);
}
for (const ing of ingredients) {
await DB.addRecipeIngredient(
rid, ing.product_id, ing.custom_name, ing.weight_g, ing.cooking_method_id
);
}
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,
});
}
});
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>
<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.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);
};
}
function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
container.innerHTML = `
<div class="rec-screen">
<div id="rec-map" class="rec-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
</div>`;
setTimeout(() => {
const mapEl = document.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
map.setView([p.lat, p.lon], 16);
} else if (state === "recording") {
statusEl.textContent = "";
statusEl.style.color = "";
} else if (state === "resumed") {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Live point updates
const distEl = document.querySelector("#rec-dist");
const spdEl = document.querySelector("#rec-speed");
const timeEl = document.querySelector("#rec-time");
const elevEl = document.querySelector("#rec-elev");
const statusEl = document.querySelector("#rec-status");
// Speed buffer for median
const speedBuf = [];
GPS.recording.onPoint = (p) => {
const ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
// Stats
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg, 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) => {
if (a.seq != null && b.seq != null) return a.seq - b.seq;
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) => {
if (a.seq != null && b.seq != null) return a.seq - b.seq;
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) => {
if (a.seq != null && b.seq != null) return a.seq - b.seq;
return 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);
}
// 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>` : ""}
${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-recalc" class="btn-secondary full-width" style="margin-top:16px">Пересчитать</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 });
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Sort by timestamp, build coords and draw in one pass
// Keep original order from DB (seq from FTB import = XML order = clean track)
const t0 = new Date(points[0].ts).getTime();
const tN = new Date(points[points.length - 1].ts).getTime();
const tRange = tN - t0 || 1;
const sortedCoords = [];
for (let i = 0; i < points.length; i++) {
sortedCoords.push([points[i].lat, points[i].lon]);
}
// Polyline first (underneath)
L.polyline(sortedCoords, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
L.polyline(sortedCoords, { color: "#bb86fc", weight: 5 }).addTo(map);
// 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);
}
// Start/end
if (sortedCoords.length > 1) {
L.circleMarker(sortedCoords[0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map);
L.circleMarker(sortedCoords[sortedCoords.length - 1], { radius: 6, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map);
map.fitBounds(L.latLngBounds(sortedCoords).pad(0.1));
}
const allCoords = sortedCoords;
// Fullscreen toggle button on map
// Fullscreen map toggle
let mapIsFullscreen = false;
function collapseMap() {
if (!mapIsFullscreen) return;
mapIsFullscreen = false;
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
function toggleMapFs() {
if (mapIsFullscreen) {
collapseMap();
map.dragging.disable();
map.scrollWheelZoom.disable();
} else {
mapIsFullscreen = true;
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
map.dragging.enable();
map.scrollWheelZoom.enable(); // zoom without ctrl in fullscreen
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
}
const fsBtn = L.control({ position: "topright" });
let fsDiv;
fsBtn.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleMapFs(); };
return fsDiv;
};
fsBtn.addTo(map);
// Locate me button
const locBtn = L.control({ position: "topleft" });
locBtn.onAdd = () => {
const div = L.DomUtil.create("div", "sd-map-fs-btn");
div.innerHTML = "&#x25CE;";
div.title = "Моё местоположение";
div.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => {
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, 15);
L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map);
},
err => console.error("GPS error:", err.message),
{ enableHighAccuracy: true, timeout: 10000 }
);
};
return div;
};
locBtn.addTo(map);
// Click map to toggle fullscreen (same as button)
map.on("click", () => {
if (!mapEl.classList.contains("sd-map-full")) toggleMapFs();
});
}, 200);
// 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
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
// Push history so back gesture collapses chart, not modal
history.pushState({ chartFull: true }, "");
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => {
e.stopPropagation();
wrap.classList.remove("chart-full");
closeBtn.remove();
};
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;
};
}
// 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();
history.back();
renderScreen("gps");
};
}, 100);
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
}
// ---------------------------------------------------------------------------
// Log supplement modal (from Today screen)
// ---------------------------------------------------------------------------
async function showLogSupplementModal() {
const supps = await DB.getSupplements();
if (supps.length === 0) {
showModal(`
<h3>Добавки</h3>
<p style="color:var(--muted)">Нет добавок. Создайте на вкладке "Добавки".</p>`,
() => {});
return;
}
showModal(`
<h3>Принял добавку</h3>
<div class="form-group">
<label>Добавка</label>
<div id="m-supp-list" class="supp-pick-list">
${supps.map(s => `<div class="supp-pick-item" data-sid="${s.id}">${esc(s.name)}${s.daily_dose_g ? ` <small>(${s.daily_dose_g}г/день)</small>` : ""}</div>`).join("")}
</div>
</div>
<div class="form-group">
<label>Количество (шт)</label>
<input id="m-qty" type="number" inputmode="decimal" value="1" step="0.5" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const selected = overlay.querySelector(".supp-pick-item.selected");
if (!selected) return false;
const sid = parseInt(selected.dataset.sid);
const qty = parseFloat(overlay.querySelector("#m-qty").value) || 1;
const time = overlay.querySelector("#m-time").value;
await DB.logSupplement(state.date, time, sid, qty);
});
const overlay = document.querySelector(".modal-overlay");
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
};
});
}
// ---------------------------------------------------------------------------
// SUPPLEMENTS screen — manage supplements catalog
// ---------------------------------------------------------------------------
const MICRO_FIELDS = [
{ key: "vit_a_mcg", label: "Вит A (мкг)" },
{ key: "vit_c_mg", label: "Вит C (мг)" },
{ key: "vit_d_mcg", label: "Вит D (мкг)" },
{ key: "vit_e_mg", label: "Вит E (мг)" },
{ key: "vit_k_mcg", label: "Вит K (мкг)" },
{ key: "vit_b1_mg", label: "B1 (мг)" },
{ key: "vit_b2_mg", label: "B2 (мг)" },
{ key: "vit_b3_mg", label: "B3 (мг)" },
{ key: "vit_b6_mg", label: "B6 (мг)" },
{ key: "vit_b12_mcg", label: "B12 (мкг)" },
{ key: "folate_mcg", label: "Фолат (мкг)" },
{ key: "calcium_mg", label: "Ca (мг)" },
{ key: "iron_mg", label: "Fe (мг)" },
{ key: "magnesium_mg", label: "Mg (мг)" },
{ key: "zinc_mg", label: "Zn (мг)" },
{ key: "copper_mg", label: "Cu (мг)" },
{ key: "selenium_mcg", label: "Se (мкг)" },
{ key: "potassium_mg", label: "K (мг)" },
{ key: "sodium_mg", label: "Na (мг)" },
];
async function renderSupplements(container) {
const supps = await DB.getSupplements();
function suppSummary(s) {
const parts = [];
for (const f of MICRO_FIELDS) {
if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`);
}
return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : "");
}
container.innerHTML = `
<div class="products-screen">
<div class="section-header">
<h2>Добавки</h2>
<button class="add-btn" id="add-new-supp">+</button>
</div>
<div id="supp-list" class="prod-list">
${supps.length ? supps.map(s => `
<div class="prod-card" data-sid="${s.id}">
<div class="prod-card-header">
<div class="prod-card-name">${esc(s.name)}
${s.daily_dose_g ? `<span class="prod-weight">${s.daily_dose_g} шт/день</span>` : ""}
</div>
</div>
<div class="supp-summary">${suppSummary(s)}</div>
</div>`).join("")
: `<div class="prod-hint">Нет добавок. Нажмите + чтобы добавить.</div>`}
</div>
</div>`;
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
const sid = parseInt(card.dataset.sid);
const s = await DB.getSupplement(sid);
if (s) showEditSupplementModal(s);
};
});
}
function showEditSupplementModal(existing) {
const isNew = !existing;
const s = existing || {};
const microHtml = MICRO_FIELDS.map(f =>
`<div class="form-group micro-field">
<label>${f.label}</label>
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${s[f.key] ?? ""}" placeholder="—" />
</div>`
).join("");
showModal(`
<h3>${isNew ? "Новая добавка" : "Редактирование"}</h3>
<div class="form-group">
<label>Название</label>
<input id="m-name" type="text" value="${esc(s.name ?? "")}" placeholder="Витамин D3" />
</div>
<div class="form-group">
<label>Таблеток в день</label>
<input id="m-dose" type="number" inputmode="decimal" step="0.5" value="${s.daily_dose_g ?? ""}" placeholder="1" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Содержание в 1 таблетке:</p>
<div class="micro-grid">${microHtml}</div>
${!isNew ? `<button id="m-del-supp" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить добавку</button>` : ""}`,
async overlay => {
const name = overlay.querySelector("#m-name").value.trim();
if (!name) return false;
const data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
for (const f of MICRO_FIELDS) {
data[f.key] = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
}
if (isNew) {
await DB.addSupplement(data);
} else {
// Update: delete + re-add (simple, keeps log intact via supplement_id)
// Actually need an update function. For now, delete logs referencing old id is bad.
// Just delete and re-add — log entries will be orphaned. Better to add updateSupplement.
// Quick approach: raw execute
const sets = ["name=?1", "daily_dose_g=?2"];
const vals = [name, data.daily_dose_g];
let idx = 3;
for (const f of MICRO_FIELDS) {
sets.push(`${f.key}=?${idx}`);
vals.push(data[f.key]);
idx++;
}
vals.push(existing.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
values: vals,
});
}
});
if (!isNew) {
setTimeout(() => {
const delBtn = document.querySelector("#m-del-supp");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteSupplement(existing.id);
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("supplements");
};
}
}, 100);
}
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
async function renderSettings(container) {
const p = state.profile;
const profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
<div class="settings-screen">
<h2>Настройки</h2>
<div class="settings-section">
<div class="settings-item" id="s-open-profile">
<div class="settings-item-left">
<div class="settings-item-title">Профиль</div>
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Карты</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Оффлайн режим</div>
<div class="settings-item-desc">Тайлы только из кэша</div>
</div>
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">GPS (запись треков)</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. скорость (км/ч)</div>
<div class="settings-item-desc">Ниже = пауза</div>
</div>
<input id="s-min-speed" type="number" step="0.1" value="${(GPS.config.minSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. точность (м)</div>
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
</div>
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. расстояние (м)</div>
<div class="settings-item-desc">Точки ближе = пропуск</div>
</div>
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Велосипеды</div>
<div 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"];
for (const id of gpsInputs) {
const el = container.querySelector("#" + id);
if (el) el.onchange = () => {
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
GPS.saveConfig();
};
}
// 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-height" type="number" inputmode="numeric" value="${p.height_cm ?? ""}" placeholder="178" />
</div>
<div class="form-group">
<label>Год рождения</label>
<input id="m-year" type="number" inputmode="numeric" value="${p.birth_year ?? ""}" placeholder="1988" />
</div>
<div class="form-group">
<label>Пол</label>
<div class="type-chips">
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
</div>
</div>
<div class="form-group">
<label>BMR по формуле (ккал/день)</label>
<input id="m-bmr-formula" type="number" inputmode="numeric" value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="m-bmr-est" type="number" inputmode="numeric" value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>`,
async overlay => {
const height = parseInt(overlay.querySelector("#m-height").value);
const year = parseInt(overlay.querySelector("#m-year").value);
const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null;
const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) return false;
await DB.setProfileValue("height_cm", height);
await DB.setProfileValue("birth_year", year);
await DB.setProfileValue("sex", selectedSex);
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
state.profile = { ...state.profile,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
bmr_formula: bFormula ? String(bFormula) : null,
bmr_estimated: bEst ? String(bEst) : null };
});
// Sex chip handler inside modal
setTimeout(() => {
const overlay = document.querySelector(".modal-overlay");
if (!overlay) return;
overlay.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
}, 100);
}
// ---------------------------------------------------------------------------
// 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 style="display:flex;align-items:center;gap:8px">
<input id="m-bike-active" type="checkbox" ${bike.is_active ? "checked" : ""} />
Активный велосипед
</label>
</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,
is_active: overlay.querySelector("#m-bike-active").checked,
};
if (isNew) {
await DB.saveBike(data);
} else {
await DB.updateBike(bikeId, data);
}
// Refresh the settings screen to show updated list
renderScreen("settings");
});
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);
}
}
// ---------------------------------------------------------------------------
// 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) => {
// If a modal is open, don't handle here (modal has its own handler)
if (document.querySelector(".modal-overlay")) 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();
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>`;
});