diff --git a/.gitignore b/.gitignore index e4b6398..baa8599 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,9 @@ chzn_session.json binaries/ test.exe +# Build script holds a deploy secret (router password); build counter is local state +build.sh +BUILD_NUMBER + # Reference material kept locally, not in the repo *.pdf diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 3f87437..006451f 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,7 +22,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; `` оставлены). `orient-dbg` (сенсор-оверлей на карте) — только при `window.__BALDAV_DEBUG`. `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) | -| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea` | +| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`. `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск | | `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` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает ``, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | | `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) | @@ -53,7 +53,7 @@ |------|-------|------------| | `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check | | `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | -| `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect | +| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) @@ -81,7 +81,7 @@ | Файл | Назначение | |------|------------| -| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS | +| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS, `clean`/авто-чистка старых бандлов (`clean_bundles`) | | `BUILD_NUMBER` | Автоинкремент номера сборки | | `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) | | `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) | @@ -142,7 +142,7 @@ ### Экраны - **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы) - **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник -- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали) +- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали), тоггл следования карты за позицией (`followMode`, кнопка `⊕` в `rec-map-btn` стеке; guard вокруг `panTo`/`setView` в `onPoint`/`onBatchUpdate`/`onStateChange`; выкл = свободный просмотр карты на ходу, повторный тап центрирует; не запоминается). Карты с тяжёлыми векторами (`showSessionDetail`, `showRoadsMap`, `showComparisonView`, `showRouteDetail`) создаются с `preferCanvas: true` — большой трек/дороги в дефолтном SVG превышают лимит памяти тайлов компоновщика Chromium на зуме (`tile_manager tile memory limits exceeded`) и тайлы гаснут до перезапуска; canvas рисует в один холст. НЕ на карте записи (leaflet-rotate плохо дружит с Canvas) и не в редакторе маршрута (клик по линии) - **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10) - **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог @@ -151,6 +151,9 @@ - Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal - Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый ``-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты) - Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save) +- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция). Склейка блокируется, если у выбранных треков разные `bike_id` (appAlert) +- Разрезание трека: кнопка `#sd-split` в `showSessionDetail` включает `#sd-map.split-mode`; в попапе каждой точки кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` (db.js): вторая половина (точки по id, чанки) в НОВУЮ сессию (id=Date.now(), копия date/activity/bike/source), `seq=NULL`, у старой правится end_time; пересчёт обоих кусков. Велик копируется, потом меняется вручную +- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`) - Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter - Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal @@ -179,6 +182,9 @@ | `ble_save_device` | Сохранение привязанного устройства | | `import_tiles` | Чтение тайлового SQLite батчами (base64) | | `download_file` | Скачивание бинарного файла по URL (без лимита размера) | +| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) | +| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД | +| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) | | `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') | | `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) | | `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` | @@ -254,8 +260,10 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200 ./build.sh android # release APK (подпись debug.keystore) ./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA) ./build.sh install -d # поставить уже собранный APK без пересборки +./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает) ``` Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK. +`clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке. ## Отладка WebView (debug-сборки) diff --git a/README.md b/README.md index b817e18..5f924cd 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,30 @@ Персональный трекер здоровья и физической активности. +## Текущие версии (2026-09-22, build 474) + +| Что | Версия | +|-----|--------| +| Приложение (BalDav) | **0.1.474**, build **474** | +| Выкачено на роутер (OTA) | build **474** - `http://192.168.1.1:88/baldav/baldav.apk` | +| Резерв OTA (VPS) | build **474** - `https://4aiman.top/baldav/baldav.apk` | +| Встроенный движок BRouter | 1.6.3 | +| Сид продуктов (`products_seed.db`) | 11 | +| Сборка | Tauri v2, Android arm64, debug-подпись | + +Версия/номер сборки бампаются автоматически при `./build.sh android` (файлы `BUILD_NUMBER`, +`tauri.conf.json`, `Cargo.toml/lock`). Телефон сам проверяет обновление: сначала роутер +(LAN `192.168.1.1:88/baldav`), затем VPS - берёт `BUILD_NUMBER`, если больше локального, +предлагает скачать `baldav.apk`. Выкатка на роутер - существующим APK (без пересборки, чтобы +номер не уезжал) либо `./build.sh android -d -u` (соберёт новый номер и зальёт на роутер+VPS). + ## Возможности - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). - **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). -- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. -- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. +- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. Подключение **только вручную** (Настройки -> «Подключить»), в т.ч. во время записи - пульс пишется в трек с момента подключения; автопоиска при старте записи нет (экономия батареи). BT выключен -> кнопка просит включить (системный диалог). Браслет не найден в скане -> сервис не висит в коннекте. +- **Зарядка (пульс)** — режим записи БЕЗ GPS для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`). +- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». - **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. - **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. @@ -24,12 +42,17 @@ - **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей. - **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё». - **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется. +- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`). +- **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске. +- **Резервная копия всех данных** - в Настройках **Экспорт** сливает всю базу (`baldav.db`) в файл через SAF (`export_db` -> `VACUUM INTO` чистой сжатой копии, ~на треть меньше), **Импорт** выбирает файл, проверяет его (`PRAGMA quick_check` + наличие таблиц BalDav) и восстанавливает с ЗАМЕНОЙ: файл кладётся в `pending_restore.db`, приложение перезапускается, и Kotlin в `onCreate` (до открытия БД) свопит его на `baldav.db`. Секретов в базе нет. Зачем: переносить наработки между сборками (**debug <-> release** - разные подписи, переустановка стирает данные) без сторонних бэкаперов. Внутриприложенческой авто-установки APK намеренно нет - только экспорт/импорт данных. +- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (``, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. - **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог. - **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки. - **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее. +- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается). - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. diff --git a/docs/gps-model-findings.md b/docs/gps-model-findings.md index bf9f629..fce3197 100644 --- a/docs/gps-model-findings.md +++ b/docs/gps-model-findings.md @@ -351,3 +351,299 @@ Aeropod). Трубка, направленная вперёд, плюс датч Готовый код обоих методов варианта 1 -- в `tools/track/track.py` (функция `wind_by_passes` и секция «ветер» в `report`), выше описан алгоритмически. + +--- + +# БЭКЛОГ: автоматический разбор повторов внутри одной поездки + +Заказано пользователем 17 сентября 2026. + +## Задача + +Пользователь катает интервалы: приезжает к подъёму, проезжает его 5 раз, +скатываясь вниз между заездами, потом уезжает домой. Сейчас, чтобы разобрать +такую тренировку, приходится либо останавливать и заново стартовать трек +на каждом повторе (пользователь называет это бредом), либо разбирать +вручную задним числом. + +Нужно: **один трек на всю тренировку, повторы находятся сами.** Пользователь +готов на отдельный режим «велотренировка», если так проще. + +## Что должно получиться на выходе + +Таблица повторов: номер, время, средняя скорость, каденс, мощность. +И главное число, по которому принимается решение о завтрашнем дне: + + отношение последнего повтора к первому + +Порог, выведенный из данных (11 и 17 сентября, по 5 повторов): +- последний быстрее или равен первому -- не на пределе, можно повторить завтра +- последний медленнее на 5-10% -- нормальная тяжёлая тренировка +- последний медленнее больше чем на 10% -- два дня спокойно + +## Алгоритм, проверен вручную на этих данных + +Повторы ищутся по самоподобию трека, без опорных точек от пользователя. + +1. **Разложить точки поездки по ячейкам** ~10 м (сетка как в `heightmap.py`). +2. **Найти ячейки, посещённые больше одного раза**, и отбросить те, где + направление движения (`bearing_deg`) при посещениях различается больше + чем на 60 градусов: проезд вниз по той же дороге повтором не считается. + **Без проверки направления половина повторов теряется или считается + задом наперёд** -- проверено, это главная ловушка. +3. **Собрать самую длинную цепочку** таких ячеек, идущую подряд по ходу + движения. Это и есть повторяемый участок. Требовать длину не меньше + 300 м и не меньше 3 проходов, иначе это перекрёсток у дома, а не подъём. +4. **Разметить проходы:** для каждого прохода записать вход и выход + по цепочке, время, длину. +5. **Нормировать время на общую длину:** у пользователя GPS даёт от 1138 + до 1199 м на одном и том же подъёме, то есть 5% разброса. Без нормировки + сравнение повторов между собой врёт. + +## Чего делать НЕ надо + +**Не просить пользователя ставить метки.** Он специально сказал, что +останавливать и перезапускать трек не будет. + +**Не опираться на высоту отдельного проезда.** Профиль повторяемого участка +брать из карты высот по многим проездам (см. соседний раздел): у одиночного +проезда GPS даёт ступеньки вида 0, 0, 0, 0, 8.5 м на ровном месте. + +**Не считать мощность усреднением по точкам.** Усреднять по времени. +При усреднении по точкам отрезки чуть разной длины дают разный вес +медленным кускам, и на одинаковой скорости выходят разные ватты +(проверено: 17.9 км/ч -> 173 Вт и 18.1 км/ч -> 209 Вт на одном подъёме). + +## Готовый код + +Ручная версия детектора по двум опорным точкам -- в истории этой сессии, +в `tools/track/`. Она находит проходы правильно, но опорные точки задаются +руками. Автоматизировать надо шаги 1-3. + +--- + +# БЭКЛОГ: массовое уточнение высот по всем трекам + +Заказано пользователем 17 сентября 2026. Приоритет свой, не срочный. + +## Что нужно + +Пункт меню, который уточняет высоты **пакетно**: идёт от последнего трека +назад по времени и обрабатывает те, где `altitude_srtm_m` ещё не заполнена. +Пользователь запускает его вручную, когда сидит на проводном интернете. + +Требования к исполнению, из общих правил проекта: +- показывать прогресс числами: сколько треков обработано, сколько осталось +- прерываемость: в поле связь рвут РЭБ и РКН, процесс должен переживать + обрыв и продолжаться с того места, где встал, а не начинать заново +- уже уточнённые треки не трогать повторно + +## Зачем, с измерениями + +Аудит всех 83 велопоездок показал, что **высота за май и июнь непригодна**: + + май-июнь: набор в БД 0-23 м, расчётный по сглаженной высоте 50-207 м + июль-сентябрь: набор в БД и расчётный сходятся (168/138, 251/275, 227/235) + +До 17 мая запись шла раз в 3-4 секунды при шуме высотомера 0.8 м между +соседними точками. Пример 10 мая (session_id 3): сырая высота скачет +`36 20 23 20 20 36 36 35 35 35 34 34 33 20 23 20 19 32 32 32 31`, то есть +приёмник бегает между двумя уровнями с размахом 16 м. Медианный фильтр +и скользящее среднее это приглаживают, но остаток даёт уклоны до 12.8% +на треке, где реального набора 14 м на 13 км. + +Практическое следствие: **любой анализ по уклонам за май и июнь даёт +фальшивые результаты**, причём правдоподобные на вид. Построенная по этим +данным кривая «лучший удержанный момент на шатунах» показала май лучшим +месяцем сезона, хотя по мощности за 20 минут рост с мая по сентябрь +составил 1.12 -> 1.75 Вт/кг. + +Отдельно найдены отказы высотомера внутри годных треков: 2 сентября +(session_id 1788361994788) после точки 4194 значение падает на 10 м +и **замерзает** -- 26.8, 26.9, 26.9, 26.9 шестьдесят точек подряд, +а после перерыва записи прыгает на 64.8 м с точностью 22 м. Уклоны +на этом участке выдуманы целиком. + +## Признаки негодной высоты, годятся как автотест + +Все три считаются по уже записанным данным, без сети: + +1. `elevation_gain_m` в сессии и набор, пересчитанный по точкам, расходятся + больше чем вдвое в любую сторону +2. медиана модуля разности высоты между соседними точками выше 1.5 м +3. доля точек с шагом записи больше 8 секунд выше 15% + +По этим признакам из 83 поездок отсеиваются 31. После уточнения высот +через SRTM проверку надо прогнать повторно: она же покажет, помогло ли. + +## Объём работы, посчитан по базе + + всего точек 418 465 + уже уточнено 47 420 (11.3%, 12 сессий из 170) + осталось 371 045 + запросов по 100 3 711 + +Размер пачки 100 задан в `frontend/js/main.js` около строки 3085 и совпадает +с потолком OpenTopoData. + +## Что надо поправить ДО массового прогона + +**Откат по цепочке источников менять не надо.** Он сделан намеренно: +лучше получить хоть какую-то высоту, чем никакой. Проблема не в откате, +а в том, что после него не остаётся следа, кто именно ответил. + +**1. Нет колонки источника.** В `gps_points` есть `altitude_srtm_m`, но нет +поля, откуда это значение пришло. При работающем откате база неизбежно +становится смесью источников, и без этой колонки отличить их потом нельзя +никак. Нужна колонка (ALTER TABLE, без пересоздания), заполняемая при записи. +Это единственное обязательное изменение. + +**2. Откат должен быть виден пользователю.** Сейчас он сообщается только +строкой в консоли (`[gpxz] http 403`). Достаточно показать в конце итог +вида «уточнено 2314 точек: GPXZ 0, open-elevation 0, open-meteo 2314». + +**3. Обработка 429 и 402 отдельно от сетевой ошибки.** По лимиту +останавливаться до завтра, по обрыву повторять. Иначе при массовом прогоне +упёршийся лимит молча отправит весь остаток базы к запасному источнику. + +## Чего ждать и чего не ждать + +Copernicus GLO-30 это сетка 30 м. На подъём длиной 200-400 м приходится +7-13 отсчётов, этого хватает. На короткий рамп 60-100 м приходится 2-3 +отсчёта, форму по ним не восстановить. То есть массовое уточнение закроет +затяжные подъёмы и общий набор, но НЕ закроет короткие рампы, на которых +пользователь срывается. Для них нужен либо барометр, либо разовый замер +профиля конкретного подъёма угломером. + +## Лимиты GPXZ против объёма + +Тарифы с их сайта: Evaluation бесплатно и 100 запросов в сутки (помечен +«testing only»), Small 99 USD/мес и 2500 в сутки, Large 249 USD/мес и 7500. + + 3711 запросов при 100/сутки = 37 дней + 3711 запросов при 2500/сутки = 1.5 дня + +Отдельно: одна поездка это медиана 19 запросов, но максимум 133. То есть +**длинная поездка не влезает в бесплатный суточный лимит даже одна.** +Если массовый прогон делать на бесплатном ключе, он обязан быть +возобновляемым и считать свой суточный расход сам. + +## Вариант без лимитов: свой Open Topo Data + +GPXZ на своём сайте прямо пишет, что открытый форк их API-сервера +называется Open Topo Data. Размер пачки 100, уже зашитый в коде, +взят как раз из его потолка, то есть **протокол совместим и менять +в клиенте нужен только базовый URL** (сейчас `https://api.gpxz.io/v1/...` +захардкожен в `frontend/js/main.js` около строки 3091). + +Считано по базе: все треки пользователя укрываются **двумя градусными +тайлами 1x1**. Для GLO-30 это порядка сотни мегабайт на диске, разово +скачивается по проводу. + +Что это даёт против платного тарифа: +- лимита нет вообще, весь бэклог прогоняется за один вечер +- в поле сеть не нужна: у пользователя её глушат, и это постоянная + проблема всех сетевых зависимостей приложения +- тот же софт и тот же набор данных, значит результаты сопоставимы + с уже уточнёнными 12 сессиями, если там был GPXZ + +Минус: нужен хост, на котором это крутится, и разовая возня с датасетом. + +## Но своего хоста может и не понадобиться + +Автор Open Topo Data держит бесплатный публичный инстанс +`api.opentopodata.org`. Его ограничения: + + 100 точек на запрос -- ровно тот размер пачки, что уже в коде + 1 запрос в секунду -- нужен троттлинг, сейчас пауз между пачками нет + 1000 запросов в сутки -- в десять раз больше бесплатного GPXZ + + весь бэклог 3711 запросов -> 3.7 суток, 62 минуты чистого времени, + то есть ~15 минут работы в день + +Сравнение по срокам на тот же объём: + + GPXZ Evaluation (бесплатно) 37.1 дней + Open Topo Data публичный 3.7 дней + GPXZ Small (99 USD/мес) 1.5 дня + +**Ключ не нужен вообще**, значит отпадает и вопрос, укладывается ли +массовый прогон личного архива в пометку «testing only» бесплатного +тарифа GPXZ. + +### Выбор датасета, и почему это не мелочь + +На публичном инстансе **нет Copernicus GLO-30**, на котором работает GPXZ. +Из глобальных 30-метровых доступны `mapzen`, `aster30m`, `srtm30m`. +`eudem25m` (25 м, лучшая точность) покрывает только страны EEA, регион +пользователя в его extent не входит. + +Отсюда два следствия: + +1. Датасет будет **не тот**, которым уточнены нынешние 12 сессий. + Смешивать нельзя, и колонка источника из пункта 2 выше становится + не пожеланием, а обязательной: без неё база превратится в винегрет + из трёх рельефов. +2. Разумный выбор по умолчанию `srtm30m` (USGS): у `aster30m` известны + шумы и артефакты, `mapzen` это композит из нескольких источников, + то есть внутри одного датасета точность неоднородна. + +Хорошая проверка после прогона: 12 уже уточнённых сессий уточнить заново +новым датасетом в отдельную колонку и сравнить набор высоты. Разойдутся +сильно - значит выбор датасета важнее, чем кажется, и это надо знать +до того, как на этих высотах строить разбор поездок. + +## ПРОВЕРЕНО: в базе лежит не то, что все думали + +Проверка сделана, и она важнее всего остального в этом разделе. + +**Ключ GPXZ не работает и, судя по всему, не работал никогда.** Запрос +к `api.gpxz.io` возвращает HTTP 403 с телом `error code: 1010` +и заголовком `Server: cloudflare`. Ответ **одинаковый с ключом и без +ключа**, то есть запрос режет Cloudflare по подписи клиента, до всякой +проверки ключа. Ключ при этом валидный на вид: 28 символов, префикс `ak_`. + +**Те 12 «уточнённых» сессий уточнены open-meteo.** Это третий, последний +запасной источник в цепочке `GPXZ -> open-elevation -> open-meteo`. +Доказательство: опорные целые значения в `altitude_srtm_m` совпадают +с ответом open-meteo точка в точку и расходятся с `srtm30m` на 2-3 м. + +**Дробные значения в базе создаёт приложение, а не источник.** Записанная +последовательность `39.000, 38.857, 38.714, 38.571, 38.429, 38.286, +38.143, 38.000` это `39 - k/7`, линейная интерполяция между двумя целыми +отсчётами. Все внешние DEM (srtm30m, aster30m, mapzen, srtm90m, +open-elevation, open-meteo) отдают **целые метры**. То есть дроби +в колонке выглядят как высокая точность, а на деле это прямая, натянутая +между узлами сетки в 30-90 м. + +Практическое следствие для любого будущего разбора: **нельзя считать +уклон по `altitude_srtm_m` на коротких кусках.** Интерполяция даёт +гладкий правдоподобный профиль там, где данных нет вообще. + +### Что из этого следует для задачи + +1. Тихий откат (пункт 1 выше) не гипотетическая опасность, а уже + случившийся факт: база заполнена последним источником в цепочке, + и об этом никто не знал. +2. Колонка источника (пункт 2) обязательна. +3. Отказ надо показывать пользователю, а не писать в консоль. + +### Покупать ли GPXZ: нет + +С карты покрытия на их сайте: лидар 0.5-2 м у GPXZ есть только в США, +западной и северной Европе и Новой Зеландии. Регион пользователя +в жёлтой зоне, где по их же легенде 30 м, а по их тексту база +из Copernicus 30m. То есть платный GPXZ дал бы Copernicus 30m вместо +бесплатного SRTM 30m: другая вертикальная точность, **та же +горизонтальная сетка 30 м**. + +А упирается всё именно в горизонтальную: подъём длиной 60-100 м, на +котором пользователь срывается, это 2-3 отсчёта на любой тридцатиметровой +сетке. Ни один DEM его не разрешит, ни платный, ни бесплатный. + +Измерено на одном подъёме (1153 м, 229 точек, первый заезд 16 сентября): +источники расходятся между собой до 2-3% уклона на сотню метров, что +того же порядка, что шум GPS. `mapzen` при этом отдаёт числа, побитово +совпадающие с `srtm30m`. + +Вывод: брать бесплатный `srtm30m` с публичного Open Topo Data. diff --git a/docs/plans/2026-09-26-map-follow-toggle.md b/docs/plans/2026-09-26-map-follow-toggle.md new file mode 100644 index 0000000..b97cc14 --- /dev/null +++ b/docs/plans/2026-09-26-map-follow-toggle.md @@ -0,0 +1,76 @@ +# План: кнопка тоггла "следование карты за позицией" на экране записи + +## Context (зачем) +Во время записи карта на экране трекинга постоянно снэпится (`panTo`) на текущую GPS-позицию +при каждой новой точке. Кейс пользователя: отклонился от трека или трек ведёт туда, где дороги +нет, надо осмотреться по карте вокруг - но карта тут же прыгает обратно на позицию, подвигать +её невозможно. Нужна кнопка-тумблер: нажал - карта перестаёт следовать за позицией (можно +свободно двигать/смотреть), нажал снова - карта центрируется на текущей позиции и следование +возобновляется. + +Решения по UX (согласованы с пользователем): +- Кнопка - плитка 32x32 в общем правом ряду кнопок карты, с символом (не словом), активное + состояние подсвечивается (как freeze/roads). Активна = следование включено. +- Только кнопка-тумблер. Драг карты пальцем следование НЕ выключает (строго как в запросе). +- Повторный тап (включение следования) сразу центрирует карту на текущей позиции. +- Следование по умолчанию включено при каждом заходе на экран (в localStorage не храним). + +## Где сейчас следование (frontend/js/main.js, функция renderRecordingInline) +Автоследование = центрирование вида карты на текущей точке. Происходит в: +- `GPS.recording.onPoint`, ~4840: `map.panTo(ll)` - основное следование при записи. +- `GPS.recording.onPoint` состояние waiting_gps, ~4857: `map.setView([p.lat,p.lon],15)`. +- `GPS.recording.onStateChange` "ready", ~4814: `map.setView([p.lat,p.lon],16)` - разовый locate. +- `GPS.recording.onBatchUpdate`, ~5005: `map.panTo(last)` - следование при пакетном апдейте. + +Маркер позиции (`updateMe`/`refreshMe`) вид карты НЕ трогает - при выключенном следовании +точка продолжит обновляться на своём месте, просто карта не будет прыгать. Компас/поворот +(`compassFrozen`, `applyBearing`) ортогональны следованию - `panTo` на bearing не влияет. + +## Фикс (frontend/js/main.js, только renderRecordingInline) +1. Флаг: рядом с `compassFrozen`/`rotOffset` (~4470-4477) добавить `let followMode = true;`. +2. Кнопка: по образцу `freezeBtn`/`roadsBtn` (~4624-4676) создать `followBtn` + (`class="rec-map-btn rec-follow-btn"`, символ-цель, `title="Следить за позицией"`), + добавить в `btnWrap`, сразу выставить `followBtn.classList.add("active")` (followMode=true). + onclick: + - `followMode = !followMode;` + - `followBtn.classList.toggle("active", followMode);` + - при включении - центрировать на текущей точке: взять последнюю точку + `GPS.recording.points` (как в onBatchUpdate: сортировка по ts, последняя) и `map.panTo`. +3. Загардить все 4 места следования условием `if (followMode)`: + - ~4840 `map.panTo(ll)` -> `if (followMode) map.panTo(ll);` + - ~4857 waiting_gps `map.setView(...)` -> обернуть в `if (followMode)` (return оставить). + - ~4814 "ready" `map.setView(...)` -> обернуть в `if (followMode)`. + - ~5005 `map.panTo(last)` -> `if (followMode) map.panTo(last);` + +## CSS (frontend/css/styles.css) +После `.rec-dark-toggle { right:8px; bottom:238px; }` (~1248) добавить позицию для новой кнопки +в следующем свободном слоте стека: `.rec-follow-btn { right: 8px; bottom: 276px; }`. +Базовый вид и `.active` уже есть у `.rec-map-btn` / `.rec-map-btn.active` - переиспользуем. + +## Символ кнопки +Плитка-цель, читается как "центрировать на мне": glyph круг-с-точкой/крест-в-круге +(например "⊕" плюс-в-круге или "◎" bullseye) - выбрать широко отрисовываемый. +Смысл несёт `title` (текст). Активная подсветка = следование включено. + +## Регрессии (проверить рядом) +- Freeze-кнопка компаса (`compassFrozen`) и поворот карты - не задеты (следование = только + центр вида, поворот = bearing). Убедиться, что обе кнопки работают независимо. +- Маркер позиции `updateMe` при выключенном следовании продолжает двигаться (карта стоит). +- Второй, отдельный экран `setupLiveUpdates` (мини-карта gpsMap, ~5030, свой panTo 5039/5055) + НЕ трогаем - это превью в списке, у него нет ряда кнопок. Пользователь просил "экран + трекинга" = renderRecordingInline. + +## Верификация +- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh`): открыть экран записи, начать запись + (или подсадить точки), проверить: + 1) следование по умолчанию включено, кнопка подсвечена, карта идёт за точкой; + 2) тап -> подсветка гаснет, карту можно свободно двигать, новые точки её не возвращают; + 3) тап -> карта центрируется на текущей позиции, подсветка загорается, следование снова идёт. + Скрин каждого состояния. +- Сборка APK + телефон (по явной просьбе): в реальной записи проехать/постоять, отжать кнопку, + подвигать карту, вернуть кнопкой. Коммит ДО теста на телефоне, доработки - отдельным коммитом. +- README/PROJECT_MAP + лог сессии обновить вместе с коммитом. + +## Не в этой задаче +Авто-выключение следования при драге карты (пользователь выбрал "только кнопка"). +Хранение состояния следования между сессиями (по умолчанию всегда включено). diff --git a/docs/session-logs/2026-09-17_repeat-intervals.md b/docs/session-logs/2026-09-17_repeat-intervals.md new file mode 100644 index 0000000..70dc302 --- /dev/null +++ b/docs/session-logs/2026-09-17_repeat-intervals.md @@ -0,0 +1,107 @@ +# Сессия 2026-09-17 - автоматический разбор повторов внутри поездки (интервалы) + +## Контекст +Заказ из `docs/gps-model-findings.md` (раздел «БЭКЛОГ: автоматический разбор повторов»). +Пользователь катает интервалы: один подъём 5 раз за поездку, один трек, без стоп/старт. +Нужно: повторы находятся сами по самоподобию, таблица заездов и число «последний/первый». +Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` +(в docs/plans не копировал - план короткий и одноразовый; здесь ссылка). + +## Сделано +- `GPS.detectRepeats(points, opts)` (gps.js) - порт `tools/track/pers.py`, но эталонный + проход находится сам: + 1. сетка ~10м (формула из `wind_by_passes`/`heightmap.py`); + 2. горячие ячейки = >= minPasses (3) входов (заход = смена ключа ячейки); + 3. эталон = ОДИН чистый проход: от первой горячей ячейки, добавляя новые, до первого + возврата к более ранней (склейка кругов давала абсурд - см. баги); + 4. проходы = порт `passes()`: позиция в seq растёт от старта (допуск назад 3, гэп 25), + покрытие >= 80%; старт-гвард `pos<=15%` не даёт спуску открыть заезд; + 5. нормировка: сравнение по `L_seq / T_прохода` (снимает 5% разброса длины GPS). + Направление без явной проверки bearing: seq хранит первую (подъёмную) позицию ячейки, + спуск роняет позицию и закрывает заезд. +- `avgPower` в возврат `GPS.computeStats` (накопление `power*dt` в вело-ветке / время + педалирования) - для колонки «Вт». +- UI: секция-аккордеон «Разбор повторов» в `showSessionDetail` (только вело), таблица + (№/время/км-ч норм/каденс/Вт), вердикт последний/первый (зелёный >=100%, жёлтый >=90%, + красный <90%), подсветка участка на карте (бирюза `#00e5ff`). CSS в styles.css. + +## Баги по пути (и фиксы) +- Эталон как «самый длинный горячий ран» склеивал 5 кругов -> segLength 4406м и заезды по + 9с (норм. скорость 1762 км/ч). Фикс: эталон = один проход до первого возврата. +- Следующая точка в ТОЙ ЖЕ ячейке считалась «возвратом» и сразу обрывала seq (seq=1). + Фикс: `c===seq[last]` -> продолжить, не обрывать. + +## Проверка +- Node-харнесс на `tools/track/baldav.db` (реальные данные): + - 11 сен (3214 тчк): участок 1577м, **5 заездов**, норм. скорости 15.0/17.1/17.8/17.9/17.5, + последний/первый 117%. + - 17 сен (3079 тчк): участок 1630м, **5 заездов**, 16.9/15.8/17.3/16.4/17.7, 105%. + Совпадает с находками («11 и 17 сентября, по 5 повторов»). + - Контроль: 5 обычных вело-поездок (8-23 км) + 2 короткие -> «повторов не найдено» (0 ложных). +- Десктоп (Xvfb): временно подсажена сессия 17 сен в дев-БД (бэкап + точечное удаление + после, БД байт-в-байт восстановлена). Секция рисуется, аккордеон работает, таблица 5 строк + совпадает с харнессом, вердикт зелёный 105%, участок подсвечен бирюзой на карте. Скрин. +- Далее: сборка APK + проверка на телефоне (реальные интервалы там же). + +## Фикс после проверки на теле (трек 16 сен) +- Симптом (пользователь): на 16 сен в повторы попала только часть горки, 729м вместо 1200+, + на карте видно прямую, что идёт дальше. +- Причина: эталон брался с ПЕРВОЙ горячей ячейки. Поездка 16 сен стартует прямо в горячей + ячейке (подъезд частично совпал с коридором), проход с индекса 0 обрывался гэпом на 143й + точке -> 729м, хотя горячих ячеек ~183 (~1830м). +- Фикс: эталон = САМЫЙ ДЛИННЫЙ чистый проход (перебор всех стартов горячих ранов, + `buildTraversal`), не первый. Дёшево (стартов немного). +- Проверка: харнесс 16 сен -> участок 1572м (было 729), 3 полных заезда. 11/17 сен без + изменений (по 5). Контроль (5 обычных) - 0 ложных. Десктоп (Xvfb, подсажена 16 сен, + бэкап+удаление): подсветка на карте покрывает весь верхний коридор, таблица 1.57км/3 заезда, + вердикт 104%. По коридору 4 траверса, но первый 74% (начат выше низа) - отсечён правилом + 80% (как pers.py MIN_FRAC), иначе нормировка длина/время завысила бы его скорость. + +## Фикс 2: остановка внутри заезда (седло) + сшивка (трек 16 сен) +- Пользователь: первый заезд 16 сен был полным, но разорвался остановкой (опущенное седло, + ноги не крутили) - такие стопы <~минуты сшивать в один заезд, время стопа оставлять в + заезде как штраф. +- Проверка данных: остановка ЕСТЬ - idx 310-319, **89 секунд** сдвиг <15м. По reported speed + её не видно (fused показывал 8 км/ч), нашлась по СМЕЩЕНИЮ. Плюс у вершины трек первого + заезда уходит с общей линии в негорячие ячейки (GPS-разброс на 20 км/ч), из-за чего покрытие + вышло 74%. +- Сделано: (1) в разметке проходов - мост через короткое прерывание рядом с коридором + (`bridgeSec` 90с, `bridgeM` 60м) + сохранена терпимость к нулевым пропускам (gap<=25); + время простоя остаётся в длительности заезда -> честный штраф скорости. (2) Порог покрытия + `minFrac` 0.8 -> **0.7**: полный заезд, у которого GPS сполз с линии у вершины, засчитывается. +- Проверка (харнесс): 16 сен -> **4 заезда** (был 3), норм.скор [19.6, 21.3, 21.2, 23.8] - + первый самый медленный (штраф за 89с стоп), вердикт 121%. 11/17 сен без изменений (по 5), + контроль 5 обычных - 0 ложных при 0.7. Десктоп: 16 сен подсажен, таблица 4 строки. + +## Фикс 3: проекция на опорную линию + время только в движении +- Пользователь: снос вбок не случайность (седло падало, подкручивал) - проецировать на + опорную линию, чтобы боковой снос не резал покрытие; остановки на краях заездов не считать + во время. +- Сделано: детект проходов переписан с «точного совпадения ячеек» на ПРОЕКЦИЮ. Опорная линия = + точки лучшего прохода с накопленной дистанцией; каждая точка поездки проецируется на неё + (ближайшая вершина через cell-индекс, допуск вбок `projTolM` 30м) -> прогресс в метрах. + Проход = прогресс растёт от низа до >= minFrac. Снос на 30м больше не роняет покрытие, + поэтому `minFrac` вернул к строгому 0.8 (как pers.py). +- Время заезда = только СЕКУНДЫ В ДВИЖЕНИИ (`movingSec`, по фактическому смещению >=0.8 м/с), + стопы на краях и в середине вырезаются. Нормировка норм.скор = длина/движ.время. +- Проверка (харнесс, minFrac 0.8): 16 сен -> 4 заезда, движ.время [281,268,271,252]с (89с стоп + вырезан из первого), норм.скор [20.1,21.1,20.9,22.5], посл/перв 112%. 11 сен 5× (16.6..18.8, + 113%), 17 сен 5× (101%), контроль 5 обычных - 0 ложных, короткие - нет. + +## Фикс 4: детекция при 2 заездах (порог 3 -> 2) +- Симптом (пользователь + разбор другого агента): при двух проездах туда-обратно детекции + нет по построению. +- Проверка по коду + харнесс: `minPasses` (по умолч. 3) работал И как порог «горячей» ячейки + (>=3 входов), И как финальный порог числа проходов. Урезанный до 2 заездов трек 16 сен: + порог 3 -> НЕ НАЙДЕНО, порог 2 -> найдено (2 заезда, 1555м). Подтверждено. +- Фикс: `minPasses` по умолчанию 2. Перекрёстки отсекаются длиной (`minLenM` 300м), не + счётчиком; обычная поездка «туда-обратно» (коммьют) не ловится, т.к. проходы считаются + монотонно в ОДНУ сторону (спуск/возврат start-guard отсекает). +- Проверка: 2-заездный -> 2×; 16 сен 4×, 11/17 по 5× (без изменений); 5 обычных поездок + (включая туда-обратно) - 0 ложных. Признаки из разбора агента (возвратный участок, время, + высота) не понадобились - длина+направление уже отсекают мусор. Снос вбок и дырки в цепочке + уже покрыты (проекция 30м в разметке проходов; gap>5 в эталонном проходе). + +## Не в этой задаче +Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET); +п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы). diff --git a/docs/session-logs/2026-09-18_merge-rides.md b/docs/session-logs/2026-09-18_merge-rides.md new file mode 100644 index 0000000..f62c0ee --- /dev/null +++ b/docs/session-logs/2026-09-18_merge-rides.md @@ -0,0 +1,41 @@ +# Сессия 2026-09-18 - объединение поездок (склейка треков) + +## Контекст +Трек 16 сен: реально 5 заездов, но первый оказался в отдельной сессии (запись +несколькими кусками). Нужна склейка: долгий тап = выделение, 2+ = объединить, +разрыв >5 мин по времени - предупреждать но не мешать, только склейка (разрез/вырез +были проблемными, позже). После склейки «Разбор повторов» должен увидеть все 5. + +## Сделано +- Миграция: `gps_points.orig_session_id TEXT` - исходная сессия точки (для будущего + разделения; склейка обратима без потери данных). +- `DB.mergeGpsSessions(idStrs)` (db.js): сессии сортируются по времени, target = самая + ранняя; точки остальных переносятся (`session_id=target`, `orig_session_id=COALESCE(...)`), + end_time продлевается, лишние сессии удаляются. `seq=NULL` у объединённого трека - + иначе совпадающие seq (у каждой сессии были 1..N) перемешивают порядок в getGpsPoints. + Ids - строками с CAST в INTEGER (i64 corruption). +- UI (`renderGpsScreen`): долгий тап 500мс (touch+mouse) -> режим выделения (`.selected`, + плавающая `#gps-sel-bar`: «Выбрано N», «Объединить» (>=2), «Отмена»). Тап в режиме + переключает. Клик после long-press подавляется. Диалог `appConfirm` с пометкой об + обратимости; предупреждение о разрыве >5 мин. После склейки - `recalculateSession + (noDelete)`, тост, ре-рендер. +- CSS: `.gps-session-item.selected`, `.gps-sel-bar`, фикс `[hidden]` (правило `display:flex` + перебивало атрибут hidden - панель висела всегда). + +## Баги по пути (и фиксы) +- Панель выделения показывалась всегда: `.gps-sel-bar{display:flex}` перебивал `hidden`. + Фикс: `.gps-sel-bar[hidden]{display:none}`. +- После склейки в приложении: участок 43 км, скорости 6745 км/ч. Причина: у трёх сессий + seq совпадали (1..N), `getGpsPoints` (ORDER BY seq) перемешивал треки. В node-тесте я + сортировал по ts и не поймал. Фикс: `seq=NULL` при склейке -> порядок по ts. + +## Проверка (десктоп Xvfb, подсажены 3 сессии 16 сен, БД восстановлена из бэкапа) +- Node: склейка 3 сессий -> detectRepeats 5 заездов, 1572м, [21.4,20.1,21.1,20.9,22.5], 105% + (по отдельности: две малые - «нет повторов», большая - 4). +- В приложении: долгий тап выделил 11:38 (рамка+фон, «Выбрано 1»), тап добрал 11:50/12:04 + («Выбрано 3»), «Объединить» -> диалог -> 3 сессии стали одной (11:38, 19.89 км, суммарно). + Открыл: «Разбор повторов» = 5 заездов, совпало с node, каденс 88-93, вердикт 105%. Скрины. +- Далее: сборка APK + проверка на телефоне (реальный трек 16 сен там же). + +## Не сделано (по просьбе - позже) +Разрезание и вырезание треков (были проблемы). Сейчас только склейка. diff --git a/docs/session-logs/2026-09-21_db-backup.md b/docs/session-logs/2026-09-21_db-backup.md new file mode 100644 index 0000000..6fc11b2 --- /dev/null +++ b/docs/session-logs/2026-09-21_db-backup.md @@ -0,0 +1,54 @@ +# Сессия 2026-09-21 (2) - экспорт/импорт всей БД (резервная копия) + +## Контекст +Смена сборки debug<->release стирает данные (разные подписи -> переустановка). Решили: +НЕ делать внутриприложенческую авто-установку APK, а сделать полный бэкап/восстановление БД. +Записано в память: `project_no_self_install`. Секретов в базе нет. + +## Сделано +- Rust (`lib.rs`): + - `db_and_root(app)` - резолв пути `baldav.db` + app-root (как в rebuild_roads). + - `export_db(filename)` - `VACUUM INTO` чистой копии в `app-root/export_db.tmp` (консистентно + при живом соединении, сжимает ~на треть), команда `exportdb:` в gps_command.txt. + - `import_db()` - валидирует `picked_import.db` (`PRAGMA quick_check`=='ok' + есть хоть одна + таблица из gps_sessions/products/profile), копирует в `pending_restore.db`, удаляет picked. + - `restart_app()` - команда `restartapp`. + - Все три в generate_handler. +- Kotlin (`MainActivity.kt`): + - `pendingDbFile` + `saveDbLauncher` (CreateDocument octet-stream, СТРИМ файла, не в память - + файл десятки МБ). + - Ветка `exportdb:` -> SAF-сохранение `export_db.tmp`. + - Ветка `restartapp` -> relaunch (getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK) + + `Runtime.exit(0)`. + - `onCreate` (сразу после appDataDir, ДО открытия БД): если есть `pending_restore.db` - + скопировать на `baldav.db`, удалить journal/wal/shm и pending. Тот же паттерн, что копия сида. +- JS (`main.js`): в Настройках блок «Резервная копия всех данных» с кнопками Экспорт/Импорт. + Экспорт -> `export_db`. Импорт -> confirm (заменит всё) -> `pickLocalFile` -> `import_db` -> + confirm перезапуска -> `restart_app`. Импорт только на Android. + +## Проверка +- JS синтаксис ок. Автономный тест `VACUUM INTO` на десктоп-БД: 53МБ -> 36МБ, quick_check ok, + сессии на месте (валидная сжатая копия). Ядро экспорта подтверждено. +- SAF-сохранение, своп при onCreate и рестарт - только на устройстве (Kotlin), десктоп не + воспроизводит. Проверка на телефоне: экспорт -> файл-бэкап валиден; импорт другого бэкапа -> + данные заменились после рестарта. (Сборка bump до 0.1.465.) + +## Замечания +- Сжатие gzip не делал: VACUUM INTO уже компактит; flate2 в зависимостях нет. Добавлю, если + нужно меньше. +- Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап + подтянется вверх, новый в старое приложение - лишние колонки безвредны. + +## Проверка на теле + фиксы +- Экспорт сперва падал: `vacuum: unable to open database: /data/user/...`. Причина - я взял + корень как `app_data_dir().parent().parent()` (на Android app_data_dir САМ = корень -> ../.. + уводит в непишемый `/data/user`). ЭТО УЖЕ ЧИНИЛИ функцией `native_root()`. Исправил на + `native_root()` (как save-команды/rebuild_roads), убрал data_dir-дубли. Записано в память + `reference_android_native_root`. Подтверждено на теле: корень `/data/user/0/dev.baldav.app` + писабелен, там baldav.db + точки + командные файлы -> native_root резолвится туда. +- Экспорт + импорт (восстановление) пользователь проверил на теле - работают. +- Авто-рестарт после импорта не срабатывал: `startActivity()+exit(0)` - гонка (процесс умирает + до подъёма активити). Исправлено: relaunch через AlarmManager+PendingIntent ПОСЛЕ смерти + процесса (ProcessPhoenix-паттерн) + finishAffinity. + +## На роутере всё ещё 0.1.464 (build 464). Бэкап-билды на роутер не выкатывал. diff --git a/docs/session-logs/2026-09-21_split-and-bike-guard.md b/docs/session-logs/2026-09-21_split-and-bike-guard.md new file mode 100644 index 0000000..24de5cc --- /dev/null +++ b/docs/session-logs/2026-09-21_split-and-bike-guard.md @@ -0,0 +1,52 @@ +# Сессия 2026-09-21 - разрезание трека + блокировка склейки разных великов + +## Контекст +Три вопроса пользователя (сначала разобрал по коду, потом делал): +1. «Пересчитать» берёт велик СЕССИИ (не активный) - пользователя устраивает, не трогаем. +2. Хочет разрезать трек в точке смены передачи, чтобы разным кускам назначать свой велик + (два велика = один и тот же, разные передачи) - для точных мощности/каденса. +3. Опасение: склейка разных великов слепит всё в один велик. Решение (по просьбе): при + склейке треков с РАЗНЫМИ великами - предупреждать и НЕ склеивать (не хранить велик по точкам). + +## Сделано +- **Блокировка склейки (D):** в обработчике `#gps-sel-merge` (main.js) - если у выбранных + сессий разные `bike_id` (Set по bike_id, null=none), `appAlert` «велосипеды разные, склеить + нельзя, задайте одинаковый и склеивайте» и return. +- **Разрезание (B):** `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` + (db.js): вторая половина -> НОВАЯ сессия (id=Date.now(), копия date/activity/bike/source, + start=part2 время, end=старый end), точки part2 переносятся по id чанками (200), `seq=NULL`; + у старой end_time=part1. UI: кнопка `#sd-split` включает `#sd-map.split-mode`; в попапе точки + кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> подтверждение -> + splitGpsSession -> пересчёт обоих кусков (`recalculateSession noDelete`) -> назад в историю. +- Точку разреза выбирают ТАПОМ ПО ТОЧКЕ (хит-таргеты уже есть), а не кликом карты - маркеры + перехватывают клик, поэтому кнопка живёт в попапе. + +## Проверка (десктоп Xvfb, подсажен склеенный трек 16 сен 1789547934760, 3614 тчк, +2й велик) +- Разрез: «Разрезать трек» -> тост + скролл к карте -> тап точки #3547 -> попап с «✂ Разрезать + здесь» -> диалог (точка/время/км) -> «Разрезать» -> в истории ДВЕ сессии 16-го (part1 3548 + тчк 19.54км, part2 66 тчк 343м), обе bike, стата пересчитана. Скрины. +- Блокировка: part2 велик -> 2 (через SQL), выделил обе (долгий тап + тап), «Объединить» -> + предупреждение «велосипеды разные, склеить нельзя», склейки не произошло. Скрин. +- БД восстановлена из бэкапа (0 сессий 16 сен, 1 велик). + +## Замечания +- Инъекция `INSERT ... SELECT *` тихо даёт 0 строк, если id сессии больше нет в pulled-БД + (tools/track/baldav.db пере-пулена, старая 1789549492046 уже склеена на телефоне в + 1789547934760). Проверять существование id перед выводами. +- `Xvfb :99` снова умирал; `pgrep -f "Xvfb :99"` ловит собственную команду (ложно «жив») - + проверять сокет `/tmp/.X11-unix/X99`. +- part2 start_time берётся из ts точки (UTC), а поле сессии - локальное; мелкая косметическая + рассинхронизация времени (данные так хранятся), на функциональность не влияет. + +## Выкатка на роутер (по просьбе) +- Залит СУЩЕСТВУЮЩИЙ `binaries/baldav-debug.apk` (0.1.464, build 464) на роутер keenetic + (LAN 192.168.1.1) в `/opt/share/www/baldav/baldav.apk` + `BUILD_NUMBER=464`, БЕЗ пересборки + (иначе номер уехал бы на 465). Проверено: файл 190101489 б, BUILD_NUMBER=464 на роутере. + Доступно: `http://192.168.1.1:88/baldav/baldav.apk`. Пароль роутера взят из build.sh, не + печатался. VPS-зеркало не трогал (просили только роутер). +- Текущие версии сведены в README (раздел «Текущие версии»): app 0.1.464/build 464, на роутере + 464, BRouter 1.6.3, сид продуктов 11. + +## Не сделано (по просьбе - позже/не нужно) +- Велик по точкам (вариант C) не делаем: пользователь просто не будет склеивать разные велики. +- Вырезание участка трека - позже. diff --git a/docs/session-logs/2026-09-21_ui-fixes.md b/docs/session-logs/2026-09-21_ui-fixes.md new file mode 100644 index 0000000..bdcb02b --- /dev/null +++ b/docs/session-logs/2026-09-21_ui-fixes.md @@ -0,0 +1,25 @@ +# Сессия 2026-09-21 (3) - мелкие UI-фиксы + +## #1 balance-trend показывал 8 дней вместо 7 (исправлено) +- Симптом: вверху страницы «Среднее за 8 дн.», а график ниже - за 7. +- Причина: `getDailyFoodTotals(7)` -> SQL `WHERE date >= date('now','-7 days')` - диапазон + включает оба конца (today-7 ... today) = **8 календарных дней**. Balance-trend показывал + `balances.length` (сырое число строк) = 8. График строит свой ряд, поэтому у него 7. +- Фикс точечный (не трогая общий запрос и график): в balance-trend `food7 = food7.slice(-7)` + - берём последние 7 дней. Корневой off-by-one в `getDailyFoodTotals` не трогал, чтобы не + сдвинуть график 30/90. + +## #2 сегодняшние повторы не опознались (исправлено) +- Трек 1790002321789 (out-and-back A(47.4915,40.0817)<->B(47.4917,40.1025), ~1.5км, 5 раз + туда-обратно). `detectRepeats` -> «не найдено». +- Диагностика: горячих ячеек полно (197 с >=3 входами, макс 10). Эталон (самый длинный проход) + при пороге горячей ячейки **2** раздувается до **2190м** - тянет разово/дважды накатанный + подъезд и развороты. Полный заезд 1585м покрывает лишь 72% этого эталона (<80%), поэтому + засчитывался лишь 1 проход. При пороге 3+ эталон сжимается до ядра **1585м** и находятся все 5. +- Причина глубже минусов порога: жадный порог 2 (нужен для «2 заездов») ломает эталон, когда + подъезд накатан 2 раза. +- Фикс: `detectRepeats` теперь ПЕРЕБИРАЕТ порог горячей ячейки от 2 до макс.входов и берёт тот, + что даёт БОЛЬШЕ проходов (при равенстве - длиннее участок). `minPasses` -> только финальный + порог числа проходов (`minReps`), развязан с порогом горячей ячейки. Вынесено в `detectAt(T)`. +- Проверка (харнесс): СЕГОДНЯ 5× 1590м [16.8,17.6,17.1,17.1,16.8]; 11сен 5×, 17сен 5×, + 16сен(склеен) 5×; 5 обычных поездок - 0 ложных. ~0.9с/сессия на десктопе (перебор порогов). diff --git a/docs/session-logs/2026-09-22_balance-selected-day-and-weight.md b/docs/session-logs/2026-09-22_balance-selected-day-and-weight.md new file mode 100644 index 0000000..21e54cc --- /dev/null +++ b/docs/session-logs/2026-09-22_balance-selected-day-and-weight.md @@ -0,0 +1,35 @@ +# Сессия 2026-09-22 - баланс/графики от выбранного дня + вес + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`. + +## Часть 1 - окно от выбранного дня +- `getRecentWeights/getDailyFoodTotals/getDailyActivityTotals` (db.js) получили опциональный + `endDate` (по умолч. 'now'). Диапазон сменён с `date >= date('now','-N days')` на + `date > date(,'-N days') AND date <= date()` - ровно N дней, кончая на endDate + (заодно чинит старый off-by-one N+1). +- `renderToday` (main.js): BMR верхнего баланса `getRecentWeights(7, state.date)`; тренд + `getDaily*(7, state.date)` (убрал костыль `slice(-7)`); `renderStatsCharts(...,refDate=state.date)` + - все три запроса графиков и чипы 30/90 теперь от выбранного дня. +- Единый BMR у баланс-графика оставлен (от веса выбранного дня); per-day BMR не делал. + +## Часть 2 - вес +### 2a синк настроек +- `DB.addWeight`: после вставки `setProfileValue("weight_kg", latest.weight_kg)` - настройки + всегда = последнее взвешивание. Колбэк модалки веса (main.js) обновляет `state.latestWeight` + и `state.profile.weight_kg`. +### 2b вес на момент поездки +- Миграция `gps_sessions.rider_weight_kg REAL`. Новый сеттер `DB.setSessionRiderWeight` (отдельно + от finishGpsSession, её зовёт и пересчёт). +- Запись: при финализации трека (gps.js) сохраняется `GPS.recording.weightKg`. +- Резолв веса везде первым берёт `session.rider_weight_kg`: `recalculateSession` (recorded+manual), + `recomputeAllStats` (per-session в цикле), деталь трека (2 места), `recomputeStatsFor`, разбор + повторов. Старые сессии (NULL) -> latest weigh-in (без регресса). + +## Ответ пользователю (вопрос) +Ккал брал вес из последнего взвешивания (не из настроек; настройки - запас). Теперь новые +поездки хранят вес на момент записи, пересчёт использует его; настройки авто-синкаются с весом. + +## Проверка +- SQL-диапазон на десктоп-БД: `> date(end,'-7') AND <= date(end)` = ровно 7 дней, кончая на + выбранном дне (старый `>=` давал 8). Подтверждено. +- Дальше: десктоп (Xvfb) - листание дней двигает баланс/тренд/графики; вес-синк; затем APK+тело. diff --git a/docs/session-logs/2026-09-22_gapfill-order-fix.md b/docs/session-logs/2026-09-22_gapfill-order-fix.md new file mode 100644 index 0000000..7c9f6aa --- /dev/null +++ b/docs/session-logs/2026-09-22_gapfill-order-fix.md @@ -0,0 +1,27 @@ +# Сессия 2026-09-22 (2) - достройка пропусков уходила «в начало» + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`. + +## Симптом +Достройка GPS-пропусков (🧩) показывала превью нормально, но сохранённый трек тянул прямую +линию от старта к месту пропуска («возврат в начало»). На любом (недавнем) треке. + +## Корень +`saveGapFill` (db.js) ставит достроенным точкам `seq = lastSeq` (seq последней РЕАЛЬНОЙ точки +перед пропуском). У склеенных/разрезанных треков реальные точки имеют `seq = NULL` (склейка/ +разрез специально обнуляют seq -> порядок по ts). Тогда `lastSeq` остаётся 0, все достроенные +точки получают seq=0, а `getGpsPoints` (`ORDER BY COALESCE(seq,999999), ts`) ставит их ПЕРЕД +реальными (NULL→999999) -> прямая к началу. Подтверждено: единственный NULL-seq трек в БД - +склеенный 16.09; недавние треки пользователя склеены. + +## Фикс +`saveGapFill`: после вставки достроенных точек - пере-нумерация seq ВСЕЙ сессии по ts (читаю +id `ORDER BY ts,id`, seq=порядковый номер, пакетный `CASE id WHEN..` чанками по 300, паттерн +из `saveSmoothedTrack`). Теперь getGpsPoints всегда в порядке ts независимо от исходного seq +(плотный/NULL/смешанный). Для плотных треков - no-op, для склеенных - чинит. + +## Проверка +- SQL: у склеенного 16.09 реальные точки seq=NULL (seq=0 достроенной встала бы перед ними) - + корень подтверждён. +- Десктоп (Xvfb): подсадка склеенного трека -> 🧩 -> Сохранить -> переоткрыть: без прямой к + началу. Затем сборка + телефон, вытянуть сегодняшний трек. diff --git a/docs/session-logs/2026-09-26_map-follow-toggle.md b/docs/session-logs/2026-09-26_map-follow-toggle.md new file mode 100644 index 0000000..61b6421 --- /dev/null +++ b/docs/session-logs/2026-09-26_map-follow-toggle.md @@ -0,0 +1,62 @@ +# Сессия 2026-09-26 - тумблер "следование карты за позицией" на экране записи + +Работали по утверждённому плану: `docs/plans/2026-09-26-map-follow-toggle.md`. + +## Контекст +Во время записи карта на экране трекинга при каждой новой GPS-точке центрируется на текущей +позиции (`map.panTo`). Кейс: отклонился от трека или трек ведёт туда, где дороги нет, надо +осмотреться по карте - но она тут же прыгает обратно, подвигать её нельзя. Нужен тумблер: +нажал - карта перестаёт следовать (можно двигать), нажал снова - центрируется на позиции и +следование возобновляется. + +Согласовано с пользователем (AskUserQuestion): кнопка - символ-плитка в общем ряду кнопок +карты (не слово), активная подсветка = слежу; только кнопка-тумблер (драг карты следование НЕ +выключает); повторный тап центрирует; состояние не запоминается между сессиями. + +## Действия +- `frontend/js/main.js`, `renderRecordingInline`: + - добавлен флаг `let followMode = true;` рядом с `compassFrozen`/`rotOffset`; + - создана кнопка `followBtn` (`class="rec-map-btn rec-follow-btn active"`, символ `⊕`, + `title="Следить за позицией"`) по образцу `freezeBtn`; onclick тогглит `followMode` и класс + `active`, при включении центрирует `map.panTo` на последней точке `GPS.recording.points` + (сортировка по ts); + - 4 места автоследования загардены `if (followMode)`: `onPoint` `panTo` (~4840), `onPoint` + waiting_gps `setView` (~4857), `onStateChange` "ready" `setView` (~4814), `onBatchUpdate` + `panTo` (~5005). +- `frontend/css/styles.css`: `.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }` + (следующий слот стека после `.rec-dark-toggle` 238px). Базовый вид и `.active` - + переиспользован `.rec-map-btn` / `.rec-map-btn.active`. +- README: добавлен буллет "Слежение карты (тумблер ⊕)". + +## Проверка +- `node -c frontend/js/main.js` - OK. +- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh` = tauri dev, без бампа версии): открыт экран + записи (Старт), кнопка `⊕` в правом стеке вверху, по умолчанию подсвечена (слежу). Тап -> + подсветка гаснет (слежу выкл). Тап -> подсветка возвращается (слежу вкл). JS-ошибок в логе + нет. Скрины: s_rec.png / s_rec_off.png / s_rec_on2.png в scratchpad. +- НЕ проверено на десктопе: живое поведение "карта не прыгает на новую точку при выкл / прыгает + при вкл" - под Xvfb настоящего GPS нет, экран в waiting_gps, поток точек не идёт. Логика - + тривиальный guard вокруг уже работающих `panTo`/`setView`. Реальная проверка потока - на + телефоне. + +## Деплой build 475 +`./build.sh -u -i -d android`: собран debug APK (0.1.475, 182М), залит на роутер +(http://192.168.1.1:88/baldav/baldav.apk, build 475) и VPS +(https://4aiman.top/baldav/baldav.apk, build 475), установлен на 320245779368 через +`adb install -r --user 0` - Success (подпись совпала, данные сохранены). Живую проверку тоггла +следования в реальной записи делает пользователь на телефоне. + +## Чистка старых бандлов (build.sh) +Место забивалось регенерируемыми выхлопами Android-сборки. Добавлена `clean_bundles()` в +build.sh: удаляет `gen/android/app/build/intermediates/intermediary_bundle` + +`outputs/bundle` (AAB - мы раздаём APK) и `outputs/apk/*.apk`. НЕ трогает `src-tauri/target` +(14Г Rust-инкрементал, иначе полная пересборка) и `binaries/` (последние APK). Вызывается +авто в конце каждой android-сборки (после копии APK в binaries/) и вручную `./build.sh clean`. +Согласовано с пользователем: объём "только бандлы/APK", запуск "авто + ручная". +Разовый прогон: gen build 1.6G -> 1.0G, освобождено ~557 МБ. + +## Следующие шаги +- Живая проверка тоггла `⊕` на телефоне: проехать/постоять, отжать, подвигать карту (новые + точки не возвращают), вернуть кнопкой (центрируется). Доработки - отдельным коммитом. +- Компас (freeze `X`) и поворот карты не задеты (следование = только центр вида, поворот = + bearing) - убедиться, что обе кнопки работают независимо. diff --git a/docs/session-logs/2026-09-29_progress-and-zoom.md b/docs/session-logs/2026-09-29_progress-and-zoom.md new file mode 100644 index 0000000..a0108d7 --- /dev/null +++ b/docs/session-logs/2026-09-29_progress-and-zoom.md @@ -0,0 +1,44 @@ +# Сессия 2026-09-29 (3) - прогресс на кнопках, память масштаба карты + +Продолжение дня (см. `2026-09-29_startup-slowness.md`). По списку задач пользователя. + +## Что значат сообщения достройки (объяснение, без правок) +`GPS.fillGaps` (gps.js) чинит только реальные потери сигнала: разрыв > 60с И хорда > 150м. +Для такого разрыва спрашивает у BRouter дорожный путь между концами: +- "больше нужного" = `skipped-long`: дорога есть, но путь длиннее прямой более чем в 2 раза + (крюк). Значит ехал не по дороге (тропа/поле/грунт, которого нет в графе) - оставляем прямую. +- "нет данных" = `skipped-noroute`: BRouter не проложил вообще - нет дорожных данных на участке + (не скачан .rd5-сегмент региона / пусто). Мостить нечем. +Это не баг, а честный отказ достраивать заведомо кривым путём. + +## Прогресс на долгих действиях (заливка фона кнопки по %) +- CSS `.btn-progress` (styles.css): фон кнопки заливается слева по `--pct` (ставится из JS через + CSS-переменную, не инлайн-стиль). Хелпер `setBtnProgress(el, pct)` (main.js). +- "Уточнить высоты" (`#sd-refine`): реальный % от сетевых батчей `fetchElevations(pts, onProgress)` + (0-80%), затем сохранение+пересчёт (85-100%). Сеть - api.gpxz.io / open-elevation / + open-meteo / opentopodata. +- "Пересчитать" (`#sd-recalc`): `GPS.recalculateSession(id, {onProgress})` шлёт честные вехи по + фазам (5 старт / 40 фильтр / 70 стата / 100 запись). +- "Перестроить" дороги (`#roads-rebuild`): Rust `rebuild_roads` эмитит `roads_progress` + [current,total] каждые ~2000 точек (в проекте раньше событий из Rust не было вообще - добавлен + `use tauri::Emitter`), JS слушает и заливает кнопку + пишет "Построение: cur / tot". Убирает + "просто подвисает". + +## Гонка reopenDetail - распространил на recalc/refine +`#sd-recalc` и `#sd-refine` раньше делали тот же ручной `winStack.pop + history.back + +showSessionDetail` (гонка с пустой картой/графиками). Переведены на `reopenDetail` (ждёт +popstate) - фикс той же гонки. + +## Память масштаба карты +- Деталь трека: `_sdMapView[sessionId]` хранит {центр, зум}, обновляется на moveend/zoomend; при + рендере, если вид сохранён - `setView` вместо `fitBounds`. Так после пересчёта/уточнения (reopen) + и при возврате к треку масштаб сохраняется, а не сбрасывается на весь трек. Первый показ - как + раньше, fitBounds. +- Карта записи (`renderRecordingInline`): зум хранится в localStorage `baldav_rec_zoom` + (сохраняется на zoomend, восстанавливается при создании карты вместо жёсткого 15). Так возврат + на экран трекинга держит масштаб. + +## Проверка +- Синтаксис JS - ок. Сборка debug + телефон. Проверить: заливка кнопок идёт по %, дороги больше + не "висят молча"; после "Пересчитать"/"Уточнить" карта на том же масштабе; вернулся на запись - + масштаб прежний. (Задача 1 - фикс гонки reopenDetail - проверена через CDP отдельно.) diff --git a/docs/session-logs/2026-09-29_startup-slowness.md b/docs/session-logs/2026-09-29_startup-slowness.md new file mode 100644 index 0000000..5ba89b4 --- /dev/null +++ b/docs/session-logs/2026-09-29_startup-slowness.md @@ -0,0 +1,115 @@ +# Сессия 2026-09-29 - долгий старт БалДава + .gitignore + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (замерить -> починить). + +## Задача +1. build.sh + BUILD_NUMBER в .gitignore (в build.sh пароль роутера, не место в git). +2. Разобраться, почему БалДав стал долго стартовать, и починить. + +## .gitignore +Добавлены `build.sh` и `BUILD_NUMBER` (оба были untracked, не в игноре). Коммит 2d91c06. + +## Диагностика старта (по требованию - сначала замер) +Собирается только debug (release не рассматриваем), значит причина не в профиле сборки, а в +работе на каждом старте. Замер на устройстве (adb am start -W + логи Tauri/Console, тайминг-логи +временно добавлял в init/lib.rs, потом убрал): +- `am start -W` TotalTime ~400ms (первый кадр - экран загрузки, быстро). Медленно - JS-init + ПОСЛЕ первого кадра, он не виден в TotalTime. +- В логах: `[db.js] Repairing seq for 3 sessions...` -> сессии 687 + 3614 + 10865 точек с + `seq IS NULL`. Репейр (db.js `runMigrations`, ~2019) делал **по одному UPDATE на точку** - + ~15000 последовательных IPC-round-trip'ов в debug-Rust. По таймингам одна сессия 3614 = + ~3.8с, сессия 10865 не досчитывалась. +- Ключевое (проверено через `run-as cat baldav.db` + sqlite3, journal_mode=delete, WAL ни при + чём): репейр гонялся на КАЖДОМ старте (2 подряд без действий пользователя) и для крупных + сессий seq НЕ сохранялся -> заново каждый запуск. Это и есть "долго": ~15с блокирующего + init'а на старте. + +Причина "стал": недавние merge/split (свежие фичи) создали NULL-seq сессии на 10865 и 3614 +точек; поточечный репейр давился на них. + +## Фикс (frontend/js/db.js, репейр seq в runMigrations) +Поточечный цикл заменён на **батчевую пере-нумерацию** (CASE чанками по 300, паттерн +`saveGapFill`/`saveSmoothedTrack`): один UPDATE на ~300 точек вместо одного на точку. Порядок - +по ts (как и было). ~15000 invoke'ов -> ~50. + +## Проверка на устройстве (build 477, debug) +- BOOT 1 (seq ещё NULL): репейр всех 3 сессий (15166 точек) за **~1.1с** (было ~15с+); + `[BOOT] migrations` = 1356ms. +- BOOT 2 (без действий между): "checking seq repair" есть, "Repairing seq" НЕТ - `nullSeqSessions` + пусто, репейр не запускался. `[BOOT] migrations` = **357ms**. seq сохранился, репейр стал + одноразовым. +- Дамп БД после чистого force-stop: 0 NULL seq во всей БД; сессия 1789547934760 - seq 1..3614, + инверсий по ts = 0 (порядок корректный, регрессии в отрисовке трека нет). + +## Осталось / отдельно +- `import_seed_products` (lib.rs) на up-to-date-старте всё равно копирует сид 8.5МБ + гоняет + дедуп = ~259ms каждый старт. Это НЕ было причиной "долго" (репейр был), поэтому seed-опт не + делал: область products/dedup имела баги (FK), трогать без нужды рискованно. Вынес пользователю + на решение. +- Тайминг-обвязку (JS [BOOT], Rust [SEED-TIMING]) убрал; lib.rs/main.js вернулись к HEAD. + +## Пропажа тайлов карты на большом треке (отдельный баг) +Симптом: смотришь трек 60 км (сессия 1790501377655, 10865 точек), зумишь/таскаешь - тайлы карты +пропадают, помогает только force-stop+старт. + +Диагностика по живому logcat (пользователь воспроизвёл: открыл трек -> достроил -> сохранил -> +reopen -> убрал достройку -> зум/таскание; буфер предварительно увеличил до 16МБ и очистил): +- `E chromium: [ERROR:cc/tiles/tile_manager.cc:1046] tile memory limits exceeded, some content + may not draw` - 40+ раз ровно в окно зум/таскания. Это лимит памяти тайлов КОМПОНОВЩИКА + Chromium (не карты, не TileCache). Никаких OOM-килла, крахов рендера, CSP по тайлам. +- Причина: трек рисуется дефолтным SVG-рендерером Leaflet (`preferCanvas` нигде не задан), да + ещё многослойно (casing/core + раскраска по уклону мелкими сегментами). Гигантский SVG-слой на + весь трек при зуме превышает бюджет растровой памяти -> компоновщик перестаёт рисовать часть, + тайлы гаснут. Force-stop сбрасывает компоновщик. Проявляется на любом крупном треке/карте. + +Фикс (frontend/js/main.js): `preferCanvas: true` на картах с тяжёлыми векторами (canvas рисует +в один вьюпортный холст вместо тысяч SVG-путей): +- `showSessionDetail` (деталь трека, исходный баг), `showRoadsMap` (карта дорог - много + полилиний, пользователь отметил подтормаживания), `showComparisonView` (два трека), + `showRouteDetail` (превью маршрута с раскраской). +- НЕ трогал: `renderRecordingInline` (там `rotate:true`, а leaflet-rotate почти не поддерживает + Canvas - 1 ссылка на L.Canvas против 5 на SVG; canvas+rotate рискован, отдельная проверка), + редактор/рисование маршрута (клик/тап по линии, малые полилинии), цель-маркер (без треков). + +Проверка: воспроизвести зум/таскание на треке 60км и на карте дорог, снять logcat, убедиться, +что `tile memory limits exceeded` больше НЕ появляется, тайлы держатся. + +### Итог проверки canvas (build 479) - помог частично +Пользователь воспроизвёл на 479: "подвисло, но восстановилось" (раньше - только force-stop). +Снял logcat + инспекция живой страницы через CDP (`test_recovery/cdp.js`, +`adb forward ... webview_devtools_remote_`): +- Canvas ПРИМЕНИЛСЯ: в DOM детали `svgPaths=0`, оверлей - canvas. +- Но `tile_manager tile memory limits exceeded` всё равно ~46 раз (столько же). Значит бюджет + растровых тайлов компоновщика забивают САМИ тайлы карты (высокий DPI: 1260 физ / 404 css ≈ 3x) + + объём трека, а не SVG. Canvas дал живучесть (само восстанавливается), но джанк от 10865 + сегментов остался. + +### Догоняющие правки (по решению пользователя: урезать тайлы + меньше точек на зум-ауте + POI) +1. `tilecache.js` - у тайл-слоя `keepBuffer: 1` (было 2) и `updateWhenZooming: false`: меньше + офскрин-тайлов в памяти компоновщика, тайлы не грузятся в процессе зума. +2. `main.js` - `smoothFactor: 2.5` на главных полилиниях трека (деталь, `showSessionDetail`) и + на сегментах карты дорог (`showRoadsMap`). smoothFactor - допуск Дугласа-Пекера в ПИКСЕЛЯХ, + поэтому сам прореживает: при зум-ауте точки сходятся в пиксели и выбрасываются (меньше + сегментов), при зум-ине - полная детализация. Ровно "меньше точек на сильном зум-ауте". +3. `poi.js` - кап маркеров POI масштабируется зумом (z>=16: 500, 15: 200, 14: 80, 13: 30): + на сильном зум-ауте показываем горстку ближайших, а не всё подряд. + +Проверка: воспроизвести тот же сценарий, снять logcat - ожидаем, что `tile memory limits +exceeded` резко упадёт/исчезнет, джанк уйдёт; POI на малом зуме - мало маркеров. +Результат (build 480): пользователь - "всё ок". + +## Баг: после "убрать достройку" пусто (карта+графики) до следующего действия +Симптом: revert достройки -> графики и карта пропадают, из экрана трека не выкидывает, +появляются только после следующего действия ("Отмена"). Стек окон + гонка. + +Корень: `reopenDetail` (main.js:1176) стрелял `history.go(-count)` (асинхронные popstate) и +СРАЗУ синхронно `showSessionDetail`. showSessionDetail пушит свою history-запись, потом +отложенные popstate от go(-count) раскручивают уже ЕЁ и десинхронят стек; при fallthrough в +renderScreen второй showSessionDetail бампает `_sessionDetailRender`, и первый async-рендер +карты/графиков бросается на проверке токена (5411) -> пусто до следующего события. Ровно тот +же класс, что чинил `closeThen()` (комментарий там про это). + +Фикс: `reopenDetail` сделан async и ДОЖИДАЕТСЯ popstate от history.go перед showSessionDetail - +через Promise + слушатель popstate (как `closeThen`), без setTimeout/setInterval. +Проверка: revert на устройстве, по [SD]-логам один прогон showSessionDetail, карта+графики +сразу на месте. diff --git a/docs/session-logs/2026-09-30_gapfill-ridden-router.md b/docs/session-logs/2026-09-30_gapfill-ridden-router.md new file mode 100644 index 0000000..aeb7d2a --- /dev/null +++ b/docs/session-logs/2026-09-30_gapfill-ridden-router.md @@ -0,0 +1,30 @@ +# Сессия 2026-09-30 - достройка пропусков предпочитает проеханные дороги + +## Проблема (замечание пользователя) +Достройка отказывалась мостить разрыв там, где реально проезжал (есть два проезда), с +сообщением "больше нужного"/"нет данных". Причина: `GPS.fillGaps` звал только публичный BRouter +"shortest" по OSM и ничего не знал о собственных треках пользователя. В проекте уже есть router +по проеханным дорогам (`rideRoute`, `get_ride_graph`), которым пользуется редактор маршрутов, но +достройка к нему не была подключена. Отсюда: где ехал по тропе/грунту (нет в OSM) - публичный +BRouter либо в объезд > 2x, либо noroute. + +## Фикс +- `GPS.fillGaps(points, opts)` (gps.js): добавлен `opts.routeFn(a,b) -> {points, ridden}`; дефолт - + публичный BRouter. Для `ridden:true` (путь по проеханным дорогам) НЕ применяется гвард "объезд + > 2x" - проеханный путь это факт, а не крюк (`rideRoute` и так ограничивает себя 2.5x). Каждому + разрыву в `gaps[]` пишется `ridden`. +- Место вызова (main.js, деталь трека): `routeFn` = `rideRoute` сначала, публичный BRouter + fallback. Так: есть моя дорога -> по ней; нет -> публичное; фэйл (в объезд / нет данных) + остаётся, но с понятным сообщением. +- Сообщение (drawGapPatches + панель): скипы считаются раздельно (skipped-long / skipped-noroute) + и словами: "Не достроено: N (нет твоей проеханной дороги; K — публичный маршрут уводит в объезд, + L — нет дорожных данных)." + +## Оговорка +Если участок проезжен ТОЛЬКО в этой поездке (где сам разрыв - точек нет), в ride-графе его нет, +идёт fallback на публичный BRouter (как раньше). Ожидаемо: не ехал по другим - нельзя достроить. + +## Проверка +Синтаксис ок. Собрать + телефон: на воскресном 60-км нажать 🧩, убедиться, что разрывы по +проеханным дорогам теперь достраиваются (голубым по реальной дороге), а сообщение по недостроенным +- понятное. Отдельно: где не ехал - остаётся честный отказ. diff --git a/docs/session-logs/2026-10-02_ble-manual-connect.md b/docs/session-logs/2026-10-02_ble-manual-connect.md new file mode 100644 index 0000000..3b1c0c2 --- /dev/null +++ b/docs/session-logs/2026-10-02_ble-manual-connect.md @@ -0,0 +1,41 @@ +# Сессия 2026-10-02 - BLE-пульсометр: подключение вручную, без авто-поиска + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`. + +## Проблема +Расход батареи: при КАЖДОМ старте записи `GPS._startHrMonitor` безусловно звал `ble_connect`, а +Kotlin `BleHrService` при отсутствии браслета всё равно делал `directConnect` (держит радио) + +сервис `START_STICKY`. Если браслета нет рядом или BT выключен - расход впустую. + +## Поведение (по указанию пользователя) +1. BT включён: НЕ искать/коннектиться автоматически. Подключение к сопряжённому браслету - только + вручную, кнопкой в настройках, доступной в любой момент (в т.ч. во время записи). Нажал -> + пульс пишется в трек с этого момента. +2. BT выключен: ничего не делать; при ручной попытке - системный запрос включить BT. + +## Правки +- `frontend/js/gps.js` `_startHrMonitor`: убран авто `ble_connect`. Остался только дешёвый опрос + `ble_get_hr` (2с -> `currentHr`), чтобы ручное подключение (сейчас или позже по ходу записи) + начало класть пульс в трек. +- `frontend/js/main.js` настройки "Пульсометр (BLE)": кнопка **"Подключить"** (видна, когда + устройство сопряжено). Обработчик: `ble_check_bluetooth`; `off` -> `ble_request_enable` + + подсказка; `on` -> `ble_connect(addr,type)`; если идёт запись - поднять опрос + `GPS._startHrMonitor()`; короткий опрос статуса ~6с -> "подключён"/"браслет не найден". +- `src-tauri/src/ble.rs`: команда `ble_request_enable` (android -> send_command + "ble_request_enable"); зарегистрирована в `lib.rs` invoke_handler. +- `MainActivity.kt`: ветка `ble_request_enable` -> `startActivity(ACTION_REQUEST_ENABLE)` под + `ensureBlePermissions()`. Разрешение BLUETOOTH_CONNECT в манифесте уже есть. +- `BleHrService.kt`: `START_STICKY` -> `START_NOT_STICKY` (scan/connect; нет churn после убийства); + `connectDevice` - если скан 5с НЕ нашёл адрес, `stopSelf` вместо `directConnect` к отсутствующему + устройству (именно directConnect держал радио). + +## Коммит +Одним коммитом (JS+Rust+Kotlin - одна связная фича: кнопка зовёт новую команду; дробить на +нерабочие промежутки смысла нет). + +## Проверка (устройство, debug) +- Запись без браслета / BT off: нет "Поиск BLE", нет коннекта (logcat BleHrService молчит). GPS ок. +- BT off -> "Подключить": системный запрос включить BT. +- BT on, браслет надет -> "Подключить" (в т.ч. во время записи): подключается, heart_rate пишется + в точки с момента подключения. +- BT on, браслета нет -> "Подключить": "браслет не найден", сервис остановился (не висит). diff --git a/docs/session-logs/2026-10-17_bt-gps-prompts.md b/docs/session-logs/2026-10-17_bt-gps-prompts.md new file mode 100644 index 0000000..6167196 --- /dev/null +++ b/docs/session-logs/2026-10-17_bt-gps-prompts.md @@ -0,0 +1,30 @@ +# Сессия 2026-10-17 (2) - попап "включи BT/гео" при старте записи + 2 настройки "не спрашивать" + +## Задача +Пользователь забывает включать Bluetooth и GPS. Нужно: при старте записи, если выключено - +предложить включить; дать отказаться; 2 отдельные настройки "не спрашивать". + +Факт-чек: телефон С Google-сервисами (ранее ошибочно решил, что без). Тихо включать BT/гео +приложение на target 36 не может (ОС), поэтому - попап + действие: BT через системный диалог +включения, гео через экран настроек геолокации. + +## Сделано +- Rust `read_state_file(name)` (allowlist "location_state.json") - читает ответ Kotlin из + native_root. Зарегистрирован. +- Kotlin MainActivity: команды `check_location` (пишет `location_state.json` {enabled} через + LocationManager.isLocationEnabled) и `open_location_settings` (ACTION_LOCATION_SOURCE_SETTINGS). +- `gps.js` `GPS.isLocationEnabled()` - дёргает check_location, читает state-файл; true/false или + null (неизвестно - тогда не спрашивать). +- `main.js` `ensureSystemReady(needBt, needGps)` - перед startRecording: BT off -> + appConfirm "Включить?" -> `ble_request_enable`; гео off -> appConfirm "Открыть настройки?" -> + `open_location_settings`. Гейтится настройками `baldav_dontask_bt` / `baldav_dontask_gps`. + Вызов в кнопке записи: needBt = есть привязанный датчик, needGps = не зарядка. +- Настройки (раздел Пульсометр): 2 чекбокса "Не спрашивать про Bluetooth" / "...геолокацию". + +Ограничение (честно): авто-ВЫКЛ BT/гео после тренировки приложение сделать не может (ОС). +Тихое включение - тоже; поэтому попап + один тап. + +## Проверка +Синтаксис JS ок. На телефоне: выключить BT и гео, старт записи трека -> два попапа по очереди +(Включить BT / Открыть настройки гео); отказ -> запись идёт как есть; включить чекбоксы "не +спрашивать" -> попапы не показываются. diff --git a/docs/session-logs/2026-10-17_hr-workout-mode.md b/docs/session-logs/2026-10-17_hr-workout-mode.md new file mode 100644 index 0000000..cdc2f22 --- /dev/null +++ b/docs/session-logs/2026-10-17_hr-workout-mode.md @@ -0,0 +1,66 @@ +# Сессия 2026-10-17 - HR-датчик Coospo H808S: чтение + режим "Зарядка" без GPS + +## Датчик прочитан с ПК (без спаривания) +`F7:72:74:A4:FB:07` (`808S 0026275`), стандарт 0x180D/0x2A37. `tools/ble_probe.py` +подключается без pair(), живой пульс ~1/сек (+ RR). `tools/hr_log.py` - фоновый логгер +(epoch,bpm в файл, авто-переподключение). Адаптер ПК - hci1 (один). Подробности в памяти +project-hr-sensor. + +## Режим "Зарядка" (workout) - запись без GPS, только пульс + нагрузка +Нужен был режим трекинга без геолокации (домашняя зарядка): меряет пульс и ккал, GPS не +включает и не пишет. + +- `frontend/js/gps.js`: + - `GPS.hrKcalNet(samples, profile)` - нетто-ккал по пульсу (Keytel gross минус BMR/мин, + как и остальная активность). samples = [[relSec,bpm]]. + - `startRecording(type, wkg, profile)` - ветка `workout`: НЕ запускает нативный GPS-сервис, + watchPosition, поллинг точек. Создаёт сессию (`activity_type="workout"`), запускает HR-опрос. + - HR-опрос (`_startHrMonitor`) при workout пушит `[relSec,bpm]` в `GPS.recording.hrSamples`. + - `stopRecording` ветка workout: считает ккал (Keytel), avg/max пульс, сохраняет `hr_samples` + (JSON) + `finishGpsSession(dist=0, kcal)`. +- `frontend/js/db.js`: миграция `gps_sessions.hr_samples TEXT`; `setSessionHrSamples`. +- `frontend/js/main.js`: + - выпадашка записи: "Зарядка (пульс)". + - `renderWorkoutInline` - экран записи без карты: крупный пульс, таймер, живые ккал, + средний/макс, кнопка "Подключить пульсометр" (с проверкой BT), Стоп -> деталь. + - `renderWorkoutDetail` - деталь без карты: график пульса (цвет по HR-зонам), время, + нагрузка (ккал), средний/макс, удалить. +- `frontend/css/styles.css`: `.workout-screen` и пр. + +Ккал зарядки идёт в дневной баланс автоматически (`getDailyActivityTotals` суммирует +`gps_sessions.total_kcal`). `recalculateSession` для workout - no-op (точек нет, возвращает null), +так что "Пересчитать всё" не затирает HR-ккал. + +## Заряд ремня ЧСС + голос/писк на 10% и 5% (сделано) +- Kotlin `BleHrService`: читает 0x180F/0x2A19 (readBattery) через 2.5с после подписки и раз в + 60с; `lastBattery` пишется в `ble_hr_data.json` как `"bat":N`. onCharacteristicRead (две + перегрузки под API 33+/старый). +- Rust `HrReading`: поле `battery: Option` (alias "bat"). +- JS `gps.js`: поллинг ловит `reading.battery` -> `GPS.recording.currentBattery` + + `GPS._checkBatteryAlert`: на 10% и 5% ОДИН раз - голос (`speechSynthesis` ru-RU) + писк + (Web Audio, гарантированный сигнал, т.к. TTS на de-Googled ROM может не быть). Сброс флагов + при заряде > 15% (перезарядили/заменили). +- Экран зарядки показывает "заряд N%" в статусе. +- Заметка: аудио в WebView может требовать user-gesture; старт записи - это жест, должно + играть. Проверить на устройстве. + +## Правка после теста: авто-ЧСС на старте + тоггл ВКЛ/ВЫКЛ вместо "Ветер" +Я перестарался: по просьбе "не искать, если браслета нет" убрал автоконнект ВООБЩЕ. Нужно было +оставить авто с проверкой наличия. Исправлено: +- `GPS.setHrOn(on)` (gps.js): ON - проверяет BT (off -> запрос включения), коннектит привязанный + датчик, запускает поллинг; OFF - стоп поллинг + disconnect. Безопасно: BleHrService при + отсутствии браслета коротко сканит и `stopSelf` (драйна нет). +- Старт записи (и GPS, и зарядки): если датчик привязан - авто `setHrOn(true)`. Так "надел + датчик -> начал -> подключился сам", без ручного коннекта каждый раз. Нет привязанного - ЧСС off. +- Экран записи: кнопка **"Ветер" убрана** (неудобна), на её месте **тоггл ЧСС** (квадрат с + сердечком+линией пульса; серый = выкл, красный = вкл), дёргает `GPS.setHrOn`. +- Ручная кнопка "Подключить" в настройках остаётся. + +Факт-чек (я ошибся ранее): на телефоне Google-сервисы ЕСТЬ (`com.google.android.gms`, Play +Store) - значит системный диалог включения гео через Play Services доступен. Авто-вкл BT/гео по +запросу (попап + 2 настройки "не спрашивать") - отдельная задача, теперь подтверждено выполнимо. + +## Проверка +Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите +пульсометр"), подключить датчик -> пульс крупно + ккал растут; Стоп -> деталь с графиком. +Нужен BT на телефоне + привязанный датчик. (Сейчас BT на телефоне выключен.) diff --git a/docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md b/docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md new file mode 100644 index 0000000..60cb6ed --- /dev/null +++ b/docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md @@ -0,0 +1,54 @@ +# Сессия 2026-10-19 - краш при выключении Bluetooth + один запрос на включение вместо нескольких + +## Контекст +Пользователь: "при выключении блютуса балдав падает", и попапы про включение BT лезут +повторно ("если запретил - не просить до нового трекинга; просить ОДИН раз, а не 20"). +Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (финальный +вариант; сохранён в docs/plans не был - план короткий, воспроизведён в этом логе). + +## Разбор (две независимые причины) +1. Краш. Крэш-буфер телефона: нативный abort libc FORTIFY "pthread_mutex_lock called on a + destroyed mutex" (pid 19579), не Kotlin-исключение. Причина: живой GATT/scanner трогают + уже после выключения BT-адаптера. В `BleHrService.kt` не было приёмника состояния BT, а + отложенные `scanner.stopScan`, fitpro-ретриггер и периодические хендлеры продолжали дёргать + GATT. Точный фрейм без tombstone не прибивался - фикс сделан defense-in-depth (решение + пользователя: сразу защита + проверка воспроизведением). +2. Двойной запрос. `ensureSystemReady` (main.js) спрашивает один раз через appConfirm, но + `startRecording` авто-зовёт `setHrOn(true)`, а тот при BT off САМ дёргал нативный + `ble_request_enable` (gps.js:899), игнорируя отказ -> второе (системное) окно сразу после + отказа, и так каждый старт. + +## Сделано (2 коммита) +Коммит 1 (краш, Kotlin `BleHrService.kt`): +- Единый `handler = Handler(Looper.getMainLooper())`; все postDelayed через него; + `handler.removeCallbacksAndMessages(null)` в `disconnect()` и `onDestroy()`. +- `BroadcastReceiver` на `BluetoothAdapter.ACTION_STATE_CHANGED`: на STATE_TURNING_OFF/OFF - + снять хендлеры, disconnect, `writeData(status:bt_off)`, stopSelf. Снятие в onDestroy в try. +- try/catch на всех нативных BLE-вызовах: startScan/stopScan/connectGatt/discoverServices/ + writeCharacteristic/setCharacteristicNotification/writeDescriptor; directConnect проверяет + adapter.isEnabled. + +Коммит 2 (попапы, JS): +- `GPS.setHrOn(on, interactive = false)` (gps.js): системное окно `ble_request_enable` только + при `interactive === true`. Авто-режим при BT off: hrOn=false, не подключаться, без окна. +- Авто-вызовы на старте (gps.js 1433 зарядка, 1462 трек) остаются `setHrOn(true)` (без + interactive) -> единственный авто-запрос при старте = один appConfirm в ensureSystemReady. +- Ручной тоггл ЧСС (main.js:5148) -> `setHrOn(..., true)`: одно системное окно на тап. + Ручное "Подключить" (настройки) не трогали - оно и так user-initiated. + +## Проверка +Синтаксис JS ок (node -c обоих файлов). На телефоне (сборка `./build.sh -i -d android`): +1) ЧСС подключён, выключить BT в записи -> app НЕ падает, ЧСС гаснет, трек идёт; в logcat + `BleHR` виден STATE_OFF и чистый stop, нет нового FATAL/FORTIFY. +2) BT off, старт трека -> ровно одно окно "Включить Bluetooth?"; отказ -> системное окно НЕ + появляется, до конца записи повторов нет; новый старт -> снова один вопрос. +3) Тап по тогглу ЧСС при BT off -> одно системное окно. +4) Регрессии рядом: зарядка с ЧСС считает kcal; "Не спрашивать" гасят свои запросы; + "Подключить" в настройках работает на ходу. + +## Отдельно (read-only, без правок) +Разобрал `wind_marks` в 5 треках из 228: отметки встречного ветра нигде не используются +(коммент в коде - "ground truth для оценки ветра из трека", которой в коде нет), и +эмпирически сигнал противоречив (в 2 из 4 велотреков "в ветер" скорость даже выше). Вывод: +пользы нет; кнопки ветра уже нет, новые треки пишут пустой wind_marks, помех нет - оставили +как есть. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 168b50c..fbc4569 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -714,6 +714,34 @@ select { .full-width { width: 100%; } +/* HR-only workout (зарядка) recording screen: big heart rate, timer, kcal, no map. */ +.workout-screen { padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; } +.wk-hr-big { font-size: 92px; font-weight: 700; line-height: 1; color: var(--primary); } +.wk-hr-lbl { color: var(--muted); margin-top: -4px; } +.wk-stats { display: flex; gap: 18px; margin: 14px 0; flex-wrap: wrap; justify-content: center; } +.wk-stat { display: flex; flex-direction: column; align-items: center; min-width: 64px; } +.wk-val { font-size: 22px; font-weight: 600; } +.wk-lbl { font-size: 12px; color: var(--muted); } +.wk-status { color: var(--muted); font-size: 13px; text-align: center; min-height: 18px; margin-bottom: 6px; } +.workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; } + +/* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */ +.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 4px; + /* Strap battery as a background fill, bottom-up, 0-100% (--bat), coloured by level (--bat-color). */ + background-image: linear-gradient(to top, var(--bat-color, transparent) var(--bat, 0%), transparent var(--bat, 0%)); } +.rec-hr-toggle svg { display: block; } +.rec-hr-toggle.active { color: #fff; } /* HR on = white icon + bpm (over the battery fill) */ +.rec-hr-num { font-size: 15px; font-weight: 700; min-width: 0; } +.rec-hr-num:empty { display: none; } + +/* Progress fill for long-running action buttons: the background fills left-to-right by + --pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps + the button's own background. Used by recompute / elevation-refine / roads-rebuild. */ +.btn-progress { + background-image: linear-gradient(90deg, var(--primary) var(--pct, 0%), rgba(0,0,0,0) var(--pct, 0%)); + transition: background-image 0.15s linear; +} + /* Products screen */ .products-screen, .stub-screen, .settings-screen { padding: 16px; @@ -1246,6 +1274,11 @@ select { .rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; } .rec-route-toggle { right: 8px; bottom: 200px; } .rec-dark-toggle { right: 8px; bottom: 238px; } +.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; } +/* nogo (⛔) and reroute (↪) had no position class — they piled at the map's top-left over the + zoom controls. Give them slots in the right-hand stack. */ +.rec-nogo-btn { right: 8px; bottom: 314px; } +.rec-reroute-btn { right: 8px; bottom: 352px; } /* Dark map: invert ONLY the tile layer (overlays — track, route, roads, puck — keep their colours). User-tuned filter on OSM standard tiles. Applies to every Leaflet map. */ body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); } @@ -1283,20 +1316,9 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } cursor: pointer; } /* Keep Leaflet zoom controls below the status bar on the recording map. */ -.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); } -/* Status-bar backdrop. The recording map is edge-to-edge (draws behind the system - status bar), so the bar had no backing and its icons were unreadable over the map - tiles. Paint a solid strip the height of the top inset — like the header on every - other screen — so the status bar stays visible. Controls already start below it. */ -.rec-map-wrap::before { - content: ""; - position: absolute; - top: 0; left: 0; right: 0; - height: var(--safe-area-top, 0px); - background: var(--surface); - z-index: 1100; - pointer-events: none; -} +/* The top strip (.rec-topbar) now sits above the map and already backs the status bar, so the + map no longer draws under the status bar — no backdrop strip, and controls start at the map top. */ +.rec-map-wrap .leaflet-top { margin-top: 0; } .rec-screen.rot-90 .rec-map-wrap::before, .rec-screen.rot-270 .rec-map-wrap::before { display: none; } .rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; } @@ -1379,11 +1401,21 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } } .rec-val { - font-size: 22px; + font-size: 27px; font-weight: 700; color: var(--on-surface); } +/* Top strip (external info): training time (left), temperature (centre), clock (right). + Same big digits as the bottom stats (.rec-val). */ +.rec-topbar { + display: flex; justify-content: space-between; align-items: baseline; + padding: 4px 16px; padding-top: calc(4px + var(--safe-area-top)); + background: var(--surface); +} +.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); } +.rec-topbar .rec-top-mid { color: var(--muted); } + .rec-lbl { font-size: 10px; color: var(--muted); @@ -1401,24 +1433,16 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } .rec-bottom { display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; - padding: 8px 12px; - padding-bottom: calc(8px + var(--safe-area-bottom)); + align-items: stretch; + gap: 8px; + padding: 6px 8px; + padding-bottom: calc(6px + var(--safe-area-bottom)); background: var(--surface); } -.rec-bottom .rec-status { - font-size: 12px; - color: var(--muted); - flex: 1; -} - -.rec-bottom .gps-rec { - padding: 12px 32px; - flex-shrink: 0; -} +/* All three controls (HR toggle, pause, stop) share the width equally — no clustering right. + border-box + one padding so the different per-button paddings don't make them unequal. */ +.rec-bottom > button { flex: 1 1 0; box-sizing: border-box; min-width: 0; padding: 12px 8px; } .gps-header { padding: 10px 12px; @@ -1542,6 +1566,18 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } font-weight: 600; } +/* Repeat / interval analysis table + verdict */ +.repeats-head { font-size: 12px; color: var(--on-bg); opacity: 0.8; margin-bottom: 8px; } +.repeats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +.repeats-tbl th, .repeats-tbl td { padding: 5px 6px; text-align: right; border-bottom: 1px solid var(--border); } +.repeats-tbl th:first-child, .repeats-tbl td:first-child { text-align: left; } +.repeats-tbl th { color: var(--primary); font-weight: 600; } +.repeats-verdict { margin-top: 10px; padding: 10px; border-radius: var(--radius); text-align: center; line-height: 1.4; } +.repeats-verdict .rv-num { font-size: 22px; font-weight: 700; } +.repeats-verdict.ok { background: rgba(46, 160, 67, 0.18); color: #4caf50; } +.repeats-verdict.warn { background: rgba(255, 179, 0, 0.16); color: #ffb300; } +.repeats-verdict.bad { background: rgba(207, 102, 121, 0.18); color: #cf6679; } + .sd-row { display: flex; gap: 8px; @@ -1728,6 +1764,45 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } border-color: var(--primary); } +.gps-session-item.selected { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 20%, var(--surface)); + box-shadow: inset 0 0 0 1px var(--primary); +} + +/* Floating action bar for multi-select merge (sits above the bottom tab bar) */ +.gps-sel-bar { + position: fixed; + left: 0; right: 0; + bottom: calc(56px + var(--safe-area-bottom)); + z-index: 90; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--surface); + border-top: 1px solid var(--border); + box-shadow: 0 -2px 8px rgba(0,0,0,0.3); +} +.gps-sel-bar[hidden] { display: none; } + +/* "Split here" button inside a point popup — visible only when the map is in split mode */ +.split-here { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + background: #ff9800; + color: #000; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +#sd-map:not(.split-mode) .split-here { display: none; } +.gps-sel-bar #gps-sel-count { flex: 1; font-size: 14px; } +.gps-sel-bar button { padding: 8px 14px; } + .gps-session-head { display: flex; justify-content: space-between; diff --git a/frontend/js/db.js b/frontend/js/db.js index f04263e..6dbed57 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -48,6 +48,9 @@ async function addWeight(date, time, kg) { "INSERT INTO weight_log (date, time, weight_kg) VALUES (?1, ?2, ?3)", [date, time, kg] ); + // Keep the settings weight in sync with the latest weigh-in (today's if there is one). + const latest = await getLatestWeight(); + if (latest && latest.weight_kg != null) await setProfileValue("weight_kg", latest.weight_kg); } async function deleteWeight(id) { @@ -65,13 +68,16 @@ async function getLatestWeight() { return rows.length ? rows[0] : null; } -async function getRecentWeights(days) { +// `days` calendar days ENDING at endDate (default today). Range is (end-days, end] so it is +// exactly `days` days (the old `>= date('now','-N')` gave N+1) and follows the selected day. +async function getRecentWeights(days, endDate) { + const end = endDate || "now"; return select( `SELECT date, MIN(weight_kg) as weight_kg FROM weight_log - WHERE date >= date('now', ?1) + WHERE date > date(?2, ?1) AND date <= date(?2) GROUP BY date ORDER BY date ASC`, - [`-${days ?? 30} days`] + [`-${days ?? 30} days`, end] ); } @@ -680,25 +686,28 @@ async function getActivityTotalsForDate(date) { return { kcal: manualKcal + gpsKcal, minutes: manual[0]?.minutes ?? 0 }; } -async function getDailyFoodTotals(days) { +// N calendar days ending at endDate (default today), range (end-N, end] = exactly N days. +async function getDailyFoodTotals(days, endDate) { + const end = endDate || "now"; return select( `SELECT date, SUM(kcal) as kcal, SUM(protein_g) as protein_g, SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g - FROM food_log WHERE date >= date('now', ?1) + FROM food_log WHERE date > date(?2, ?1) AND date <= date(?2) GROUP BY date ORDER BY date ASC`, - [`-${days ?? 30} days`] + [`-${days ?? 30} days`, end] ); } -async function getDailyActivityTotals(days) { +async function getDailyActivityTotals(days, endDate) { const offset = `-${days ?? 30} days`; + const end = endDate || "now"; return select( `SELECT date, SUM(kcal) as kcal FROM ( - SELECT date, kcal FROM activity_log WHERE date >= date('now', ?1) + SELECT date, kcal FROM activity_log WHERE date > date(?2, ?1) AND date <= date(?2) UNION ALL - SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?2) AND total_kcal IS NOT NULL + SELECT date, total_kcal as kcal FROM gps_sessions WHERE date > date(?2, ?1) AND date <= date(?2) AND total_kcal IS NOT NULL ) GROUP BY date ORDER BY date ASC`, - [offset, offset] + [offset, end] ); } @@ -801,6 +810,24 @@ async function saveGapFill(sessionId, points) { } await execute(`INSERT INTO gps_points (session_id, ts, lat, lon, seq, estimated) VALUES ${rowSql.join(",")}`, vals); } + // Re-sequence the WHOLE session by ts so getGpsPoints (ORDER BY seq,ts) returns the true + // order. Without this, on merged/split tracks the real points have seq=NULL, the inserted + // gap-fill points get seq=0, and they sort to the FRONT — a straight line from the start to + // the gap ("returns to the beginning"). Re-numbering by ts is a no-op for dense tracks and + // fixes NULL/mixed ones. Batched CASE update (like saveSmoothedTrack) to beat the IPC/bind cost. + let ordered = await select("SELECT id FROM gps_points WHERE session_id=?1 ORDER BY ts ASC, id ASC", [sessionId]); + let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit + for (let i = 0; i < ordered.length; i += SEQCHUNK) { + let chunk = ordered.slice(i, i + SEQCHUNK); + let when = [], ids = [], params = []; + for (let k = 0; k < chunk.length; k++) { + params.push(chunk[k].id); let idi = params.length; + params.push(i + k + 1); let sqi = params.length; + when.push(`WHEN ?${idi} THEN ?${sqi}`); + ids.push(`?${idi}`); + } + await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params); + } await execute("UPDATE gps_sessions SET gap_filled=1 WHERE id=?1", [sessionId]); return toInsert.length; } @@ -914,6 +941,66 @@ async function deleteGpsSession(id) { await execute("DELETE FROM gps_sessions WHERE id = ?1", [id]); } +// Merge several sessions into one. Points from every session move into the EARLIEST +// (by start time) session; each moved point keeps its original session in +// orig_session_id (so a future "split" can undo this), the target's end_time extends to +// the latest, and the other session rows are deleted. Ids are text (large i64 read back +// off-by-one), passed as strings and CAST to INTEGER for the id/session_id columns. +// Returns the surviving (target) session id_str. Caller recomputes stats afterwards. +async function mergeGpsSessions(idStrs) { + if (!Array.isArray(idStrs) || idStrs.length < 2) return null; + const sessions = []; + for (const id of idStrs) { const s = await getGpsSession(id); if (s) sessions.push(s); } + if (sessions.length < 2) return null; + const ord = (s) => (s.date || "1970-01-01") + "T" + (s.start_time || "00:00:00"); + sessions.sort((a, b) => ord(a) < ord(b) ? -1 : ord(a) > ord(b) ? 1 : 0); + const target = sessions[0]; + const targetId = target.id_str; + const endOf = (s) => (s.date || "1970-01-01") + "T" + (s.end_time || s.start_time || "00:00:00"); + const latestEnd = sessions.reduce((m, s) => endOf(s) > endOf(m) ? s : m, target).end_time; + // Tag the target's own points too, so orig_session_id is complete for a later split. + await execute("UPDATE gps_points SET orig_session_id = CAST(?1 AS TEXT) WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NULL", [targetId]); + for (const s of sessions.slice(1)) { + await execute( + "UPDATE gps_points SET orig_session_id = COALESCE(orig_session_id, CAST(session_id AS TEXT)), session_id = CAST(?1 AS INTEGER) WHERE session_id = CAST(?2 AS INTEGER)", + [targetId, s.id_str] + ); + await execute("DELETE FROM gps_sessions WHERE id = CAST(?1 AS INTEGER)", [s.id_str]); + } + if (latestEnd) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [latestEnd, targetId]); + // Each source session numbered its points seq 1..N, so after the move the seq values + // collide and "ORDER BY seq" would interleave the tracks (measured: a 43 km "segment" + // and 6745 km/h passes). Clear seq so getGpsPoints falls back to timestamp order, which + // is the true order across the merged tracks. + await execute("UPDATE gps_points SET seq = NULL WHERE session_id = CAST(?1 AS INTEGER)", [targetId]); + return targetId; +} + +// Split one session in two at a chosen point. The second part (points whose ids are in +// part2Ids) moves to a NEW session that copies the original's date / activity / bike / +// source; the first part keeps the original session. Bike stays the same on both halves - +// the rider then reassigns the bike on a half and recomputes if the gear differed. Times +// are "HH:MM:SS". Returns the new (second-part) session id_str. Caller recomputes both. +async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime) { + if (!Array.isArray(part2Ids) || part2Ids.length === 0) return null; + const s = await getGpsSession(sessionId); + if (!s) return null; + const newId = Date.now(); + await execute( + `INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, + [newId, s.date, part2StartTime, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null] + ); + let CHUNK = 200; + for (let i = 0; i < part2Ids.length; i += CHUNK) { + let ch = part2Ids.slice(i, i + CHUNK); + let ph = ch.map((_, k) => "?" + (k + 2)).join(","); + await execute(`UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), seq = NULL WHERE id IN (${ph})`, [newId, ...ch]); + } + if (part1EndTime) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [part1EndTime, sessionId]); + return String(newId); +} + async function importGpxSession(session, points) { await execute( `INSERT INTO gps_sessions (date, start_time, end_time, activity_type, @@ -1012,6 +1099,18 @@ async function updateSessionBike(sessionId, bikeId) { await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]); } +// Store the rider's weight at ride time (set once when a recording finalises). A separate +// setter from finishGpsSession, which recompute also calls — recompute must NOT overwrite it. +async function setSessionRiderWeight(sessionId, kg) { + if (kg == null || isNaN(kg)) return; + await execute("UPDATE gps_sessions SET rider_weight_kg = ?1 WHERE id = ?2", [kg, sessionId]); +} + +// HR-only "workout" session: store the heart-rate time series as JSON [[relSec,bpm],...]. +async function setSessionHrSamples(sessionId, samplesJson) { + await execute("UPDATE gps_sessions SET hr_samples = ?1 WHERE id = ?2", [samplesJson, sessionId]); +} + // Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}]. // Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline. async function updatePointsSrtm(rows) { @@ -1289,6 +1388,8 @@ window.DB.getGpsSessionsForDate = getGpsSessionsForDate; window.DB.getGpsSession = getGpsSession; window.DB.getGpsPoints = getGpsPoints; window.DB.deleteGpsSession = deleteGpsSession; +window.DB.mergeGpsSessions = mergeGpsSessions; +window.DB.splitGpsSession = splitGpsSession; window.DB.importGpxSession = importGpxSession; window.DB.saveGapFill = saveGapFill; window.DB.revertGapFill = revertGapFill; @@ -1313,6 +1414,8 @@ window.DB.saveBike = saveBike; window.DB.updateBike = updateBike; window.DB.deleteBike = deleteBike; window.DB.updateSessionBike = updateSessionBike; +window.DB.setSessionRiderWeight = setSessionRiderWeight; +window.DB.setSessionHrSamples = setSessionHrSamples; window.DB.updatePointsSrtm = updatePointsSrtm; window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog; window.DB.addBikeMaintenance = addBikeMaintenance; @@ -1516,6 +1619,7 @@ async function runMigrations() { if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL"); if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads) + if (!gpsCols.includes("orig_session_id")) await execute("ALTER TABLE gps_points ADD COLUMN orig_session_id TEXT"); // set on merge = the session this point first belonged to (lets a future split undo the merge) // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( @@ -1634,6 +1738,8 @@ async function runMigrations() { if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate) if (!gsCols.includes("gap_filled")) await execute("ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0"); // BRouter gap-fill applied (reversible) + if (!gsCols.includes("rider_weight_kg")) await execute("ALTER TABLE gps_sessions ADD COLUMN rider_weight_kg REAL"); // rider weight at ride time; recompute uses it (NULL on old sessions -> latest weigh-in) + if (!gsCols.includes("hr_samples")) await execute("ALTER TABLE gps_sessions ADD COLUMN hr_samples TEXT"); // HR-only "workout" sessions (no GPS): JSON [[relSec,bpm],...] // Add tire_pressure_bar to bikes (for Crr correction by tire pressure) const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name); @@ -1925,13 +2031,26 @@ async function runMigrations() { ); if (nullSeqSessions.length > 0) { console.log(`[db.js] Repairing seq for ${nullSeqSessions.length} sessions...`); - for (const row of nullSeqSessions) { - const pts = await select( - "SELECT rowid, ts FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, rowid ASC", + for (let row of nullSeqSessions) { + // Batched CASE update (like saveGapFill/saveSmoothedTrack): one UPDATE per ~300 points + // instead of one per point. The per-point loop was ~15000 awaited IPC round-trips on + // merged/split tracks (seq=NULL by design) — many seconds of startup, EVERY boot, and + // for the big sessions it did not even persist before the next launch re-ran it. + let pts = await select( + "SELECT id FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, id ASC", [row.session_id] ); - for (let i = 0; i < pts.length; i++) { - await execute("UPDATE gps_points SET seq = ?1 WHERE rowid = ?2", [i + 1, pts[i].rowid]); + let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit + for (let i = 0; i < pts.length; i += SEQCHUNK) { + let chunk = pts.slice(i, i + SEQCHUNK); + let when = [], ids = [], params = []; + for (let k = 0; k < chunk.length; k++) { + params.push(chunk[k].id); let idi = params.length; + params.push(i + k + 1); let sqi = params.length; + when.push(`WHEN ?${idi} THEN ?${sqi}`); + ids.push(`?${idi}`); + } + await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params); } console.log(`[db.js] Repaired seq for session ${row.session_id}: ${pts.length} points`); } diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 7b91e7e..1e6e8db 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -91,6 +91,141 @@ GPS.detectWalk = function(points, opts) { return out; }; +// Auto-detect repeated segments within ONE ride (interval training: the same climb +// ridden N times, one track, no stop/restart). The repeated corridor is found by +// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run +// where position along a reference segment rises monotonically), but the reference +// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride +// revisits), not given as road_points ids. +// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }. +// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST +// (uphill) position, so coasting back down makes position fall and ends the pass, and +// the "must start near seq beginning" guard stops a descent from opening a new pass. +GPS.detectRepeats = function(points, opts) { + opts = opts || {}; + let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap) + let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long + let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate) + let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment + let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long + let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor + let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor + let backM = opts.backM ?? 30; // progress may dip this far without ending the pass + let n = points ? points.length : 0; + if (n < 20) return { found: false }; + + // Grid cell key per point. + let key = new Array(n); + for (let i = 0; i < n; i++) { + let la = points[i].lat, lo = points[i].lon; + if (la == null || lo == null) { key[i] = null; continue; } + key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell); + } + + // Entries per cell: count each time the track enters a cell (key changes into it). + let visits = new Map(); + for (let i = 0; i < n; i++) { + if (key[i] == null) continue; + if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1); + } + let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v; + if (maxEntries < 2) return { found: false }; + + // Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor). + // Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot + // cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where + // progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a + // small backstep. Pass time = moving seconds only (stops trimmed). + let detectAt = (hotThr) => { + let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr; + let buildTraversal = (start) => { + let seq = [], order = new Map(), end = start; + for (let k = start, gap = 0; k < n; k++) { + let c = key[k]; + if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor + gap = 0; + if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell + if (order.has(c)) break; // revisit of an earlier cell -> traversal complete + order.set(c, seq.length); seq.push(c); end = k; + } + let len = 0; + for (let k = start + 1; k <= end; k++) + len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); + return { seq, len, start, end }; + }; + let best = null; + for (let k = 0; k < n; k++) { + if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start + let t = buildTraversal(k); + if (!best || t.len > best.len) best = t; + } + if (!best || best.seq.length < 3 || best.len < minLenM) return null; + let rLat = [], rLon = [], cum = [0]; + for (let k = best.start; k <= best.end; k++) { + rLat.push(points[k].lat); rLon.push(points[k].lon); + if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon)); + } + let refLen = cum[cum.length - 1]; + if (rLat.length < 3 || refLen < minLenM) return null; + let refCell = new Map(); + for (let v = 0; v < rLat.length; v++) { + let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell); + (refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v); + } + let rad = Math.ceil(projTolM / cell); + let prog = new Array(n).fill(null); + for (let i = 0; i < n; i++) { + if (points[i].lat == null) continue; + let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell); + let bv = -1, bd = projTolM; + for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) { + let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue; + for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } } + } + if (bv >= 0) prog[i] = cum[bv]; + } + let passes = []; + let p = 0; + while (p < n) { + if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; } + let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0; + while (q < n - 1) { + q++; + if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; } + let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000; + let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon); + if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue; + break; + } + if ((hi - lo) >= refLen * minFrac) { + let mov = 0; + for (let k = p + 1; k <= anchor; k++) { + let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000; + let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); + if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded + } + passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) }); + } + p = Math.max(q, anchor) + 1; + } + return { segLength: Math.round(refLen), passes }; + }; + + // A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the + // reference, inflating it so a full rep no longer covers minFrac (measured on a real + // out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to + // the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment). + let bestRes = null; + for (let T = 2; T <= Math.min(maxEntries, 20); T++) { + let r = detectAt(T); + if (!r || r.passes.length < minReps) continue; + if (!bestRes || r.passes.length > bestRes.passes.length || + (r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r; + } + if (!bestRes) return { found: false }; + return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes }; +}; + // One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes // "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives. // Only touch the value if it's still the old default; respect a user-set threshold. @@ -246,6 +381,9 @@ GPS.computeStats = function(points, weightKg, opts) { let totalKcal = 0; const basalPerMin = (1.0 * 3.5 * wKg) / 200; + // Mechanical work while pedalling, for avgPower output (used by the repeat/interval + // analysis to compare passes). Averaged over pedalling time, like tools/track pers.py. + let mechWorkJ = 0, powerSec = 0; // Cadence tracking let totalPedalRevs = 0; @@ -407,6 +545,7 @@ GPS.computeStats = function(points, weightKg, opts) { // 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest // BMR already covers — the empirically measured cost. const physicsKcal = Math.max(0, power) * dt / efficiency / 4184; + mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power const vKmh = v * 3.6; if (vKmh >= 5) { const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0; @@ -474,6 +613,8 @@ GPS.computeStats = function(points, weightKg, opts) { const avgCadence = (hasGearData && pedalingSeconds > 0) ? Math.round(totalPedalRevs / (pedalingSeconds / 60)) : null; + // Average mechanical power (W) over pedalling time, null if never pedalled. + const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null; // --------------------------------------------------------------------------- // Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat @@ -570,6 +711,7 @@ GPS.computeStats = function(points, weightKg, opts) { kcal: Math.round(totalKcal), duration: Math.round(duration), avgCadence, + avgPower, segments, walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs) walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance) @@ -680,6 +822,19 @@ GPS.requestBackgroundLocation = async function() { } catch (e) { console.log("[GPS] reqbgloc:", e); } }; +// Is the OS location toggle ON (not just the permission)? Kotlin checks LocationManager and +// writes location_state.json; we read it back. Returns true/false, or null if undeterminable +// (desktop / no answer) — callers must not prompt on null. +GPS.isLocationEnabled = async function() { + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_location" }); + await new Promise(r => setTimeout(r, 300)); + let raw = await window.__TAURI__.core.invoke("read_state_file", { name: "location_state.json" }); + if (!raw) return null; + return !!JSON.parse(raw).enabled; + } catch { return null; } +}; + // BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses. // HR keeps measuring even when rider is stationary. GPS._startHrMonitor = async function() { @@ -689,24 +844,23 @@ GPS._startHrMonitor = async function() { const type = await DB.getProfileValue("ble_device_type"); if (!addr || !type) { console.log("[HR] No paired device"); return; } - const connected = await window.__TAURI__.core.invoke("ble_is_connected"); - if (!connected) { - console.log("[HR] Connecting to", addr, type); - // Just send connect — Kotlin handles scan+connect internally - try { - await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); - } catch (e) { - console.log("[HR] Connect failed:", e); - } - } - // Start polling HR regardless — data will appear when device connects - console.log("[HR] Starting HR poll"); + // Do NOT auto-connect/scan here. That ran a BLE scan+connect on every ride even when the + // band was absent or Bluetooth was off, holding the radio and draining the battery for + // nothing. Connecting is MANUAL now (Settings -> "Подключить", works mid-ride too). We only + // run the cheap HR poll (reads a local file), so a manual connect now or later starts + // feeding HR into the track via currentHr. + console.log("[HR] Starting HR poll (connect is manual)"); GPS.recording.hrPollInterval = setInterval(async () => { try { const reading = await window.__TAURI__.core.invoke("ble_get_hr"); + if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); } if (reading && reading.hr > 0) { GPS.recording.currentHr = reading.hr; GPS.recording.currentBleData = reading; + // HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading. + if (GPS.recording.activityType === "workout" && GPS.recording.startTime) { + GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]); + } if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading); } } catch {} @@ -727,6 +881,80 @@ GPS._stopHrMonitor = function() { try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} }; +// Turn HR on/off during a recording (the toggle on the record screen). ON connects to the +// paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning / +// drain when the strap isn't there) and polls; OFF stops the poll and disconnects. +// +// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then +// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start +// pass interactive=false: if BT is off they just leave HR off (no second popup after the single +// start-time prompt in ensureSystemReady), and a later manual connect still works. +GPS.setHrOn = async function(on, interactive = false) { + GPS.recording.hrOn = !!on; + if (!on) { + GPS._stopHrMonitor(); + try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {} + return false; + } + const addr = await DB.getProfileValue("ble_device_addr"); + const type = await DB.getProfileValue("ble_device_type"); + if (!addr) { GPS.recording.hrOn = false; return false; } + try { + const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); + if (bt === "off") { + // Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't + // connect with the radio off — leave HR off so the toggle shows grey, not a fake "on". + if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} } + GPS.recording.hrOn = false; + return false; + } + if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} } + } catch {} + try { await GPS._startHrMonitor(); } catch {} + return true; +}; + +// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%. +// Resets when the strap reads healthy again (recharged / battery replaced). +GPS._batAlerted = {}; +GPS._checkBatteryAlert = function(bat) { + if (bat == null || bat < 0 || bat > 100) return; + if (bat > 15) { GPS._batAlerted = {}; return; } + for (let thr of [10, 5]) { + if (bat <= thr && !GPS._batAlerted[thr]) { + GPS._batAlerted[thr] = true; + GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`); + } + } +}; +GPS._speakOrBeep = function(text) { + let spoke = false; + try { + if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") { + let u = new SpeechSynthesisUtterance(text); + u.lang = "ru-RU"; u.rate = 0.95; + window.speechSynthesis.speak(u); + spoke = true; + } + } catch {} + // Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue). + try { GPS._beep(spoke ? 1 : 3); } catch {} +}; +GPS._beep = function(times) { + times = times || 1; + let ctx = new (window.AudioContext || window.webkitAudioContext)(); + let one = (i) => { + let o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = 880; o.type = "sine"; + o.connect(g); g.connect(ctx.destination); + g.gain.value = 0.25; + let t0 = ctx.currentTime + i * 0.35; + o.start(t0); o.stop(t0 + 0.2); + }; + for (let i = 0; i < times; i++) one(i); + setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400); +}; + // Read points from native service file // Parse raw JSONL (one native point per line) into sorted point objects. GPS._parseNativePoints = function(raw) { @@ -1160,15 +1388,65 @@ GPS.recoverSession = async function() { } }; -GPS.startRecording = async function(activityType, weightKg) { +// Net kcal from a heart-rate series (Keytel 2005 gross, minus BMR for the same time so it is +// consistent with the app's NET model — BMR is already booked in the daily balance). +// samples: [[relSec, bpm], ...]. profile: {weightKg, age, sex ("m"/"f"), heightCm}. +GPS.hrKcalNet = function(samples, profile) { + if (!samples || samples.length < 2) return 0; + profile = profile || {}; + const W = parseFloat(profile.weightKg) || 75, A = parseInt(profile.age) || 38; + const H = parseFloat(profile.heightCm) || 178, male = (profile.sex || "m") === "m"; + const bmrMin = (10 * W + 6.25 * H - 5 * A + (male ? -5 : 161)) / 1440; // Mifflin -> kcal/min + const grossPerMin = hr => male + ? (-55.0969 + 0.6309 * hr + 0.1988 * W + 0.2017 * A) / 4.184 + : (-20.4022 + 0.4472 * hr - 0.1263 * W + 0.0740 * A) / 4.184; + let kcal = 0; + for (let i = 1; i < samples.length; i++) { + const dtMin = Math.max(0, samples[i][0] - samples[i-1][0]) / 60; + if (dtMin <= 0 || dtMin > 1) continue; // skip > 1 min gaps (disconnect) + const net = grossPerMin((samples[i-1][1] + samples[i][1]) / 2) - bmrMin; + if (net > 0) kcal += net * dtMin; + } + return Math.round(kcal); +}; + +GPS.startRecording = async function(activityType, weightKg, profile) { if (GPS.recording.active) return; - if (!navigator.geolocation) throw new Error("Geolocation not available"); GPS.recording.active = true; GPS.recording.activityType = activityType || "bike"; GPS.recording.points = []; + GPS.recording.hrSamples = []; + GPS.recording.hrProfile = profile || null; + // Clear any HR left over from the PREVIOUS session (else a stale bpm hangs on the display and + // gets written onto this session's points). Only a fresh reading this session re-fills it. + GPS.recording.currentHr = null; + GPS.recording.currentBleData = null; + GPS.recording.currentBattery = null; + try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} GPS.recording.startTime = null; GPS.recording.weightKg = parseFloat(weightKg) || 80; + + // HR-only "workout" (зарядка): no GPS service, no location, no track points — just the + // heart-rate poll + a timer. kcal comes from HR (Keytel) at finish. + if (GPS.recording.activityType === "workout") { + GPS.recording.state = "recording"; + GPS.recording.startTime = new Date(); + GPS.recording.nativeService = false; + GPS.recording.sessionId = null; + try { + GPS.recording.sessionId = await DB.createGpsSession( + new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), "workout"); + } catch (e) { console.error("[workout] createGpsSession failed:", e); } + // Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean. + try { + const hrAddr = await DB.getProfileValue("ble_device_addr"); + if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false; + } catch (e) { console.error("[HR] start error:", e); } + return; + } + + if (!navigator.geolocation) throw new Error("Geolocation not available"); GPS.recording.state = "waiting_gps"; GPS.recording.sessionId = null; GPS.recording.pauseStart = null; @@ -1187,8 +1465,13 @@ GPS.startRecording = async function(activityType, weightKg) { // Start native foreground service (Android) await GPS._startNativeService(); - // Start BLE HR monitor (auto-connect to paired device) - try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); } + // HR: default ON if a strap is paired — auto-connect + poll at start (the record screen has an + // on/off toggle). Safe: BleHrService scans briefly and stops if the strap isn't present, so no + // constant scanning/drain when it's absent. No paired strap -> HR stays off. + try { + const hrAddr = await DB.getProfileValue("ble_device_addr"); + if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false; + } catch (e) { console.error("[HR] start error:", e); } // Poll native service output file if (GPS.recording.nativeService) { @@ -1264,6 +1547,26 @@ GPS.stopRecording = async function() { GPS.recording.active = false; if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer); + + // HR-only workout: no GPS to stop. Compute net kcal from HR, save samples, finalise. + if (GPS.recording.activityType === "workout") { + const samples = GPS.recording.hrSamples || []; + const kcal = GPS.hrKcalNet(samples, GPS.recording.hrProfile); + const durationSec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0; + const hrs = samples.map(s => s[1]); + const avgHr = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null; + const maxHr = hrs.length ? Math.max(...hrs) : null; + const sid = GPS.recording.sessionId; + GPS._stopHrMonitor(); + if (sid) { + try { await DB.setSessionHrSamples(sid, JSON.stringify(samples)); } catch (e) { console.error("[workout] save samples", e); } + // Reuse finishGpsSession: distance 0, kcal from HR; avg/max HR ride the speed columns' siblings? No — + // store them nowhere extra; detail derives avg/max from hr_samples. Just distance 0 + kcal. + try { await DB.finishGpsSession(sid, new Date().toTimeString().slice(0, 5), 0, kcal, null, null, 0, 0); } catch (e) { console.error("[workout] finish", e); } + } + return { sessionId: sid, workout: true, kcal, durationSec, avgHr, maxHr, samples }; + } + GPS._stopHrMonitor(); if (GPS.recording.watchId != null) { @@ -1339,6 +1642,8 @@ GPS.stopRecording = async function() { stats.elevGain, stats.elevLoss, recPauses ); try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); } + // Persist the rider weight used for this ride, so a later recompute keeps ride-time weight. + try { await DB.setSessionRiderWeight(GPS.recording.sessionId, GPS.recording.weightKg); } catch (e) { console.error("[GPS] rider weight save failed:", e); } // DB save confirmed — now safe to delete the points file if (nativePointsFile) { try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {} @@ -1454,6 +1759,13 @@ GPS.fillGaps = async function(points, opts) { let minChord = opts.minChordM ?? 150; // and only when the endpoints are genuinely far apart let ratio = opts.maxRatio ?? 2; // road > 2× chord = big detour, likely wrong → keep straight let minGain = opts.minGainM ?? 40; // road ≈ chord = nothing to gain → keep straight + // Router for one gap: the caller can prefer already-ridden roads (returns {points, ridden:true} + // when the bridge runs over the user's own tracks); default is the public BRouter shortest. + // A ridden path is what actually happened, so it is NOT subject to the "detour too long" guard. + let routeFn = opts.routeFn || (async (a, b) => { + let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); + return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false }; + }); let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); let out = []; let gaps = []; @@ -1465,16 +1777,17 @@ GPS.fillGaps = async function(points, opts) { let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon); if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime(); - let routed = [], status = "skipped-noroute", addedM = 0; + let routed = [], status = "skipped-noroute", addedM = 0, ridden = false; try { - let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); - let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; + let r = await routeFn(a, b); + let rp = (r && r.points) || []; + ridden = !!(r && r.ridden); if (rp.length >= 3) { let rlen = 0; for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon); addedM = Math.max(0, rlen - chord); - if (rlen > chord * ratio) { - status = "skipped-long"; // road detoured too far — keep the chord + if (!ridden && rlen > chord * ratio) { + status = "skipped-long"; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is) } else if (addedM < minGain) { status = "skipped-nogain"; // road ≈ straight — nothing to bridge } else { @@ -1488,7 +1801,7 @@ GPS.fillGaps = async function(points, opts) { } } } catch (e) { status = "skipped-noroute"; } - gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord }); + gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord, ridden }); } return { points: out, gaps }; }; @@ -1766,6 +2079,9 @@ GPS.exportGPX = async function(sessionId) { // --------------------------------------------------------------------------- GPS.recalculateSession = async function(sessionId, opts) { opts = opts || {}; + // Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer. + const onProg = (typeof opts.onProgress === "function") ? opts.onProgress : () => {}; + onProg(5); const session = await DB.getGpsSession(sessionId); if (!session) return null; const points = await DB.getGpsPoints(sessionId); @@ -1807,7 +2123,7 @@ GPS.recalculateSession = async function(sessionId, opts) { // Compute stats and finish const latestW = await DB.getLatestWeight(); const profile = await DB.getFullProfile(); - const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; const stats = GPS.computeStats(sorted, weightKg, { @@ -1818,6 +2134,7 @@ GPS.recalculateSession = async function(sessionId, opts) { }); await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + onProg(100); return stats; } @@ -1872,6 +2189,7 @@ GPS.recalculateSession = async function(sessionId, opts) { } if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`); } + onProg(40); // Interpolate gaps where accelerometer shows movement // If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal @@ -1930,10 +2248,10 @@ GPS.recalculateSession = async function(sessionId, opts) { } } - // Get user weight: latest weight log, or profile, or 80 + // Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80. const latestW = await DB.getLatestWeight(); const profile = await DB.getFullProfile(); - const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; // Get bike if linked to session, otherwise use active bike const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); @@ -1943,6 +2261,7 @@ GPS.recalculateSession = async function(sessionId, opts) { // Use cleaned sorted array, not original points const sessPauses = Array.isArray(session.pauses) ? session.pauses : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); + onProg(70); const stats = GPS.computeStats(sorted, weightKg, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", @@ -1961,6 +2280,7 @@ GPS.recalculateSession = async function(sessionId, opts) { stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, sessPauses ); + onProg(100); return stats; }; @@ -1997,7 +2317,8 @@ GPS.recomputeAllStats = async function(onProgress) { const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); const bulkPauses = Array.isArray(session.pauses) ? session.pauses : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); - const stats = GPS.computeStats(points, weightKg, { + const sessW = parseFloat(session.rider_weight_kg) || weightKg; // ride-time weight, else the fallback + const stats = GPS.computeStats(points, sessW, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006, diff --git a/frontend/js/main.js b/frontend/js/main.js index e55336d..9b83673 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -220,6 +220,32 @@ async function init() { // Router // --------------------------------------------------------------------------- +// Keep the vertical scroll position across a re-render of the SAME screen (day nav, adding +// food, deletes) instead of jumping to the top. Anchor on the topmost visible top-level +// section + its offset (robust to height changes, e.g. one more food row or async charts). +function captureScrollAnchor(el) { + let top = el.scrollTop, kids = el.children, idx = 0, delta = top; + for (let i = 0; i < kids.length; i++) { + if (kids[i].offsetTop <= top + 4) { idx = i; delta = top - kids[i].offsetTop; } else break; + } + return { idx, delta }; +} +function applyScrollAnchor(el, a) { + let kid = el.children[a.idx]; + if (kid) el.scrollTop = Math.max(0, kid.offsetTop + a.delta); +} +// The screen fills asynchronously (charts, nutrient strips), so re-apply for a short window as +// content settles; stop early if the user scrolls themselves. +function scheduleScrollRestore(el, a) { + let stop = false, off = () => { stop = true; }; + el.addEventListener("wheel", off, { passive: true, once: true }); + el.addEventListener("touchmove", off, { passive: true, once: true }); + let apply = () => { if (!stop) applyScrollAnchor(el, a); }; + apply(); + requestAnimationFrame(apply); + [60, 180, 400].forEach(ms => setTimeout(apply, ms)); +} + function renderScreen(name, fromPopstate) { // Release screen wake lock and compass listener when leaving recording screen if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; } @@ -243,6 +269,12 @@ function renderScreen(name, fromPopstate) { supplements: renderSupplements, settings: renderSettings, }; + // Re-render of the same screen: remember where we were scrolled so we can stay put. + let scrollAnchor = null; + if (prevScreen === name) { + let oldContent = app.querySelector(".screen-content"); + if (oldContent && oldContent.scrollTop > 0) scrollAnchor = captureScrollAnchor(oldContent); + } app.innerHTML = ""; // Floating recording bar (visible on all screens except recording) @@ -271,6 +303,7 @@ function renderScreen(name, fromPopstate) { content.className = "screen-content"; app.appendChild(content); screens[name](content); + if (scrollAnchor) scheduleScrollRestore(content, scrollAnchor); } function buildNav(active) { @@ -318,7 +351,7 @@ async function renderToday(container) { return; } - let recentWeightsForBmr = await DB.getRecentWeights(7); + let recentWeightsForBmr = await DB.getRecentWeights(7, state.date); let bmr = calcBmr(recentWeightsForBmr); let actLevel = parseFloat(state.profile.activity_level) || 1.2; let dailyBase = bmr ? Math.round(bmr * actLevel) : null; @@ -512,9 +545,10 @@ async function renderToday(container) { let trendEl = container.querySelector("#balance-trend"); if (!trendEl || dailyBase == null) return; let [food7, act7] = await Promise.all([ - DB.getDailyFoodTotals(7), - DB.getDailyActivityTotals(7), + DB.getDailyFoodTotals(7, state.date), + DB.getDailyActivityTotals(7, state.date), ]); + // The range is now exactly 7 days ending at the selected day (no off-by-one slice needed). if (food7.length === 0) return; let foodMap = {}; food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0)); @@ -616,12 +650,13 @@ async function renderToday(container) { let statsContainer = container.querySelector("#stats-charts"); let statsPeriod = container.querySelector(".stats-period"); if (statsContainer && statsPeriod) { - renderStatsCharts(statsContainer, 30, foodTotals, dailyBase); + let chartRefDate = state.date; // window ends at the selected day + renderStatsCharts(statsContainer, 30, foodTotals, dailyBase, chartRefDate); statsPeriod.querySelectorAll(".type-chip").forEach(chip => { chip.onclick = () => { statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected")); chip.classList.add("selected"); - renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase); + renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase, chartRefDate); }; }); } @@ -633,7 +668,7 @@ async function renderToday(container) { let statsChartInstances = []; -async function renderStatsCharts(container, days, foodTotals, bmr) { +async function renderStatsCharts(container, days, foodTotals, bmr, refDate) { // Destroy old charts statsChartInstances.forEach(c => c.destroy()); statsChartInstances.length = 0; @@ -643,9 +678,9 @@ async function renderStatsCharts(container, days, foodTotals, bmr) { let weights, dailyFood, dailyActivity; try { [weights, dailyFood, dailyActivity] = await Promise.all([ - DB.getRecentWeights(days), - DB.getDailyFoodTotals(days), - DB.getDailyActivityTotals(days), + DB.getRecentWeights(days, refDate), + DB.getDailyFoodTotals(days, refDate), + DB.getDailyActivityTotals(days, refDate), ]); } catch (e) { container.innerHTML = `Ошибка загрузки статистики`; @@ -1138,7 +1173,44 @@ function appAlert(message, okText = "OK") { // 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) { +// Fill a button's background to `pct` (0-100) as a live progress bar; pct=null clears it. +// The % rides on a CSS custom property (.btn-progress reads --pct) so no inline style rule. +function setBtnProgress(el, pct) { + if (!el) return; + if (pct == null) { el.classList.remove("btn-progress"); el.style.removeProperty("--pct"); return; } + el.classList.add("btn-progress"); + el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%"); +} + +// Show the HR strap charge as a background fill on the HR toggle (0-100%, coloured by level). +function setHrBatteryFill(el, bat) { + if (!el) return; + if (bat == null || bat < 0 || bat > 100) { el.style.removeProperty("--bat"); el.style.removeProperty("--bat-color"); return; } + el.style.setProperty("--bat", bat + "%"); + el.style.setProperty("--bat-color", bat > 40 ? "rgba(76,175,80,0.5)" : bat > 15 ? "rgba(255,183,77,0.55)" : "rgba(239,83,80,0.6)"); +} + +// Before a recording, nudge the user to turn on what it needs: Bluetooth (for the HR strap) and +// location (for the track). Can't silently toggle them on modern Android, so prompt + act: BT via +// the system enable dialog, location via the location-settings screen. Declining is fine, and the +// two "Не спрашивать" settings suppress each prompt. Never prompts when the state is unknown. +async function ensureSystemReady(needBt, needGps) { + let inv = window.__TAURI__.core.invoke; + if (needBt && localStorage.getItem("baldav_dontask_bt") !== "1") { + let bt = "on"; try { bt = await inv("ble_check_bluetooth"); } catch {} + if (bt === "off" && await appConfirm("Для пульсометра нужен Bluetooth. Включить?", "Включить")) { + try { await inv("ble_request_enable"); } catch {} + } + } + if (needGps && localStorage.getItem("baldav_dontask_gps") !== "1") { + let locOn = await GPS.isLocationEnabled(); + if (locOn === false && await appConfirm("Для записи трека нужна геолокация. Открыть настройки, чтобы включить?", "Открыть")) { + try { await inv("gps_service_command", { cmd: "open_location_settings" }); } catch {} + } + } +} + +async function reopenDetail(sessionId) { let count = 0; while (winStack.length > 0) { let top = winStack.pop(); @@ -1146,7 +1218,20 @@ function reopenDetail(sessionId) { try { top.close(); } catch {} if (top.type === "modal") break; } - if (count > 0) { _skipPops = count; history.go(-count); } + // AWAIT the history.go(-count) popstate(s) before re-rendering. Rendering synchronously + // after history.go races it: showSessionDetail pushes its own history entry, then the + // pending popstates unwind THAT entry and desync the stack, and the async map/chart build + // comes up blank until the next event repaints it (the "graphs+map gone until Cancel" bug). + // Same fix as closeThen() — resolve on the popstates, never a timeout. + if (count > 0) { + _skipPops = count; + await new Promise(res => { + let remaining = count; + let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } }; + window.addEventListener("popstate", h); + history.go(-count); + }); + } showSessionDetail(sessionId); } @@ -1165,7 +1250,12 @@ function showWeightModal() { let kg = parseFloat(overlay.querySelector("#m-kg").value); let time = overlay.querySelector("#m-time").value; if (isNaN(kg)) return false; - await DB.addWeight(state.date, time, kg); + await DB.addWeight(state.date, time, kg); // also syncs profile weight_kg to the latest weigh-in + // Refresh in-memory state so kcal/BMR and the settings weight field are current at once. + try { + state.latestWeight = await DB.getLatestWeight(); + if (state.profile && state.latestWeight?.weight_kg != null) state.profile.weight_kg = state.latestWeight.weight_kg; + } catch {} }); } @@ -2722,6 +2812,7 @@ async function renderGpsScreen(container) { Вело Ходьба Бег + Зарядка (пульс) ${isRec ? "Трекинг" : "Старт"} @@ -2746,6 +2837,11 @@ async function renderGpsScreen(container) { ${sessions.length ? sessions.map(sessionCard).join("") : `Нет записей`} Показать ещё + + Выбрано 0 + Объединить + Отмена + `; @@ -2778,16 +2874,98 @@ async function renderGpsScreen(container) { GPS.recording.gearCount = bike.gear_count; } } - await GPS.startRecording(type, wkg); + // Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's + // choice: they can decline, and the two "Не спрашивать" settings suppress each prompt. + await ensureSystemReady(!!state.profile?.ble_device_addr, type !== "workout"); + + // Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel). + let hrProfile = type === "workout" ? { + weightKg: wkg, + age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), + sex: state.profile?.sex || "m", + heightCm: parseFloat(state.profile?.height_cm) || 178, + } : null; + await GPS.startRecording(type, wkg, hrProfile); renderScreen("recording"); } }; - // Session click → detail view. Keep sid as a STRING (numeric id from the - // SQL plugin is corrupted off-by-1 for large i64 values). - let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); }; + // Session click → detail view; LONG-PRESS → multi-select for merging tracks. Keep sid a + // STRING (numeric id from the SQL plugin is corrupted off-by-1 for large i64 values). + let selMode = false, selected = new Set(); + let selBar = container.querySelector("#gps-sel-bar"); + let selCount = container.querySelector("#gps-sel-count"); + let selMergeBtn = container.querySelector("#gps-sel-merge"); + let refreshSel = () => { + container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid))); + if (selBar) selBar.hidden = !selMode; + if (selCount) selCount.textContent = "Выбрано " + selected.size; + if (selMergeBtn) selMergeBtn.disabled = selected.size < 2; + }; + let exitSel = () => { selMode = false; selected.clear(); refreshSel(); }; + let toggleSel = (sid) => { + if (selected.has(sid)) selected.delete(sid); else selected.add(sid); + if (selected.size === 0) { exitSel(); return; } + refreshSel(); + }; + let enterSel = (sid) => { selMode = true; selected.add(sid); refreshSel(); }; + + // Long-press: hold ~500 ms without moving. Works for touch and (desktop) mouse; the + // click that follows is suppressed so a long-press doesn't also open the detail. + let attachLongPress = (item) => { + let timer = null, longFired = false; + let cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; + let start = () => { longFired = false; cancel(); timer = setTimeout(() => { timer = null; longFired = true; if (!selMode) enterSel(item.dataset.sid); else toggleSel(item.dataset.sid); }, 500); }; + item.addEventListener("touchstart", start, { passive: true }); + item.addEventListener("touchmove", cancel, { passive: true }); + item.addEventListener("touchend", cancel); + item.addEventListener("mousedown", start); + item.addEventListener("mouseup", cancel); + item.addEventListener("mouseleave", cancel); + item.onclick = () => { if (longFired) { longFired = false; return; } if (selMode) toggleSel(item.dataset.sid); else showSessionDetail(item.dataset.sid); }; + }; + let bindSessionClick = attachLongPress; container.querySelectorAll(".gps-session-item").forEach(bindSessionClick); + let sessTs = (s, which) => new Date((s.date || "1970-01-01") + "T" + ((which === "end" ? s.end_time : s.start_time) || "00:00:00")); + if (container.querySelector("#gps-sel-cancel")) container.querySelector("#gps-sel-cancel").onclick = () => exitSel(); + if (selMergeBtn) selMergeBtn.onclick = async () => { + if (selected.size < 2) return; + let ids = [...selected]; + let sess = []; + for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); } + if (sess.length < 2) return; + sess.sort((a, b) => sessTs(a) - sessTs(b)); + // Different bikes must not be merged: gear/weight differ, so power and cadence would be + // computed wrong for the mixed track. Refuse and tell the rider how to proceed. + let bikeSet = new Set(sess.map(s => s.bike_id ?? "none")); + if (bikeSet.size > 1) { + await appAlert("Велосипеды у выбранных треков разные - склеить нельзя: передача и вес отличаются, мощность и каденс посчитались бы неверно.\n\nЕсли это на самом деле один велосипед, задайте его всем трекам одинаково (в детали трека: «Велосипед», затем «Пересчитать») и склеивайте снова."); + return; + } + let gapWarn = false; + for (let i = 1; i < sess.length; i++) { + let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000; + if (gapMin > 5) gapWarn = true; + } + let msg = "Объединить " + sess.length + " тренировки в одну?" + + (gapWarn ? "\n\nВнимание: между треками разрыв больше 5 минут - точки лягут с пропусками." : "") + + "\n\nОбратимо: исходные тренировки помечаются в точках (можно будет разделить позже)."; + if (!(await appConfirm(msg, "Объединить"))) return; + try { + selMergeBtn.disabled = true; selMergeBtn.textContent = "Объединяю..."; + let targetId = await DB.mergeGpsSessions(ids); + if (targetId) await GPS.recalculateSession(targetId, { noDelete: true }); + showToast("Объединено в одну тренировку"); + exitSel(); + renderScreen("gps"); + } catch (e) { + console.error("[merge]", e); + showToast("Ошибка объединения: " + (e.message || e)); + selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить"; + } + }; + // Pagination: "Показать ещё" loads the next page from the DB and appends it. let sessOffset = sessions.length; let moreBtn = container.querySelector("#gps-more"); @@ -3072,7 +3250,7 @@ async function showAddGoalMarkerModal() { // Draw manual route on map // --------------------------------------------------------------------------- -async function fetchElevations(points) { +async function fetchElevations(points, onProgress) { // Terrain elevation, tried in order until one answers a batch: // 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m). // 2. open-elevation.com (SRTM, reachable in RU). @@ -3124,6 +3302,7 @@ async function fetchElevations(points) { } catch (e) { console.log("[opentopodata] batch fail:", e.message); } } if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; + if (onProgress) try { onProgress(Math.min(i + 100, points.length), points.length); } catch {} } return elevations.some(x => x != null) ? elevations : null; } @@ -3691,7 +3870,7 @@ async function showRouteDetail(container, routeId) { if (mapEl) { void mapEl.offsetHeight; // force layout so Leaflet reads the real size L.Icon.Default.imagePath = "lib/"; - let map = L.map(mapEl, { attributionControl: false, zoomControl: false }); + let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false }); // canvas: slope-coloured route vectors vs SVG compositor tile-memory limit map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing) new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map); let routeLayer = drawRoutePolyline(map, pts, route.color, mode); @@ -4286,6 +4465,115 @@ function posPuckIcon(fill, withTriangle) { }); } +// HR-only "workout" session detail: no map — HR chart + load (kcal) + avg/max. +function renderWorkoutDetail(session) { + let samples = []; + try { samples = JSON.parse(session.hr_samples || "[]"); } catch {} + let hrs = samples.map(s => s[1]); + let durSec = samples.length ? samples[samples.length - 1][0] : 0; + let avg = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null; + let max = hrs.length ? Math.max(...hrs) : null; + let kcal = Math.round(session.total_kcal || 0); + let html = ` + Зарядка · ${session.date} ${session.start_time ?? ""} + + ${GPS.formatDuration(durSec)}Время + ${kcal} ккалНагрузка + + + ${avg ?? "—"}Средний пульс + ${max ?? "—"}Макс пульс + + + Пульс + + + Удалить зарядку`; + showModal(html, () => {}, (overlay) => { + let ctx = overlay.querySelector("#wk-hr-chart"); + if (ctx && samples.length) { + new Chart(ctx, { + type: "line", + data: { labels: samples.map(s => Math.round(s[0] / 60) + "м"), + datasets: [{ data: hrs, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3, + segment: { borderColor: c => hrZoneColor(c.p1.parsed.y) } }] }, + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false } } }, + }); + } + let del = overlay.querySelector("#wk-del"); + if (del) del.onclick = async () => { + if (!(await appConfirm("Удалить эту зарядку?", "Удалить"))) return; + await DB.deleteGpsSession(session.id); + if (winStack.length > 0) winStack.pop().close(); + _skipPops = 1; history.back(); + renderScreen("gps"); + }; + }); +} + +// HR-only "workout" recording screen (зарядка): no map/GPS, live heart rate + timer + kcal. +function renderWorkoutInline(container) { + container.innerHTML = ` + + -- + уд/мин + + 0:00Время + 0ккал + --средний + --макс + + Подключите пульсометр + Подключить пульсометр + Стоп + `; + + let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time"); + let kcalEl = container.querySelector("#wk-kcal"), avgEl = container.querySelector("#wk-avg"); + let maxEl = container.querySelector("#wk-max"), statusEl = container.querySelector("#wk-status"); + + GPS.recording.onHrUpdate = (reading) => { + if (hrEl) hrEl.textContent = reading.hr; + if (statusEl) statusEl.textContent = "Пульсометр на связи"; + }; + + let tick = () => { + if (!GPS.recording.active) { clearInterval(window._wkTimer); return; } + let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0; + if (timeEl) timeEl.textContent = GPS.formatDuration(sec); + let samples = GPS.recording.hrSamples || [], hrs = samples.map(s => s[1]); + if (kcalEl) kcalEl.textContent = GPS.hrKcalNet(samples, GPS.recording.hrProfile); + if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--"; + if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--"; + if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr; + if (statusEl && GPS.recording.currentHr) { + let b = GPS.recording.currentBattery; + statusEl.textContent = "Пульсометр на связи" + (b != null ? ` · заряд ${b}%` : ""); + } + }; + clearInterval(window._wkTimer); + window._wkTimer = setInterval(tick, 1000); + tick(); + + container.querySelector("#wk-connect").onclick = async () => { + let addr = state.profile?.ble_device_addr; + if (!addr) { showToast("Сначала привяжите датчик: Настройки -> Пульсометр -> Поиск"); return; } + let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} + if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth"); return; } + if (statusEl) statusEl.textContent = "Подключение..."; + try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: state.profile?.ble_device_type || "hr_standard" }); } catch (e) { showToast("Ошибка: " + (e.message || e)); } + }; + + container.querySelector("#wk-stop").onclick = async () => { + clearInterval(window._wkTimer); + let res = await GPS.stopRecording(); + if (res && res.sessionId) { + showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`); + showSessionDetail(res.sessionId); + } else { renderScreen("gps"); } + }; +} + async function renderRecordingInline(container) { if (!GPS.recording.active) { renderScreen("gps"); return; } @@ -4296,30 +4584,29 @@ async function renderRecordingInline(container) { }).catch(() => {}); } + // HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal. + if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; } + container.innerHTML = ` + + 0:00 + — + ${new Date().toTimeString().slice(0,5)} + ${GPS.recording.state === "waiting_gps" ? 'Поиск GPS...' : ''} - - -- - bpm - - - + --Пульс 0 мДистанция 0км/ч - 0:00Время —Высота - ${new Date().toTimeString().slice(0,5)}Часы - —t°C - ${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""} - Ветер + ${GPS.recording.manualPaused ? "Продолжить" : "Пауза"} Стоп @@ -4335,7 +4622,11 @@ async function renderRecordingInline(container) { let lastPt = GPS.recording.lastMovingPoint; let initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47; let initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07; - let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], 15); + // Remember the recording map's zoom across screen switches (persist on zoom, restore here). + let recZoom = parseInt(localStorage.getItem("baldav_rec_zoom"), 10); + if (!(recZoom >= 1 && recZoom <= 19)) recZoom = 15; + let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], recZoom); + map.on("zoomend", () => { try { localStorage.setItem("baldav_rec_zoom", String(map.getZoom())); } catch {} }); mapEl.addEventListener("wheel", (e) => { if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); } }, { passive: false }); @@ -4353,6 +4644,7 @@ async function renderRecordingInline(container) { // BOTH directions (orange = compass, green = GPS course) for visual comparison. let rotOffset = 0; // 0/90/180/270 — whole-screen rotation let compassFrozen = false; // X button: freeze current heading + let followMode = true; // ⊕ button: keep the map centred on the current position let frozenHeading = 0; // heading captured at the moment of freezing let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW) let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW) @@ -4518,6 +4810,26 @@ async function renderRecordingInline(container) { }; btnWrap.appendChild(freezeBtn); + // Follow button — ⊕: toggle whether the map keeps re-centring on the current position. + // Off = free pan (look around when lost / off-route); tapping it back on recentres now. + let followBtn = document.createElement("button"); + followBtn.className = "rec-map-btn rec-follow-btn active"; + followBtn.textContent = "⊕"; // ⊕ + followBtn.title = "Следить за позицией"; + followBtn.onclick = () => { + followMode = !followMode; + followBtn.classList.toggle("active", followMode); + if (followMode) { + // Re-enabling: jump straight back to where we are now. + let pts = GPS.recording.points; + if (pts && pts.length) { + let last = pts.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts))[pts.length - 1]; + if (last) map.panTo([last.lat, last.lon]); + } + } + }; + btnWrap.appendChild(followBtn); + // Roads toggle button let roadsBtn = document.createElement("button"); roadsBtn.className = "rec-map-btn rec-roads-toggle"; @@ -4595,7 +4907,7 @@ async function renderRecordingInline(container) { async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); } refreshRecNogos(); let recNogoBtn = document.createElement("button"); - recNogoBtn.className = "rec-map-btn"; + recNogoBtn.className = "rec-map-btn rec-nogo-btn"; recNogoBtn.textContent = "⛔"; recNogoBtn.title = "Здесь нет проезда (отметить текущее место)"; recNogoBtn.onclick = async () => { @@ -4612,7 +4924,7 @@ async function renderRecordingInline(container) { // route or the recording. Tap again to rebuild from the new position; long-press to clear. let rerouteLayer = null; let rerouteBtn = document.createElement("button"); - rerouteBtn.className = "rec-map-btn"; + rerouteBtn.className = "rec-map-btn rec-reroute-btn"; rerouteBtn.textContent = "↪"; rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом"; rerouteBtn.onclick = async () => { @@ -4695,7 +5007,7 @@ async function renderRecordingInline(container) { if (ph) ph.remove(); statusEl.textContent = "GPS OK. Начните движение..."; statusEl.style.color = "#03dac6"; - map.setView([p.lat, p.lon], 16); + if (followMode) map.setView([p.lat, p.lon], 16); } else if (state === "recording") { statusEl.textContent = ""; statusEl.style.color = ""; @@ -4710,7 +5022,7 @@ async function renderRecordingInline(container) { let spdEl = document.querySelector("#rec-speed"); let timeEl = document.querySelector("#rec-time"); let elevEl = document.querySelector("#rec-elev"); - let statusEl = document.querySelector("#rec-status"); + let statusEl = { style: {} }; // status line removed from the UI; keep a no-op sink so the existing statusEl.* writes don't crash // Speed buffer for median let speedBuf = []; @@ -4721,7 +5033,7 @@ async function renderRecordingInline(container) { let ll = [p.lat, p.lon]; borderLine.addLatLng(ll); trailLine.addLatLng(ll); - map.panTo(ll); + if (followMode) map.panTo(ll); // Save position for next session start (avoid black screen) DB.setProfileValue("last_lat", String(p.lat)); DB.setProfileValue("last_lon", String(p.lon)); @@ -4738,7 +5050,7 @@ async function renderRecordingInline(container) { if (recState === "waiting_gps") { statusEl.textContent = "Поиск GPS..."; statusEl.style.color = "#ffb74d"; - map.setView([p.lat, p.lon], 15); + if (followMode) map.setView([p.lat, p.lon], 15); return; } if (recState === "ready") { @@ -4773,19 +5085,13 @@ async function renderRecordingInline(container) { // HR update from BLE GPS.recording.onHrUpdate = (reading) => { - let row = document.querySelector("#rec-hr-row"); - if (!row) return; - row.style.display = "flex"; - let hrEl = document.querySelector("#rec-hr-val"); - if (hrEl) { - hrEl.textContent = reading.hr; - hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr); - } - let spo2El = document.querySelector("#rec-spo2"); - if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : ""; - let bpEl = document.querySelector("#rec-bp"); - if (bpEl) bpEl.textContent = (reading.bp_sys && reading.bp_dia) ? `${reading.bp_sys}/${reading.bp_dia}` : ""; + let hrEl = document.querySelector("#rec-hr-val"); // HR stat in the bottom row + if (hrEl) { hrEl.textContent = reading.hr; hrEl.className = "rec-val " + hrZoneClass(reading.hr); } + let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself + if (hrNum) hrNum.textContent = reading.hr; + setHrBatteryFill(document.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // strap charge }; + setHrBatteryFill(container.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // on render, if already known // Timer — excludes pauses, starts only when recording let timer = setInterval(() => { @@ -4834,11 +5140,15 @@ async function renderRecordingInline(container) { // Headwind label toggle: tap to open a "wind in the face" interval, tap to close. // Marks are ground truth for validating the from-track wind estimate — not kcal. - let windBtn = container.querySelector("#rec-wind"); - if (windBtn) windBtn.onclick = () => { - let active = GPS.toggleWindMark(); - windBtn.style.background = active ? "#ffb74d" : ""; - windBtn.style.color = active ? "#1a1a1a" : ""; + // HR on/off toggle (replaces the old "Ветер" button). ON connects the paired strap + polls; + // OFF disconnects. Grey = off, coloured = on. + let hrToggle = container.querySelector("#rec-hr-toggle"); + if (hrToggle) hrToggle.onclick = async () => { + hrToggle.disabled = true; + await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once) + hrToggle.classList.toggle("active", !!GPS.recording.hrOn); + if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill + hrToggle.disabled = false; }; // Pause button @@ -4886,7 +5196,7 @@ async function renderRecordingInline(container) { trailLine.setLatLngs(coords); if (coords.length) { let last = coords[coords.length - 1]; - map.panTo(last); + if (followMode) map.panTo(last); updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing) updateMe(last); } @@ -5069,12 +5379,17 @@ function showMapDownloadModal() { // --------------------------------------------------------------------------- let _sessionDetailRender = 0; +// Remember each track's last map view (centre+zoom) so a recompute/refine reopen — or coming +// back to a track you looked at — restores where you were instead of re-fitting the whole track. +let _sdMapView = {}; async function showSessionDetail(sessionId) { let renderToken = ++_sessionDetailRender; try { console.log("[SD] loading session", sessionId); let session = await DB.getGpsSession(sessionId); if (!session) { console.error("[SD] session not found"); return; } + // HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path. + if (session.activity_type === "workout") { renderWorkoutDetail(session); return; } console.log("[SD] loading points"); let rawPoints = await DB.getGpsPoints(sessionId); console.log("[SD] got", rawPoints.length, "raw points"); @@ -5094,7 +5409,7 @@ async function showSessionDetail(sessionId) { elevGain: Math.round(session.elevation_gain_m || 0), elevLoss: Math.round(session.elevation_loss_m || 0), duration: 0, - } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + } : GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); // Manual-pause total (ms) to exclude from displayed duration let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0); // Compute duration from points regardless (minus manual pauses) @@ -5103,7 +5418,7 @@ async function showSessionDetail(sessionId) { } // Always compute segments from points (needed for the Segments section) if (!stats.segments && points.length >= 3) { - let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + let fullStats = GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); stats.segments = fullStats.segments; } @@ -5153,6 +5468,21 @@ async function showSessionDetail(sessionId) { : "—"; let kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—"; + // HR-based kcal "what if" (Keytel) — comparison ONLY. The activity balance keeps the physics/MET + // number (don't disturb the established diet); here we just show what HR would give, if present. + let _t0hr = points.length ? new Date(points[0].ts).getTime() : 0; + let hrSamples = points.filter(p => p.heart_rate > 0).map(p => [(new Date(p.ts).getTime() - _t0hr) / 1000, p.heart_rate]); + let hasHr = hrSamples.length > 2; + let hrProfile = { + weightKg: session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 75, + age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), + sex: state.profile?.sex || "m", + heightCm: parseFloat(state.profile?.height_cm) || 178, + }; + let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null; + let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null; + let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null; + showModal(` ${actName} ID: ${session.id} @@ -5178,27 +5508,29 @@ async function showSessionDetail(sessionId) { - - Скорость + + Скорость ${stats.avgSpeed} км/чСредняя ${stats.maxSpeed} км/чМакс. - + Калории - ${session.total_kcal ?? stats.kcal} ккалВсего + ${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"} ${kcalMin} ккал/минРасход + ${hasHr ? `${hrKcal} ккалПо ЧСС (сравнение) + В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.` : ""} ${elevations.length > 0 ? ` - - Высота + + Высота ${minElev} мМин. ${maxElev} мМакс. @@ -5208,8 +5540,17 @@ async function showSessionDetail(sessionId) { ${stats.elevLoss} мСпуск - - ` : ""} + ` : ""} + + ${hasHr ? ` + + Пульс + + ${hrAvg}Средний + ${hrMax}Макс. + + + ` : ""} ${(stats.segments && stats.segments.length > 1) ? ` @@ -5231,6 +5572,12 @@ async function showSessionDetail(sessionId) { ` : ""} + ${session.activity_type === 'bike' ? ` + + Разбор повторов + Считаю... + ` : ""} + Цели @@ -5246,6 +5593,7 @@ async function showSessionDetail(sessionId) { Сравнить с... Пересчитать Уточнить высоты (онлайн) + Разрезать трек Экспорт GPX Похвастаться Удалить тренировку @@ -5258,8 +5606,14 @@ async function showSessionDetail(sessionId) { if (mapEl && points.length) { void mapEl.offsetHeight; // force layout so Leaflet reads the real container size L.Icon.Default.imagePath = "lib/"; - let map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true }); + // preferCanvas: big tracks (10k+ points, multi-layer slope colouring) as SVG blow the + // Chromium compositor's tile-memory limit on zoom/pan → map tiles stop drawing until a + // restart ("tile memory limits exceeded, some content may not draw"). Canvas renders the + // vectors into one viewport-bounded canvas instead of thousands of SVG paths. + let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true }); mapEl._leafletMap = map; // save ref for winStack collapse + // Remember the view for this track so a reopen (after recompute / refine) keeps the zoom. + map.on("moveend zoomend", () => { _sdMapView[sessionId] = { c: map.getCenter(), z: map.getZoom() }; }); // Ctrl+wheel zoom on desktop mapEl.addEventListener("wheel", (e) => { if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); } @@ -5285,7 +5639,7 @@ async function showSessionDetail(sessionId) { if (gapBtn) gapBtn.classList.remove("active"); } function drawGapPatches(gaps) { - let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0; + let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0, skippedLong = 0, skippedNoroute = 0; for (let g of gaps) { if (g.status === "filled") { filledN++; addedM += g.addedM; @@ -5297,12 +5651,13 @@ async function showSessionDetail(sessionId) { // a real gap we couldn't bridge — mark it red so the user knows it's unfilled. // "skipped-nogain" (road ≈ straight) is fine as-is → not drawn, not counted. skippedN++; + if (g.status === "skipped-long") skippedLong++; else skippedNoroute++; L.polyline([[g.a.lat, g.a.lon], [g.b.lat, g.b.lon]], { color: "#e53935", weight: 3, opacity: 0.9, dashArray: "6,6" }).addTo(group); } } group.addTo(map); gapLayer = group; - return { filledN, skippedN, addedM }; + return { filledN, skippedN, addedM, skippedLong, skippedNoroute }; } function panel(html) { let el = document.createElement("div"); @@ -5345,13 +5700,27 @@ async function showSessionDetail(sessionId) { // Compute preview. b.disabled = true; b.classList.add("active"); try { - let res = await GPS.fillGaps(points); + // Prefer bridging a dropout over roads YOU actually rode (ride-graph); fall back to + // the public BRouter only where you've never ridden. A ridden bridge is trusted (no + // "detour too long" rejection); public routing keeps that guard. + let res = await GPS.fillGaps(points, { + routeFn: async (a, b) => { + try { let r = await rideRoute(a.lat, a.lon, b.lat, b.lon); if (r && r.length >= 2) return { points: r, ridden: true }; } catch {} + try { let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false }; } catch { return { points: [], ridden: false }; } + } + }); if (!res.gaps.length) { showToast("Пропусков для достройки нет"); b.classList.remove("active"); b.disabled = false; return; } gapCache = res; let s = drawGapPatches(res.gaps); if (!s.filledN && !s.skippedN) { showToast("Пропусков для достройки нет"); clearGapPreview(); b.disabled = false; return; } let km = (s.addedM / 1000).toFixed(2); - gapPanel = panel(`Достроено пропусков: ${s.filledN} (+${km} км).${s.skippedN ? " Не удалось: " + s.skippedN + " (дорога слишком длинная или нет данных)." : ""}Так трек будет выглядеть после сохранения. + // Clear reason for the ones we couldn't bridge: they aren't on roads you've ridden, + // and the public router either detours far (skipped-long) or has no data (noroute). + let why = []; + if (s.skippedLong) why.push(`${s.skippedLong} — публичный маршрут уводит в объезд`); + if (s.skippedNoroute) why.push(`${s.skippedNoroute} — нет дорожных данных`); + let skippedTxt = s.skippedN ? ` Не достроено: ${s.skippedN} (нет твоей проеханной дороги${why.length ? "; " + why.join(", ") : ""}).` : ""; + gapPanel = panel(`Достроено пропусков: ${s.filledN} (+${km} км).${skippedTxt}Так трек будет выглядеть после сохранения. СохранитьОтмена`); gapPanel.querySelector("#gap-cancel").onclick = () => clearGapPreview(); gapPanel.querySelector("#gap-save").onclick = async () => { @@ -5428,7 +5797,7 @@ async function showSessionDetail(sessionId) { }; async function recomputeStatsFor(pts) { let latestW = await DB.getLatestWeight(), profile = await DB.getFullProfile(); - let weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + let weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); let CRR = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; let st = GPS.computeStats(pts, weightKg, { @@ -5527,8 +5896,11 @@ async function showSessionDetail(sessionId) { let allCoords = []; segments.forEach(seg => { if (seg.length < 2) { allCoords.push(...seg); return; } - L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map); - L.polyline(seg, { color: GPS.config.trackColor || "#bb86fc", weight: 5 }).addTo(map); + // smoothFactor: Douglas-Peucker tolerance in PIXELS, so it drops points that collapse + // together on screen - full detail zoomed in, far fewer segments when zoomed out (where + // a 10k-point track otherwise janks and floods the compositor). 2.5px is imperceptible. + L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8, smoothFactor: 2.5 }).addTo(map); + L.polyline(seg, { color: GPS.config.trackColor || "#bb86fc", weight: 5, smoothFactor: 2.5 }).addTo(map); L.circleMarker(seg[0], { radius: 5, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map); L.circleMarker(seg[seg.length - 1], { radius: 5, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map); allCoords.push(...seg); @@ -5573,6 +5945,52 @@ async function showSessionDetail(sessionId) { if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км"; } + // Interval / repeat analysis: the same segment ridden several times in one ride + // (intervals). Passes found by self-similarity (GPS.detectRepeats), per-pass stats + // via computeStats, plus the last/first verdict; the corridor is highlighted on the + // map. Length-normalised speed (segLength / pass time) cancels GPS length noise so + // passes compare fairly. Bike only. + if (session.activity_type === "bike") { + (async () => { + let body = document.querySelector("#sd-repeats-body"); + if (!body) return; + let rep = GPS.detectRepeats(points); + if (!rep.found) { body.textContent = "Повторов не найдено (обычная поездка)."; return; } + let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); + let weightKg = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; + let opts = { + activityType: "bike", bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type, + chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, + wheelDiameterInch: bike?.wheel_diameter_inch, gearCount: bike?.gear_count, + }; + let rows = rep.passes.map((p, i) => { + let seg = points.slice(p.startIdx, p.endIdx + 1); + let st = GPS.computeStats(seg, weightKg, opts); + let T = Math.max(1, p.movingSec); // moving time only (stops trimmed) + return { i: i + 1, T, normV: rep.segLength / T * 3.6, cad: st.avgCadence, pw: st.avgPower }; + }); + let ratio = rows[rows.length - 1].normV / rows[0].normV, pct = Math.round(ratio * 100); + let verdict, vClass; + if (ratio >= 1.0) { verdict = "последний не медленнее первого - можно повторить завтра"; vClass = "ok"; } + else if (ratio >= 0.90) { verdict = "нормальная тяжёлая тренировка"; vClass = "warn"; } + else { verdict = "просел больше 10% - два дня спокойно"; vClass = "bad"; } + let segStr = rep.segLength >= 1000 ? (rep.segLength / 1000).toFixed(2) + " км" : rep.segLength + " м"; + body.innerHTML = + `Участок ${segStr}, заездов ${rep.passCount} (скорость нормирована на длину участка)` + + `№времякм/чкаденсВт` + + rows.map(r => `${r.i}${GPS.formatDuration(Math.round(r.T))}${r.normV.toFixed(1)}${r.cad ?? "-"}${r.pw ?? "-"}`).join("") + + `` + + `${pct}% последний / первый${verdict}`; + // Highlight the repeated corridor on the map (every pass draws the same line). + try { + for (let p of rep.passes) { + let seg = points.slice(p.startIdx, p.endIdx + 1).map(q => [q.lat, q.lon]); + if (seg.length >= 2) L.polyline(seg, { color: "#00e5ff", weight: 5, opacity: 0.85 }).addTo(map); + } + } catch {} + })(); + } + // Invisible but tappable hit-targets on top of track for (let i = 0; i < points.length; i++) { let p = points[i]; @@ -5584,14 +6002,47 @@ async function showSessionDetail(sessionId) { let bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : ""; let bleInfo = [hr, spo2, bp].filter(Boolean).join(" "); let info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`; + // "Split here" button lives in every point popup but is shown only in split mode + // (mapEl.split-mode, toggled by the "Разрезать трек" button) — see CSS. + info += `✂ Разрезать здесь`; L.circleMarker([p.lat, p.lon], { radius: 8, weight: 0, fillOpacity: 0, interactive: true, }).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map); } + // Delegated: tap "Разрезать здесь" in a point popup → split the track at that point. + mapEl.addEventListener("click", (ev) => { + let b = ev.target.closest(".split-here"); + if (b) { ev.stopPropagation(); confirmSplitAt(parseInt(b.dataset.idx)); } + }); + async function confirmSplitAt(k) { + if (!(k >= 2 && k <= points.length - 3)) { showToast("Выберите точку не у самого края трека"); return; } + try { map.closePopup(); } catch {} + let tOf = (p) => (p.ts.split("T")[1] || "").slice(0, 8); + let distToHere = 0; + for (let i = 1; i <= k; i++) distToHere += GPS.distance(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon); + if (!(await appConfirm("Разрезать трек после точки #" + k + " (время " + tOf(points[k]) + ", ~" + (distToHere / 1000).toFixed(2) + " км от старта)?\n\nВторой кусок станет отдельной тренировкой с тем же велосипедом. Потом можно сменить велосипед у неё и пересчитать.", "Разрезать"))) return; + let part2Ids = points.slice(k + 1).map(p => p.id).filter(id => id != null); + if (part2Ids.length < 2) { showToast("Во втором куске слишком мало точек"); return; } + try { + let newId = await DB.splitGpsSession(sessionId, part2Ids, tOf(points[k]), tOf(points[k + 1])); + if (newId) { + await GPS.recalculateSession(sessionId, { noDelete: true }); + await GPS.recalculateSession(newId, { noDelete: true }); + } + showToast("Трек разрезан на две тренировки"); + if (winStack.length > 0) winStack.pop().close(); + _skipPops = 1; + history.back(); + renderScreen("gps"); + } catch (e) { console.error("[split]", e); showToast("Ошибка разреза: " + (e.message || e)); } + } // Ensure Leaflet has correct container size, then fit map.invalidateSize(); - if (allCoords.length > 1) { + let savedView = _sdMapView[sessionId]; + if (savedView) { + map.setView(savedView.c, savedView.z); // reopen after recompute/return → keep the zoom + } else if (allCoords.length > 1) { map.fitBounds(L.latLngBounds(allCoords).pad(0.1)); } else if (points.length) { // Degenerate track (a single usable point): fitBounds needs 2+ coords, so @@ -5917,28 +6368,40 @@ async function showSessionDetail(sessionId) { let compareBtn = document.querySelector("#sd-compare"); if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points); + // Split-track button: toggles split mode on the map (shows a "Разрезать здесь" action + // inside each point popup). Hidden for tracks too short to split. + let splitBtn = document.querySelector("#sd-split"); + if (splitBtn) { + if (points.length < 5) splitBtn.style.display = "none"; + else splitBtn.onclick = () => { + let m = document.querySelector("#sd-map"); + if (!m) return; + let on = m.classList.toggle("split-mode"); + splitBtn.textContent = on ? "Готово (выберите точку на карте)" : "Разрезать трек"; + if (on) { showToast("Тапните точку на карте, затем «Разрезать здесь»"); m.scrollIntoView({ behavior: "smooth", block: "center" }); } + }; + } + // Recalculate button let recalcBtn = document.querySelector("#sd-recalc"); if (recalcBtn) recalcBtn.onclick = async () => { recalcBtn.textContent = "Пересчёт..."; recalcBtn.disabled = true; - let newStats = await GPS.recalculateSession(sessionId); + setBtnProgress(recalcBtn, 0); + let newStats = await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(recalcBtn, p) }); + setBtnProgress(recalcBtn, null); recalcBtn.textContent = "Пересчитать"; recalcBtn.disabled = false; - if (newStats) { - // Close modal and reopen with fresh data - if (winStack.length > 0) winStack.pop().close(); - _skipPops = 1; - history.back(); - showSessionDetail(sessionId); - } + // reopenDetail awaits the popstate before re-rendering (no blank map/charts race). + if (newStats) reopenDetail(sessionId); }; let refineBtn = document.querySelector("#sd-refine"); if (refineBtn) refineBtn.onclick = async () => { - let reset = (t) => { refineBtn.textContent = t; refineBtn.disabled = false; }; + let reset = (t) => { setBtnProgress(refineBtn, null); refineBtn.textContent = t; refineBtn.disabled = false; }; refineBtn.disabled = true; - refineBtn.textContent = "Загрузка высот..."; + refineBtn.textContent = "Высоты..."; + setBtnProgress(refineBtn, 0); try { let pts = await DB.getGpsPoints(sessionId); if (pts.length < 2) return reset("Уточнить высоты (онлайн)"); @@ -5947,7 +6410,8 @@ async function showSessionDetail(sessionId) { let sampleIdx = []; for (let i = 0; i < pts.length; i += step) sampleIdx.push(i); if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1); - let terr = await fetchElevations(sampleIdx.map(i => pts[i])); + // The network fetch is the slow part → drive the bar 0-80% by fetched batches. + let terr = await fetchElevations(sampleIdx.map(i => pts[i]), (done, total) => setBtnProgress(refineBtn, total ? done / total * 80 : 0)); if (!terr || !terr.some(x => x != null)) { if (typeof showToast === "function") showToast("Сервис высот недоступен"); return reset("Уточнить высоты (онлайн)"); @@ -5961,13 +6425,12 @@ async function showSessionDetail(sessionId) { } let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null); refineBtn.textContent = "Сохранение..."; + setBtnProgress(refineBtn, 85); await DB.updatePointsSrtm(rows); - await GPS.recalculateSession(sessionId); + await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(refineBtn, 85 + p * 0.15) }); + setBtnProgress(refineBtn, null); if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам"); - if (winStack.length > 0) winStack.pop().close(); - _skipPops = 1; - history.back(); - showSessionDetail(sessionId); + reopenDetail(sessionId); // awaits popstate → no blank map/charts race } catch (e) { console.log("[refine] fail:", e.message); if (typeof showToast === "function") showToast("Ошибка высот: " + e.message); @@ -6202,7 +6665,7 @@ async function showComparisonView(currentId, compareId, currentSession, currentP if (btns) btns.style.display = "none"; L.Icon.Default.imagePath = "lib/"; - let map = L.map(mapEl, { attributionControl: false, zoomControl: false, touchZoom: true, dragging: true }); + let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false, touchZoom: true, dragging: true }); // canvas: two big tracks vs SVG compositor tile-memory limit mapEl._leafletMap = map; addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); @@ -6688,6 +7151,16 @@ async function renderSettings(container) { Сброс + + + Резервная копия всех данных + Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение. + + + Экспорт + Импорт + + Очистить продукты без магазина @@ -6901,10 +7374,21 @@ async function renderSettings(container) { ${state.profile.ble_device_addr ? `Отвязать` : ""} + ${state.profile.ble_device_addr ? `Подключить` : ""} Поиск + + Не спрашивать про Bluetooth + Не предлагать включить BT при старте записи + + + + Не спрашивать про геолокацию + Не предлагать включить GPS при старте записи трека + + @@ -7093,6 +7577,38 @@ async function renderSettings(container) { let sbgClear = container.querySelector("#s-share-bg-clear"); if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; + // Full-DB backup: export the whole database to a file (SAF save), and restore from one. + let expDbBtn = container.querySelector("#s-export-db"); + if (expDbBtn) expDbBtn.onclick = async () => { + let prev = expDbBtn.textContent; + expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю..."; + try { + let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db"; + let res = await window.__TAURI__.core.invoke("export_db", { filename }); + let size = 0; try { size = JSON.parse(res).size || 0; } catch {} + showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить"); + } catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); } + expDbBtn.disabled = false; expDbBtn.textContent = prev; + }; + let impDbBtn = container.querySelector("#s-import-db"); + if (impDbBtn) impDbBtn.onclick = async () => { + if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; } + if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return; + let prev = impDbBtn.textContent; + impDbBtn.disabled = true; impDbBtn.textContent = "Выбор..."; + try { + let path = await pickLocalFile("*/*"); + if (!path) { showToast("Файл не выбран"); return; } + impDbBtn.textContent = "Проверка..."; + await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it + // No auto-restart: on a single-activity app the process-restart is unreliable (Android 12+ + // blocks background activity starts). The staged DB is swapped on the next launch, so ask + // the user to fully reopen the app — reliable, and they confirmed it works. + await appAlert("База проверена и готова. Чтобы применить восстановление:\n\n1. Полностью закройте приложение (смахните его из списка недавних).\n2. Откройте снова.\n\nПосле этого данные будут заменены восстановленными."); + } catch (e) { appAlert("Импорт не удался: " + (e.message || e)); } + impDbBtn.disabled = false; impDbBtn.textContent = prev; + }; + // Download tiles from router — the list comes from the router's directory // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { @@ -7267,6 +7783,51 @@ async function renderSettings(container) { }; } + // Manual connect to the already-paired band. Works any time, including during a ride. + // Nothing auto-connects anymore (battery), so this is how HR starts flowing into the track. + let bleConnectBtn = container.querySelector("#s-ble-connect"); + if (bleConnectBtn) bleConnectBtn.onclick = async () => { + let addr = state.profile.ble_device_addr, type = state.profile.ble_device_type || "hr_standard"; + if (!addr) return; + let statusEl = container.querySelector("#s-ble-status"); + let bt = "on"; + try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} + if (bt === "off") { + try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} + showToast("Включите Bluetooth, затем нажмите «Подключить»"); + return; + } + if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; } + bleConnectBtn.disabled = true; bleConnectBtn.textContent = "..."; + if (statusEl) statusEl.textContent = "Подключение..."; + try { + await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); + // If a ride is recording, make sure the HR poll runs so HR starts landing in the track. + if (GPS.recording.active && !GPS.recording.hrPollInterval) { try { await GPS._startHrMonitor(); } catch {} } + // Poll briefly (Kotlin scans+connects in the background) for a connection or first reading. + let ok = false; + for (let i = 0; i < 6; i++) { + await new Promise(r => setTimeout(r, 1000)); + try { + if (await window.__TAURI__.core.invoke("ble_is_connected")) { ok = true; break; } + let rd = await window.__TAURI__.core.invoke("ble_get_hr"); + if (rd && rd.hr > 0) { ok = true; break; } + } catch {} + } + if (statusEl) statusEl.textContent = ok ? addr + " (подключён)" : "браслет не найден"; + showToast(ok ? "Пульсометр подключён" : "Браслет не найден - включите его рядом"); + } catch (e) { + if (statusEl) statusEl.textContent = "ошибка подключения"; + showToast("Ошибка подключения: " + (e.message || e)); + } + bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить"; + }; + + let dontAskBt = container.querySelector("#s-dontask-bt"); + if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} }; + let dontAskGps = container.querySelector("#s-dontask-gps"); + if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} }; + let bleScanBtn = container.querySelector("#s-ble-scan"); let bleResults = container.querySelector("#s-ble-results"); if (bleScanBtn) { @@ -8110,7 +8671,7 @@ async function showRoadsMap() { let mapEl = document.querySelector("#roads-map"); L.Icon.Default.imagePath = "lib/"; - let map = L.map(mapEl, { attributionControl: false }).setView([47.42, 40.08], 13); + let map = L.map(mapEl, { preferCanvas: true, attributionControl: false }).setView([47.42, 40.08], 13); // canvas: many road polylines vs SVG compositor tile-memory limit addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); new CachedLayer({ maxZoom: 19 }).addTo(map); @@ -8124,15 +8685,28 @@ async function showRoadsMap() { let rebuildBtn = document.querySelector("#roads-rebuild"); async function doRebuild() { rebuildBtn.disabled = true; - rebuildBtn.textContent = "..."; + rebuildBtn.textContent = "Построение..."; + setBtnProgress(rebuildBtn, 0); infoEl.textContent = "Построение (может занять минуту)..."; + // Rust emits roads_progress [current,total] as it scans points → fill the button by %. + let unlisten = null; try { + try { + unlisten = await window.__TAURI__.event.listen("roads_progress", (ev) => { + let cur = ev.payload?.[0] ?? 0, tot = ev.payload?.[1] ?? 0; + if (tot > 0) { setBtnProgress(rebuildBtn, cur / tot * 100); infoEl.textContent = `Построение: ${cur} / ${tot}`; } + }); + } catch {} let count = await DB.rebuildRoads(); + if (unlisten) unlisten(); + setBtnProgress(rebuildBtn, null); infoEl.textContent = `${count} точек`; rebuildBtn.textContent = "Перестроить"; rebuildBtn.disabled = false; showRoadsMap(); } catch (e) { + if (unlisten) unlisten(); + setBtnProgress(rebuildBtn, null); infoEl.textContent = "Ошибка: " + e; rebuildBtn.textContent = "Перестроить"; rebuildBtn.disabled = false; @@ -8178,7 +8752,7 @@ async function showRoadsMap() { let allCoords = []; for (let s of segments) { let color = roadColor(s.atype); - L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map); + L.polyline(s.coords, { color, weight: 4, opacity: 1, smoothFactor: 2.5 }).addTo(map); // px-space simplify: fewer points zoomed out allCoords.push(...s.coords); } diff --git a/frontend/js/poi.js b/frontend/js/poi.js index 6caa6d0..51497bd 100644 --- a/frontend/js/poi.js +++ b/frontend/js/poi.js @@ -49,7 +49,11 @@ function render() { group.clearLayers(); - if (!shown || map.getZoom() < MINZOOM || !POI._data) return; + let z = map.getZoom(); + if (!shown || z < MINZOOM || !POI._data) return; + // Fewer markers the further you zoom out: a wide view spans too many POIs to read (and to + // draw). Scale the cap with zoom - a handful near MINZOOM, the full CAP once zoomed in. + let cap = z >= 16 ? CAP : z >= 15 ? 200 : z >= 14 ? 80 : 30; let b = map.getBounds(), n = 0; for (let i = 0; i < POI._data.length; i++) { let p = POI._data[i]; @@ -59,7 +63,7 @@ let c = CATS[p.c] || { l: p.c }; m.bindPopup((p.n ? "" + p.n + "" : "") + c.l); group.addLayer(m); - if (++n >= CAP) break; + if (++n >= cap) break; } } map.on("moveend zoomend", render); diff --git a/frontend/js/tilecache.js b/frontend/js/tilecache.js index fb07f84..69ec187 100644 --- a/frontend/js/tilecache.js +++ b/frontend/js/tilecache.js @@ -127,6 +127,13 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) { // Custom Leaflet TileLayer that checks IndexedDB first TileCache.createLayer = function() { return L.TileLayer.extend({ + options: { + // Keep fewer off-screen tile rings and don't reload tiles mid-zoom: on a high-DPI + // screen the compositor's raster-tile budget (cc/tiles "tile memory limits exceeded") + // is filled by the map tiles themselves, so trimming retained tiles keeps content drawing. + keepBuffer: 1, // default 2 + updateWhenZooming: false, // defer tile loads until the zoom gesture settles + }, createTile: function(coords, done) { const tile = document.createElement("img"); const key = `${coords.z}/${coords.x}/${coords.y}`; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9601b68..c5a960e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.457" +version = "0.1.496" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24e8bc2..e83bd7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.457" +version = "0.1.496" edition = "2021" [lib] diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index ac1ccb5..fb9ec27 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -7,10 +7,15 @@ import android.app.PendingIntent import android.app.Service import android.bluetooth.* import android.bluetooth.le.* +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.ParcelUuid import android.util.Log import java.io.File @@ -31,6 +36,9 @@ class BleHrService : Service() { val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // Standard Battery Service (strap charge) + val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb") + val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb") // FitPro Nordic UART val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d") @@ -51,15 +59,40 @@ class BleHrService : Service() { private var gatt: BluetoothGatt? = null private var deviceType = "hr_standard" private var dataFile: File? = null + private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null private var fitproGatt: BluetoothGatt? = null + // One handler for all postDelayed work, so teardown can cancel every pending callback at once + // (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack). + private val handler = Handler(Looper.getMainLooper()) + + // When the user turns Bluetooth off, stop touching GATT immediately and shut the service down. + private val btStateReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) { + Log.d(TAG, "Bluetooth turning off - stopping BLE service") + handler.removeCallbacksAndMessages(null) + disconnect() + writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() + } + } + } + + private fun adapter(): BluetoothAdapter? = + (getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() createNotificationChannel() + try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) } + catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -72,7 +105,7 @@ class BleHrService : Service() { ACTION_SCAN -> { startForeground(NOTIFICATION_ID, buildNotification("Поиск BLE...")) scanDevices() - return START_STICKY + return START_NOT_STICKY // don't let Android restart us into an empty null-intent churn } else -> { val address = intent?.getStringExtra(EXTRA_ADDRESS) @@ -84,7 +117,7 @@ class BleHrService : Service() { dataFile = File(appDataDir, "ble_hr_data.json") connectDevice(address) } - return START_STICKY + return START_NOT_STICKY // connect is user-initiated; no sticky restart } } } @@ -134,10 +167,10 @@ class BleHrService : Service() { } } - scanner.startScan(callback) + try { scanner.startScan(callback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); writeScanResults("[]"); stopSelf(); return } // Stop after 5 seconds - android.os.Handler(mainLooper).postDelayed({ - scanner.stopScan(callback) + handler.postDelayed({ + try { scanner.stopScan(callback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } writeScanResults("[${results.joinToString(",")}]") Log.d(TAG, "Scan complete: ${results.size} devices") // Don't stopSelf() — connect intent may follow immediately @@ -170,8 +203,9 @@ class BleHrService : Service() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (result.device.address == address) { Log.d(TAG, "Found target device in scan, connecting...") - scanner.stopScan(this) - gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + try { scanner.stopScan(this) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } + try { gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } + catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") } } } override fun onScanFailed(errorCode: Int) { @@ -179,21 +213,28 @@ class BleHrService : Service() { directConnect(adapter, address) } } - scanner.startScan(scanCallback) + try { scanner.startScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); stopSelf(); return } // Timeout: if device not found in 5 seconds, try direct connect - android.os.Handler(mainLooper).postDelayed({ - scanner.stopScan(scanCallback) + handler.postDelayed({ + try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } if (gatt == null) { - Log.d(TAG, "Scan timeout, trying direct connect to $address") - directConnect(adapter, address) + // Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to + // an absent device (that keeps the radio busy and drains the battery). Give up; + // the user re-taps "Подключить" once the band is on. + Log.d(TAG, "Scan timeout, device $address not found - stopping") + writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() } }, 5000) } private fun directConnect(adapter: BluetoothAdapter, address: String) { - val device = adapter.getRemoteDevice(address) - Log.d(TAG, "Direct connecting to $address ($deviceType)") - gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + if (!adapter.isEnabled) { Log.e(TAG, "directConnect: adapter off"); stopSelf(); return } + try { + val device = adapter.getRemoteDevice(address) + Log.d(TAG, "Direct connecting to $address ($deviceType)") + gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() } } private val gattCallback = object : BluetoothGattCallback() { @@ -201,7 +242,7 @@ class BleHrService : Service() { if (newState == BluetoothProfile.STATE_CONNECTED) { Log.d(TAG, "Connected, discovering services...") updateNotification("Подключён, поиск сервисов...") - g.discoverServices() + try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { Log.d(TAG, "Disconnected") isRunning = false @@ -256,6 +297,15 @@ class BleHrService : Service() { override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) { onCharacteristicChanged(g, char, char.value) } + // Battery level read (0x2A19). Two overloads: API 33+ passes the value, older reads char.value. + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray, status: Int) { + if (char.uuid == BATTERY_LEVEL && value.isNotEmpty()) { lastBattery = value[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } + @Deprecated("Deprecated in API 33") + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) { + val v = char.value + if (char.uuid == BATTERY_LEVEL && v != null && v.isNotEmpty()) { lastBattery = v[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Log.d(TAG, "onDescriptorWrite status=$status") @@ -273,13 +323,32 @@ class BleHrService : Service() { if (service == null) { Log.e(TAG, "HR service not found"); return } val char = service.getCharacteristic(HR_MEASUREMENT_UUID) if (char == null) { Log.e(TAG, "HR characteristic not found"); return } - g.setCharacteristicNotification(char, true) - val desc = char.getDescriptor(CCCD_UUID) - if (desc != null) { - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) - } + try { + g.setCharacteristicNotification(char, true) + val desc = char.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + } + } catch (e: Exception) { Log.e(TAG, "HR notify setup: ${e.message}"); return } Log.d(TAG, "Standard HR setup complete") + // Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s. + val readBat = object : Runnable { + override fun run() { + if (!isRunning) return + readBattery(g) + handler.postDelayed(this, 60000) + } + } + handler.postDelayed(readBat, 2500) + } + + private fun readBattery(g: BluetoothGatt) { + try { + val bs = g.getService(BATTERY_SERVICE) ?: return + val bc = bs.getCharacteristic(BATTERY_LEVEL) ?: return + g.readCharacteristic(bc) + } catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") } } private val gattQueue = mutableListOf<() -> Unit>() @@ -298,7 +367,7 @@ class BleHrService : Service() { fun onGattOperationComplete() { gattBusy = false - android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100) + handler.postDelayed({ processGattQueue() }, 100) } private fun setupFitPro(g: BluetoothGatt) { @@ -311,16 +380,18 @@ class BleHrService : Service() { // Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE enqueueGatt { - g.setCharacteristicNotification(notifyChar, true) - val desc = notifyChar.getDescriptor(CCCD_UUID) - if (desc != null) { - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) - Log.d(TAG, "FitPro: writing CCCD descriptor") - } else { - Log.d(TAG, "FitPro: no CCCD, skipping") - onGattOperationComplete() - } + try { + g.setCharacteristicNotification(notifyChar, true) + val desc = notifyChar.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + Log.d(TAG, "FitPro: writing CCCD descriptor") + } else { + Log.d(TAG, "FitPro: no CCCD, skipping") + onGattOperationComplete() + } + } catch (e: Exception) { Log.e(TAG, "FitPro notify setup: ${e.message}"); onGattOperationComplete() } } enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) } enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) } @@ -333,37 +404,36 @@ class BleHrService : Service() { // Re-trigger measurement every 25 seconds fitproWriteChar = writeChar fitproGatt = g - android.os.Handler(mainLooper).also { h -> - val retrigger = object : Runnable { - override fun run() { - if (!isRunning) return - Log.d(TAG, "FitPro: re-trigger measurement") - fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> - writeCommand(gg, wc, FITPRO_MEASURE_START) - android.os.Handler(mainLooper).postDelayed({ - writeCommand(gg, wc, FITPRO_MEASURE_OLD) - }, 300) - }} - h.postDelayed(this, 25000) - } + val retrigger = object : Runnable { + override fun run() { + if (!isRunning) return + Log.d(TAG, "FitPro: re-trigger measurement") + fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> + writeCommand(gg, wc, FITPRO_MEASURE_START) + handler.postDelayed({ + writeCommand(gg, wc, FITPRO_MEASURE_OLD) + }, 300) + }} + handler.postDelayed(this, 25000) } - h.postDelayed(retrigger, 25000) } + handler.postDelayed(retrigger, 25000) } private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) - // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms - android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) - } else { - @Suppress("DEPRECATION") - char.value = data - char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - @Suppress("DEPRECATION") - g.writeCharacteristic(char) - android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) - } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + } else { + @Suppress("DEPRECATION") + char.value = data + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + @Suppress("DEPRECATION") + g.writeCharacteristic(char) + } + } catch (e: Exception) { Log.e(TAG, "writeCommand: ${e.message}") } + // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms + handler.postDelayed({ onGattOperationComplete() }, 200) } private fun parseStandardHr(data: ByteArray) { @@ -378,8 +448,9 @@ class BleHrService : Service() { } if (hr <= 0) return val ts = System.currentTimeMillis() / 1000 - writeData("""{"hr":$hr,"ts":$ts}""") - Log.d(TAG, "HR: $hr") + val batJson = if (lastBattery in 0..100) ""","bat":$lastBattery""" else "" + writeData("""{"hr":$hr,"ts":$ts$batJson}""") + Log.d(TAG, "HR: $hr bat: $lastBattery") } private fun parseFitProData(data: ByteArray) { @@ -430,10 +501,13 @@ class BleHrService : Service() { } private fun disconnect() { - gatt?.disconnect() - gatt?.close() - gatt = null isRunning = false + handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this + try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") } + try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") } + gatt = null + fitproGatt = null + fitproWriteChar = null } private fun createNotificationChannel() { @@ -462,6 +536,7 @@ class BleHrService : Service() { } override fun onDestroy() { + try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ } disconnect() super.onDestroy() } diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index f69328f..caf488c 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -25,6 +25,7 @@ class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null private var pendingSaveContent: String? = null private var pendingImageBytes: ByteArray? = null + private var pendingDbFile: File? = null private var backWebView: WebView? = null // Disable WryActivity's generated back handler: its mWebView.goBack() does NOT @@ -78,6 +79,22 @@ class MainActivity : TauriActivity() { pendingImageBytes = null } + // SAF "create document" for the full DB backup — streams the staged file (can be tens of MB) + // straight to the picked location instead of loading it into memory. + private val saveDbLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + val src = pendingDbFile + if (uri != null && src != null && src.exists()) { + try { + contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } } + Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)") + src.delete() + } catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) } + } + pendingDbFile = null + } + // Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND). private fun shareImage(bytes: ByteArray, filename: String) { try { @@ -127,6 +144,22 @@ class MainActivity : TauriActivity() { val appDataDir = filesDir.parentFile ?: filesDir Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}") + // Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW, + // before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear + // any stale journal so the restored file is the single source of truth. + try { + val pending = File(appDataDir, "pending_restore.db") + if (pending.exists() && pending.length() > 0) { + val dbFile = File(appDataDir, "baldav.db") + pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } } + File(appDataDir, "baldav.db-journal").delete() + File(appDataDir, "baldav.db-wal").delete() + File(appDataDir, "baldav.db-shm").delete() + pending.delete() + Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)") + } + } catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) } + // Create command files if not exist (FileObserver needs them) val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) cmdFile.createNewFile() @@ -474,6 +507,26 @@ class MainActivity : TauriActivity() { intent.action = BleHrService.ACTION_STOP startService(intent) } + cmd == "ble_request_enable" -> { + Log.d("BalDav", "Requesting Bluetooth enable") + if (ensureBlePermissions()) { + try { startActivity(Intent(android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE)) } + catch (e: Exception) { Log.e("BalDav", "BT enable request failed: ${e.message}") } + } + } + cmd == "check_location" -> { + val lm = getSystemService(LOCATION_SERVICE) as? LocationManager + val enabled = when { + lm == null -> false + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> lm.isLocationEnabled + else -> lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + File(appDataDir, "location_state.json").writeText("""{"enabled":$enabled}""") + } + cmd == "open_location_settings" -> { + try { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } + catch (e: Exception) { Log.e("BalDav", "open location settings: ${e.message}") } + } cmd == "check_update" -> { checkForUpdate(appDataDir) } @@ -515,6 +568,33 @@ class MainActivity : TauriActivity() { } } } + cmd.startsWith("exportdb:") -> { + // exportdb: — SAF-save the DB copy Rust wrote to export_db.tmp. + val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" } + val f = File(appDataDir, "export_db.tmp") + if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) } + else Log.e("BalDav", "exportdb: export_db.tmp missing") + } + cmd == "restartapp" -> { + // Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate. + // startActivity()+exit() races (the new activity doesn't come up before the process + // dies — measured: app just closed, no restart). Schedule the relaunch via AlarmManager + // so it fires AFTER this process is gone (the ProcessPhoenix approach). + Log.d("BalDav", "Restarting app (DB restore)") + try { + val intent = packageManager.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + val piFlags = android.app.PendingIntent.FLAG_CANCEL_CURRENT or + (if (Build.VERSION.SDK_INT >= 23) android.app.PendingIntent.FLAG_IMMUTABLE else 0) + val pi = android.app.PendingIntent.getActivity(this, 0, intent, piFlags) + val am = getSystemService(ALARM_SERVICE) as android.app.AlarmManager + am.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 300, pi) + } + } catch (e: Exception) { Log.e("BalDav", "restart schedule failed", e) } + finishAffinity() + Runtime.getRuntime().exit(0) + } } } diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 6148583..d20d531 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -25,6 +25,8 @@ pub struct HrReading { pub timestamp: u64, #[serde(default)] pub device: String, + #[serde(alias = "bat", default)] + pub battery: Option, } // Shared state for BLE operations @@ -438,6 +440,21 @@ pub async fn ble_disconnect( } } +// Ask the OS to turn Bluetooth on (system ACTION_REQUEST_ENABLE dialog). BLUETOOTH_CONNECT is +// already granted, so no extra permission. Called when the user taps "Подключить" with BT off. +#[tauri::command] +pub async fn ble_request_enable( + app: tauri::AppHandle, +) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok("desktop: no-op".to_string()) } + #[cfg(target_os = "android")] + { + android::send_command(&app, "ble_request_enable")?; + Ok("Requesting enable".to_string()) + } +} + #[tauri::command] pub fn ble_get_hr( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9466747..7a8be8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ use tauri_plugin_sql::{Migration, MigrationKind}; #[allow(unused_imports)] use tauri::Manager; +#[allow(unused_imports)] +use tauri::Emitter; // app.emit(...) for progress events (Tauri v2) use serde::Serialize; #[allow(unused, dead_code)] @@ -226,6 +228,14 @@ fn read_points_file(app: tauri::AppHandle, filename: String) -> Result Result { + if name != "location_state.json" { return Err("bad name".to_string()); } + let p = native_root(&app).join(&name); + if p.exists() { std::fs::read_to_string(&p).map_err(|e| e.to_string()) } else { Ok(String::new()) } +} + // Download binary file from URL to local path (for tile DB import from router) // read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads #[tauri::command] @@ -284,8 +294,12 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result Result Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } +// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path. +// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc +// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the +// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the +// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do. +fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { + let root = native_root(app); + let db = root.join("baldav.db"); + Ok((db, root)) +} + +// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to +// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open. +#[tauri::command] +fn export_db(app: tauri::AppHandle, filename: String) -> Result { + let (db_path, root) = db_and_root(&app)?; // root = native_root: the writable app dir Kotlin watches + if !db_path.exists() { return Err("baldav.db не найдена".into()); } + let out = root.join("export_db.tmp"); + let _ = std::fs::remove_file(&out); + { + let conn = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?; + conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?; + } + let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + let _ = std::fs::write(root.join("gps_command.txt"), format!("exportdb:{}", filename)); + Ok(format!("{{\"size\":{}}}", size)) +} + +// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db. +// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok; +// caller then restarts the app. +#[tauri::command] +fn import_db(app: tauri::AppHandle) -> Result { + let (_db, root) = db_and_root(&app)?; // root = native_root: Kotlin wrote picked_import.db here + let picked = root.join("picked_import.db"); + if !picked.exists() { return Err("файл не выбран".into()); } + { + let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?; + let ok: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0)).map_err(|e| format!("проверка: {e}"))?; + if ok != "ok" { return Err(format!("база повреждена: {ok}")); } + let has: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile')", + [], |r| r.get(0)).map_err(|e| format!("схема: {e}"))?; + if has < 1 { return Err("это не похоже на базу BalDav".into()); } + } + let pending = root.join("pending_restore.db"); + std::fs::copy(&picked, &pending).map_err(|e| format!("подготовка: {e}"))?; + let _ = std::fs::remove_file(&picked); + Ok("staged".into()) +} + +// Ask Kotlin to restart the app process (used after import_db to load the restored DB). +#[tauri::command] +fn restart_app(app: tauri::AppHandle) -> Result { + let (_db, root) = db_and_root(&app)?; // root = native_root, the dir Kotlin's FileObserver watches + let _ = std::fs::write(root.join("gps_command.txt"), "restartapp"); + Ok("ok".into()) +} + // Read tiles from a SQLite file in batches for import into IndexedDB #[tauri::command] fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result { @@ -1088,7 +1162,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable, read_state_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 44ddaed..d021293 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.457", + "version": "0.1.496", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" diff --git a/tools/hr_log.py b/tools/hr_log.py new file mode 100644 index 0000000..d810183 --- /dev/null +++ b/tools/hr_log.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Фоновый логгер ЧСС с BLE HR-датчика (стандарт 0x180D / 0x2A37, напр. Coospo H808S). +Пишет строки 'epoch,bpm' в указанный файл, пока процесс не убьют. Авто-переподключение +при обрыве. Спаривание не требуется. + +Использование: python3 tools/hr_log.py +""" +import asyncio, sys, time +from bleak import BleakClient, BleakScanner + +ADDR = sys.argv[1] +LOG = sys.argv[2] +HR_CHR = "00002a37-0000-1000-8000-00805f9b34fb" + + +def make_cb(logf): + def cb(_, data: bytearray): + flags = data[0] + bpm = int.from_bytes(data[1:3], "little") if flags & 1 else data[1] + logf.write(f"{time.time():.1f},{bpm}\n") + logf.flush() + return cb + + +async def main(): + logf = open(LOG, "a") + logf.write(f"# {time.time():.1f} start addr={ADDR}\n") + logf.flush() + while True: + try: + dev = await BleakScanner.find_device_by_address(ADDR, timeout=15.0) + if dev is None: + logf.write(f"# {time.time():.1f} not_found, retry\n") + logf.flush() + await asyncio.sleep(3) + continue + async with BleakClient(dev) as cli: + logf.write(f"# {time.time():.1f} connected\n") + logf.flush() + await cli.start_notify(HR_CHR, make_cb(logf)) + while cli.is_connected: + await asyncio.sleep(1) + logf.write(f"# {time.time():.1f} disconnected, reconnect\n") + logf.flush() + except Exception as e: + logf.write(f"# {time.time():.1f} err {e}\n") + logf.flush() + await asyncio.sleep(3) + + +asyncio.run(main())