GPX export: button in session detail, standard GPX 1.1 format. Background GPS fixes: - Native service: batched writes (10s flush) instead of per-point appendText — eliminates race condition with reader - JS reader: skip corrupt lines, sort by timestamp, track by ts instead of line count — handles partial writes and reordering - Recovery: idempotent import using max DB timestamp - Live recording: seq numbers on every point - FileObserver watches correct path (app root, not files/dev.baldav.app) - Removed ACCESS_BACKGROUND_LOCATION (causes throttling) Nav: "Трекинг" button on GPS screen when recording active.
2034 lines
79 KiB
JavaScript
2034 lines
79 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,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
}
|
||
|
||
// Wait for DB migrations
|
||
if (DB._migrationsReady) await DB._migrationsReady;
|
||
|
||
// Recover any orphaned GPS recording session
|
||
await GPS.recoverSession();
|
||
|
||
// Import seed products if needed
|
||
try {
|
||
app.innerHTML = `<div class="loading">Проверка продуктов...</div>`;
|
||
const result = await window.__TAURI__.core.invoke("import_seed_products");
|
||
console.log("[SEED]", result);
|
||
if (result.startsWith("imported:")) {
|
||
const [_, count, total] = result.split(":");
|
||
console.log(`[SEED] Imported ${count}/${total} products`);
|
||
}
|
||
} catch (e) {
|
||
console.error("[SEED] error:", e);
|
||
}
|
||
|
||
// Apply safe area insets (Android status bar / nav bar)
|
||
try {
|
||
const insets = await window.__TAURI__.core.invoke("get_safe_insets");
|
||
if (insets.top || insets.bottom) {
|
||
document.documentElement.style.setProperty("--safe-area-top", insets.top + "px");
|
||
document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px");
|
||
}
|
||
} catch {}
|
||
|
||
state.profile = await DB.getFullProfile();
|
||
state.latestWeight = await DB.getLatestWeight();
|
||
|
||
if (false) {
|
||
renderScreen("settings");
|
||
} else {
|
||
renderScreen("today");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Router
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function renderScreen(name) {
|
||
const prevScreen = state.screen;
|
||
state.screen = name;
|
||
// Push history so "back" navigates between screens instead of exiting app
|
||
if (prevScreen && prevScreen !== name) {
|
||
history.pushState({ screen: name }, "");
|
||
}
|
||
const screens = {
|
||
today: renderToday,
|
||
products: renderProducts,
|
||
gps: renderGpsScreen,
|
||
recording: renderRecordingInline,
|
||
supplements: renderSupplements,
|
||
settings: renderSettings,
|
||
};
|
||
app.innerHTML = "";
|
||
|
||
// Floating recording bar (visible on all screens except recording)
|
||
if (GPS.recording.active && name !== "recording") {
|
||
const bar = document.createElement("div");
|
||
bar.className = "rec-floating-bar";
|
||
bar.onclick = () => renderScreen("recording");
|
||
bar.innerHTML = `<span class="rec-bar-dot"></span><span id="rec-bar-text">Запись...</span>`;
|
||
app.appendChild(bar);
|
||
// Update bar every second
|
||
if (window._recBarTimer) clearInterval(window._recBarTimer);
|
||
window._recBarTimer = setInterval(() => {
|
||
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
|
||
const pts = GPS.recording.points;
|
||
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
|
||
const sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
|
||
const el = document.querySelector("#rec-bar-text");
|
||
if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`;
|
||
}, 1000);
|
||
} else if (window._recBarTimer) {
|
||
clearInterval(window._recBarTimer);
|
||
}
|
||
|
||
app.appendChild(buildNav(name));
|
||
const content = document.createElement("div");
|
||
content.className = "screen-content";
|
||
app.appendChild(content);
|
||
screens[name](content);
|
||
}
|
||
|
||
function buildNav(active) {
|
||
const nav = document.createElement("nav");
|
||
nav.className = "bottom-nav";
|
||
[
|
||
{ id: "today", label: "Сегодня" },
|
||
{ id: "products", label: "Продукты" },
|
||
{ id: "gps", label: "Трек" },
|
||
{ id: "supplements", label: "Добавки" },
|
||
{ id: "settings", label: "Настройки" },
|
||
].forEach(item => {
|
||
const btn = document.createElement("button");
|
||
btn.className = "nav-btn" + (item.id === active ? " active" : "");
|
||
btn.textContent = item.label;
|
||
btn.onclick = () => renderScreen(item.id);
|
||
nav.appendChild(btn);
|
||
});
|
||
return nav;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TODAY screen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function renderToday(container) {
|
||
container.innerHTML = `<div class="loading">Загрузка...</div>`;
|
||
|
||
const [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),
|
||
]);
|
||
|
||
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;
|
||
|
||
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}</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.map(f =>
|
||
`<li class="log-item food-edit-item" data-food-id="${f.id}">
|
||
<span class="log-time">${f.time ?? ""}</span>
|
||
<span class="log-desc">${esc(f.product_name ?? 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>
|
||
`;
|
||
|
||
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-act-btn").onclick = () => showActivityModal();
|
||
container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal();
|
||
container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 = () => {
|
||
// If fullscreen map is open inside this modal, collapse it instead of closing 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 { map.dragging.disable(); } catch {}
|
||
history.pushState({ modal: true }, "");
|
||
window.addEventListener("popstate", popHandler, { once: true });
|
||
return;
|
||
}
|
||
closeModal(false);
|
||
};
|
||
window.addEventListener("popstate", popHandler, { once: true });
|
||
|
||
function closeModal(goBack) {
|
||
overlay.remove();
|
||
window.removeEventListener("popstate", popHandler);
|
||
if (goBack) history.back();
|
||
// Ensure history isn't empty after modal close (prevents app exit on next back)
|
||
history.replaceState({ screen: state.screen }, "");
|
||
}
|
||
|
||
overlay.querySelector("#modal-back").onclick = () => closeModal(true);
|
||
overlay.querySelector("#modal-cancel").onclick = () => closeModal(true);
|
||
overlay.querySelector("#modal-ok").onclick = async () => {
|
||
const ok = await onSubmit(overlay);
|
||
if (ok !== false) { closeModal(true); renderScreen(state.screen); }
|
||
};
|
||
setTimeout(() => overlay.querySelector("input")?.focus(), 50);
|
||
}
|
||
|
||
function showWeightModal() {
|
||
showModal(`
|
||
<h3>Вес</h3>
|
||
<div class="form-group">
|
||
<label>кг</label>
|
||
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const kg = parseFloat(overlay.querySelector("#m-kg").value);
|
||
const time = overlay.querySelector("#m-time").value;
|
||
if (isNaN(kg)) return false;
|
||
await DB.addWeight(state.date, time, kg);
|
||
});
|
||
}
|
||
|
||
function showFoodModal() {
|
||
let selectedProduct = null;
|
||
|
||
showModal(`
|
||
<h3>Еда</h3>
|
||
<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 id="m-selected" class="modal-selected" 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>Количество (г)</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 amount = parseFloat(overlay.querySelector("#m-amount").value) || 100;
|
||
|
||
if (selectedProduct) {
|
||
await DB.addFoodEntry(state.date, time, selectedProduct.id, amount, null);
|
||
} 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);
|
||
}
|
||
});
|
||
|
||
// Wire up product search inside the modal
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
const searchInput = overlay.querySelector("#m-prod-search");
|
||
const resultsEl = overlay.querySelector("#m-prod-results");
|
||
const selectedEl = overlay.querySelector("#m-selected");
|
||
const manualFields = overlay.querySelector("#m-manual-fields");
|
||
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, 10);
|
||
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);
|
||
selectedProduct = results.find(r => r.id === pid);
|
||
resultsEl.innerHTML = "";
|
||
searchInput.value = "";
|
||
selectedEl.style.display = "block";
|
||
selectedEl.innerHTML = `<span>${esc(selectedProduct.name)}</span>`;
|
||
manualFields.style.display = "none";
|
||
|
||
selectedEl.onclick = () => {
|
||
selectedProduct = null;
|
||
selectedEl.style.display = "none";
|
||
manualFields.style.display = "block";
|
||
searchInput.focus();
|
||
};
|
||
};
|
||
});
|
||
}, 300);
|
||
};
|
||
}
|
||
|
||
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, 10);
|
||
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">
|
||
<h2>Продукты</h2>
|
||
<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>`;
|
||
|
||
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();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ---------------------------------------------------------------------------
|
||
// 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">Вело</option>
|
||
<option value="walk">Ходьба</option>
|
||
<option value="run">Бег</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
|
||
container.querySelector("#gps-rec-btn").onclick = async () => {
|
||
if (GPS.recording.active) {
|
||
renderScreen("recording");
|
||
} else {
|
||
const type = container.querySelector("#gps-type").value;
|
||
const wkg = state.latestWeight?.weight_kg ?? 80;
|
||
await GPS.startRecording(type, wkg);
|
||
renderScreen("recording");
|
||
}
|
||
};
|
||
|
||
// Session click → detail view
|
||
container.querySelectorAll(".gps-session-item").forEach(item => {
|
||
item.onclick = () => {
|
||
const sid = parseInt(item.dataset.sid);
|
||
showSessionDetail(sid);
|
||
};
|
||
});
|
||
|
||
// Three-dot menu
|
||
const menuBtn = container.querySelector("#gps-menu-btn");
|
||
const menu = container.querySelector("#gps-menu");
|
||
menuBtn.onclick = () => menu.classList.toggle("hidden");
|
||
document.addEventListener("click", e => {
|
||
if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden");
|
||
}, { once: false });
|
||
|
||
// Download map tiles — region picker
|
||
container.querySelector("#gps-download-map").onclick = () => {
|
||
menu.classList.add("hidden");
|
||
showMapDownloadModal();
|
||
};
|
||
|
||
// Clear tile cache
|
||
container.querySelector("#gps-clear-cache").onclick = async () => {
|
||
menu.classList.add("hidden");
|
||
const count = await TileCache.count();
|
||
await TileCache.clear();
|
||
console.log("Cache cleared");
|
||
};
|
||
|
||
// Unified import — detect by extension
|
||
container.querySelector("#gps-import").onclick = () => {
|
||
menu.classList.add("hidden");
|
||
pickFile("", async (data, file) => {
|
||
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
|
||
const ext = file.name.split(".").pop().toLowerCase();
|
||
console.log("[IMPORT] ext:", ext);
|
||
const wkg = state.latestWeight?.weight_kg ?? 80;
|
||
try {
|
||
if (ext === "gpx") {
|
||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||
await GPS.importGPX(text, wkg);
|
||
} else if (ext === "ftb" || ext === "zip") {
|
||
const buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
|
||
console.log("[IMPORT] FTB buf size:", buf.byteLength);
|
||
const count = await GPS.importFTB(buf, wkg);
|
||
console.log("[IMPORT] done, count:", count);
|
||
} else {
|
||
console.error("[IMPORT] Unsupported file type: " + ext);
|
||
}
|
||
renderScreen("gps");
|
||
} catch (e) {
|
||
console.error("[IMPORT] error:", e, String(e));
|
||
}
|
||
}, true);
|
||
};
|
||
}
|
||
|
||
function renderRecordingInline(container) {
|
||
if (!GPS.recording.active) { renderScreen("gps"); return; }
|
||
|
||
container.innerHTML = `
|
||
<div class="rec-screen">
|
||
<div id="rec-map" class="rec-map"></div>
|
||
<div class="rec-stats">
|
||
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
|
||
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
|
||
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
|
||
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
|
||
</div>
|
||
<div class="rec-bottom">
|
||
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
|
||
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
|
||
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
setTimeout(() => {
|
||
const mapEl = document.querySelector("#rec-map");
|
||
if (!mapEl) return;
|
||
|
||
L.Icon.Default.imagePath = "lib/";
|
||
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
|
||
mapEl.addEventListener("wheel", (e) => {
|
||
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
|
||
}, { passive: false });
|
||
addZoomDisplay(map);
|
||
const CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
|
||
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
|
||
let posMarker = null;
|
||
|
||
// State changes
|
||
GPS.recording.onStateChange = (state, p) => {
|
||
if (state === "ready") {
|
||
statusEl.textContent = "GPS OK. Начните движение...";
|
||
statusEl.style.color = "#03dac6";
|
||
map.setView([p.lat, p.lon], 16);
|
||
} else if (state === "recording") {
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
} else if (state === "resumed") {
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
}
|
||
};
|
||
|
||
// Live point updates
|
||
const distEl = document.querySelector("#rec-dist");
|
||
const spdEl = document.querySelector("#rec-speed");
|
||
const timeEl = document.querySelector("#rec-time");
|
||
const elevEl = document.querySelector("#rec-elev");
|
||
const statusEl = document.querySelector("#rec-status");
|
||
|
||
// Speed buffer for median
|
||
const speedBuf = [];
|
||
|
||
GPS.recording.onPoint = (p) => {
|
||
const ll = [p.lat, p.lon];
|
||
borderLine.addLatLng(ll);
|
||
trailLine.addLatLng(ll);
|
||
map.panTo(ll);
|
||
|
||
if (posMarker) map.removeLayer(posMarker);
|
||
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
|
||
|
||
// Stats
|
||
const pts = GPS.recording.points;
|
||
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
|
||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||
|
||
// Show current state
|
||
const recState = GPS.recording.state;
|
||
if (recState === "waiting_gps") {
|
||
statusEl.textContent = "Поиск GPS...";
|
||
statusEl.style.color = "#ffb74d";
|
||
map.setView([p.lat, p.lon], 15);
|
||
return;
|
||
}
|
||
if (recState === "ready") {
|
||
// Position marker only
|
||
if (posMarker) map.removeLayer(posMarker);
|
||
posMarker = L.circleMarker([p.lat, p.lon], { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
|
||
return;
|
||
}
|
||
|
||
// Speed
|
||
const curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
|
||
speedBuf.push(curSpeed);
|
||
if (speedBuf.length > 5) speedBuf.shift();
|
||
const sorted = speedBuf.slice().sort((a, b) => a - b);
|
||
const median = sorted[Math.floor(sorted.length / 2)] || 0;
|
||
if (spdEl) spdEl.textContent = median.toFixed(1);
|
||
|
||
// Elevation
|
||
const alt = p.altitude;
|
||
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
|
||
|
||
// Pause indicator
|
||
if (GPS.recording.pauseStart) {
|
||
statusEl.textContent = "Пауза";
|
||
statusEl.style.color = "#ffb74d";
|
||
} else {
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
}
|
||
};
|
||
|
||
// Timer — excludes pauses, starts only when recording
|
||
const timer = setInterval(() => {
|
||
if (!GPS.recording.active) { clearInterval(timer); return; }
|
||
if (!GPS.recording.startTime) return; // not started yet
|
||
const totalMs = Date.now() - GPS.recording.startTime.getTime();
|
||
const activeMs = totalMs - GPS.recording.totalPauseMs -
|
||
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
|
||
const sec = Math.max(0, Math.round(activeMs / 1000));
|
||
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
|
||
}, 1000);
|
||
|
||
// Pause button
|
||
container.querySelector("#rec-pause").onclick = () => {
|
||
if (GPS.recording.manualPaused) {
|
||
GPS.resumeRecording();
|
||
container.querySelector("#rec-pause").textContent = "Пауза";
|
||
statusEl.textContent = "";
|
||
statusEl.style.color = "";
|
||
} else {
|
||
GPS.pauseRecording();
|
||
container.querySelector("#rec-pause").textContent = "Продолжить";
|
||
statusEl.textContent = "Пауза (ручная)";
|
||
statusEl.style.color = "#ffb74d";
|
||
}
|
||
};
|
||
|
||
// Stop button
|
||
container.querySelector("#rec-stop").onclick = async () => {
|
||
await GPS.stopRecording();
|
||
clearInterval(timer);
|
||
renderScreen("gps");
|
||
};
|
||
|
||
// Restore trail if already recording
|
||
if (GPS.recording.points.length > 0) {
|
||
const coords = GPS.recording.points.map(p => [p.lat, p.lon]);
|
||
borderLine.setLatLngs(coords);
|
||
trailLine.setLatLngs(coords);
|
||
map.fitBounds(trailLine.getBounds().pad(0.1));
|
||
}
|
||
}, 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]);
|
||
|
||
// Update stats
|
||
const pts = GPS.recording.points;
|
||
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
|
||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||
if (spdEl) {
|
||
const curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
|
||
spdEl.textContent = curSpd + " км/ч";
|
||
}
|
||
};
|
||
|
||
// Timer for duration display
|
||
timer = setInterval(() => {
|
||
if (!GPS.recording.active) { clearInterval(timer); return; }
|
||
const sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
|
||
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
|
||
}, 1000);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Map download modal — pick region + zoom range
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MAP_REGIONS = [
|
||
{ name: "Текущий вид карты", bounds: null },
|
||
{ name: "--- Города ---", bounds: null, disabled: true },
|
||
{ name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] },
|
||
{ name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] },
|
||
{ name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] },
|
||
{ name: "город + окрестности", bounds: [[47.35, 39.95], [47.52, 40.22]] },
|
||
{ name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] },
|
||
{ name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] },
|
||
{ name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] },
|
||
{ name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] },
|
||
{ name: "--- Области ---", bounds: null, disabled: true },
|
||
{ name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] },
|
||
{ name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] },
|
||
{ name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] },
|
||
{ name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] },
|
||
];
|
||
|
||
function showMapDownloadModal() {
|
||
const regionOpts = MAP_REGIONS.map((r, i) =>
|
||
r.disabled
|
||
? `<div class="supp-pick-divider">${r.name.replace(/---? ?/g, "")}</div>`
|
||
: `<div class="supp-pick-item" data-idx="${i}">${r.name}</div>`
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>Скачать карту</h3>
|
||
<div class="form-group">
|
||
<label>Регион</label>
|
||
<div id="m-region-list" class="supp-pick-list">${regionOpts}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Мин. зум</label>
|
||
<input id="m-zmin" type="number" value="12" min="1" max="18" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Макс. зум</label>
|
||
<input id="m-zmax" type="number" value="15" min="1" max="18" />
|
||
</div>
|
||
<div id="m-tile-count" style="color:var(--muted);font-size:12px;margin-top:4px"></div>`,
|
||
async overlay => {
|
||
const sel = overlay.querySelector(".supp-pick-item.selected");
|
||
if (!sel) return false;
|
||
const idx = parseInt(sel.dataset.idx);
|
||
const region = MAP_REGIONS[idx];
|
||
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
|
||
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
|
||
let bounds;
|
||
if (region.bounds) {
|
||
bounds = L.latLngBounds(region.bounds);
|
||
} else if (gpsMap) {
|
||
bounds = gpsMap.getBounds();
|
||
} else return false;
|
||
|
||
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
|
||
const okBtn = overlay.querySelector("#modal-ok");
|
||
okBtn.textContent = `0 / ${total}`;
|
||
okBtn.disabled = true;
|
||
await TileCache.downloadArea(bounds, zMin, zMax, (done, tot) => {
|
||
okBtn.textContent = `${done} / ${tot}`;
|
||
});
|
||
okBtn.textContent = "Готово";
|
||
okBtn.disabled = false;
|
||
});
|
||
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
const countEl = overlay.querySelector("#m-tile-count");
|
||
|
||
// Selection
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
|
||
item.onclick = () => {
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
|
||
item.classList.add("selected");
|
||
updateTileCount();
|
||
};
|
||
});
|
||
|
||
function updateTileCount() {
|
||
const sel = overlay.querySelector(".supp-pick-item.selected");
|
||
if (!sel) return;
|
||
const idx = parseInt(sel.dataset.idx);
|
||
const region = MAP_REGIONS[idx];
|
||
const zMin = parseInt(overlay.querySelector("#m-zmin").value);
|
||
const zMax = parseInt(overlay.querySelector("#m-zmax").value);
|
||
let bounds;
|
||
if (region.bounds) bounds = L.latLngBounds(region.bounds);
|
||
else if (gpsMap) bounds = gpsMap.getBounds();
|
||
else return;
|
||
const total = TileCache.countTilesForArea(bounds, zMin, zMax);
|
||
countEl.textContent = `~${total} тайлов`;
|
||
}
|
||
|
||
overlay.querySelector("#m-zmin").oninput = updateTileCount;
|
||
overlay.querySelector("#m-zmax").oninput = updateTileCount;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Session detail view
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showSessionDetail(sessionId) {
|
||
try {
|
||
console.log("[SD] loading session", sessionId);
|
||
const session = await DB.getGpsSession(sessionId);
|
||
if (!session) { console.error("[SD] session not found"); return; }
|
||
console.log("[SD] loading points");
|
||
const rawPoints = await DB.getGpsPoints(sessionId);
|
||
console.log("[SD] got", rawPoints.length, "raw points");
|
||
const points = GPS.filterNoise(rawPoints, session.activity_type);
|
||
console.log("[SD] filtered to", points.length, "points");
|
||
// Use stored session stats if available (computed correctly at import time),
|
||
// fallback to recomputing from points
|
||
const storedStats = session.total_distance_m != null;
|
||
const stats = storedStats ? {
|
||
distance: Math.round(session.total_distance_m || 0),
|
||
avgSpeed: session.avg_speed_kmh || 0,
|
||
maxSpeed: session.max_speed_kmh || 0,
|
||
kcal: Math.round(session.total_kcal || 0),
|
||
elevGain: Math.round(session.elevation_gain_m || 0),
|
||
elevLoss: Math.round(session.elevation_loss_m || 0),
|
||
duration: 0,
|
||
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? 80);
|
||
// Compute duration from points regardless
|
||
if (points.length >= 2) {
|
||
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
|
||
}
|
||
|
||
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
|
||
const rawSpeeds = [];
|
||
for (let i = 1; i < points.length; i++) {
|
||
const devSpd = points[i].speed_ms;
|
||
if (devSpd != null && devSpd >= 0) {
|
||
rawSpeeds.push(devSpd * 3.6);
|
||
} else {
|
||
const d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
|
||
const dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
|
||
}
|
||
}
|
||
// 31-point moving average — kills GPS jitter from phone-in-pocket
|
||
const HALF_WIN = 15;
|
||
const smoothed = rawSpeeds.map((_, i) => {
|
||
let sum = 0, count = 0;
|
||
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
|
||
sum += rawSpeeds[j]; count++;
|
||
}
|
||
return Math.round(sum / count * 10) / 10;
|
||
});
|
||
|
||
// maxSpeed from smoothed graph (not raw)
|
||
const graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
|
||
|
||
// Elevation data
|
||
const elevations = points.filter(p => p.altitude_gps_m != null).map(p => p.altitude_gps_m);
|
||
const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
|
||
const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
|
||
|
||
// Time labels (minutes from start)
|
||
const t0 = points.length ? new Date(points[0].ts).getTime() : 0;
|
||
const timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
|
||
|
||
const actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||
const actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
|
||
const duration = session.end_time && session.start_time
|
||
? GPS.formatDuration(stats.duration)
|
||
: "—";
|
||
const tempo = stats.duration > 0 && stats.distance > 0
|
||
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
|
||
: "—";
|
||
const kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
|
||
|
||
showModal(`
|
||
<h3>${actName}</h3>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Время</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.date}</span><span class="sd-lbl">Дата</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${duration}</span><span class="sd-lbl">Длительность</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.start_time ?? "—"}</span><span class="sd-lbl">Начало</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${session.end_time ?? "—"}</span><span class="sd-lbl">Конец</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(stats.distance)}</span><span class="sd-lbl">Расстояние</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${tempo}</span><span class="sd-lbl">Темп</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Маршрут</div>
|
||
<div id="sd-map" class="sd-map"></div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Скорость</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
|
||
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
|
||
</div>
|
||
<canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas>
|
||
</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>
|
||
<canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas>
|
||
</div>` : ""}
|
||
|
||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт GPX</button>
|
||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||
`, () => {});
|
||
|
||
// Render map
|
||
setTimeout(() => {
|
||
const mapEl = document.querySelector("#sd-map");
|
||
if (!mapEl || !points.length) return;
|
||
|
||
L.Icon.Default.imagePath = "lib/";
|
||
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
|
||
// Ctrl+wheel zoom on desktop
|
||
mapEl.addEventListener("wheel", (e) => {
|
||
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
|
||
}, { passive: false });
|
||
addZoomDisplay(map);
|
||
const CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
// Sort by timestamp, build coords and draw in one pass
|
||
// Keep original order from DB (seq from FTB import = XML order = clean track)
|
||
const t0 = new Date(points[0].ts).getTime();
|
||
const tN = new Date(points[points.length - 1].ts).getTime();
|
||
const tRange = tN - t0 || 1;
|
||
|
||
const sortedCoords = [];
|
||
for (let i = 0; i < points.length; i++) {
|
||
sortedCoords.push([points[i].lat, points[i].lon]);
|
||
}
|
||
|
||
// Polyline first (underneath)
|
||
L.polyline(sortedCoords, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
|
||
L.polyline(sortedCoords, { color: "#bb86fc", weight: 5 }).addTo(map);
|
||
|
||
// Dots on top
|
||
for (let i = 0; i < points.length; i++) {
|
||
const p = points[i];
|
||
const frac = (new Date(p.ts).getTime() - t0) / tRange;
|
||
// blue → red → yellow → green
|
||
let cr, cg, cb;
|
||
if (frac < 0.33) { const t = frac / 0.33; cr = Math.round(t * 220); cg = 0; cb = Math.round((1 - t) * 220); }
|
||
else if (frac < 0.66) { const t = (frac - 0.33) / 0.33; cr = 220; cg = Math.round(t * 200); cb = 0; }
|
||
else { const t = (frac - 0.66) / 0.34; cr = Math.round((1 - t) * 220); cg = 180; cb = 0; }
|
||
const dotColor = `rgb(${cr},${cg},${cb})`;
|
||
L.circleMarker([p.lat, p.lon], { radius: 5, color: "#333", fillColor: "#333", fillOpacity: 1, weight: 0 }).addTo(map);
|
||
L.circleMarker([p.lat, p.lon], { radius: 3.5, color: dotColor, fillColor: dotColor, fillOpacity: 1, weight: 0 })
|
||
.bindTooltip(`#${i} ${p.ts.split("T")[1].slice(0, 12)}`, { permanent: false })
|
||
.addTo(map);
|
||
}
|
||
|
||
// Start/end
|
||
if (sortedCoords.length > 1) {
|
||
L.circleMarker(sortedCoords[0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map);
|
||
L.circleMarker(sortedCoords[sortedCoords.length - 1], { radius: 6, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map);
|
||
map.fitBounds(L.latLngBounds(sortedCoords).pad(0.1));
|
||
}
|
||
|
||
const allCoords = sortedCoords;
|
||
|
||
// Fullscreen toggle button on map
|
||
// Fullscreen map toggle
|
||
let mapIsFullscreen = false;
|
||
|
||
function collapseMap() {
|
||
if (!mapIsFullscreen) return;
|
||
mapIsFullscreen = false;
|
||
mapEl.classList.remove("sd-map-full");
|
||
fsDiv.innerHTML = "⛶";
|
||
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
|
||
}
|
||
|
||
function toggleMapFs() {
|
||
if (mapIsFullscreen) {
|
||
collapseMap();
|
||
map.dragging.disable();
|
||
} else {
|
||
mapIsFullscreen = true;
|
||
mapEl.classList.add("sd-map-full");
|
||
fsDiv.innerHTML = "✕";
|
||
map.dragging.enable();
|
||
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);
|
||
|
||
// 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: timeLabels,
|
||
datasets: [{
|
||
data: smoothed,
|
||
borderColor: "#bb86fc",
|
||
borderWidth: 1.5,
|
||
pointRadius: 0,
|
||
fill: false,
|
||
tension: 0.3,
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: { display: false },
|
||
zoom: {
|
||
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
|
||
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: elevLabels,
|
||
datasets: [{
|
||
data: elevations,
|
||
borderColor: "#ffb74d",
|
||
borderWidth: 1.5,
|
||
pointRadius: 0,
|
||
fill: true,
|
||
backgroundColor: "rgba(255,183,77,0.1)",
|
||
tension: 0.3,
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: { display: false },
|
||
zoom: {
|
||
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
|
||
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);
|
||
|
||
const del = document.querySelector("#sd-delete");
|
||
if (del) del.onclick = async () => {
|
||
await DB.deleteGpsSession(sessionId);
|
||
document.querySelector(".modal-overlay")?.remove();
|
||
history.back();
|
||
renderScreen("gps");
|
||
};
|
||
}, 100);
|
||
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Log supplement modal (from Today screen)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function showLogSupplementModal() {
|
||
const supps = await DB.getSupplements();
|
||
if (supps.length === 0) {
|
||
showModal(`
|
||
<h3>Добавки</h3>
|
||
<p style="color:var(--muted)">Нет добавок. Создайте на вкладке "Добавки".</p>`,
|
||
() => {});
|
||
return;
|
||
}
|
||
|
||
showModal(`
|
||
<h3>Принял добавку</h3>
|
||
<div class="form-group">
|
||
<label>Добавка</label>
|
||
<div id="m-supp-list" class="supp-pick-list">
|
||
${supps.map(s => `<div class="supp-pick-item" data-sid="${s.id}">${esc(s.name)}${s.daily_dose_g ? ` <small>(${s.daily_dose_g}г/день)</small>` : ""}</div>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Количество (шт)</label>
|
||
<input id="m-qty" type="number" inputmode="decimal" value="1" step="0.5" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
async overlay => {
|
||
const selected = overlay.querySelector(".supp-pick-item.selected");
|
||
if (!selected) return false;
|
||
const sid = parseInt(selected.dataset.sid);
|
||
const qty = parseFloat(overlay.querySelector("#m-qty").value) || 1;
|
||
const time = overlay.querySelector("#m-time").value;
|
||
await DB.logSupplement(state.date, time, sid, qty);
|
||
});
|
||
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
|
||
item.onclick = () => {
|
||
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
|
||
item.classList.add("selected");
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SUPPLEMENTS screen — manage supplements catalog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MICRO_FIELDS = [
|
||
{ key: "vit_a_mcg", label: "Вит A (мкг)" },
|
||
{ key: "vit_c_mg", label: "Вит C (мг)" },
|
||
{ key: "vit_d_mcg", label: "Вит D (мкг)" },
|
||
{ key: "vit_e_mg", label: "Вит E (мг)" },
|
||
{ key: "vit_k_mcg", label: "Вит K (мкг)" },
|
||
{ key: "vit_b1_mg", label: "B1 (мг)" },
|
||
{ key: "vit_b2_mg", label: "B2 (мг)" },
|
||
{ key: "vit_b3_mg", label: "B3 (мг)" },
|
||
{ key: "vit_b6_mg", label: "B6 (мг)" },
|
||
{ key: "vit_b12_mcg", label: "B12 (мкг)" },
|
||
{ key: "folate_mcg", label: "Фолат (мкг)" },
|
||
{ key: "calcium_mg", label: "Ca (мг)" },
|
||
{ key: "iron_mg", label: "Fe (мг)" },
|
||
{ key: "magnesium_mg", label: "Mg (мг)" },
|
||
{ key: "zinc_mg", label: "Zn (мг)" },
|
||
{ key: "copper_mg", label: "Cu (мг)" },
|
||
{ key: "selenium_mcg", label: "Se (мкг)" },
|
||
{ key: "potassium_mg", label: "K (мг)" },
|
||
{ key: "sodium_mg", label: "Na (мг)" },
|
||
];
|
||
|
||
async function renderSupplements(container) {
|
||
const supps = await DB.getSupplements();
|
||
|
||
function suppSummary(s) {
|
||
const parts = [];
|
||
for (const f of MICRO_FIELDS) {
|
||
if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`);
|
||
}
|
||
return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : "");
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="products-screen">
|
||
<div class="section-header">
|
||
<h2>Добавки</h2>
|
||
<button class="add-btn" id="add-new-supp">+</button>
|
||
</div>
|
||
<div id="supp-list" class="prod-list">
|
||
${supps.length ? supps.map(s => `
|
||
<div class="prod-card" data-sid="${s.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">${esc(s.name)}
|
||
${s.daily_dose_g ? `<span class="prod-weight">${s.daily_dose_g} шт/день</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="supp-summary">${suppSummary(s)}</div>
|
||
</div>`).join("")
|
||
: `<div class="prod-hint">Нет добавок. Нажмите + чтобы добавить.</div>`}
|
||
</div>
|
||
</div>`;
|
||
|
||
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
|
||
|
||
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
|
||
card.querySelector(".prod-card-header").onclick = async () => {
|
||
const sid = parseInt(card.dataset.sid);
|
||
const s = await DB.getSupplement(sid);
|
||
if (s) showEditSupplementModal(s);
|
||
};
|
||
});
|
||
}
|
||
|
||
function showEditSupplementModal(existing) {
|
||
const isNew = !existing;
|
||
const s = existing || {};
|
||
|
||
const microHtml = MICRO_FIELDS.map(f =>
|
||
`<div class="form-group micro-field">
|
||
<label>${f.label}</label>
|
||
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${s[f.key] ?? ""}" placeholder="—" />
|
||
</div>`
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>${isNew ? "Новая добавка" : "Редактирование"}</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="m-name" type="text" value="${esc(s.name ?? "")}" placeholder="Витамин D3" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Таблеток в день</label>
|
||
<input id="m-dose" type="number" inputmode="decimal" step="0.5" value="${s.daily_dose_g ?? ""}" placeholder="1" />
|
||
</div>
|
||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Содержание в 1 таблетке:</p>
|
||
<div class="micro-grid">${microHtml}</div>
|
||
${!isNew ? `<button id="m-del-supp" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить добавку</button>` : ""}`,
|
||
async overlay => {
|
||
const name = overlay.querySelector("#m-name").value.trim();
|
||
if (!name) return false;
|
||
const data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
|
||
for (const f of MICRO_FIELDS) {
|
||
data[f.key] = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
|
||
}
|
||
if (isNew) {
|
||
await DB.addSupplement(data);
|
||
} else {
|
||
// Update: delete + re-add (simple, keeps log intact via supplement_id)
|
||
// Actually need an update function. For now, delete logs referencing old id is bad.
|
||
// Just delete and re-add — log entries will be orphaned. Better to add updateSupplement.
|
||
// Quick approach: raw execute
|
||
const sets = ["name=?1", "daily_dose_g=?2"];
|
||
const vals = [name, data.daily_dose_g];
|
||
let idx = 3;
|
||
for (const f of MICRO_FIELDS) {
|
||
sets.push(`${f.key}=?${idx}`);
|
||
vals.push(data[f.key]);
|
||
idx++;
|
||
}
|
||
vals.push(existing.id);
|
||
await window.__TAURI__.core.invoke("plugin:sql|execute", {
|
||
db: "sqlite:baldav.db",
|
||
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
|
||
values: vals,
|
||
});
|
||
}
|
||
});
|
||
|
||
if (!isNew) {
|
||
setTimeout(() => {
|
||
const delBtn = document.querySelector("#m-del-supp");
|
||
if (delBtn) {
|
||
delBtn.onclick = async (e) => {
|
||
e.stopPropagation();
|
||
await DB.deleteSupplement(existing.id);
|
||
document.querySelector(".modal-overlay")?.remove();
|
||
history.back();
|
||
renderScreen("supplements");
|
||
};
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SETTINGS screen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function renderSettings(container) {
|
||
const p = state.profile;
|
||
const profileSummary = [
|
||
p.height_cm ? `${p.height_cm} см` : null,
|
||
p.birth_year ? `${p.birth_year} г.р.` : null,
|
||
p.sex === "f" ? "Ж" : "М",
|
||
].filter(Boolean).join(", ");
|
||
|
||
container.innerHTML = `
|
||
<div class="settings-screen">
|
||
<h2>Настройки</h2>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-item" id="s-open-profile">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Профиль</div>
|
||
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
|
||
</div>
|
||
<span class="settings-arrow">›</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Карты</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Оффлайн режим</div>
|
||
<div class="settings-item-desc">Тайлы только из кэша</div>
|
||
</div>
|
||
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">GPS (запись треков)</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Ниже = пауза</div>
|
||
</div>
|
||
<input id="s-min-speed" type="number" step="0.1" value="${(GPS.config.minSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. точность (м)</div>
|
||
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
|
||
</div>
|
||
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. расстояние (м)</div>
|
||
<div class="settings-item-desc">Точки ближе = пропуск</div>
|
||
</div>
|
||
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Разрешения</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Геолокация (GPS)</div>
|
||
<div class="settings-item-desc" id="s-gps-status">Проверка...</div>
|
||
</div>
|
||
<button class="settings-toggle" id="s-gps-toggle"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">О приложении</div>
|
||
<div class="settings-item" id="s-about">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">BalDav</div>
|
||
<div class="settings-item-desc" id="s-version">...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Profile button → open profile modal
|
||
container.querySelector("#s-open-profile").onclick = () => showProfileModal();
|
||
|
||
// Offline toggle
|
||
const offToggle = container.querySelector("#s-offline-toggle");
|
||
offToggle.onclick = () => {
|
||
const newVal = !TileCache.offlineMode;
|
||
TileCache.setOfflineMode(newVal);
|
||
offToggle.classList.toggle("on", newVal);
|
||
};
|
||
|
||
// GPS config inputs
|
||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist"];
|
||
for (const id of gpsInputs) {
|
||
const el = container.querySelector("#" + id);
|
||
if (el) el.onchange = () => {
|
||
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
|
||
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
|
||
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
|
||
GPS.saveConfig();
|
||
};
|
||
}
|
||
|
||
// GPS permission check — works on both desktop and mobile
|
||
const gpsStatus = container.querySelector("#s-gps-status");
|
||
const gpsToggle = container.querySelector("#s-gps-toggle");
|
||
|
||
// Check GPS availability
|
||
if (!navigator.geolocation) {
|
||
gpsStatus.textContent = "Недоступно";
|
||
} else {
|
||
// Try to actually get position to check real permission
|
||
gpsStatus.textContent = "Проверка...";
|
||
navigator.geolocation.getCurrentPosition(
|
||
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
|
||
(err) => {
|
||
if (err.code === 1) gpsStatus.textContent = "Нажмите для запроса";
|
||
else gpsStatus.textContent = "Ошибка: " + err.message;
|
||
},
|
||
{ enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 }
|
||
);
|
||
}
|
||
|
||
gpsToggle.onclick = () => {
|
||
gpsStatus.textContent = "Запрашиваю...";
|
||
navigator.geolocation.getCurrentPosition(
|
||
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
|
||
(err) => {
|
||
gpsStatus.textContent = err.code === 1 ? "Запрещено" : "Ошибка: " + err.message;
|
||
gpsToggle.classList.remove("on");
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 10000 }
|
||
);
|
||
};
|
||
|
||
// App version info
|
||
try {
|
||
const info = await window.__TAURI__.core.invoke("get_app_info");
|
||
container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`;
|
||
} catch {
|
||
container.querySelector("#s-version").textContent = "dev";
|
||
}
|
||
}
|
||
|
||
function showProfileModal() {
|
||
const p = state.profile;
|
||
let selectedSex = p.sex ?? "m";
|
||
|
||
showModal(`
|
||
<h3>Профиль</h3>
|
||
<div class="form-group">
|
||
<label>Рост (см)</label>
|
||
<input id="m-height" type="number" inputmode="numeric" value="${p.height_cm ?? ""}" placeholder="178" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Год рождения</label>
|
||
<input id="m-year" type="number" inputmode="numeric" value="${p.birth_year ?? ""}" placeholder="1988" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Пол</label>
|
||
<div class="type-chips">
|
||
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
|
||
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>BMR по формуле (ккал/день)</label>
|
||
<input id="m-bmr-formula" type="number" inputmode="numeric" value="${p.bmr_formula ?? ""}" placeholder="1800" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>BMR реальный / адаптированный</label>
|
||
<input id="m-bmr-est" type="number" inputmode="numeric" value="${p.bmr_estimated ?? ""}" placeholder="1580" />
|
||
</div>`,
|
||
async overlay => {
|
||
const height = parseInt(overlay.querySelector("#m-height").value);
|
||
const year = parseInt(overlay.querySelector("#m-year").value);
|
||
const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null;
|
||
const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null;
|
||
if (isNaN(height) || isNaN(year)) return false;
|
||
|
||
await DB.setProfileValue("height_cm", height);
|
||
await DB.setProfileValue("birth_year", year);
|
||
await DB.setProfileValue("sex", selectedSex);
|
||
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
|
||
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
|
||
|
||
state.profile = { ...state.profile,
|
||
height_cm: String(height), birth_year: String(year), sex: selectedSex,
|
||
bmr_formula: bFormula ? String(bFormula) : null,
|
||
bmr_estimated: bEst ? String(bEst) : null };
|
||
});
|
||
|
||
// Sex chip handler inside modal
|
||
setTimeout(() => {
|
||
const overlay = document.querySelector(".modal-overlay");
|
||
if (!overlay) return;
|
||
overlay.querySelectorAll("[data-sex]").forEach(btn => {
|
||
btn.onclick = () => {
|
||
selectedSex = btn.dataset.sex;
|
||
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
|
||
btn.classList.add("selected");
|
||
};
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// РСП — рекомендуемая суточная потребность (взрослый мужчина)
|
||
const RDA = {
|
||
vit_a_mcg: 900, vit_c_mg: 90, vit_d_mcg: 15, vit_e_mg: 15, vit_k_mcg: 120,
|
||
vit_b1_mg: 1.2, vit_b2_mg: 1.3, vit_b3_mg: 16, vit_b6_mg: 1.7,
|
||
vit_b12_mcg: 2.4, folate_mcg: 400,
|
||
calcium_mg: 1000, iron_mg: 8, magnesium_mg: 400, zinc_mg: 11,
|
||
copper_mg: 0.9, selenium_mcg: 55, potassium_mg: 3400, sodium_mg: 1500,
|
||
};
|
||
|
||
const RDA_LABELS = {
|
||
vit_a_mcg: "A", vit_c_mg: "C", vit_d_mcg: "D", vit_e_mg: "E", vit_k_mcg: "K",
|
||
vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3", vit_b6_mg: "B6",
|
||
vit_b12_mcg: "B12", folate_mcg: "Фол",
|
||
calcium_mg: "Ca", iron_mg: "Fe", magnesium_mg: "Mg", zinc_mg: "Zn",
|
||
copper_mg: "Cu", selenium_mcg: "Se", potassium_mg: "K+", sodium_mg: "Na",
|
||
};
|
||
|
||
function buildMicroStrip(totals) {
|
||
const items = [];
|
||
for (const [key, rda] of Object.entries(RDA)) {
|
||
const val = totals[key];
|
||
if (val == null || val === 0) continue;
|
||
const pct = Math.round((val / rda) * 100);
|
||
const cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low";
|
||
items.push(`<span class="micro-chip ${cls}">${RDA_LABELS[key]} ${pct}%</span>`);
|
||
}
|
||
if (items.length === 0) return "";
|
||
return `<div class="micro-strip">${items.join("")}</div>`;
|
||
}
|
||
|
||
function calcBmr(weights) {
|
||
const p = state.profile;
|
||
if (!p.height_cm || !p.birth_year) return null;
|
||
if (p.bmr_estimated) return parseInt(p.bmr_estimated);
|
||
const kg = weights.length ? weights[weights.length - 1].weight_kg
|
||
: state.latestWeight?.weight_kg;
|
||
if (!kg) return parseInt(p.bmr_formula) || null;
|
||
return BMR.calculateBmr(kg, parseInt(p.height_cm),
|
||
BMR.getAge(parseInt(p.birth_year)), p.sex ?? "m");
|
||
}
|
||
|
||
function balanceClass(balance) {
|
||
if (balance == null) return "";
|
||
return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced";
|
||
}
|
||
|
||
function formatSleepInfo(s) {
|
||
const lines = [];
|
||
if (s.asleep_time && s.wake_time) {
|
||
const a = toMin(s.asleep_time), w = toMin(s.wake_time);
|
||
const h = ((a >= 720 ? 1440 - a + w : w - a) / 60).toFixed(1);
|
||
lines.push(`Сон: ${h} ч (${s.asleep_time} – ${s.wake_time})`);
|
||
}
|
||
if (s.nap_minutes) lines.push(`Дневной: ${s.nap_minutes} мин`);
|
||
if (s.notes) lines.push(esc(s.notes));
|
||
return lines.map(l => `<span class="sleep-line">${l}</span>`).join("") ||
|
||
`<span class="log-empty">нет данных</span>`;
|
||
}
|
||
|
||
function toMin(t) {
|
||
const [h, m] = (t || "0:0").split(":").map(Number);
|
||
return h * 60 + m;
|
||
}
|
||
|
||
function todayIso() {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function nowTime() {
|
||
return new Date().toTimeString().slice(0, 5);
|
||
}
|
||
|
||
function shiftDate(iso, days) {
|
||
const d = new Date(iso);
|
||
d.setDate(d.getDate() + days);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatDate(iso) {
|
||
return new Date(iso + "T00:00:00")
|
||
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
|
||
}
|
||
|
||
function addZoomDisplay(map) {
|
||
const ctrl = L.control({ position: "bottomright" });
|
||
ctrl.onAdd = () => {
|
||
const div = L.DomUtil.create("div", "zoom-display");
|
||
div.textContent = map.getZoom();
|
||
map.on("zoomend", () => { div.textContent = map.getZoom(); });
|
||
return div;
|
||
};
|
||
ctrl.addTo(map);
|
||
}
|
||
|
||
function pickFile(accept, callback, asBinary) {
|
||
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
if (accept) input.accept = accept;
|
||
input.style.display = "none";
|
||
document.body.appendChild(input);
|
||
input.addEventListener("change", async () => {
|
||
console.log("[pickFile] change event, files:", input.files.length);
|
||
const file = input.files[0];
|
||
document.body.removeChild(input);
|
||
if (!file) return;
|
||
console.log("[pickFile] file:", file.name, file.size);
|
||
try {
|
||
if (asBinary) {
|
||
const buf = await file.arrayBuffer();
|
||
await callback(buf, file);
|
||
} else {
|
||
const text = await file.text();
|
||
await callback(text, file);
|
||
}
|
||
} catch (e) {
|
||
console.error("[pickFile] error:", e);
|
||
}
|
||
});
|
||
input.click();
|
||
}
|
||
|
||
function esc(str) {
|
||
return String(str ?? "")
|
||
.replace(/&/g, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Global back navigation between screens
|
||
window.addEventListener("popstate", (e) => {
|
||
// If a modal is open, don't handle here (modal has its own handler)
|
||
if (document.querySelector(".modal-overlay")) return;
|
||
// Navigate to previous screen or stay on today
|
||
if (e.state && e.state.screen) {
|
||
renderScreen(e.state.screen);
|
||
}
|
||
});
|
||
|
||
init().catch(err => {
|
||
console.error(err);
|
||
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
|
||
});
|