diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index eef219a..9e4e7a4 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -9,7 +9,7 @@ | Строк кода | ~22000 | | Файлов исходников | 50+ | | Язык | JS 70%, Rust 6%, Kotlin 12%, CSS 12% | -| Таблиц в БД | 20 | +| Таблиц в БД | 21 | | Экранов | 7 основных + 15 модалок | | IPC команд (Tauri) | 14 | | Библиотеки | Leaflet, Chart.js, Hammer.js, JSZip | @@ -21,9 +21,9 @@ | Файл | Строк | Назначение | |------|-------|------------| -| `js/main.js` | ~5800 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги | -| `js/db.js` | ~1500 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync | -| `js/gps.js` | ~1600 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg | +| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев) | +| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера | +| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, DOMParser) | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | | `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) | | `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад | @@ -100,8 +100,9 @@ | `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m | | `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы | | `supplement_log` | Приём добавок | date, supplement_id, dose_g | -| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id | -| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw | +| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер) | +| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw | +| `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json, created_at | | `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active | | `rdi_standards` | Стандарты РСП (RU/EU/US) | code, name | | `rdi_values` | Нормы нутриентов | standard_id, nutrient_key, value, norm_type (rdi/aup) | @@ -129,14 +130,14 @@ ### Экраны - **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы) - **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник -- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, вело/пешие) +- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М»), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали) - **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10) - **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг, OTA обновления, импорт тайлов, карта дорог ### Модалки - Еда: showFoodModal, showEditFoodModal, showFoodFromRecipeModal - Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal -- Тренировки: showSessionDetail (карта, графики скорости/высоты, пересчёт) +- Тренировки: showSessionDetail (карта, графики скорости/высоты, пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты) - Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal ## IPC команды (Rust → JS) diff --git a/README.md b/README.md index f16690b..fe90b77 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ - **Графики** — скорость, высота, ЧСС тренировки с зумом и pan. Тренд веса. Донат макронутриентов. Баланс ккал (приход/расход). - **OTA обновления** — проверка BUILD_NUMBER на роутере (LAN) или VPS, скачивание через браузер. - **Ручные маршруты** — рисование трека на карте тапами, elevation из прошлых треков или open-meteo API. +- **Сохранённые маршруты** — импорт GPX-маршрутов, нарисованных заранее (например, в конструкторе Яндекс.Карт: формат ``), выбор активного, наложение цветной линией поверх карты во время записи И на экране просмотра прошлого трека — для визуальной навигации. Управление на отдельной странице (импорт/переименование/цвет/удаление, один активный за раз), переключение линии кнопкой-тогглом. Цвета «проезженных дорог» настраиваются раздельно для вело/пеших/бега. +- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД для будущего пересчёта с порогом точности. - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. @@ -32,7 +34,7 @@ ./build.sh -d android # debug APK ./build.sh android # release APK ./build.sh -i android -d # debug + установка на устройство -./build.sh -u android # release + upload на VPS (OTA) +./build.sh -u android # release + upload на роутер (LAN) и VPS (OTA) ./build.sh -d -i -u android # debug + install + upload ``` diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 670e282..e36ad38 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -628,6 +628,47 @@ select { flex-shrink: 0; } +/* In-app confirm / prompt dialog — centered card over a dimmed backdrop, + above full-screen modals (z-index 500) and fullscreen maps (9999). */ +.confirm-overlay { + background: rgba(0, 0, 0, 0.6); + align-items: center; + padding: 0 24px; + z-index: 10000; +} +.confirm-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 18px 16px; + width: 100%; + max-width: 340px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.confirm-msg { + font-size: 15px; + line-height: 1.4; + margin-bottom: 16px; + color: var(--on-bg); +} +.confirm-input { + width: 100%; + padding: 10px; + margin-bottom: 16px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--on-bg); + font-size: 15px; +} +.confirm-btns { + display: flex; + gap: 10px; +} +.confirm-btns button { + flex: 1; +} + .btn-primary, .btn-secondary { flex: 1; padding: 12px; diff --git a/frontend/js/db.js b/frontend/js/db.js index 23114b8..da59fc9 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -762,11 +762,28 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa // setSessionSmoothed(id, false). `smoothedPoints` carry each point's `id` + the // smoothed `lat`/`lon`. async function saveSmoothedTrack(sessionId, smoothedPoints, window, iters, onProgress) { - for (let i = 0; i < smoothedPoints.length; i++) { - const p = smoothedPoints[i]; - if (p.id == null) continue; - await execute("UPDATE gps_points SET lat_smoothed=?1, lon_smoothed=?2 WHERE id=?3", [p.lat, p.lon, p.id]); - if (onProgress && (i % 100 === 0 || i === smoothedPoints.length - 1)) { try { onProgress(i + 1, smoothedPoints.length); } catch {} } + // One UPDATE per point over IPC is thousands of round-trips (slow, ~1 pt/ms). + // Batch into a single CASE-per-chunk UPDATE — ~15 statements instead of 4000+. + // Chunk keeps bound params under SQLite's 999 limit (250 pts × 3 params = 750). + let pts = smoothedPoints.filter(p => p.id != null); + let CHUNK = 250; + for (let start = 0; start < pts.length; start += CHUNK) { + let chunk = pts.slice(start, start + CHUNK); + let latWhen = [], lonWhen = [], ids = [], params = []; + for (let p of chunk) { + params.push(p.id); let idi = params.length; + params.push(p.lat); let lati = params.length; + params.push(p.lon); let loni = params.length; + latWhen.push(`WHEN ?${idi} THEN ?${lati}`); + lonWhen.push(`WHEN ?${idi} THEN ?${loni}`); + ids.push(`?${idi}`); + } + await execute( + `UPDATE gps_points SET lat_smoothed=CASE id ${latWhen.join(" ")} END, ` + + `lon_smoothed=CASE id ${lonWhen.join(" ")} END WHERE id IN (${ids.join(",")})`, + params + ); + if (onProgress) { try { onProgress(Math.min(start + CHUNK, pts.length), pts.length); } catch {} } } await execute("UPDATE gps_sessions SET smoothed=1, smooth_window=?1, smooth_iters=?2 WHERE id=?3", [window, iters, sessionId]); } diff --git a/frontend/js/main.js b/frontend/js/main.js index f8cc840..506b49c 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1019,6 +1019,91 @@ function showModal(html, onSubmit, onMount) { if (onMount) onMount(overlay); } +// In-app confirm dialog (replaces the ugly native confirm()). Integrated with the +// winStack/history so the hardware back button cancels it. Returns Promise. +function appConfirm(message, okText = "OK") { + return new Promise(resolve => { + let ov = document.createElement("div"); + ov.className = "modal-overlay confirm-overlay"; + ov.innerHTML = ` +
+
${message}
+
+ + +
+
`; + document.body.appendChild(ov); + let settled = false; + let base = winStack.length; + winPush("confirm", () => { ov.remove(); if (!settled) { settled = true; resolve(false); } }); + let finish = (val) => { + if (settled) return; + settled = true; + let count = winStack.length - base; + for (let i = 0; i < count; i++) winStack.pop().close(); + _skipPops = count; + if (count > 0) history.go(-count); + ov.remove(); + resolve(val); + }; + ov.querySelector("#cf-cancel").onclick = () => finish(false); + ov.querySelector("#cf-ok").onclick = () => finish(true); + }); +} + +// In-app text prompt (replaces native prompt()). Returns Promise. +function appPrompt(message, defaultValue = "", okText = "OK") { + return new Promise(resolve => { + let ov = document.createElement("div"); + ov.className = "modal-overlay confirm-overlay"; + ov.innerHTML = ` +
+
${message}
+ +
+ + +
+
`; + document.body.appendChild(ov); + let input = ov.querySelector("#cf-input"); + let settled = false; + let base = winStack.length; + winPush("prompt", () => { ov.remove(); if (!settled) { settled = true; resolve(null); } }); + let finish = (val) => { + if (settled) return; + settled = true; + let count = winStack.length - base; + for (let i = 0; i < count; i++) winStack.pop().close(); + _skipPops = count; + if (count > 0) history.go(-count); + ov.remove(); + resolve(val); + }; + ov.querySelector("#cf-cancel").onclick = () => finish(null); + ov.querySelector("#cf-ok").onclick = () => finish(input.value); + input.focus(); + input.select(); + }); +} + +// Close the currently-open track-detail modal (plus its fullscreen-map child, if any), +// balance the history stack, then reopen it fresh. Mirrors the recalc/refine pattern +// but pops down to the detail modal so it also works while the map is fullscreen — +// re-rendering in place instead of stacking a second overlay (which broke the map/charts). +function reopenDetail(sessionId) { + let count = 0; + while (winStack.length > 0) { + let top = winStack.pop(); + count++; + try { top.close(); } catch {} + if (top.type === "modal") break; + } + if (count > 0) { _skipPops = count; history.go(-count); } + showSessionDetail(sessionId); +} + function showWeightModal() { showModal(`

Вес

@@ -2843,13 +2928,13 @@ async function showRoutesScreen(container) { row.querySelector(".route-swatch").style.background = e.target.value; }; row.querySelector(".route-rename").onclick = async () => { - let name = prompt("Новое название:", curName); + let name = await appPrompt("Новое название:", curName, "Сохранить"); if (name == null || !name.trim()) return; await DB.renameRoute(id, name.trim()); render(); }; row.querySelector(".route-del").onclick = async () => { - if (!confirm(`Удалить маршрут «${curName}»?`)) return; + if (!(await appConfirm(`Удалить маршрут «${curName}»?`, "Удалить"))) return; await DB.deleteRoute(id); if (active === id) { active = ""; localStorage.removeItem("baldav_active_route"); } render(); @@ -3968,13 +4053,13 @@ async function showSessionDetail(sessionId) { smPanel.querySelector("#sm-w").oninput = e => { smParams.W = +e.target.value; smPanel.querySelector("#sm-wv").textContent = smParams.W; drawPreview(); }; smPanel.querySelector("#sm-i").oninput = e => { smParams.it = +e.target.value; smPanel.querySelector("#sm-iv").textContent = smParams.it; drawPreview(); }; smPanel.querySelector("#sm-save").onclick = async () => { - if (!confirm(`Сохранить сглаженный трек (окно ${smParams.W}, итераций ${smParams.it})? Оригинал сохранится, можно откатить.`)) return; + if (!(await appConfirm(`Сохранить сглаженный трек (окно ${smParams.W}, итераций ${smParams.it})? Оригинал сохранится, можно откатить.`, "Сохранить"))) return; let btn = smPanel.querySelector("#sm-save"); btn.disabled = true; let sm = GPS.smoothTrackSG(rawPoints, smParams.W, smParams.it); await DB.saveSmoothedTrack(sessionId, sm, smParams.W, smParams.it, (i, n) => { btn.textContent = `Сохранение ${i}/${n}`; }); await recomputeStatsFor(sm); showToast("Сглаженный трек сохранён"); - showSessionDetail(sessionId); + reopenDetail(sessionId); }; let rev = smPanel.querySelector("#sm-revert"); if (rev) rev.onclick = async () => { @@ -3982,7 +4067,7 @@ async function showSessionDetail(sessionId) { await DB.setSessionSmoothed(sessionId, false); await recomputeStatsFor(rawPoints); showToast("Возвращён оригинал"); - showSessionDetail(sessionId); + reopenDetail(sessionId); }; }; let smCtl = L.control({ position: "topleft" }); @@ -3990,7 +4075,8 @@ async function showSessionDetail(sessionId) { smBtnDiv = L.DomUtil.create("div", "sd-map-fs-btn"); smBtnDiv.innerHTML = "≈"; smBtnDiv.title = "Сглаживание трека"; - smBtnDiv.classList.toggle("active", !!session.smoothed); + // "active" reflects the PANEL-OPEN state only (set by smSetActive), not whether + // the track is smoothed — otherwise it looks permanently pressed after a save. smBtnDiv.onclick = e => { e.stopPropagation(); toggleSmoothPanel(); }; return smBtnDiv; }; @@ -4100,6 +4186,9 @@ async function showSessionDetail(sessionId) { fsDiv.innerHTML = "⛶"; map.dragging.disable(); map.scrollWheelZoom.disable(); + // Closing the map also closes the smoothing overlay panel (it's a fixed + // element tied to the map, otherwise it lingers over the collapsed map). + if (smPanel) toggleSmoothPanel(); map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds); }); winStack[winStack.length - 1].overlay = parentOverlay; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index af1789f..44d571b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.384" +version = "0.1.393" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c1592c..5ef591c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.384" +version = "0.1.393" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f22883c..2519227 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.384", + "version": "0.1.393", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"