Idea 1 — Tire pressure: - tire_pressure_bar field in bikes table and bike modal - Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar - Passed through all opts chains (recording, recovery, recalculate) Idea 3 — Lap comparison: - "Сравнить с..." button in session detail - Shows sessions with same type and similar distance (+-30%) - Overlay: current track green/red (faster/slower), comparison cyan dashed - Nearest-neighbor speed matching between tracks - Summary: time and speed difference Idea 4 — Auto climb segments: - computeStats returns segments[] with type (climb/flat/descent) - Gradient > 2% = climb, < -2% = descent, else flat - Min 50m per segment, adjacent same-type merged - Stats per segment: distance, elevation, gradient, duration, speed - UI section in session detail with colored icons Idea 5 — Bike maintenance log: - bike_maintenance table (date, description, mileage, next_service_km) - Total mileage from GPS sessions per bike - Overdue indicator when current mileage >= next_service_km - Add/delete entries in bike modal - CRUD functions on window.DB
3622 lines
147 KiB
JavaScript
3622 lines
147 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;
|
||
// Restore nav stack from localStorage so back gesture works after app restart
|
||
const navStack = (() => {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem("baldav_nav_stack"));
|
||
if (Array.isArray(saved) && saved.length > 0) return saved;
|
||
} catch {}
|
||
return ["today"];
|
||
})();
|
||
|
||
function persistNavStack() {
|
||
try { localStorage.setItem("baldav_nav_stack", JSON.stringify(navStack)); } catch {}
|
||
}
|
||
|
||
// Flag: modal popHandler already handled this popstate, global handler should skip
|
||
let _modalPopHandled = false;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Boot
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function init() {
|
||
if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") {
|
||
app.innerHTML = `<div class="loading" style="color:#cf6679">
|
||
Ошибка: db.js не загрузился.<br>
|
||
window.DB = ${JSON.stringify(typeof window.DB)}<br>
|
||
__TAURI__ = ${JSON.stringify(typeof window.__TAURI__)}
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Show loading immediately so the user sees something
|
||
app.innerHTML = `<div class="seed-progress">
|
||
<div class="seed-label" id="boot-label">Загрузка...</div>
|
||
<div class="seed-bar"><div class="seed-bar-fill" id="seed-fill"></div></div>
|
||
<div class="seed-count" id="seed-count"></div>
|
||
</div>`;
|
||
|
||
const bootEl = document.getElementById("boot-label");
|
||
const bootCount = document.getElementById("seed-count");
|
||
const bootFill = document.getElementById("seed-fill");
|
||
|
||
// Wait for DB migrations (fast — just checks columns exist)
|
||
try { if (DB._migrationsReady) await DB._migrationsReady; } catch (e) { console.error("[BOOT]", e); }
|
||
|
||
// Recover any orphaned GPS recording session
|
||
try { await GPS.recoverSession(); } catch (e) { /* ignore */ }
|
||
|
||
// Import seed products if needed — show progress only if actually importing
|
||
try {
|
||
let unlisten = null;
|
||
try {
|
||
unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => {
|
||
const { current, total } = ev.payload;
|
||
const pct = total > 0 ? Math.round(current / total * 100) : 0;
|
||
if (bootEl) bootEl.textContent = "Обновление продуктов (" + pct + "%)";
|
||
if (bootFill) { bootFill.classList.add("determinate"); bootFill.style.width = pct + "%"; }
|
||
if (bootCount) bootCount.textContent = current + " / " + total;
|
||
});
|
||
} catch {}
|
||
|
||
const result = await window.__TAURI__.core.invoke("import_seed_products");
|
||
if (unlisten) unlisten();
|
||
} catch (e) { console.error("[SEED]", e); }
|
||
|
||
// Apply safe area insets (Android status bar / nav bar)
|
||
try {
|
||
const insets = await window.__TAURI__.core.invoke("get_safe_insets");
|
||
if (insets.top || insets.bottom) {
|
||
document.documentElement.style.setProperty("--safe-area-top", insets.top + "px");
|
||
document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px");
|
||
}
|
||
} catch {}
|
||
|
||
state.profile = await DB.getFullProfile();
|
||
state.latestWeight = await DB.getLatestWeight();
|
||
|
||
// Seed browser history from navStack so back gesture works after restart
|
||
history.replaceState({ screen: navStack[0] }, "");
|
||
for (let i = 1; i < navStack.length; i++) {
|
||
history.pushState({ screen: navStack[i] }, "");
|
||
}
|
||
// Add padding entries so first back doesn't exit
|
||
for (let i = 0; i < 3; i++) history.pushState({ screen: navStack[navStack.length - 1] }, "");
|
||
|
||
// Restore last screen (fromPopstate=true to avoid duplicate navStack push)
|
||
renderScreen(navStack[navStack.length - 1], true);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Router
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function renderScreen(name, fromPopstate) {
|
||
// Release screen wake lock when leaving recording screen
|
||
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
|
||
|
||
const prevScreen = state.screen;
|
||
state.screen = name;
|
||
// Push to nav stack and browser history (unless triggered by popstate)
|
||
if (!fromPopstate && prevScreen && prevScreen !== name) {
|
||
navStack.push(name);
|
||
if (navStack.length > NAV_MAX) navStack.shift();
|
||
persistNavStack();
|
||
history.pushState({ screen: name }, "");
|
||
}
|
||
const screens = {
|
||
today: renderToday,
|
||
products: renderProducts,
|
||
recipes: renderRecipes,
|
||
gps: renderGpsScreen,
|
||
recording: renderRecordingInline,
|
||
supplements: renderSupplements,
|
||
settings: renderSettings,
|
||
};
|
||
app.innerHTML = "";
|
||
|
||
// Floating recording bar (visible on all screens except recording)
|
||
if (GPS.recording.active && name !== "recording") {
|
||
const bar = document.createElement("div");
|
||
bar.className = "rec-floating-bar";
|
||
bar.onclick = () => renderScreen("recording");
|
||
bar.innerHTML = `<span class="rec-bar-dot"></span><span id="rec-bar-text">Запись...</span>`;
|
||
app.appendChild(bar);
|
||
// Update bar every second
|
||
if (window._recBarTimer) clearInterval(window._recBarTimer);
|
||
window._recBarTimer = setInterval(() => {
|
||
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
|
||
const pts = GPS.recording.points;
|
||
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||
const sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
|
||
const el = document.querySelector("#rec-bar-text");
|
||
if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`;
|
||
}, 1000);
|
||
} else if (window._recBarTimer) {
|
||
clearInterval(window._recBarTimer);
|
||
}
|
||
|
||
app.appendChild(buildNav(name));
|
||
const content = document.createElement("div");
|
||
content.className = "screen-content";
|
||
app.appendChild(content);
|
||
screens[name](content);
|
||
}
|
||
|
||
function buildNav(active) {
|
||
const nav = document.createElement("nav");
|
||
nav.className = "bottom-nav";
|
||
[
|
||
{ id: "today", label: "Сегодня" },
|
||
{ id: "products", label: "Продукты" },
|
||
{ id: "gps", label: "Трек" },
|
||
{ id: "supplements", label: "Добавки" },
|
||
{ id: "settings", label: "Настройки" },
|
||
].forEach(item => {
|
||
const btn = document.createElement("button");
|
||
btn.className = "nav-btn" + (item.id === active ? " active" : "");
|
||
btn.textContent = item.label;
|
||
btn.onclick = () => renderScreen(item.id);
|
||
nav.appendChild(btn);
|
||
});
|
||
return nav;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TODAY screen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function renderToday(container) {
|
||
container.innerHTML = `<div class="loading">Загрузка...</div>`;
|
||
|
||
let weights = [], food = [], activities = [], sleep = [], foodTotals = {}, actTotals = {}, suppLog = [], suppTotals = {};
|
||
try {
|
||
[weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals] = await Promise.all([
|
||
DB.getWeightsForDate(state.date),
|
||
DB.getFoodForDate(state.date),
|
||
DB.getActivitiesForDate(state.date),
|
||
DB.getSleepForDate(state.date),
|
||
DB.getFoodTotalsForDate(state.date),
|
||
DB.getActivityTotalsForDate(state.date),
|
||
DB.getSupplementLogForDate(state.date),
|
||
DB.getSupplementTotalsForDate(state.date),
|
||
]);
|
||
} catch (e) {
|
||
console.error("[renderToday] DB error:", e);
|
||
container.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка загрузки: ${esc(String(e))}</div>`;
|
||
return;
|
||
}
|
||
|
||
const bmr = calcBmr(weights);
|
||
const intake = Math.round(foodTotals.kcal ?? 0);
|
||
const actKcal = Math.round(actTotals.kcal ?? 0);
|
||
const expend = bmr != null ? bmr + actKcal : null;
|
||
const balance = expend != null ? intake - expend : null;
|
||
const hasGuessed = food.some(f => f.nutrients_guessed);
|
||
|
||
container.innerHTML = `
|
||
<div class="day-header">
|
||
<button class="day-nav" id="prev-day">←</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 = "✕";
|
||
function collapseChart() {
|
||
wrap.classList.remove("chart-full");
|
||
closeBtn.remove();
|
||
window.removeEventListener("popstate", chartPopHandler);
|
||
}
|
||
closeBtn.onclick = (e) => { e.stopPropagation(); collapseChart(); history.back(); };
|
||
const chartPopHandler = () => { collapseChart(); };
|
||
window.addEventListener("popstate", chartPopHandler, { once: true });
|
||
wrap.appendChild(closeBtn);
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Modals
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function showModal(html, onSubmit) {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "modal-overlay";
|
||
overlay.innerHTML = `
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<button class="modal-back" id="modal-back">←</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, stay in modal.
|
||
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 {
|
||
const mapObj = fsMap._leafletMap;
|
||
if (mapObj) {
|
||
mapObj.dragging.disable();
|
||
mapObj.scrollWheelZoom.disable();
|
||
setTimeout(() => {
|
||
mapObj.invalidateSize();
|
||
const bounds = fsMap._bounds;
|
||
if (bounds) mapObj.fitBounds(bounds);
|
||
}, 100);
|
||
}
|
||
} catch {}
|
||
history.pushState({ modal: true }, "");
|
||
return;
|
||
}
|
||
// Fullscreen chart inside modal? Collapse it, stay in modal.
|
||
const fsChart = overlay.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. Set flag so global handler skips this event.
|
||
_modalPopHandled = true;
|
||
window.removeEventListener("popstate", popHandler);
|
||
overlay.remove();
|
||
};
|
||
window.addEventListener("popstate", popHandler);
|
||
|
||
function closeModal() {
|
||
window.removeEventListener("popstate", popHandler);
|
||
overlay.remove();
|
||
history.back(); // pops {modal: true}, returns to previous screen
|
||
}
|
||
|
||
overlay.querySelector("#modal-back").onclick = () => closeModal();
|
||
overlay.querySelector("#modal-cancel").onclick = () => closeModal();
|
||
overlay.querySelector("#modal-ok").onclick = async () => {
|
||
const ok = await onSubmit(overlay);
|
||
if (ok !== false) { closeModal(); renderScreen(state.screen); }
|
||
};
|
||
setTimeout(() => overlay.querySelector("input")?.focus(), 50);
|
||
}
|
||
|
||
function showWeightModal() {
|
||
showModal(`
|
||
<h3>Вес</h3>
|
||
<div class="form-group">
|
||
<label>кг</label>
|
||
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const kg = parseFloat(overlay.querySelector("#m-kg").value);
|
||
const time = overlay.querySelector("#m-time").value;
|
||
if (isNaN(kg)) return false;
|
||
await DB.addWeight(state.date, time, kg);
|
||
});
|
||
}
|
||
|
||
function showFoodModal() {
|
||
let selectedProduct = null;
|
||
let selectedUnit = null; // { id, name, name_short, grams_per_unit }
|
||
let availableUnits = [];
|
||
|
||
showModal(`
|
||
<h3>Еда</h3>
|
||
<div id="m-search-wrap"></div>
|
||
<div id="m-selected" class="modal-selected" style="display:none"></div>
|
||
<div id="m-unit-chips" class="unit-chips" style="display:none"></div>
|
||
<div id="m-manual-fields">
|
||
<div class="form-group">
|
||
<label>Или вручную: описание</label>
|
||
<input id="m-desc" type="text" placeholder="гречка с молоком" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>ккал (примерно)</label>
|
||
<input id="m-kcal" type="number" inputmode="numeric" placeholder="350" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label id="m-amount-label">Количество (г)</label>
|
||
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const time = overlay.querySelector("#m-time").value;
|
||
const rawAmount = parseFloat(overlay.querySelector("#m-amount").value) || 100;
|
||
|
||
if (selectedProduct) {
|
||
let amountG = rawAmount;
|
||
let unitId = null;
|
||
let unitAmount = null;
|
||
if (selectedUnit && selectedUnit.grams_per_unit) {
|
||
// Convert unit amount to grams: e.g. 2 шт * 60г = 120г
|
||
unitId = selectedUnit.id;
|
||
unitAmount = rawAmount;
|
||
amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10;
|
||
}
|
||
await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount);
|
||
} else {
|
||
const desc = overlay.querySelector("#m-desc").value.trim();
|
||
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
|
||
if (!desc) return false;
|
||
await DB.addFoodManual(state.date, time, desc, kcal);
|
||
}
|
||
});
|
||
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
const selectedEl = overlay.querySelector("#m-selected");
|
||
const manualFields = overlay.querySelector("#m-manual-fields");
|
||
const unitChipsEl = overlay.querySelector("#m-unit-chips");
|
||
const amountLabel = overlay.querySelector("#m-amount-label");
|
||
const amountInput = overlay.querySelector("#m-amount");
|
||
|
||
function renderUnitChips() {
|
||
// "г" chip is always first (default), then available units
|
||
let html = `<button class="unit-chip${!selectedUnit ? " selected" : ""}" data-unit-id="0">г</button>`;
|
||
for (const u of availableUnits) {
|
||
if (u.grams_per_unit == null) continue; // skip units without conversion
|
||
html += `<button class="unit-chip${selectedUnit && selectedUnit.id === u.id ? " selected" : ""}" data-unit-id="${u.id}">${esc(u.name_short || u.name)}</button>`;
|
||
}
|
||
unitChipsEl.innerHTML = html;
|
||
unitChipsEl.style.display = "";
|
||
|
||
unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => {
|
||
chip.onclick = () => {
|
||
const uid = parseInt(chip.dataset.unitId);
|
||
if (uid === 0) {
|
||
selectedUnit = null;
|
||
amountLabel.textContent = "Количество (г)";
|
||
amountInput.placeholder = "100";
|
||
amountInput.value = "100";
|
||
} else {
|
||
selectedUnit = availableUnits.find(u => u.id === uid);
|
||
amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`;
|
||
amountInput.placeholder = "1";
|
||
amountInput.value = "1";
|
||
}
|
||
unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected"));
|
||
chip.classList.add("selected");
|
||
};
|
||
});
|
||
}
|
||
|
||
createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => {
|
||
selectedProduct = product;
|
||
selectedUnit = null;
|
||
selectedEl.style.display = "block";
|
||
selectedEl.innerHTML = `<span>${esc(product.name)}</span>`;
|
||
manualFields.style.display = "none";
|
||
|
||
// Load available units for this product
|
||
availableUnits = await DB.getUnitsForProduct(product.id);
|
||
if (availableUnits.length > 0) {
|
||
renderUnitChips();
|
||
} else {
|
||
unitChipsEl.style.display = "none";
|
||
}
|
||
|
||
// Reset amount to grams mode
|
||
amountLabel.textContent = "Количество (г)";
|
||
amountInput.placeholder = "100";
|
||
amountInput.value = "100";
|
||
|
||
selectedEl.onclick = () => {
|
||
selectedProduct = null;
|
||
selectedUnit = null;
|
||
availableUnits = [];
|
||
selectedEl.style.display = "none";
|
||
unitChipsEl.style.display = "none";
|
||
manualFields.style.display = "";
|
||
amountLabel.textContent = "Количество (г)";
|
||
amountInput.placeholder = "100";
|
||
amountInput.value = "100";
|
||
};
|
||
});
|
||
}
|
||
|
||
function showEditFoodModal(entry) {
|
||
let newProduct = null; // if user picks a different product
|
||
const origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? "";
|
||
|
||
showModal(`
|
||
<h3>Редактирование</h3>
|
||
<div id="m-current" class="modal-selected">
|
||
<span>${esc(origLabel)}</span>
|
||
</div>
|
||
<div id="m-search-block" style="display:none">
|
||
<div class="form-group">
|
||
<label>Заменить на</label>
|
||
<input id="m-prod-search" type="search" placeholder="Поиск продукта..." />
|
||
<div id="m-prod-results" class="modal-search-results"></div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Количество (г)</label>
|
||
<input id="m-amount" type="number" inputmode="decimal" value="${entry.amount_g || 100}" />
|
||
</div>
|
||
${!entry.product_id ? `
|
||
<div class="form-group" id="m-kcal-group">
|
||
<label>ккал</label>
|
||
<input id="m-kcal" type="number" inputmode="numeric" value="${entry.kcal != null ? Math.round(entry.kcal) : ""}" />
|
||
</div>` : ""}
|
||
<div class="form-group">
|
||
<label>Заметка</label>
|
||
<input id="m-note" type="text" value="${esc(entry.note ?? "")}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${entry.time ?? ""}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const amount = parseFloat(overlay.querySelector("#m-amount").value);
|
||
const time = overlay.querySelector("#m-time").value;
|
||
const note = overlay.querySelector("#m-note")?.value.trim() || null;
|
||
if (isNaN(amount) || amount <= 0) return false;
|
||
|
||
if (newProduct) {
|
||
// Replace: delete old, add new
|
||
await DB.deleteFoodEntry(entry.id);
|
||
await DB.addFoodEntry(entry.date ?? state.date, time, newProduct.id, amount, note);
|
||
} else {
|
||
const kcalEl = overlay.querySelector("#m-kcal");
|
||
const kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null;
|
||
await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride);
|
||
}
|
||
});
|
||
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
const currentEl = overlay.querySelector("#m-current");
|
||
const searchBlock = overlay.querySelector("#m-search-block");
|
||
|
||
currentEl.onclick = () => {
|
||
searchBlock.style.display = "block";
|
||
searchBlock.querySelector("#m-prod-search").focus();
|
||
};
|
||
|
||
const searchInput = overlay.querySelector("#m-prod-search");
|
||
const resultsEl = overlay.querySelector("#m-prod-results");
|
||
let debounce = null;
|
||
|
||
searchInput.oninput = () => {
|
||
clearTimeout(debounce);
|
||
debounce = setTimeout(async () => {
|
||
const q = searchInput.value.trim();
|
||
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
|
||
const results = await DB.searchProducts(q);
|
||
resultsEl.innerHTML = results.map(p =>
|
||
`<div class="modal-prod-item" data-pid="${p.id}">
|
||
<span>${esc(p.name)}</span>
|
||
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
|
||
</div>`
|
||
).join("") || `<div class="prod-hint">Не найдено</div>`;
|
||
|
||
resultsEl.querySelectorAll(".modal-prod-item").forEach(item => {
|
||
item.onclick = () => {
|
||
const pid = parseInt(item.dataset.pid);
|
||
newProduct = results.find(r => r.id === pid);
|
||
currentEl.querySelector("span").textContent = newProduct.name;
|
||
searchBlock.style.display = "none";
|
||
resultsEl.innerHTML = "";
|
||
searchInput.value = "";
|
||
// Hide manual kcal field since now it's a product
|
||
const kcalGroup = overlay.querySelector("#m-kcal-group");
|
||
if (kcalGroup) kcalGroup.style.display = "none";
|
||
};
|
||
});
|
||
}, 300);
|
||
};
|
||
}
|
||
|
||
function showActivityModal() {
|
||
showModal(`
|
||
<h3>Активность</h3>
|
||
<div class="form-group">
|
||
<label>Описание</label>
|
||
<input id="m-desc" type="text" placeholder="прогулка" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Минуты</label>
|
||
<input id="m-min" type="number" inputmode="numeric" placeholder="40" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>ккал расход (примерно)</label>
|
||
<input id="m-kcal" type="number" inputmode="numeric" placeholder="150" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const desc = overlay.querySelector("#m-desc").value.trim();
|
||
const min = parseInt(overlay.querySelector("#m-min").value) || null;
|
||
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
|
||
const time = overlay.querySelector("#m-time").value;
|
||
if (!desc) return false;
|
||
await DB.addActivity(state.date, time, "manual", desc, min, kcal);
|
||
});
|
||
}
|
||
|
||
function showSleepModal(existing) {
|
||
showModal(`
|
||
<h3>Сон</h3>
|
||
<div class="form-group">
|
||
<label>Лёг в кровать</label>
|
||
<input id="m-bed" type="time" value="${existing?.bed_time ?? ""}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Уснул</label>
|
||
<input id="m-asleep" type="time" value="${existing?.asleep_time ?? ""}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Проснулся</label>
|
||
<input id="m-wake" type="time" value="${existing?.wake_time ?? ""}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Дневной сон (мин)</label>
|
||
<input id="m-nap" type="number" inputmode="numeric" value="${existing?.nap_minutes ?? 0}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Заметки</label>
|
||
<input id="m-notes" type="text" value="${esc(existing?.notes ?? "")}" />
|
||
</div>`,
|
||
async overlay => {
|
||
await DB.saveSleep(
|
||
state.date,
|
||
overlay.querySelector("#m-bed").value || null,
|
||
overlay.querySelector("#m-asleep").value || null,
|
||
overlay.querySelector("#m-wake").value || null,
|
||
parseInt(overlay.querySelector("#m-nap").value) || 0,
|
||
overlay.querySelector("#m-notes").value.trim() || null
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PRODUCTS screen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function renderProducts(container) {
|
||
let debounceTimer = null;
|
||
let expandedId = null; // currently expanded product card id
|
||
|
||
container.innerHTML = `
|
||
<div class="products-screen">
|
||
<div class="sub-tabs">
|
||
<button class="sub-tab active" data-tab="products">Продукты</button>
|
||
<button class="sub-tab" data-tab="recipes">Рецепты</button>
|
||
</div>
|
||
<div id="products-tab">
|
||
<input id="prod-search" class="search-input" type="search"
|
||
placeholder="Название, бренд или ингредиент..." />
|
||
<div id="prod-count" class="prod-count"></div>
|
||
<div id="prod-results" class="prod-list"></div>
|
||
</div>
|
||
<div id="recipes-tab" style="display:none"></div>
|
||
</div>`;
|
||
|
||
// Sub-tab switching
|
||
container.querySelectorAll(".sub-tab").forEach(btn => {
|
||
btn.onclick = () => {
|
||
container.querySelectorAll(".sub-tab").forEach(b => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
const tab = btn.dataset.tab;
|
||
container.querySelector("#products-tab").style.display = tab === "products" ? "" : "none";
|
||
container.querySelector("#recipes-tab").style.display = tab === "recipes" ? "" : "none";
|
||
if (tab === "recipes") renderRecipes(container.querySelector("#recipes-tab"));
|
||
};
|
||
});
|
||
|
||
const input = container.querySelector("#prod-search");
|
||
const countEl = container.querySelector("#prod-count");
|
||
const listEl = container.querySelector("#prod-results");
|
||
|
||
let lastResults = [];
|
||
|
||
async function doSearch(resetExpanded) {
|
||
const q = input.value.trim();
|
||
if (q.length < 2) {
|
||
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
|
||
countEl.textContent = "";
|
||
lastResults = [];
|
||
return;
|
||
}
|
||
if (resetExpanded) expandedId = null;
|
||
lastResults = await DB.searchProducts(q);
|
||
countEl.textContent = lastResults.length ? `Найдено: ${lastResults.length}` : "";
|
||
if (!lastResults.length) {
|
||
listEl.innerHTML = `<div class="prod-hint">Ничего не найдено</div>`;
|
||
return;
|
||
}
|
||
renderList();
|
||
}
|
||
|
||
function renderList() {
|
||
listEl.innerHTML = lastResults.map(p => renderProdCard(p)).join("");
|
||
bindProdCards(listEl);
|
||
}
|
||
|
||
function renderProdCard(p) {
|
||
const open = p.id === expandedId;
|
||
const kcal = p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) : null;
|
||
return `
|
||
<div class="prod-card ${open ? "open" : ""}" data-pid="${p.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">
|
||
${esc(p.name)}
|
||
${p.brand ? `<span class="prod-brand">${esc(p.brand)}</span>` : ""}
|
||
${p.weight_g ? `<span class="prod-weight">${p.weight_g}г</span>` : ""}
|
||
</div>
|
||
<div class="prod-card-kcal">${kcal != null ? kcal + " кк" : "?"}</div>
|
||
</div>
|
||
${open ? renderProdDetails(p) : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function renderProdDetails(p) {
|
||
const r = v => v != null ? v : "—";
|
||
return `
|
||
<div class="prod-details">
|
||
<div class="prod-macros">
|
||
<div class="macro"><span class="macro-val">${r(p.protein_per_100g)}</span><span class="macro-lbl">Б</span></div>
|
||
<div class="macro"><span class="macro-val">${r(p.fat_per_100g)}</span><span class="macro-lbl">Ж</span></div>
|
||
<div class="macro"><span class="macro-val">${r(p.carbs_per_100g)}</span><span class="macro-lbl">У</span></div>
|
||
<div class="macro"><span class="macro-val">${r(p.fiber_per_100g)}</span><span class="macro-lbl">Кл</span></div>
|
||
</div>
|
||
${p.composition ? `<div class="prod-composition">${esc(p.composition)}</div>` : ""}
|
||
<button class="btn-primary prod-add-btn" data-pid="${p.id}">Добавить в дневник</button>
|
||
</div>`;
|
||
}
|
||
|
||
function bindProdCards(el) {
|
||
el.querySelectorAll(".prod-card-header").forEach(hdr => {
|
||
hdr.onclick = () => {
|
||
const card = hdr.closest(".prod-card");
|
||
const pid = parseInt(card.dataset.pid);
|
||
expandedId = (expandedId === pid) ? null : pid;
|
||
renderList();
|
||
};
|
||
});
|
||
el.querySelectorAll(".prod-add-btn").forEach(btn => {
|
||
btn.onclick = e => {
|
||
e.stopPropagation();
|
||
showAddFoodFromProduct(parseInt(btn.dataset.pid));
|
||
};
|
||
});
|
||
}
|
||
|
||
function showAddFoodFromProduct(productId) {
|
||
showModal(`
|
||
<h3>Добавить в дневник</h3>
|
||
<div class="form-group">
|
||
<label>Количество (г)</label>
|
||
<input id="m-amount" type="number" inputmode="decimal" placeholder="100" value="100" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Заметка</label>
|
||
<input id="m-note" type="text" placeholder="" />
|
||
</div>`,
|
||
async overlay => {
|
||
const amount = parseFloat(overlay.querySelector("#m-amount").value);
|
||
const time = overlay.querySelector("#m-time").value;
|
||
const note = overlay.querySelector("#m-note").value.trim() || null;
|
||
if (isNaN(amount) || amount <= 0) return false;
|
||
await DB.addFoodEntry(state.date, time, productId, amount, note);
|
||
});
|
||
}
|
||
|
||
input.oninput = () => {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => doSearch(true), 300);
|
||
};
|
||
|
||
listEl.innerHTML = `<div class="prod-hint">Введите название продукта</div>`;
|
||
input.focus();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RECIPES screen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function renderRecipes(container) {
|
||
const recipes = await DB.getRecipes();
|
||
|
||
// Pre-calculate nutrition for each recipe to show kcal in list
|
||
const nutritionMap = {};
|
||
for (const r of recipes) {
|
||
try {
|
||
nutritionMap[r.id] = await DB.calculateRecipeNutrition(r.id);
|
||
} catch (e) {
|
||
nutritionMap[r.id] = null;
|
||
}
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="products-screen">
|
||
<div class="section-header">
|
||
<h2>Рецепты</h2>
|
||
<button class="add-btn" id="add-recipe-btn">+</button>
|
||
</div>
|
||
<div id="recipe-list" class="prod-list">
|
||
${recipes.length ? recipes.map(r => {
|
||
const n = nutritionMap[r.id];
|
||
const kcalStr = n ? n.kcal + " кк" : "";
|
||
const macroStr = n ? `Б${n.protein} Ж${n.fat} У${n.carbs}` : "";
|
||
return `
|
||
<div class="prod-card recipe-card" data-rid="${r.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">
|
||
${esc(r.name)}
|
||
${r.cooking_method_name ? `<span class="prod-brand">${esc(r.cooking_method_name)}</span>` : ""}
|
||
${r.servings > 1 ? `<span class="prod-weight">${r.servings} порц.</span>` : ""}
|
||
</div>
|
||
<div class="prod-card-kcal">${kcalStr}</div>
|
||
</div>
|
||
${macroStr ? `<div class="recipe-macros-hint">${macroStr}</div>` : ""}
|
||
</div>`;
|
||
}).join("") : `<div class="prod-hint">Нет рецептов. Нажмите + чтобы создать.</div>`}
|
||
</div>
|
||
</div>`;
|
||
|
||
container.querySelector("#add-recipe-btn").onclick = () => showRecipeEditorModal(null);
|
||
|
||
container.querySelectorAll(".recipe-card").forEach(card => {
|
||
card.onclick = () => {
|
||
const rid = parseInt(card.dataset.rid);
|
||
showRecipeDetailModal(rid);
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recipe detail modal — view recipe, log to food diary
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showRecipeDetailModal(recipeId) {
|
||
const recipe = await DB.getRecipe(recipeId);
|
||
if (!recipe) return;
|
||
const ingredients = await DB.getRecipeIngredients(recipeId);
|
||
const nutr = await DB.calculateRecipeNutrition(recipeId);
|
||
|
||
const ingList = ingredients.map(ing => {
|
||
const name = ing.product_name ?? ing.custom_name ?? "?";
|
||
const cm = ing.cooking_method_name ? ` (${ing.cooking_method_name})` : "";
|
||
return `<li class="log-item">
|
||
<span class="log-desc">${esc(name)}${cm}</span>
|
||
<span class="log-kcal">${ing.amount_g} г</span>
|
||
</li>`;
|
||
}).join("");
|
||
|
||
showModal(`
|
||
<h3>${esc(recipe.name)}</h3>
|
||
${recipe.cooking_method_name ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Способ: ${esc(recipe.cooking_method_name)}</p>` : ""}
|
||
${recipe.servings > 1 ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Порций: ${recipe.servings}</p>` : ""}
|
||
${recipe.notes ? `<p style="color:var(--muted);font-size:13px;margin-bottom:12px">${esc(recipe.notes)}</p>` : ""}
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Ингредиенты</div>
|
||
<ul class="log-list">${ingList || `<li class="log-empty">нет ингредиентов</li>`}</ul>
|
||
</div>
|
||
|
||
${nutr ? `
|
||
<div class="sd-section" style="margin-top:12px">
|
||
<div class="sd-title">Пищевая ценность (на порцию)</div>
|
||
<div class="prod-macros">
|
||
<div class="macro"><span class="macro-val">${nutr.kcal}</span><span class="macro-lbl">ккал</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.protein}</span><span class="macro-lbl">Б</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.fat}</span><span class="macro-lbl">Ж</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.carbs}</span><span class="macro-lbl">У</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.fiber}</span><span class="macro-lbl">Кл</span></div>
|
||
</div>
|
||
</div>` : ""}
|
||
|
||
<button id="rd-log" class="btn-primary full-width" style="margin-top:16px">Записать в дневник</button>
|
||
<button id="rd-edit" class="btn-secondary full-width" style="margin-top:8px">Редактировать</button>
|
||
<button id="rd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить рецепт</button>
|
||
`, () => {});
|
||
|
||
// Wire up buttons after DOM is ready
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
const logBtn = overlay.querySelector("#rd-log");
|
||
const editBtn = overlay.querySelector("#rd-edit");
|
||
const delBtn = overlay.querySelector("#rd-delete");
|
||
|
||
logBtn.onclick = async () => {
|
||
await DB.addFoodFromRecipe(state.date, nowTime(), recipeId);
|
||
history.back(); // popHandler closes modal
|
||
renderScreen(state.screen);
|
||
};
|
||
|
||
editBtn.onclick = () => {
|
||
history.back(); // popHandler closes modal
|
||
setTimeout(() => showRecipeEditorModal(recipeId), 50);
|
||
};
|
||
|
||
delBtn.onclick = async () => {
|
||
await DB.deleteRecipe(recipeId);
|
||
history.back(); // popHandler closes modal
|
||
renderScreen("recipes");
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recipe editor modal — create / edit recipe with ingredient list
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showRecipeEditorModal(recipeId) {
|
||
const isNew = !recipeId;
|
||
let recipe = isNew ? { name: "", cooking_method_id: null, servings: 1, notes: "" } : await DB.getRecipe(recipeId);
|
||
if (!recipe) return;
|
||
|
||
const cookingMethods = await DB.getCookingMethods();
|
||
const existingIngredients = isNew ? [] : await DB.getRecipeIngredients(recipeId);
|
||
|
||
// Local in-memory ingredient list for editing
|
||
// Each item: { product_id, product_name, custom_name, weight_g, cooking_method_id, cooking_method_name }
|
||
const ingredients = existingIngredients.map(ing => ({
|
||
product_id: ing.product_id,
|
||
product_name: ing.product_name,
|
||
custom_name: ing.custom_name,
|
||
weight_g: ing.amount_g,
|
||
cooking_method_id: ing.cooking_method_id,
|
||
cooking_method_name: ing.cooking_method_name,
|
||
}));
|
||
|
||
const cmOptions = cookingMethods.map(cm =>
|
||
`<option value="${cm.id}">${esc(cm.name)}</option>`
|
||
).join("");
|
||
|
||
function renderIngredientRows() {
|
||
if (ingredients.length === 0) return `<div class="log-empty">Добавьте ингредиенты</div>`;
|
||
return ingredients.map((ing, idx) => {
|
||
const name = ing.product_name ?? ing.custom_name ?? "?";
|
||
return `<div class="recipe-ing-row" data-idx="${idx}">
|
||
<div class="recipe-ing-info">
|
||
<span class="recipe-ing-name">${esc(name)}</span>
|
||
<span class="recipe-ing-weight">${ing.weight_g} г</span>
|
||
${ing.cooking_method_name ? `<span class="recipe-ing-cm">${esc(ing.cooking_method_name)}</span>` : ""}
|
||
</div>
|
||
<button class="del-btn recipe-ing-del" data-idx="${idx}">✕</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 ?? state.profile?.weight_kg ?? 80;
|
||
// Get active bike for bike sessions — store bike data in recording state
|
||
if (type === "bike") {
|
||
const bike = await DB.getActiveBike();
|
||
if (bike) {
|
||
GPS.recording.bikeId = bike.id;
|
||
GPS.recording.bikeWeightKg = bike.weight_kg ?? 0;
|
||
GPS.recording.surfaceCrr = ({asphalt: 0.004, mixed: 0.006, gravel: 0.008})[bike.surface_type] ?? 0.006;
|
||
GPS.recording.tirePressureBar = bike.tire_pressure_bar ?? 3.0;
|
||
GPS.recording.chainringTeeth = bike.chainring_teeth;
|
||
GPS.recording.cogTeeth = bike.cog_teeth;
|
||
GPS.recording.wheelDiameterInch = bike.wheel_diameter_inch;
|
||
}
|
||
}
|
||
await GPS.startRecording(type, wkg);
|
||
renderScreen("recording");
|
||
}
|
||
};
|
||
|
||
// Session click → detail view
|
||
container.querySelectorAll(".gps-session-item").forEach(item => {
|
||
item.onclick = () => {
|
||
const sid = parseInt(item.dataset.sid);
|
||
showSessionDetail(sid);
|
||
};
|
||
});
|
||
|
||
// Three-dot menu
|
||
const menuBtn = container.querySelector("#gps-menu-btn");
|
||
const menu = container.querySelector("#gps-menu");
|
||
menuBtn.onclick = () => menu.classList.toggle("hidden");
|
||
document.addEventListener("click", e => {
|
||
if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden");
|
||
}, { once: false });
|
||
|
||
// Download map tiles — region picker
|
||
container.querySelector("#gps-download-map").onclick = () => {
|
||
menu.classList.add("hidden");
|
||
showMapDownloadModal();
|
||
};
|
||
|
||
// Clear tile cache
|
||
container.querySelector("#gps-clear-cache").onclick = async () => {
|
||
menu.classList.add("hidden");
|
||
const count = await TileCache.count();
|
||
await TileCache.clear();
|
||
console.log("Cache cleared");
|
||
};
|
||
|
||
// Unified import — detect by extension
|
||
container.querySelector("#gps-import").onclick = () => {
|
||
menu.classList.add("hidden");
|
||
pickFile("", async (data, file) => {
|
||
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
|
||
const ext = file.name.split(".").pop().toLowerCase();
|
||
console.log("[IMPORT] ext:", ext);
|
||
const wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
|
||
try {
|
||
if (ext === "gpx") {
|
||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||
await GPS.importGPX(text, wkg);
|
||
} else if (ext === "ftb" || ext === "zip") {
|
||
const buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
|
||
console.log("[IMPORT] FTB buf size:", buf.byteLength);
|
||
const count = await GPS.importFTB(buf, wkg);
|
||
console.log("[IMPORT] done, count:", count);
|
||
} else {
|
||
console.error("[IMPORT] Unsupported file type: " + ext);
|
||
}
|
||
renderScreen("gps");
|
||
} catch (e) {
|
||
console.error("[IMPORT] error:", e, String(e));
|
||
}
|
||
}, true);
|
||
};
|
||
}
|
||
|
||
function renderRecordingInline(container) {
|
||
if (!GPS.recording.active) { renderScreen("gps"); return; }
|
||
|
||
// Keep screen on while recording is visible
|
||
if (navigator.wakeLock) {
|
||
navigator.wakeLock.request("screen").then(lock => {
|
||
window._screenLock = lock;
|
||
}).catch(() => {});
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="rec-screen">
|
||
<div id="rec-map" class="rec-map"></div>
|
||
<div class="rec-stats">
|
||
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
|
||
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
|
||
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
|
||
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
|
||
</div>
|
||
<div class="rec-bottom">
|
||
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
|
||
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
|
||
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
setTimeout(() => {
|
||
const mapEl = document.querySelector("#rec-map");
|
||
if (!mapEl) return;
|
||
|
||
L.Icon.Default.imagePath = "lib/";
|
||
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
|
||
mapEl.addEventListener("wheel", (e) => {
|
||
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
|
||
}, { passive: false });
|
||
addZoomDisplay(map);
|
||
const CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
|
||
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
|
||
let posMarker = null;
|
||
|
||
// State changes
|
||
GPS.recording.onStateChange = (state, p) => {
|
||
if (state === "ready") {
|
||
statusEl.textContent = "GPS OK. Начните движение...";
|
||
statusEl.style.color = "#03dac6";
|
||
map.setView([p.lat, p.lon], 16);
|
||
} else if (state === "recording") {
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
} else if (state === "resumed") {
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
}
|
||
};
|
||
|
||
// Live point updates
|
||
const distEl = document.querySelector("#rec-dist");
|
||
const spdEl = document.querySelector("#rec-speed");
|
||
const timeEl = document.querySelector("#rec-time");
|
||
const elevEl = document.querySelector("#rec-elev");
|
||
const statusEl = document.querySelector("#rec-status");
|
||
|
||
// Speed buffer for median
|
||
const speedBuf = [];
|
||
|
||
GPS.recording.onPoint = (p) => {
|
||
const ll = [p.lat, p.lon];
|
||
borderLine.addLatLng(ll);
|
||
trailLine.addLatLng(ll);
|
||
map.panTo(ll);
|
||
|
||
if (posMarker) map.removeLayer(posMarker);
|
||
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
|
||
|
||
// Stats
|
||
const pts = GPS.recording.points;
|
||
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||
|
||
// Show current state
|
||
const recState = GPS.recording.state;
|
||
if (recState === "waiting_gps") {
|
||
statusEl.textContent = "Поиск GPS...";
|
||
statusEl.style.color = "#ffb74d";
|
||
map.setView([p.lat, p.lon], 15);
|
||
return;
|
||
}
|
||
if (recState === "ready") {
|
||
// Position marker only
|
||
if (posMarker) map.removeLayer(posMarker);
|
||
posMarker = L.circleMarker([p.lat, p.lon], { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
|
||
return;
|
||
}
|
||
|
||
// Speed
|
||
const curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
|
||
speedBuf.push(curSpeed);
|
||
if (speedBuf.length > 5) speedBuf.shift();
|
||
const sorted = speedBuf.slice().sort((a, b) => a - b);
|
||
const median = sorted[Math.floor(sorted.length / 2)] || 0;
|
||
if (spdEl) spdEl.textContent = median.toFixed(1);
|
||
|
||
// Elevation
|
||
const alt = p.altitude;
|
||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
|
||
|
||
// Pause indicator
|
||
if (GPS.recording.pauseStart) {
|
||
statusEl.textContent = "Пауза";
|
||
statusEl.style.color = "#ffb74d";
|
||
} else {
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
}
|
||
};
|
||
|
||
// Timer — excludes pauses, starts only when recording
|
||
const timer = setInterval(() => {
|
||
if (!GPS.recording.active) { clearInterval(timer); return; }
|
||
if (!GPS.recording.startTime) return; // not started yet
|
||
const totalMs = Date.now() - GPS.recording.startTime.getTime();
|
||
const activeMs = totalMs - GPS.recording.totalPauseMs -
|
||
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
|
||
const sec = Math.max(0, Math.round(activeMs / 1000));
|
||
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
|
||
}, 1000);
|
||
|
||
// Pause button
|
||
container.querySelector("#rec-pause").onclick = () => {
|
||
if (GPS.recording.manualPaused) {
|
||
GPS.resumeRecording();
|
||
container.querySelector("#rec-pause").textContent = "Пауза";
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
} else {
|
||
GPS.pauseRecording();
|
||
container.querySelector("#rec-pause").textContent = "Продолжить";
|
||
statusEl.textContent = "Пауза (ручная)";
|
||
statusEl.style.color = "#ffb74d";
|
||
}
|
||
};
|
||
|
||
// Stop button
|
||
container.querySelector("#rec-stop").onclick = async () => {
|
||
await GPS.stopRecording();
|
||
clearInterval(timer);
|
||
renderScreen("gps");
|
||
};
|
||
|
||
// Restore trail if already recording — sort by seq/ts to avoid jumps
|
||
if (GPS.recording.points.length > 0) {
|
||
const sorted = GPS.recording.points.slice().sort((a, b) => {
|
||
return new Date(a.ts) - new Date(b.ts);
|
||
});
|
||
const coords = sorted.map(p => [p.lat, p.lon]);
|
||
borderLine.setLatLngs(coords);
|
||
trailLine.setLatLngs(coords);
|
||
map.fitBounds(trailLine.getBounds().pad(0.1));
|
||
}
|
||
|
||
// Batch update callback — called after poll reads multiple points at once.
|
||
// Rebuilds polyline from all sorted points instead of adding one-by-one.
|
||
GPS.recording.onBatchUpdate = () => {
|
||
const sorted = GPS.recording.points.slice().sort((a, b) => {
|
||
return new Date(a.ts) - new Date(b.ts);
|
||
});
|
||
const coords = sorted.map(p => [p.lat, p.lon]);
|
||
borderLine.setLatLngs(coords);
|
||
trailLine.setLatLngs(coords);
|
||
if (coords.length) {
|
||
const last = coords[coords.length - 1];
|
||
map.panTo(last);
|
||
if (posMarker) map.removeLayer(posMarker);
|
||
posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
|
||
}
|
||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||
};
|
||
}, 200);
|
||
}
|
||
|
||
function setupLiveUpdates(container) {
|
||
const distEl = container.querySelector("#gs-dist");
|
||
const spdEl = container.querySelector("#gs-speed");
|
||
const timeEl = container.querySelector("#gs-time");
|
||
let timer = null;
|
||
|
||
GPS.recording.onPoint = (p) => {
|
||
if (!gpsTrailLine || !gpsMap) return;
|
||
gpsTrailLine.addLatLng([p.lat, p.lon]);
|
||
gpsMap.panTo([p.lat, p.lon]);
|
||
|
||
const pts = GPS.recording.points;
|
||
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||
if (spdEl) {
|
||
const curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
|
||
spdEl.textContent = curSpd + " км/ч";
|
||
}
|
||
};
|
||
|
||
GPS.recording.onBatchUpdate = () => {
|
||
if (!gpsTrailLine || !gpsMap) return;
|
||
const sorted = GPS.recording.points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||
const coords = sorted.map(p => [p.lat, p.lon]);
|
||
gpsTrailLine.setLatLngs(coords);
|
||
if (coords.length) gpsMap.panTo(coords[coords.length - 1]);
|
||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
|
||
};
|
||
|
||
// Timer for duration display
|
||
timer = setInterval(() => {
|
||
if (!GPS.recording.active) { clearInterval(timer); return; }
|
||
const sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
|
||
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
|
||
}, 1000);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Map download modal — pick region + zoom range
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MAP_REGIONS = [
|
||
{ name: "Текущий вид карты", bounds: null },
|
||
{ name: "--- Города ---", bounds: null, disabled: true },
|
||
{ name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] },
|
||
{ name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] },
|
||
{ name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] },
|
||
{ name: "город + окрестности", bounds: [[47.35, 39.95], [47.52, 40.22]] },
|
||
{ name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] },
|
||
{ name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] },
|
||
{ name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] },
|
||
{ name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] },
|
||
{ name: "--- Области ---", bounds: null, disabled: true },
|
||
{ name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] },
|
||
{ name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] },
|
||
{ name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] },
|
||
{ name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] },
|
||
];
|
||
|
||
function showMapDownloadModal() {
|
||
const regionOpts = MAP_REGIONS.map((r, i) =>
|
||
r.disabled
|
||
? `<div class="supp-pick-divider">${r.name.replace(/---? ?/g, "")}</div>`
|
||
: `<div class="supp-pick-item" data-idx="${i}">${r.name}</div>`
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>Скачать карту</h3>
|
||
<div class="form-group">
|
||
<label>Регион</label>
|
||
<div id="m-region-list" class="supp-pick-list">${regionOpts}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Мин. зум</label>
|
||
<input id="m-zmin" type="number" value="12" min="1" max="18" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Макс. зум</label>
|
||
<input id="m-zmax" type="number" value="15" min="1" max="18" />
|
||
</div>
|
||
<div id="m-tile-count" style="color:var(--muted);font-size:12px;margin-top:4px"></div>`,
|
||
async overlay => {
|
||
const sel = overlay.querySelector(".supp-pick-item.selected");
|
||
if (!sel) return false;
|
||
const idx = parseInt(sel.dataset.idx);
|
||
const region = MAP_REGIONS[idx];
|
||
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
|
||
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
|
||
let bounds;
|
||
if (region.bounds) {
|
||
bounds = L.latLngBounds(region.bounds);
|
||
} else if (gpsMap) {
|
||
bounds = gpsMap.getBounds();
|
||
} else return false;
|
||
|
||
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
|
||
const okBtn = overlay.querySelector("#modal-ok");
|
||
okBtn.textContent = `0 / ${total}`;
|
||
okBtn.disabled = true;
|
||
await TileCache.downloadArea(bounds, zMin, zMax, (done, tot) => {
|
||
okBtn.textContent = `${done} / ${tot}`;
|
||
});
|
||
okBtn.textContent = "Готово";
|
||
okBtn.disabled = false;
|
||
});
|
||
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
const countEl = overlay.querySelector("#m-tile-count");
|
||
|
||
// Selection
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
|
||
item.onclick = () => {
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
|
||
item.classList.add("selected");
|
||
updateTileCount();
|
||
};
|
||
});
|
||
|
||
function updateTileCount() {
|
||
const sel = overlay.querySelector(".supp-pick-item.selected");
|
||
if (!sel) return;
|
||
const idx = parseInt(sel.dataset.idx);
|
||
const region = MAP_REGIONS[idx];
|
||
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
|
||
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
|
||
let bounds;
|
||
if (region.bounds) bounds = L.latLngBounds(region.bounds);
|
||
else if (gpsMap) bounds = gpsMap.getBounds();
|
||
else return;
|
||
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
|
||
countEl.textContent = `~${total} тайлов`;
|
||
}
|
||
|
||
overlay.querySelector("#m-zmin").oninput = updateTileCount;
|
||
overlay.querySelector("#m-zmax").oninput = updateTileCount;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Session detail view
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showSessionDetail(sessionId) {
|
||
try {
|
||
console.log("[SD] loading session", sessionId);
|
||
const session = await DB.getGpsSession(sessionId);
|
||
if (!session) { console.error("[SD] session not found"); return; }
|
||
console.log("[SD] loading points");
|
||
const rawPoints = await DB.getGpsPoints(sessionId);
|
||
console.log("[SD] got", rawPoints.length, "raw points");
|
||
const points = GPS.filterNoise(rawPoints, session.activity_type);
|
||
console.log("[SD] filtered to", points.length, "points");
|
||
// Use stored session stats if available (computed correctly at import time),
|
||
// fallback to recomputing from points
|
||
const storedStats = session.total_distance_m != null;
|
||
const stats = storedStats ? {
|
||
distance: Math.round(session.total_distance_m || 0),
|
||
avgSpeed: session.avg_speed_kmh || 0,
|
||
maxSpeed: session.max_speed_kmh || 0,
|
||
kcal: Math.round(session.total_kcal || 0),
|
||
elevGain: Math.round(session.elevation_gain_m || 0),
|
||
elevLoss: Math.round(session.elevation_loss_m || 0),
|
||
duration: 0,
|
||
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||
// Compute duration from points regardless
|
||
if (points.length >= 2) {
|
||
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
|
||
}
|
||
// Always compute segments from points (needed for the Segments section)
|
||
if (!stats.segments && points.length >= 3) {
|
||
const fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||
stats.segments = fullStats.segments;
|
||
}
|
||
|
||
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
|
||
const rawSpeeds = [];
|
||
for (let i = 1; i < points.length; i++) {
|
||
const devSpd = points[i].speed_ms;
|
||
if (devSpd != null && devSpd >= 0) {
|
||
rawSpeeds.push(devSpd * 3.6);
|
||
} else {
|
||
const d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
|
||
const dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
|
||
}
|
||
}
|
||
// 31-point moving average — kills GPS jitter from phone-in-pocket
|
||
const HALF_WIN = 15;
|
||
const smoothed = rawSpeeds.map((_, i) => {
|
||
let sum = 0, count = 0;
|
||
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
|
||
sum += rawSpeeds[j]; count++;
|
||
}
|
||
return Math.round(sum / count * 10) / 10;
|
||
});
|
||
|
||
// maxSpeed from smoothed graph (not raw)
|
||
const graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
|
||
|
||
// Elevation data
|
||
const elevations = points.filter(p => p.altitude_gps_m != null).map(p => p.altitude_gps_m);
|
||
const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
|
||
const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
|
||
|
||
// Time labels (minutes from start)
|
||
const t0 = points.length ? new Date(points[0].ts).getTime() : 0;
|
||
const timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
|
||
|
||
const actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||
const actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
|
||
const duration = session.end_time && session.start_time
|
||
? GPS.formatDuration(stats.duration)
|
||
: "—";
|
||
const tempo = stats.duration > 0 && stats.distance > 0
|
||
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
|
||
: "—";
|
||
const kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
|
||
|
||
showModal(`
|
||
<h3>${actName}</h3>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Время</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.date}</span><span class="sd-lbl">Дата</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${duration}</span><span class="sd-lbl">Длительность</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.start_time ?? "—"}</span><span class="sd-lbl">Начало</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${session.end_time ?? "—"}</span><span class="sd-lbl">Конец</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(stats.distance)}</span><span class="sd-lbl">Расстояние</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${tempo}</span><span class="sd-lbl">Темп</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Маршрут</div>
|
||
<div id="sd-map" class="sd-map"></div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Скорость</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
|
||
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
|
||
</div>
|
||
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Калории</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.total_kcal ?? stats.kcal} ккал</span><span class="sd-lbl">Всего</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${kcalMin} ккал/мин</span><span class="sd-lbl">Расход</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
${elevations.length > 0 ? `
|
||
<div class="sd-section">
|
||
<div class="sd-title">Высота</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${minElev} м</span><span class="sd-lbl">Мин.</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${maxElev} м</span><span class="sd-lbl">Макс.</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${stats.elevGain} м</span><span class="sd-lbl">Подъём</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${stats.elevLoss} м</span><span class="sd-lbl">Спуск</span></div>
|
||
</div>
|
||
<div class="chart-wrap" id="sd-elev-wrap"><canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||
</div>` : ""}
|
||
|
||
${(stats.segments && stats.segments.length > 1) ? `
|
||
<div class="sd-section">
|
||
<div class="sd-title">Сегменты</div>
|
||
<div class="climb-segments">
|
||
${stats.segments.map(seg => {
|
||
const icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-";
|
||
const distStr = seg.distance >= 1000 ? (seg.distance / 1000).toFixed(1) + " км" : seg.distance + " м";
|
||
const elevStr = seg.type === "climb" ? `+${seg.elevGain} м, ${seg.avgGradient}%`
|
||
: seg.type === "descent" ? `-${seg.elevLoss} м, ${seg.avgGradient}%`
|
||
: `+/-${Math.max(seg.elevGain, seg.elevLoss)} м`;
|
||
const durStr = GPS.formatDuration(seg.duration);
|
||
return `<div class="segment-item ${seg.type}">
|
||
<span class="seg-icon">${icon}</span>
|
||
<span class="seg-info">${distStr}, ${elevStr}</span>
|
||
<span class="seg-stats">${durStr} | ${seg.avgSpeed} км/ч</span>
|
||
</div>`;
|
||
}).join("")}
|
||
</div>
|
||
</div>` : ""}
|
||
|
||
${session.bike_id ? `<div class="sd-section"><div class="sd-title">Велосипед</div><div class="sd-row"><div class="sd-stat"><span class="sd-val" id="sd-bike-name">...</span><span class="sd-lbl">Название</span></div></div></div>` : ''}
|
||
${session.activity_type === 'bike' ? `<div class="sd-section"><div class="sd-row"><div class="form-group" style="width:100%;margin:0"><label>Велосипед</label><select id="sd-bike-select" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)"><option value="">-- Нет --</option></select></div></div></div>` : ''}
|
||
<button id="sd-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
|
||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
|
||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
|
||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||
`, () => {});
|
||
|
||
// Render map
|
||
setTimeout(() => {
|
||
const mapEl = document.querySelector("#sd-map");
|
||
if (!mapEl || !points.length) return;
|
||
|
||
L.Icon.Default.imagePath = "lib/";
|
||
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
|
||
mapEl._leafletMap = map; // save for popHandler map collapse
|
||
// Ctrl+wheel zoom on desktop
|
||
mapEl.addEventListener("wheel", (e) => {
|
||
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
|
||
}, { passive: false });
|
||
addZoomDisplay(map);
|
||
const CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
// Sort by timestamp, build coords and draw in one pass
|
||
// Keep original order from DB (seq from FTB import = XML order = clean track)
|
||
const t0 = new Date(points[0].ts).getTime();
|
||
const tN = new Date(points[points.length - 1].ts).getTime();
|
||
const tRange = tN - t0 || 1;
|
||
|
||
// Split into segments by GPS gap threshold
|
||
const gapSec = GPS.config.gapThresholdSec ?? 30;
|
||
const segments = []; // array of arrays of [lat, lon]
|
||
let curSeg = [[points[0].lat, points[0].lon]];
|
||
for (let i = 1; i < points.length; i++) {
|
||
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||
if (gap > gapSec) {
|
||
segments.push(curSeg);
|
||
curSeg = [];
|
||
}
|
||
curSeg.push([points[i].lat, points[i].lon]);
|
||
}
|
||
segments.push(curSeg);
|
||
|
||
// Draw each segment as separate polyline with start/end markers
|
||
const allCoords = [];
|
||
segments.forEach(seg => {
|
||
if (seg.length < 2) { allCoords.push(...seg); return; }
|
||
L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
|
||
L.polyline(seg, { color: "#bb86fc", weight: 5 }).addTo(map);
|
||
L.circleMarker(seg[0], { radius: 5, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map);
|
||
L.circleMarker(seg[seg.length - 1], { radius: 5, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map);
|
||
allCoords.push(...seg);
|
||
});
|
||
|
||
// Invisible but tappable hit-targets on top of track
|
||
for (let i = 0; i < points.length; i++) {
|
||
const p = points[i];
|
||
const alt = p.altitude_gps_m ?? p.altitude;
|
||
const spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : "";
|
||
const time = p.ts.split("T")[1].slice(0, 8);
|
||
const info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}`;
|
||
L.circleMarker([p.lat, p.lon], {
|
||
radius: 8, weight: 0, fillOpacity: 0, interactive: true,
|
||
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
|
||
}
|
||
|
||
// Fit map to all points
|
||
if (allCoords.length > 1) {
|
||
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
|
||
}
|
||
mapEl._bounds = allCoords.length > 1 ? L.latLngBounds(allCoords).pad(0.1) : null;
|
||
|
||
// Fullscreen toggle button on map
|
||
// Fullscreen map toggle
|
||
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
|
||
// Chart fullscreen in modal — NO pushState. Modal popHandler handles back.
|
||
document.querySelectorAll(".chart-wrap").forEach(wrap => {
|
||
wrap.onclick = () => {
|
||
if (wrap.classList.contains("chart-full")) return;
|
||
wrap.classList.add("chart-full");
|
||
const closeBtn = document.createElement("div");
|
||
closeBtn.className = "chart-close-btn";
|
||
closeBtn.innerHTML = "✕";
|
||
closeBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
wrap.classList.remove("chart-full");
|
||
closeBtn.remove();
|
||
};
|
||
wrap.appendChild(closeBtn);
|
||
};
|
||
});
|
||
|
||
// Bike name display
|
||
if (session.bike_id) {
|
||
DB.getBike(session.bike_id).then(b => {
|
||
const el = document.querySelector("#sd-bike-name");
|
||
if (el && b) el.textContent = b.name;
|
||
else if (el) el.textContent = "—";
|
||
});
|
||
}
|
||
|
||
// Bike selector (for bike sessions)
|
||
const bikeSelect = document.querySelector("#sd-bike-select");
|
||
if (bikeSelect) {
|
||
DB.getBikes().then(bikes => {
|
||
for (const b of bikes) {
|
||
const opt = document.createElement("option");
|
||
opt.value = b.id;
|
||
opt.textContent = b.name + (b.is_active ? " [активный]" : "");
|
||
if (session.bike_id && session.bike_id === b.id) opt.selected = true;
|
||
bikeSelect.appendChild(opt);
|
||
}
|
||
});
|
||
bikeSelect.onchange = async () => {
|
||
const val = bikeSelect.value ? parseInt(bikeSelect.value) : null;
|
||
await DB.updateSessionBike(sessionId, val);
|
||
session.bike_id = val;
|
||
};
|
||
}
|
||
|
||
// Compare button
|
||
const compareBtn = document.querySelector("#sd-compare");
|
||
if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points);
|
||
|
||
// Recalculate button
|
||
const recalcBtn = document.querySelector("#sd-recalc");
|
||
if (recalcBtn) recalcBtn.onclick = async () => {
|
||
recalcBtn.textContent = "Пересчёт...";
|
||
recalcBtn.disabled = true;
|
||
const newStats = await GPS.recalculateSession(sessionId);
|
||
recalcBtn.textContent = "Пересчитать";
|
||
recalcBtn.disabled = false;
|
||
if (newStats) {
|
||
// Close modal and reopen with fresh data
|
||
document.querySelector(".modal-overlay")?.remove();
|
||
history.back();
|
||
showSessionDetail(sessionId);
|
||
}
|
||
};
|
||
|
||
const del = document.querySelector("#sd-delete");
|
||
if (del) del.onclick = async () => {
|
||
await DB.deleteGpsSession(sessionId);
|
||
document.querySelector(".modal-overlay")?.remove();
|
||
// Remove modal popstate handler, go to gps screen
|
||
history.replaceState({ screen: "gps" }, "");
|
||
renderScreen("gps", true);
|
||
};
|
||
}, 100);
|
||
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Lap comparison — overlay two tracks on the same map
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showCompareModal(sessionId, currentSession, currentPoints) {
|
||
const dist = currentSession.total_distance_m || 0;
|
||
const sessions = await DB.getGpsSessions(100);
|
||
// Filter: same activity type, similar distance (+-30%), not the same session
|
||
const similar = sessions.filter(s =>
|
||
s.id !== sessionId &&
|
||
s.activity_type === currentSession.activity_type &&
|
||
s.total_distance_m != null &&
|
||
s.total_distance_m > dist * 0.7 && s.total_distance_m < dist * 1.3
|
||
);
|
||
|
||
if (similar.length === 0) {
|
||
showModal(`<h3>Сравнение</h3><p style="color:var(--muted)">Нет похожих тренировок (та же активность, расстояние +-30%).</p>`, () => {});
|
||
return;
|
||
}
|
||
|
||
const actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||
showModal(`
|
||
<h3>Сравнить с...</h3>
|
||
<div class="compare-list">
|
||
${similar.map(s => {
|
||
const d = s.total_distance_m != null ? GPS.formatDistance(Math.round(s.total_distance_m)) : "?";
|
||
const spd = s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : "";
|
||
return `<div class="compare-item" data-sid="${s.id}">
|
||
<div style="font-weight:600">${s.date} ${s.start_time ?? ""}</div>
|
||
<div style="font-size:12px;color:var(--muted)">${d} | ${spd}</div>
|
||
</div>`;
|
||
}).join("")}
|
||
</div>`, () => {});
|
||
|
||
setTimeout(() => {
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
if (!overlay) return;
|
||
// Hide save/cancel buttons — this is a selection modal
|
||
const btns = overlay.querySelector(".modal-btns");
|
||
if (btns) btns.style.display = "none";
|
||
|
||
overlay.querySelectorAll(".compare-item").forEach(item => {
|
||
item.onclick = async () => {
|
||
const compId = parseInt(item.dataset.sid);
|
||
// Close this modal, open comparison view
|
||
overlay.remove();
|
||
history.back();
|
||
await showComparisonView(sessionId, compId, currentSession, currentPoints);
|
||
};
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
async function showComparisonView(currentId, compareId, currentSession, currentPoints) {
|
||
const compSession = await DB.getGpsSession(compareId);
|
||
if (!compSession) return;
|
||
const compPoints = GPS.filterNoise(await DB.getGpsPoints(compareId), compSession.activity_type);
|
||
if (compPoints.length < 2 || currentPoints.length < 2) return;
|
||
|
||
// Compute duration diff
|
||
const curDur = currentPoints.length >= 2
|
||
? (new Date(currentPoints[currentPoints.length-1].ts) - new Date(currentPoints[0].ts)) / 1000 : 0;
|
||
const compDur = compPoints.length >= 2
|
||
? (new Date(compPoints[compPoints.length-1].ts) - new Date(compPoints[0].ts)) / 1000 : 0;
|
||
const durDiff = curDur - compDur;
|
||
const durDiffStr = (durDiff <= 0 ? "" : "+") + GPS.formatDuration(Math.abs(Math.round(durDiff)));
|
||
const durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково";
|
||
|
||
const curAvg = currentSession.avg_speed_kmh ?? 0;
|
||
const compAvg = compSession.avg_speed_kmh ?? 0;
|
||
const spdDiff = Math.round((curAvg - compAvg) * 10) / 10;
|
||
|
||
showModal(`
|
||
<h3>Сравнение треков</h3>
|
||
<div class="sd-section">
|
||
<div class="sd-title">Результат</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val" style="color:${durDiff <= 0 ? "#4caf50" : "#f44336"}">${durLabel}</span><span class="sd-lbl">Время</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${curAvg} км/ч</span><span class="sd-lbl">Текущая средняя</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${compAvg} км/ч</span><span class="sd-lbl">Сравнение</span></div>
|
||
<div class="sd-stat"><span class="sd-val" style="color:${spdDiff >= 0 ? "#4caf50" : "#f44336"}">${spdDiff > 0 ? "+" : ""}${spdDiff}</span><span class="sd-lbl">Разница</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="sd-section">
|
||
<div class="sd-title">Карта</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Фиолетовый — текущий, голубой — сравнение. Зелёные участки — быстрее, красные — медленнее.</div>
|
||
<div id="compare-map" class="sd-map" style="height:300px"></div>
|
||
</div>
|
||
`, () => {});
|
||
|
||
setTimeout(() => {
|
||
const mapEl = document.querySelector("#compare-map");
|
||
if (!mapEl) return;
|
||
|
||
// Hide save/cancel
|
||
const btns = mapEl.closest(".modal-overlay")?.querySelector(".modal-btns");
|
||
if (btns) btns.style.display = "none";
|
||
|
||
L.Icon.Default.imagePath = "lib/";
|
||
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, touchZoom: true, dragging: true });
|
||
mapEl._leafletMap = map;
|
||
addZoomDisplay(map);
|
||
const CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
// Draw comparison track (thin dashed cyan)
|
||
const compCoords = compPoints.map(p => [p.lat, p.lon]);
|
||
L.polyline(compCoords, { color: "#4dd0e1", weight: 3, dashArray: "8,6", opacity: 0.7 }).addTo(map);
|
||
|
||
// Build speed comparison: for each point on current track, find nearest on comparison track
|
||
// Pre-compute comparison speeds for quick nearest-neighbor lookup
|
||
const compSpeeds = [];
|
||
for (let i = 0; i < compPoints.length; i++) {
|
||
const spd = compPoints[i].speed_ms != null ? compPoints[i].speed_ms * 3.6 : 0;
|
||
compSpeeds.push(spd);
|
||
}
|
||
|
||
// For each current segment, find nearest comparison point and compare speeds
|
||
// Draw colored polyline segments
|
||
for (let i = 1; i < currentPoints.length; i++) {
|
||
const cp = currentPoints[i];
|
||
const cpPrev = currentPoints[i - 1];
|
||
const curSpd = cp.speed_ms != null ? cp.speed_ms * 3.6 : 0;
|
||
|
||
// Find nearest comparison point (simple brute force — OK for <2000 points)
|
||
let nearestIdx = 0, nearestDist = Infinity;
|
||
for (let j = 0; j < compPoints.length; j++) {
|
||
const d = GPS.distance(cp.lat, cp.lon, compPoints[j].lat, compPoints[j].lon);
|
||
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
|
||
}
|
||
const compSpd = compSpeeds[nearestIdx];
|
||
|
||
// Color: green if faster, red if slower, white if similar (within 1 km/h)
|
||
let segColor;
|
||
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare — use default
|
||
else if (curSpd > compSpd + 1) segColor = "#4caf50"; // faster
|
||
else if (curSpd < compSpd - 1) segColor = "#f44336"; // slower
|
||
else segColor = "#bb86fc"; // similar
|
||
|
||
L.polyline(
|
||
[[cpPrev.lat, cpPrev.lon], [cp.lat, cp.lon]],
|
||
{ color: segColor, weight: 5, opacity: 0.9 }
|
||
).addTo(map);
|
||
}
|
||
|
||
// Fit to both tracks
|
||
const allCoords = currentPoints.map(p => [p.lat, p.lon]).concat(compCoords);
|
||
if (allCoords.length > 1) {
|
||
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
|
||
}
|
||
|
||
// Fullscreen toggle
|
||
let fsState = false;
|
||
const fsBtn = L.control({ position: "topright" });
|
||
let fsDiv;
|
||
fsBtn.onAdd = () => {
|
||
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
|
||
fsDiv.innerHTML = "⛶";
|
||
fsDiv.onclick = (e) => {
|
||
e.stopPropagation();
|
||
if (fsState) {
|
||
fsState = false;
|
||
mapEl.classList.remove("sd-map-full");
|
||
fsDiv.innerHTML = "⛶";
|
||
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
|
||
} else {
|
||
fsState = true;
|
||
mapEl.classList.add("sd-map-full");
|
||
fsDiv.innerHTML = "✕";
|
||
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
|
||
}
|
||
};
|
||
return fsDiv;
|
||
};
|
||
fsBtn.addTo(map);
|
||
}, 300);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Log supplement modal (from Today screen)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showLogSupplementModal() {
|
||
const supps = await DB.getSupplements();
|
||
if (supps.length === 0) {
|
||
showModal(`
|
||
<h3>Добавки</h3>
|
||
<p style="color:var(--muted)">Нет добавок. Создайте на вкладке "Добавки".</p>`,
|
||
() => {});
|
||
return;
|
||
}
|
||
|
||
showModal(`
|
||
<h3>Принял добавку</h3>
|
||
<div class="form-group">
|
||
<label>Добавка</label>
|
||
<div id="m-supp-list" class="supp-pick-list">
|
||
${supps.map(s => `<div class="supp-pick-item" data-sid="${s.id}">${esc(s.name)}${s.daily_dose_g ? ` <small>(${s.daily_dose_g}г/день)</small>` : ""}</div>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Количество (шт)</label>
|
||
<input id="m-qty" type="number" inputmode="decimal" value="1" step="0.5" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const selected = overlay.querySelector(".supp-pick-item.selected");
|
||
if (!selected) return false;
|
||
const sid = parseInt(selected.dataset.sid);
|
||
const qty = parseFloat(overlay.querySelector("#m-qty").value) || 1;
|
||
const time = overlay.querySelector("#m-time").value;
|
||
await DB.logSupplement(state.date, time, sid, qty);
|
||
});
|
||
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
|
||
item.onclick = () => {
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
|
||
item.classList.add("selected");
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SUPPLEMENTS screen — manage supplements catalog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MICRO_FIELDS = [
|
||
{ key: "vit_a_mcg", label: "Вит A (мкг)" },
|
||
{ key: "vit_c_mg", label: "Вит C (мг)" },
|
||
{ key: "vit_d_mcg", label: "Вит D (мкг)" },
|
||
{ key: "vit_e_mg", label: "Вит E (мг)" },
|
||
{ key: "vit_k_mcg", label: "Вит K (мкг)" },
|
||
{ key: "vit_b1_mg", label: "B1 (мг)" },
|
||
{ key: "vit_b2_mg", label: "B2 (мг)" },
|
||
{ key: "vit_b3_mg", label: "B3 (мг)" },
|
||
{ key: "vit_b6_mg", label: "B6 (мг)" },
|
||
{ key: "vit_b12_mcg", label: "B12 (мкг)" },
|
||
{ key: "folate_mcg", label: "Фолат (мкг)" },
|
||
{ key: "calcium_mg", label: "Ca (мг)" },
|
||
{ key: "iron_mg", label: "Fe (мг)" },
|
||
{ key: "magnesium_mg", label: "Mg (мг)" },
|
||
{ key: "zinc_mg", label: "Zn (мг)" },
|
||
{ key: "copper_mg", label: "Cu (мг)" },
|
||
{ key: "selenium_mcg", label: "Se (мкг)" },
|
||
{ key: "potassium_mg", label: "K (мг)" },
|
||
{ key: "sodium_mg", label: "Na (мг)" },
|
||
];
|
||
|
||
async function renderSupplements(container) {
|
||
const supps = await DB.getSupplements();
|
||
|
||
function suppSummary(s) {
|
||
const parts = [];
|
||
for (const f of MICRO_FIELDS) {
|
||
if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`);
|
||
}
|
||
return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : "");
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="products-screen">
|
||
<div class="section-header">
|
||
<h2>Добавки</h2>
|
||
<button class="add-btn" id="add-new-supp">+</button>
|
||
</div>
|
||
<div id="supp-list" class="prod-list">
|
||
${supps.length ? supps.map(s => `
|
||
<div class="prod-card" data-sid="${s.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">${esc(s.name)}
|
||
${s.daily_dose_g ? `<span class="prod-weight">${s.daily_dose_g} шт/день</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="supp-summary">${suppSummary(s)}</div>
|
||
</div>`).join("")
|
||
: `<div class="prod-hint">Нет добавок. Нажмите + чтобы добавить.</div>`}
|
||
</div>
|
||
</div>`;
|
||
|
||
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
|
||
|
||
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
|
||
card.querySelector(".prod-card-header").onclick = async () => {
|
||
const sid = parseInt(card.dataset.sid);
|
||
const s = await DB.getSupplement(sid);
|
||
if (s) showEditSupplementModal(s);
|
||
};
|
||
});
|
||
}
|
||
|
||
function showEditSupplementModal(existing) {
|
||
const isNew = !existing;
|
||
const s = existing || {};
|
||
|
||
const microHtml = MICRO_FIELDS.map(f =>
|
||
`<div class="form-group micro-field">
|
||
<label>${f.label}</label>
|
||
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${s[f.key] ?? ""}" placeholder="—" />
|
||
</div>`
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>${isNew ? "Новая добавка" : "Редактирование"}</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="m-name" type="text" value="${esc(s.name ?? "")}" placeholder="Витамин D3" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Таблеток в день</label>
|
||
<input id="m-dose" type="number" inputmode="decimal" step="0.5" value="${s.daily_dose_g ?? ""}" placeholder="1" />
|
||
</div>
|
||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Содержание в 1 таблетке:</p>
|
||
<div class="micro-grid">${microHtml}</div>
|
||
${!isNew ? `<button id="m-del-supp" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить добавку</button>` : ""}`,
|
||
async overlay => {
|
||
const name = overlay.querySelector("#m-name").value.trim();
|
||
if (!name) return false;
|
||
const data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
|
||
for (const f of MICRO_FIELDS) {
|
||
data[f.key] = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
|
||
}
|
||
if (isNew) {
|
||
await DB.addSupplement(data);
|
||
} else {
|
||
// Update: delete + re-add (simple, keeps log intact via supplement_id)
|
||
// Actually need an update function. For now, delete logs referencing old id is bad.
|
||
// Just delete and re-add — log entries will be orphaned. Better to add updateSupplement.
|
||
// Quick approach: raw execute
|
||
const sets = ["name=?1", "daily_dose_g=?2"];
|
||
const vals = [name, data.daily_dose_g];
|
||
let idx = 3;
|
||
for (const f of MICRO_FIELDS) {
|
||
sets.push(`${f.key}=?${idx}`);
|
||
vals.push(data[f.key]);
|
||
idx++;
|
||
}
|
||
vals.push(existing.id);
|
||
await window.__TAURI__.core.invoke("plugin:sql|execute", {
|
||
db: "sqlite:baldav.db",
|
||
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
|
||
values: vals,
|
||
});
|
||
}
|
||
});
|
||
|
||
if (!isNew) {
|
||
setTimeout(() => {
|
||
const delBtn = document.querySelector("#m-del-supp");
|
||
if (delBtn) {
|
||
delBtn.onclick = async (e) => {
|
||
e.stopPropagation();
|
||
await DB.deleteSupplement(existing.id);
|
||
document.querySelector(".modal-overlay")?.remove();
|
||
history.back();
|
||
renderScreen("supplements");
|
||
};
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SETTINGS screen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function renderSettings(container) {
|
||
const p = state.profile;
|
||
const profileSummary = [
|
||
p.height_cm ? `${p.height_cm} см` : null,
|
||
p.birth_year ? `${p.birth_year} г.р.` : null,
|
||
p.sex === "f" ? "Ж" : "М",
|
||
].filter(Boolean).join(", ");
|
||
|
||
container.innerHTML = `
|
||
<div class="settings-screen">
|
||
<h2>Настройки</h2>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-item" id="s-open-profile">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Профиль</div>
|
||
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
|
||
</div>
|
||
<span class="settings-arrow">›</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Карты</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Оффлайн режим</div>
|
||
<div class="settings-item-desc">Тайлы только из кэша</div>
|
||
</div>
|
||
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">GPS (запись треков)</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Ниже = пауза</div>
|
||
</div>
|
||
<input id="s-min-speed" type="number" step="0.1" value="${(GPS.config.minSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. точность (м)</div>
|
||
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
|
||
</div>
|
||
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. расстояние (м)</div>
|
||
<div class="settings-item-desc">Точки ближе = пропуск</div>
|
||
</div>
|
||
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Выше = глитч GPS, игнор</div>
|
||
</div>
|
||
<input id="s-max-speed" type="number" step="1" value="${GPS.config.maxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. пауза GPS (сек)</div>
|
||
<div class="settings-item-desc">Пробел длиннее = новый сегмент</div>
|
||
</div>
|
||
<input id="s-gap-threshold" type="number" step="5" value="${GPS.config.gapThresholdSec}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Аэросопротивление</div>
|
||
<div class="settings-item-desc">Город 0.6, спорт 0.4</div>
|
||
</div>
|
||
<input id="s-air-drag" type="number" step="0.05" value="${GPS.config.airDrag}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Плотность воздуха (кг/м3)</div>
|
||
<div class="settings-item-desc">Море 1.225, горы 1.0</div>
|
||
</div>
|
||
<input id="s-air-density" type="number" step="0.01" value="${GPS.config.airDensity}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">КПД педалирования (%)</div>
|
||
<div class="settings-item-desc">Человек ~25%</div>
|
||
</div>
|
||
<input id="s-pedal-eff" type="number" step="1" value="${GPS.config.pedalEfficiency}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Велосипеды</div>
|
||
<div id="s-bikes-list"></div>
|
||
<div class="settings-item" id="s-add-bike" style="cursor:pointer">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title" style="color:var(--primary)">+ Добавить велосипед</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Разрешения</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Геолокация (GPS)</div>
|
||
<div class="settings-item-desc" id="s-gps-status">Проверка...</div>
|
||
</div>
|
||
<button class="settings-toggle" id="s-gps-toggle"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">О приложении</div>
|
||
<div class="settings-item" id="s-about">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">BalDav</div>
|
||
<div class="settings-item-desc" id="s-version">...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Profile button → open profile modal
|
||
container.querySelector("#s-open-profile").onclick = () => showProfileModal();
|
||
|
||
// Offline toggle
|
||
const offToggle = container.querySelector("#s-offline-toggle");
|
||
offToggle.onclick = () => {
|
||
const newVal = !TileCache.offlineMode;
|
||
TileCache.setOfflineMode(newVal);
|
||
offToggle.classList.toggle("on", newVal);
|
||
};
|
||
|
||
// GPS config inputs
|
||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff"];
|
||
for (const id of gpsInputs) {
|
||
const el = container.querySelector("#" + id);
|
||
if (el) el.onchange = () => {
|
||
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
|
||
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
|
||
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
|
||
GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60;
|
||
GPS.config.gapThresholdSec = parseFloat(container.querySelector("#s-gap-threshold").value) || 30;
|
||
GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6;
|
||
GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225;
|
||
GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25;
|
||
GPS.saveConfig();
|
||
};
|
||
}
|
||
|
||
// Bikes list
|
||
async function loadBikesList() {
|
||
const bikes = await DB.getBikes();
|
||
const listEl = container.querySelector("#s-bikes-list");
|
||
if (!listEl) return;
|
||
listEl.innerHTML = bikes.map(b => `
|
||
<div class="settings-item" data-bike-id="${b.id}" style="cursor:pointer">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">${esc(b.name)}${b.is_active ? ' <span style="color:var(--primary);font-size:12px">[активный]</span>' : ''}</div>
|
||
<div class="settings-item-desc">${b.weight_kg ? b.weight_kg + ' кг' : ''}${b.surface_type ? ' / ' + ({asphalt:'Асфальт',mixed:'Смешанный',gravel:'Грунт'}[b.surface_type] || b.surface_type) : ''}</div>
|
||
</div>
|
||
<span class="settings-arrow">›</span>
|
||
</div>`).join("");
|
||
listEl.querySelectorAll("[data-bike-id]").forEach(item => {
|
||
item.onclick = () => showBikeModal(parseInt(item.dataset.bikeId));
|
||
});
|
||
}
|
||
loadBikesList();
|
||
|
||
container.querySelector("#s-add-bike").onclick = () => showBikeModal(null);
|
||
|
||
// GPS permission check — works on both desktop and mobile
|
||
const gpsStatus = container.querySelector("#s-gps-status");
|
||
const gpsToggle = container.querySelector("#s-gps-toggle");
|
||
|
||
// Check GPS availability
|
||
if (!navigator.geolocation) {
|
||
gpsStatus.textContent = "Недоступно";
|
||
} else {
|
||
// Try to actually get position to check real permission
|
||
gpsStatus.textContent = "Проверка...";
|
||
navigator.geolocation.getCurrentPosition(
|
||
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
|
||
(err) => {
|
||
if (err.code === 1) gpsStatus.textContent = "Нажмите для запроса";
|
||
else gpsStatus.textContent = "Ошибка: " + err.message;
|
||
},
|
||
{ enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 }
|
||
);
|
||
}
|
||
|
||
gpsToggle.onclick = () => {
|
||
gpsStatus.textContent = "Запрашиваю...";
|
||
navigator.geolocation.getCurrentPosition(
|
||
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
|
||
(err) => {
|
||
gpsStatus.textContent = err.code === 1 ? "Запрещено" : "Ошибка: " + err.message;
|
||
gpsToggle.classList.remove("on");
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 10000 }
|
||
);
|
||
};
|
||
|
||
// App version info
|
||
try {
|
||
const info = await window.__TAURI__.core.invoke("get_app_info");
|
||
container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`;
|
||
} catch {
|
||
container.querySelector("#s-version").textContent = "dev";
|
||
}
|
||
}
|
||
|
||
function showProfileModal() {
|
||
const p = state.profile;
|
||
let selectedSex = p.sex ?? "m";
|
||
|
||
showModal(`
|
||
<h3>Профиль</h3>
|
||
<div class="form-group">
|
||
<label>Вес (кг)</label>
|
||
<input id="m-weight" type="number" inputmode="decimal" step="0.1" value="${p.weight_kg ?? ""}" placeholder="80" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Рост (см)</label>
|
||
<input id="m-height" type="number" inputmode="numeric" value="${p.height_cm ?? ""}" placeholder="178" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Год рождения</label>
|
||
<input id="m-year" type="number" inputmode="numeric" value="${p.birth_year ?? ""}" placeholder="1988" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Пол</label>
|
||
<div class="type-chips">
|
||
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
|
||
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>BMR по формуле (ккал/день)</label>
|
||
<input id="m-bmr-formula" type="number" inputmode="numeric" value="${p.bmr_formula ?? ""}" placeholder="1800" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>BMR реальный / адаптированный</label>
|
||
<input id="m-bmr-est" type="number" inputmode="numeric" value="${p.bmr_estimated ?? ""}" placeholder="1580" />
|
||
</div>`,
|
||
async overlay => {
|
||
const weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
|
||
const height = parseInt(overlay.querySelector("#m-height").value);
|
||
const year = parseInt(overlay.querySelector("#m-year").value);
|
||
const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null;
|
||
const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null;
|
||
if (isNaN(height) || isNaN(year)) return false;
|
||
|
||
if (weight) await DB.setProfileValue("weight_kg", weight);
|
||
await DB.setProfileValue("height_cm", height);
|
||
await DB.setProfileValue("birth_year", year);
|
||
await DB.setProfileValue("sex", selectedSex);
|
||
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
|
||
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
|
||
|
||
state.profile = { ...state.profile,
|
||
weight_kg: weight ? weight : state.profile.weight_kg,
|
||
height_cm: String(height), birth_year: String(year), sex: selectedSex,
|
||
bmr_formula: bFormula ? String(bFormula) : null,
|
||
bmr_estimated: bEst ? String(bEst) : null };
|
||
});
|
||
|
||
// Sex chip handler inside modal
|
||
setTimeout(() => {
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
if (!overlay) return;
|
||
overlay.querySelectorAll("[data-sex]").forEach(btn => {
|
||
btn.onclick = () => {
|
||
selectedSex = btn.dataset.sex;
|
||
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
|
||
btn.classList.add("selected");
|
||
};
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bike profile modal
|
||
// ---------------------------------------------------------------------------
|
||
async function showBikeModal(bikeId) {
|
||
const isNew = bikeId == null;
|
||
const bike = isNew ? {} : await DB.getBike(bikeId);
|
||
if (!isNew && !bike) return;
|
||
|
||
const surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" };
|
||
const surfaceVal = bike.surface_type ?? "mixed";
|
||
|
||
showModal(`
|
||
<h3>${isNew ? "Новый велосипед" : "Редактирование"}</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="m-bike-name" type="text" value="${esc(bike.name ?? "")}" placeholder="Мой велосипед" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Вес велосипеда (кг)</label>
|
||
<input id="m-bike-weight" type="number" inputmode="decimal" step="0.1" value="${bike.weight_kg ?? ""}" placeholder="12" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Диаметр колеса (дюймы)</label>
|
||
<input id="m-bike-wheel" type="number" inputmode="decimal" step="0.5" value="${bike.wheel_diameter_inch ?? 26}" placeholder="26" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Передняя звезда (зубцы)</label>
|
||
<input id="m-bike-chainring" type="number" inputmode="numeric" value="${bike.chainring_teeth ?? ""}" placeholder="42" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Задняя звезда (зубцы)</label>
|
||
<input id="m-bike-cog" type="number" inputmode="numeric" value="${bike.cog_teeth ?? ""}" placeholder="18" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Покрытие</label>
|
||
<select id="m-bike-surface" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||
<option value="asphalt"${surfaceVal === "asphalt" ? " selected" : ""}>Асфальт</option>
|
||
<option value="mixed"${surfaceVal === "mixed" ? " selected" : ""}>Смешанный</option>
|
||
<option value="gravel"${surfaceVal === "gravel" ? " selected" : ""}>Грунт</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Давление в шинах (бар)</label>
|
||
<input id="m-bike-pressure" type="number" inputmode="decimal" step="0.1" value="${bike.tire_pressure_bar ?? 3.0}" placeholder="3.0" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Активный велосипед</label>
|
||
<div class="type-chips">
|
||
<button class="type-chip ${bike.is_active ? "selected" : ""}" id="m-bike-active-yes" data-active="1">Да</button>
|
||
<button class="type-chip ${!bike.is_active ? "selected" : ""}" id="m-bike-active-no" data-active="0">Нет</button>
|
||
</div>
|
||
</div>
|
||
${!isNew ? `
|
||
<div class="sd-section" style="margin-top:20px">
|
||
<div class="sd-title">Обслуживание</div>
|
||
<div id="m-bike-mileage" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
||
<div id="m-bike-maint-list"></div>
|
||
<button id="m-bike-maint-add" class="btn-secondary full-width" style="margin-top:8px">+ Добавить</button>
|
||
<div id="m-bike-maint-form" style="display:none;margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
|
||
<div class="form-group"><label>Дата</label><input id="m-maint-date" type="date" value="${new Date().toISOString().slice(0,10)}" /></div>
|
||
<div class="form-group"><label>Описание</label><input id="m-maint-desc" type="text" placeholder="Смазка цепи, подкачка шин..." /></div>
|
||
<div class="form-group"><label>След. обслуживание через (км)</label><input id="m-maint-next" type="number" inputmode="decimal" step="1" placeholder="100" /></div>
|
||
<div class="form-group"><label>Заметки</label><input id="m-maint-notes" type="text" placeholder="" /></div>
|
||
<button id="m-maint-save" class="btn-primary full-width">Сохранить запись</button>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${!isNew ? `<button id="m-del-bike" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить велосипед</button>` : ""}`,
|
||
async overlay => {
|
||
const name = overlay.querySelector("#m-bike-name").value.trim();
|
||
if (!name) return false;
|
||
const data = {
|
||
name,
|
||
weight_kg: parseFloat(overlay.querySelector("#m-bike-weight").value) || null,
|
||
wheel_diameter_inch: parseFloat(overlay.querySelector("#m-bike-wheel").value) || 26,
|
||
chainring_teeth: parseInt(overlay.querySelector("#m-bike-chainring").value) || null,
|
||
cog_teeth: parseInt(overlay.querySelector("#m-bike-cog").value) || null,
|
||
surface_type: overlay.querySelector("#m-bike-surface").value,
|
||
tire_pressure_bar: parseFloat(overlay.querySelector("#m-bike-pressure").value) || null,
|
||
is_active: overlay.querySelector("#m-bike-active-yes").classList.contains("selected"),
|
||
};
|
||
if (isNew) {
|
||
await DB.saveBike(data);
|
||
} else {
|
||
await DB.updateBike(bikeId, data);
|
||
}
|
||
// Refresh the settings screen to show updated list
|
||
renderScreen("settings");
|
||
});
|
||
|
||
// Active toggle chips
|
||
setTimeout(() => {
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
if (!overlay) return;
|
||
overlay.querySelectorAll("#m-bike-active-yes, #m-bike-active-no").forEach(btn => {
|
||
btn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
overlay.querySelector("#m-bike-active-yes").classList.toggle("selected", btn.dataset.active === "1");
|
||
overlay.querySelector("#m-bike-active-no").classList.toggle("selected", btn.dataset.active === "0");
|
||
};
|
||
});
|
||
}, 50);
|
||
|
||
if (!isNew) {
|
||
setTimeout(() => {
|
||
const delBtn = document.querySelector("#m-del-bike");
|
||
if (delBtn) {
|
||
delBtn.onclick = async (e) => {
|
||
e.stopPropagation();
|
||
await DB.deleteBike(bikeId);
|
||
document.querySelector(".modal-overlay")?.remove();
|
||
history.back();
|
||
renderScreen("settings");
|
||
};
|
||
}
|
||
}, 100);
|
||
|
||
// Bike maintenance section
|
||
setTimeout(async () => {
|
||
const mileageEl = document.querySelector("#m-bike-mileage");
|
||
const listEl = document.querySelector("#m-bike-maint-list");
|
||
const addBtn = document.querySelector("#m-bike-maint-add");
|
||
const formEl = document.querySelector("#m-bike-maint-form");
|
||
if (!listEl) return;
|
||
|
||
const totalKm = await DB.getBikeTotalMileage(bikeId);
|
||
if (mileageEl) mileageEl.textContent = `Общий пробег: ${totalKm} км`;
|
||
|
||
async function renderMaintList() {
|
||
const entries = await DB.getBikeMaintenanceLog(bikeId);
|
||
if (entries.length === 0) {
|
||
listEl.innerHTML = '<div style="font-size:13px;color:var(--muted);padding:8px 0">Нет записей</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = entries.map(e => {
|
||
const overdue = e.next_service_km != null && totalKm >= e.next_service_km;
|
||
const nextInfo = e.next_service_km != null ? `, след. при ${e.next_service_km} км` : "";
|
||
const dateShort = e.date.slice(5).replace("-", ".");
|
||
return `<div class="maint-item${overdue ? " maint-overdue" : ""}" data-mid="${e.id}">
|
||
<div class="maint-info">
|
||
<span class="maint-date">${dateShort}</span>
|
||
<span class="maint-desc">${esc(e.description)}</span>
|
||
${e.mileage_km != null ? `<span class="maint-km">(${e.mileage_km} км${nextInfo})</span>` : ""}
|
||
${overdue ? '<span class="maint-warn">[!]</span>' : ""}
|
||
</div>
|
||
<button class="maint-del" data-mid="${e.id}">×</button>
|
||
</div>`;
|
||
}).join("");
|
||
listEl.querySelectorAll(".maint-del").forEach(btn => {
|
||
btn.onclick = async (ev) => {
|
||
ev.stopPropagation();
|
||
await DB.deleteBikeMaintenance(parseInt(btn.dataset.mid));
|
||
await renderMaintList();
|
||
};
|
||
});
|
||
}
|
||
|
||
await renderMaintList();
|
||
|
||
if (addBtn) {
|
||
addBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
formEl.style.display = formEl.style.display === "none" ? "block" : "none";
|
||
};
|
||
}
|
||
|
||
const saveBtn = document.querySelector("#m-maint-save");
|
||
if (saveBtn) {
|
||
saveBtn.onclick = async (e) => {
|
||
e.stopPropagation();
|
||
const date = document.querySelector("#m-maint-date").value;
|
||
const desc = document.querySelector("#m-maint-desc").value.trim();
|
||
if (!desc) return;
|
||
const nextKm = parseFloat(document.querySelector("#m-maint-next").value) || null;
|
||
const notes = document.querySelector("#m-maint-notes").value.trim() || null;
|
||
// next_service_km = current mileage + interval
|
||
const nextServiceKm = nextKm != null ? Math.round((totalKm + nextKm) * 10) / 10 : null;
|
||
await DB.addBikeMaintenance(bikeId, date, desc, totalKm, nextServiceKm, notes);
|
||
document.querySelector("#m-maint-desc").value = "";
|
||
document.querySelector("#m-maint-next").value = "";
|
||
document.querySelector("#m-maint-notes").value = "";
|
||
formEl.style.display = "none";
|
||
await renderMaintList();
|
||
};
|
||
}
|
||
}, 150);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// РСП — рекомендуемая суточная потребность (взрослый мужчина)
|
||
const RDA = {
|
||
vit_a_mcg: 900, vit_c_mg: 90, vit_d_mcg: 15, vit_e_mg: 15, vit_k_mcg: 120,
|
||
vit_b1_mg: 1.2, vit_b2_mg: 1.3, vit_b3_mg: 16, vit_b6_mg: 1.7,
|
||
vit_b12_mcg: 2.4, folate_mcg: 400,
|
||
calcium_mg: 1000, iron_mg: 8, magnesium_mg: 400, zinc_mg: 11,
|
||
copper_mg: 0.9, selenium_mcg: 55, potassium_mg: 3400, sodium_mg: 1500,
|
||
};
|
||
|
||
const RDA_LABELS = {
|
||
vit_a_mcg: "A", vit_c_mg: "C", vit_d_mcg: "D", vit_e_mg: "E", vit_k_mcg: "K",
|
||
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3", vit_b6_mg: "B6",
|
||
vit_b12_mcg: "B12", folate_mcg: "Фол",
|
||
calcium_mg: "Ca", iron_mg: "Fe", magnesium_mg: "Mg", zinc_mg: "Zn",
|
||
copper_mg: "Cu", selenium_mcg: "Se", potassium_mg: "K+", sodium_mg: "Na",
|
||
};
|
||
|
||
function buildMicroStrip(totals) {
|
||
const items = [];
|
||
for (const [key, rda] of Object.entries(RDA)) {
|
||
const val = totals[key];
|
||
if (val == null || val === 0) continue;
|
||
const pct = Math.round((val / rda) * 100);
|
||
const cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low";
|
||
items.push(`<span class="micro-chip ${cls}">${RDA_LABELS[key]} ${pct}%</span>`);
|
||
}
|
||
if (items.length === 0) return "";
|
||
return `<div class="micro-strip">${items.join("")}</div>`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reusable product search dropdown component
|
||
// parentEl: element to insert into (must have position:relative or will be set)
|
||
// onSelect(product): called when user picks a product from results
|
||
// opts.placeholder, opts.onClear (called when input cleared after selection)
|
||
// Returns { el, focus() }
|
||
// ---------------------------------------------------------------------------
|
||
function createProductSearch(parentEl, onSelect, opts) {
|
||
opts = opts || {};
|
||
parentEl.style.position = "relative";
|
||
|
||
const wrap = document.createElement("div");
|
||
wrap.innerHTML = `
|
||
<input type="search" class="search-input" placeholder="${esc(opts.placeholder || "Поиск продукта...")}" />
|
||
<div class="search-dropdown" style="display:none"></div>`;
|
||
parentEl.appendChild(wrap);
|
||
|
||
const input = wrap.querySelector("input");
|
||
const dropdown = wrap.querySelector(".search-dropdown");
|
||
let debounce = null;
|
||
|
||
input.oninput = () => {
|
||
clearTimeout(debounce);
|
||
debounce = setTimeout(async () => {
|
||
const q = input.value.trim();
|
||
if (q.length < 2) { dropdown.style.display = "none"; dropdown.innerHTML = ""; if (opts.onClear) opts.onClear(); return; }
|
||
const results = await DB.searchProducts(q);
|
||
dropdown.style.display = "";
|
||
dropdown.innerHTML = results.map(p =>
|
||
`<div class="sd-item" data-pid="${p.id}">
|
||
<span>${esc(p.name)}</span>
|
||
<span class="sd-item-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
|
||
</div>`
|
||
).join("") || `<div class="prod-hint">Не найдено</div>`;
|
||
|
||
dropdown.querySelectorAll(".sd-item").forEach(item => {
|
||
item.onclick = async () => {
|
||
const pid = parseInt(item.dataset.pid);
|
||
const product = results.find(r => r.id === pid) || await DB.getProduct(pid);
|
||
dropdown.style.display = "none";
|
||
dropdown.innerHTML = "";
|
||
input.value = product.name;
|
||
onSelect(product);
|
||
};
|
||
});
|
||
}, 300);
|
||
};
|
||
|
||
return { el: wrap, input, focus() { input.focus(); } };
|
||
}
|
||
|
||
function calcBmr(weights) {
|
||
const p = state.profile;
|
||
if (!p.height_cm || !p.birth_year) return null;
|
||
if (p.bmr_estimated) return parseInt(p.bmr_estimated);
|
||
const kg = weights.length ? weights[weights.length - 1].weight_kg
|
||
: state.latestWeight?.weight_kg;
|
||
if (!kg) return parseInt(p.bmr_formula) || null;
|
||
return BMR.calculateBmr(kg, parseInt(p.height_cm),
|
||
BMR.getAge(parseInt(p.birth_year)), p.sex ?? "m");
|
||
}
|
||
|
||
function balanceClass(balance) {
|
||
if (balance == null) return "";
|
||
return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced";
|
||
}
|
||
|
||
function formatSleepInfo(s) {
|
||
const lines = [];
|
||
if (s.asleep_time && s.wake_time) {
|
||
const a = toMin(s.asleep_time), w = toMin(s.wake_time);
|
||
const h = ((a >= 720 ? 1440 - a + w : w - a) / 60).toFixed(1);
|
||
lines.push(`Сон: ${h} ч (${s.asleep_time} – ${s.wake_time})`);
|
||
}
|
||
if (s.nap_minutes) lines.push(`Дневной: ${s.nap_minutes} мин`);
|
||
if (s.notes) lines.push(esc(s.notes));
|
||
return lines.map(l => `<span class="sleep-line">${l}</span>`).join("") ||
|
||
`<span class="log-empty">нет данных</span>`;
|
||
}
|
||
|
||
function toMin(t) {
|
||
const [h, m] = (t || "0:0").split(":").map(Number);
|
||
return h * 60 + m;
|
||
}
|
||
|
||
function todayIso() {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function nowTime() {
|
||
return new Date().toTimeString().slice(0, 5);
|
||
}
|
||
|
||
function shiftDate(iso, days) {
|
||
const d = new Date(iso);
|
||
d.setDate(d.getDate() + days);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatDate(iso) {
|
||
return new Date(iso + "T00:00:00")
|
||
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
|
||
}
|
||
|
||
function addZoomDisplay(map) {
|
||
const ctrl = L.control({ position: "bottomright" });
|
||
ctrl.onAdd = () => {
|
||
const div = L.DomUtil.create("div", "zoom-display");
|
||
div.textContent = map.getZoom();
|
||
map.on("zoomend", () => { div.textContent = map.getZoom(); });
|
||
return div;
|
||
};
|
||
ctrl.addTo(map);
|
||
}
|
||
|
||
function pickFile(accept, callback, asBinary) {
|
||
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
if (accept) input.accept = accept;
|
||
input.style.display = "none";
|
||
document.body.appendChild(input);
|
||
input.addEventListener("change", async () => {
|
||
console.log("[pickFile] change event, files:", input.files.length);
|
||
const file = input.files[0];
|
||
document.body.removeChild(input);
|
||
if (!file) return;
|
||
console.log("[pickFile] file:", file.name, file.size);
|
||
try {
|
||
if (asBinary) {
|
||
const buf = await file.arrayBuffer();
|
||
await callback(buf, file);
|
||
} else {
|
||
const text = await file.text();
|
||
await callback(text, file);
|
||
}
|
||
} catch (e) {
|
||
console.error("[pickFile] error:", e);
|
||
}
|
||
});
|
||
input.click();
|
||
}
|
||
|
||
function esc(str) {
|
||
return String(str ?? "")
|
||
.replace(/&/g, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Global back navigation between screens
|
||
window.addEventListener("popstate", (e) => {
|
||
// Modal popHandler already handled this event — skip
|
||
if (_modalPopHandled) { _modalPopHandled = false; return; }
|
||
// If a modal is open, don't handle here (modal has its own handler)
|
||
if (document.querySelector(".modal-overlay")) return;
|
||
// Chart fullscreen on screen (not in modal) — collapse it
|
||
const fsChart = document.querySelector(".chart-full");
|
||
if (fsChart) {
|
||
fsChart.classList.remove("chart-full");
|
||
const btn = fsChart.querySelector(".chart-close-btn");
|
||
if (btn) btn.remove();
|
||
return;
|
||
}
|
||
// Stale modal/chart entries left after app restart — skip them
|
||
if (e.state && (e.state.modal || e.state.chartFull)) {
|
||
history.back();
|
||
return;
|
||
}
|
||
// Pop our nav stack and navigate to previous screen
|
||
if (navStack.length > 1) {
|
||
navStack.pop();
|
||
persistNavStack();
|
||
const prev = navStack[navStack.length - 1];
|
||
renderScreen(prev, true);
|
||
} else if (e.state && e.state.screen) {
|
||
renderScreen(e.state.screen, true);
|
||
}
|
||
});
|
||
|
||
init().catch(err => {
|
||
console.error(err);
|
||
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
|
||
});
|