Navigation:
- Stale {modal:true}/{chartFull:true} history entries after app restart
caused back gesture to do nothing (no screen to render, no modal open).
Now global popstate handler skips these stale entries with history.back().
Rust seed import:
- Fixed SQL operator precedence in nutrients_guessed UPDATE:
WHERE (guessed IS NULL OR guessed = 0) AND name_lower IN (...)
was previously parsed as: guessed IS NULL OR (guessed = 0 AND ...)
which matched ALL products with NULL guessed regardless of name.
3029 lines
117 KiB
JavaScript
3029 lines
117 KiB
JavaScript
// 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);
|
||
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">←</button>
|
||
<h2>${formatDate(state.date)}</h2>
|
||
<button class="day-nav" id="next-day">→</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}">✕</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}">✕</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}">✕</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}">✕</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}">✕</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">✎</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 = "✕";
|
||
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">←</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 = "⛶";
|
||
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}">✕</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">Нет рецептов. Создайте в Продукты → Рецепты.</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">⋮</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) : "—"}
|
||
· ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
|
||
· ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
|
||
${s.elevation_gain_m ? " · +" + 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 ?? 80;
|
||
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 ?? 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);
|
||
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);
|
||
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);
|
||
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);
|
||
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 ?? 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>` : ""}
|
||
|
||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт 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 = "⛶";
|
||
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 = "✕";
|
||
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 = "⛶";
|
||
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 = "◎";
|
||
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 = "✕";
|
||
closeBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
wrap.classList.remove("chart-full");
|
||
closeBtn.remove();
|
||
};
|
||
wrap.appendChild(closeBtn);
|
||
};
|
||
});
|
||
|
||
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">›</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 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();
|
||
};
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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>`;
|
||
});
|