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
1602 lines
61 KiB
JavaScript
1602 lines
61 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;
|
||
}
|
||
|
||
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">←</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 = () => 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">⋮</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) : "—"}
|
||
· ${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>`;
|
||
|
||
// 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 ? "✕" : "⛶";
|
||
if (isFull) {
|
||
history.pushState({ mapFs: true }, "");
|
||
mapFsPopHandler = () => {
|
||
mapEl.classList.remove("sd-map-full");
|
||
fsDiv.innerHTML = "⛶";
|
||
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 = "⛶";
|
||
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, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start
|
||
// ---------------------------------------------------------------------------
|
||
|
||
init().catch(err => {
|
||
console.error(err);
|
||
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
|
||
});
|