baldav/frontend/js/main.js
4aiman 1affadb05d Initial commit: BalDav health tracker
Tauri 2 + pure JS + SQLite personal health tracking app.

Nutrition module:
- 9299 products in DB (Magnit SSR scraping + KDV)
- Product search with case-insensitive Cyrillic (name_lower column)
- Food logging from product DB or manual entry
- Supplement tracking with per-pill micronutrients
- Micronutrient % strip on Today screen

Fitness/GPS module:
- FitoTrack .ftb and GPX import with seq-based ordering
- Session detail view: map, speed/elevation charts (Chart.js)
- Offline tile caching (IndexedDB) with region download
- Track rendering with colored time-gradient points

UI:
- Fullscreen modals with back-button (history API)
- Dark theme, desktop-responsive layout
- 5 tabs: Today, Products, Track, Supplements, Profile

Tools:
- Magnit/KDV HTML parsers
- Dedup import with discrepancy detection
- FitoTrack backup analysis
2026-05-13 10:21:24 +03:00

1602 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// BalDav main app — pure JS, no framework, no bundler.
// Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script.
const app = document.getElementById("app");
let state = {
screen: "today",
date: todayIso(),
profile: {},
latestWeight: null,
};
// ---------------------------------------------------------------------------
// 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;
}
state.profile = await DB.getFullProfile();
state.latestWeight = await DB.getLatestWeight();
if (!state.profile.height_cm) {
renderScreen("settings");
} else {
renderScreen("today");
}
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function renderScreen(name) {
state.screen = name;
const screens = {
today: renderToday,
products: renderProducts,
gps: renderGpsScreen,
supplements: renderSupplements,
settings: renderSettings,
};
app.innerHTML = "";
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">&#8592;</button>
<h2>${formatDate(state.date)}</h2>
<button class="day-nav" id="next-day">&#8594;</button>
</div>
<div class="summary-row">
<div class="summary-card">
<span class="summary-val">${intake}</span>
<span class="summary-lbl">ккал приход</span>
</div>
<div class="summary-card">
<span class="summary-val">${expend ?? "—"}</span>
<span class="summary-lbl">ккал расход</span>
</div>
<div class="summary-card ${balanceClass(balance)}">
<span class="summary-val">${balance != null ? (balance >= 0 ? "+" : "") + balance : "—"}</span>
<span class="summary-lbl">баланс</span>
</div>
</div>
${buildMicroStrip(suppTotals)}
<section class="log-section">
<div class="section-header"><h3>Вес</h3>
<button class="add-btn" id="add-weight-btn">+</button></div>
<ul class="log-list">
${weights.map(w =>
`<li class="log-item">
<span class="log-time">${w.time ?? ""}</span>
<span class="log-desc">${w.weight_kg} кг</span>
<button class="del-btn" data-table="weight" data-id="${w.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Еда</h3>
<button class="add-btn" id="add-food-btn">+</button></div>
<ul class="log-list">
${food.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}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Активность</h3>
<button class="add-btn" id="add-act-btn">+</button></div>
<ul class="log-list">
${activities.map(a =>
`<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${esc(a.desc ?? "—")}
${a.minutes ? `<small>${a.minutes} мин</small>` : ""}</span>
<span class="log-kcal">${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Добавки</h3>
<button class="add-btn" id="add-supp-btn">+</button></div>
<ul class="log-list">
${suppLog.map(s =>
`<li class="log-item">
<span class="log-time">${s.time ?? ""}</span>
<span class="log-desc">${esc(s.supplement_name)}${s.dose_g ? ` <small>${s.dose_g} шт</small>` : ""}</span>
<button class="del-btn" data-table="supp" data-id="${s.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Сон</h3>
<button class="add-btn" id="add-sleep-btn">&#x270E;</button></div>
<div class="sleep-info">
${sleep ? formatSleepInfo(sleep) : `<span class="log-empty">нет данных</span>`}
</div>
</section>
`;
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">&#8592;</button>
</div>
<div class="modal-body">${html}</div>
<div class="modal-btns">
<button id="modal-cancel" class="btn-secondary">Отмена</button>
<button id="modal-ok" class="btn-primary">Сохранить</button>
</div>
</div>`;
document.body.appendChild(overlay);
history.pushState({ modal: true }, "");
const popHandler = () => closeModal(false);
window.addEventListener("popstate", popHandler, { once: true });
function closeModal(goBack) {
overlay.remove();
window.removeEventListener("popstate", popHandler);
if (goBack) history.back();
}
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 id="gps-map" class="gps-map"></div>
<div class="gps-controls">
<div class="gps-stats" id="gps-stats">
<span id="gs-dist">0 м</span>
<span id="gs-speed">0 км/ч</span>
<span id="gs-time">0:00</span>
</div>
<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">&#x22EE;</button>
<div id="gps-menu" class="gps-menu hidden">
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import-gpx">Импорт GPX</div>
<div class="gps-menu-item" id="gps-import-ftb">Импорт FitoTrack (.ftb)</div>
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
</div>
</div>
</div>
</div>
<div class="gps-sessions">
<h3>История</h3>
${sessions.length ? sessions.map(s => `
<div class="gps-session-item" data-sid="${s.id}">
<div class="gps-session-head">
<span>${s.date} ${s.start_time ?? ""}</span>
<span class="gps-session-type">${s.activity_type ?? ""}</span>
</div>
<div class="gps-session-stats">
${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"}
&middot; ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
&middot; ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
${s.elevation_gain_m ? " &middot; +" + Math.round(s.elevation_gain_m) + "м" : ""}
</div>
</div>
`).join("") : `<div class="prod-hint">Нет записей</div>`}
</div>
</div>`;
// Init Leaflet map
setTimeout(() => {
const mapEl = container.querySelector("#gps-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
gpsMap = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 13);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(gpsMap);
L.polyline([], { color: "#ffffff", weight: 6, opacity: 0.8 }).addTo(gpsMap); // border
gpsTrailLine = L.polyline([], { color: "#bb86fc", weight: 3 }).addTo(gpsMap);
// If recording, restore trail
if (GPS.recording.active && GPS.recording.points.length > 0) {
const coords = GPS.recording.points.map(p => [p.lat, p.lon]);
gpsTrailLine.setLatLngs(coords);
gpsMap.fitBounds(gpsTrailLine.getBounds().pad(0.1));
} else {
// Try to center on user location
navigator.geolocation?.getCurrentPosition(
pos => gpsMap.setView([pos.coords.latitude, pos.coords.longitude], 14),
() => {}, { timeout: 5000 }
);
}
}, 100);
// Recording button
container.querySelector("#gps-rec-btn").onclick = async () => {
if (GPS.recording.active) {
const result = await GPS.stopRecording();
renderScreen("gps");
} else {
const type = container.querySelector("#gps-type").value;
const wkg = state.latestWeight?.weight_kg ?? 80;
await GPS.startRecording(type, wkg);
setupLiveUpdates(container);
container.querySelector("#gps-rec-btn").textContent = "Стоп";
container.querySelector("#gps-rec-btn").classList.add("recording");
}
};
// If already recording, hook up live updates
if (GPS.recording.active) setupLiveUpdates(container);
// 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();
if (!confirm(`Удалить ${count} кэшированных тайлов?`)) return;
await TileCache.clear();
alert("Кэш очищен");
};
// GPX import
container.querySelector("#gps-import-gpx").onclick = () => {
menu.classList.add("hidden");
pickFile(".gpx", async (text) => {
const wkg = state.latestWeight?.weight_kg ?? 80;
await GPS.importGPX(text, wkg);
renderScreen("gps");
});
};
// FitoTrack .ftb import
container.querySelector("#gps-import-ftb").onclick = () => {
menu.classList.add("hidden");
pickFile(".ftb,.zip", async (blob, file) => {
console.log("[FTB] file:", file.name, "size:", blob.byteLength);
try {
const wkg = state.latestWeight?.weight_kg ?? 80;
const count = await GPS.importFTB(blob, wkg);
alert(`Импортировано ${count} тренировок`);
renderScreen("gps");
} catch (e) {
console.error("[FTB] import error:", e);
alert("FTB import error: " + (e.message || e));
}
}, true);
};
}
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="10" min="1" max="18" />
</div>
<div class="form-group">
<label>Макс. зум</label>
<input id="m-zmax" type="number" value="16" 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) {
const session = await DB.getGpsSession(sessionId);
if (!session) return;
const rawPoints = await DB.getGpsPoints(sessionId);
const points = GPS.filterNoise(rawPoints, session.activity_type);
const stats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? 80);
// 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);
}
}
// 9-point moving average on top
const smoothed = rawSpeeds.map((_, i) => {
let sum = 0, count = 0;
for (let j = Math.max(0, i - 4); j <= Math.min(rawSpeeds.length - 1, i + 4); j++) {
sum += rawSpeeds[j]; count++;
}
return Math.round(sum / count * 10) / 10;
});
// 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 = new Date(points[0]?.ts).getTime();
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">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<canvas id="sd-speed-chart" height="150"></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"></canvas>
</div>` : ""}
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:16px;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 });
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 mapFsPopHandler = null;
function toggleMapFs() {
const isFull = mapEl.classList.toggle("sd-map-full");
fsDiv.innerHTML = isFull ? "&#x2715;" : "&#x26F6;";
if (isFull) {
history.pushState({ mapFs: true }, "");
mapFsPopHandler = () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
};
window.addEventListener("popstate", mapFsPopHandler, { once: true });
} else {
if (mapFsPopHandler) { window.removeEventListener("popstate", mapFsPopHandler); mapFsPopHandler = null; }
history.back();
}
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");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleMapFs(); };
return fsDiv;
};
fsBtn.addTo(map);
}, 200);
// Render speed chart
setTimeout(() => {
const ctx = document.querySelector("#sd-speed-chart");
if (!ctx || !smoothed.length) return;
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 } },
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 } },
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 del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
if (!confirm("Удалить тренировку?")) return;
await DB.deleteGpsSession(sessionId);
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("gps");
};
}, 100);
}
// ---------------------------------------------------------------------------
// 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;
container.innerHTML = `
<div class="settings-screen">
<h2>Профиль</h2>
<div class="form-group">
<label>Рост (см)</label>
<input id="s-height" type="number" inputmode="numeric"
value="${p.height_cm ?? ""}" placeholder="178" />
</div>
<div class="form-group">
<label>Год рождения</label>
<input id="s-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="s-bmr-formula" type="number" inputmode="numeric"
value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="s-bmr-est" type="number" inputmode="numeric"
value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>
<button id="save-profile" class="btn-primary full-width">Сохранить</button>
</div>`;
let selectedSex = p.sex ?? "m";
container.querySelectorAll(".type-chip").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
container.querySelectorAll(".type-chip").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
container.querySelector("#save-profile").onclick = async () => {
const height = parseInt(container.querySelector("#s-height").value);
const year = parseInt(container.querySelector("#s-year").value);
const bFormula = parseInt(container.querySelector("#s-bmr-formula").value) || null;
const bEst = parseInt(container.querySelector("#s-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) { alert("Введите рост и год рождения"); return; }
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 };
renderScreen("today");
};
}
// ---------------------------------------------------------------------------
// 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 pickFile(accept, callback, asBinary) {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
try {
if (asBinary) {
const buf = await file.arrayBuffer();
await callback(buf, file);
} else {
const text = await file.text();
await callback(text, file);
}
} catch (e) {
alert("Import error: " + e.message);
}
};
input.click();
}
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
init().catch(err => {
console.error(err);
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
});