Compare commits

...

13 commits

Author SHA1 Message Date
c7dd588f37 TEF in daily balance + separate HR-only "vr" (hands/VR) workout type
TEF (thermic effect of food): optional profile toggle, 10% of intake,
introduced so maintenance is preserved - base = BMR*actLevel*(1-tef),
then +tef*intake. On a deficit day expenditure is slightly lower (eat
less, digest less), which keeps the existing calibration rather than
inflating the deficit. Day summary shows "расход: база + переваривание
+ активность"; the 7-day trend uses it too.

New activity_type "vr" (Руки/VR): HR-only like "workout" (no GPS, kcal
from HR), tracked separately. GPS.isHrOnly() replaces the hardcoded
"workout" checks so both kinds share the no-GPS record/detail path.
2026-10-22 00:51:31 +03:00
76efc9238b Roads overlay: glue repeated passes, thicken line by ride count
rebuild_roads now counts how many times each ~2m cell was ridden
(by cell entry, not per point, so a slow crawl stays one pass) and
stores it in road_points.passes. get_road_points returns it; the
overlay draws each stretch at base width x min(3, 1 + 0.2*(passes-1)),
split into runs of equal thickness. Optional via the "Толщина дорог
по проездам" setting (default on). Both the recording-screen roads
toggle and the Дороги screen honour it.
2026-10-22 00:49:18 +03:00
1cb6efbee3 Track detail: reference calibrated-HR kcal figure (Keytel x personal factor)
HR methods over-read vs physics (ride) and weight (sedentary day); a rough
single factor ~0.65 reconciles both. Add config.hrCalibFactor (0.65) and show a
'ЧСС откалибр. (грубо)' figure next to the Keytel one in the calories block,
clearly labelled as a rough reference. Activity balance still uses physics.
2026-10-21 11:22:46 +03:00
435a0dd27c Tracks: un-merge a merged session back into its original rides (UI button)
A merged session keeps orig_session_id on every point, so split it back:
unmergeGpsSession recreates the original session rows by that id, retags points,
and sets each piece's date/times from its own points. A 'Разделить склейку (N)'
button shows in track detail only when the session is a merge; it un-merges and
recomputes each piece.
2026-10-20 23:49:17 +03:00
abc4e3cef0 Strap usage tracking: accrue real HR-delivering time + battery steps natively
The strap's battery % is coarse/laggy (reads 100% for >24h), so to judge real
battery life we accumulate the time it actually delivered HR plus every battery
step into strap_uptime.json in the foreground service (persistent across
sessions). ble_read_strap_uptime / ble_reset_strap_uptime expose and reset it;
Settings shows hours-since-charge + steps with a Charged reset button.
2026-10-20 23:49:17 +03:00
9bc71b19c9 BleHrService: auto-reconnect a dropped strap (bounded, never for an absent one)
On an unexpected disconnect of an established connection (connectedOnce, i.e.
services were discovered), reconnect via the scan path up to 3 times, 8s apart,
resetting the budget on each successful reconnect. The scan gives up (stopSelf)
if the strap isn't found, so we never hammer a band that wasn't present at start
or wasn't connected by hand. A user stop, Bluetooth off, or onDestroy sets a
stopping flag that suppresses reconnect.
2026-10-20 23:25:06 +03:00
ee47af4479 Workout HR: log samples natively so recording survives screen-off
The JS 2s poll that stored workout HR samples is frozen by Android when the
screen is off, so a sleep-long recording kept only the on-screen stretches
(the strap and live display were fine — the persist path was the gap). The
foreground service now appends every reading to ble_hr_log.jsonl; ble_read_hr_log
exposes it; on stop the workout rebuilds its samples from that log (falling back
to the poll array). ble_clear_hr truncates the log at start.
2026-10-20 23:09:50 +03:00
85d5e47972 Bump version (build.sh output) 2026-10-19 23:16:39 +03:00
10ff635192 Session log and plan: builds for all OS uploaded to the router (build 506) 2026-10-19 23:16:33 +03:00
f2d39459e6 Builds: any mix of targets and flags, Windows icon, router upload per file
icon.ico is required by tauri-build for the Windows resource; made from the
existing icon.png. Docs describe build.sh (gitignored): targets combine freely
under one build number, -da makes only the APK debug (the phone runs
debug-signed builds), and BUILD_NUMBER on the router is written only with the
APK, since the phone's OTA reads it and would otherwise offer the old APK.
2026-10-19 23:05:54 +03:00
1d133d0067 Docs: desktop builds for Linux/Windows/macOS and router upload via build.sh
build.sh itself is gitignored; this records the new targets (linux, windows,
macos, release, upload), the router file names and the osxcross location so
the flow is reproducible.
2026-10-19 22:47:44 +03:00
3f092cced0 Tiles: heal blank tiles online (periodic OSM-reachability check + backfill)
The on-demand loader never retries a tile whose fetch failed, so holes from a
bad signal stayed blank even after the network recovered. Add a healer: every
30s, when OSM is reachable, re-fetch MISSING tiles (current zoom) for the
visible area and along the ridden route, cache them, and redraw. Gentle to OSM
(one-tile probe, 300ms throttle, <=40 fetches/cycle); respects offline mode.
Wired on record/detail/compare/roads maps, stopped on screen change.
2026-10-19 20:07:05 +03:00
d4dda3da0c Dark map: give the roads overlay the same invert/hue/brightness filter as tiles
Road colours were tuned for light tiles and blended into the inverted dark
tiles. Filter the roads pane identically: add the CSS rule for
.leaflet-roadsPane (record screen roads already use that pane), and on the
roads screen draw the polylines through a canvas renderer bound to a new
roadsPane so the same rule applies there too.
2026-10-19 19:26:20 +03:00
23 changed files with 908 additions and 95 deletions

View file

@ -22,11 +22,11 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `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` не осталось; `<select>` оставлены). `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`. `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск |
| `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`. `unmergeGpsSession`/`countMergedOrigins` — разделение склейки обратно по `orig_session_id` (парно к `mergeGpsSessions`): пересоздаёт сессии по исходным id, ретег точек, выставляет дату/время каждой из её точек; кнопка "Разделить склейку" в деталях трека (видна только если склейка). `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<id>` в имени, дедуп ±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`, читает `<ele>`, 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`) |
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
| `js/tilecache.js` | ~300 | Оффлайн кэш тайлов карты (IndexedDB). `createLayer` — Leaflet-слой, тянет из IndexedDB, при промахе (если не offline) фетчит с OSM и кэширует; НЕ ретраит упавший тайл (Leaflet метит его loaded-with-error). `startHealer/stopHealer/probeOsm` — онлайн-долечивание: раз в 30с, если OSM доступен, дофетчивает НЕДОСТАЮЩИЕ тайлы (текущий зум) по видимой области и вдоль проеханного маршрута (rotating cursor, трот 300мс, ≤40 фетчей/цикл — бережно к OSM), кэширует и `layer.redraw()`. Гейтится offline-режимом. Запускается на картах записи/детали/сравнения/дорог, глушится в `renderScreen` |
| `js/router.js` | ~80 | Оффлайн-роутинг BRouter. `BRouter.route(points,[profile])`: Android → встроенный движок (`invoke("brouter_route")`), иначе/при ошибке → PC-RouteServer по HTTP (`http_request`, мимо CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Профили `trekking` (планирование) / `shortest` (гэпы). `BRouter.route(points, profile, nogos)` — nogos `[{lat,lon,radius}]` → `&nogos=lon,lat,r` в RouteServer и в запрос встроенного движка (Kotlin `rc.nogopoints`). Докачка сегментов `.rd5`: `downloadTile/downloadStatus/listTiles/downloadTileAndWait` |
| `js/poi.js` | ~200 | POI-оверлей на картах: круглые контрастные бейджи (эмодзи в круге, не пины), фильтр категорий (`POI.attach(map)`, данные `data/pois.json`, localStorage `baldav_poi_cats/shown`) |
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
@ -42,7 +42,7 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView |
| `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR |
| `src/ble.rs` | ~480 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR. `ble_read_hr_log` — читает нативный лог ЧСС `ble_hr_log.jsonl` (пишет foreground-сервис, переживает гаснущий экран); `ble_clear_hr` чистит и его. `ble_read_strap_uptime`/`ble_reset_strap_uptime` — наработка ремня (время реальной отдачи ЧСС + шаги заряда, персистентно в `strap_uptime.json`, для расчёта ресурса батареи самими) |
| `src/main.rs` | 5 | Точка входа |
| `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid |
| `tauri.conf.json` | — | Конфиг приложения, плагины, ресурсы |
@ -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<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
| `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 |
| `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. Каждый замер ЧСС дописывается в `ble_hr_log.jsonl` (append) прямо в сервисе — запись истории переживает гаснущий экран (JS-поллинг в фоне заморожен Android); воркаут при остановке пересобирает сэмплы из этого лога (`GPS._readHrLogSamples`), а не из JS-массива. Авто-реконнект: обрыв УЖЕ установленного соединения (connectedOnce) переподключается через скан (до 3 попыток, 8с), сброс бюджета при успехе; если скан не нашёл ремень или это stopping (ACTION_STOP/BT-off/onDestroy) - stopSelf, не долбится в отсутствующий/не запущенный вручную ремень. Наработка ремня: `accrueUptime`/`writeUptimeFile` копят время реальной отдачи ЧСС (только промежутки без разрывов) + шаги заряда в `strap_uptime.json` (персистентно, не чистится per-session, flush ~20с и при обрыве) |
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
### Инструменты (`tools/`)
@ -81,7 +81,7 @@
| Файл | Назначение |
|------|------------|
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS, `clean`/авто-чистка старых бандлов (`clean_bundles`) |
| `build.sh` | Сборка: android/debug/release, ПК linux/windows/macos (кросс), sync frontend, версионирование (один бамп за запуск), любые комбинации целей и флагов, `-u` выкладка собранного на роутер (`upload_router`, `.build` на файл) + APK на VPS (`upload_vps`), `clean`/авто-чистка старых бандлов (`clean_bundles`) |
| `BUILD_NUMBER` | Автоинкремент номера сборки |
| `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) |
| `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) |
@ -112,7 +112,7 @@
| `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m |
| `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы |
| `supplement_log` | Приём добавок | date, supplement_id, dose_g |
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо») |
| `gps_sessions` | GPS тренировки | date, activity_type (bike/walk/run/workout/vr), distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо»), hr_samples (ряд ЧСС для workout/vr) |
| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw |
| `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json (`{lat,lon,ele?}`), created_at |
| `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active |
@ -120,7 +120,7 @@
| `rdi_values` | Нормы нутриентов | standard_id, nutrient_key, value, norm_type (rdi/aup) |
| `nutrient_halflife` | Полураспад нутриентов | nutrient_key, halflife_avg_days |
| `nutrient_reserves` | Запасы нутриентов по дням | date, reserves (JSON) |
| `road_points` | Дедуплицированные точки всех треков | lat, lon, atype (bike/walk) |
| `road_points` | Дедуплицированные точки всех треков | lat, lon, atype (bike/walk), passes (число проездов ячейки, для толщины линии) |
### Seed DB (products_seed.db)
@ -185,13 +185,13 @@
| `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') |
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп в памяти по сетке; пеший point (`walk=1`) → atype 'walk'; считает passes = входы в ячейку = число проездов) |
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |
## Формула ккал (GPS)
### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя)
### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя; с опциональным TEF: база = BMR*activityLevel*(1−tefFrac), затем +tefFrac*intake, tefFrac=0.10 — сохраняет поддержку, масштабирует переваривание от реальной еды)
```
Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2)
m = rider_kg + bike_kg; gradient из СГЛАЖЕННОЙ высоты
@ -261,7 +261,12 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200
./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA)
./build.sh install -d # поставить уже собранный APK без пересборки
./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает)
./build.sh linux|windows|macos # ПК под одну ОС (windows - mingw, macos - osxcross, оба кросс)
./build.sh pc | all # все ПК | android + все ПК
./build.sh -da upload # = -u all: debug APK + release ПК, один номер, -> keenetic:/opt/share/www/baldav
```
Цели и флаги (`-d`, `-da`, `-i`, `-u`) комбинируются в любом виде, номер один на запуск. `BUILD_NUMBER`
на роутере пишется только вместе с APK (по нему OTA телефона); у каждого файла есть свой `<файл>.build`.
Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK.
`clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке.

View file

@ -22,10 +22,10 @@
## Возможности
- **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.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).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.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). Онлайн-долечивание пустых тайлов: раз в 30с, когда OSM доступен, БалДав дофетчивает недостающие тайлы (текущий зум) по видимой области и вдоль проеханного маршрута, кэширует и перерисовывает - дырки, оставшиеся от плохого сигнала в поездке, закрываются сами, когда связь есть (бережно к OSM: троттлинг, лимит на цикл; в режиме «офлайн карты» не фетчит).
- **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 для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`). Режим **«Руки/VR (пульс)»** (`activity_type="vr"`) - то же самое для махательных/танцевальных/VR-тренировок, трекается отдельно от зарядки.
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает 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) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». Опциональный **TEF** (термический эффект пищи, галочка в профиле, 10% прихода): вводится с сохранением поддержки - база = BMR*actLevel*(1-0.1), затем +0.1*приход, так что на поддержке баланс не меняется, а в дефиците расход чуть ниже (ел меньше - переварил меньше). В сводке дня строка «расход: база + переваривание + активность».
- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
@ -50,7 +50,7 @@
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут остаются цветными), выбор запоминается. Оверлей дорог получает ТОТ ЖЕ фильтр (пан `roadsPane`), чтобы его цвета сдвигались вместе с тайлами и оставались контрастными на тёмной карте. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
- **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается).
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
@ -72,8 +72,26 @@
./build.sh -i android -d # debug + установка на устройство
./build.sh -u android # release + upload на роутер (LAN) и VPS (OTA)
./build.sh -d -i -u android # debug + install + upload
./build.sh linux # ПК Linux: .deb, .rpm, .AppImage
./build.sh windows # ПК Windows: NSIS-установщик (кросс, mingw)
./build.sh macos # ПК macOS: .app в zip, Intel + Apple Silicon (кросс, osxcross)
./build.sh pc # linux + windows + macos
./build.sh all # android + pc
./build.sh -da upload # = -u all, APK debug (как на телефоне), ПК release -> роутер
./build.sh -u android windows # любая комбинация целей и флагов
```
Цели и флаги комбинируются как угодно; за запуск номер сборки растёт один раз и общий для всех
целей. `-d` - debug для всего, `-da` - debug только для APK. `-u` выкладывает в
`http://192.168.1.1:88/baldav/` всё, что собралось в этом запуске: `baldav.apk`,
`baldav-linux-amd64.deb`, `baldav-linux-x86_64.rpm`, `baldav-linux-x86_64.AppImage`,
`baldav-windows-x64-setup.exe`, `baldav-macos-x64.zip`, `baldav-macos-arm64.zip`, рядом с каждым
`<файл>.build` с номером сборки. `BUILD_NUMBER` (по нему телефон проверяет OTA) пишется только
вместе с APK, иначе телефону предложилось бы «обновление» на старый APK; APK, как и раньше, уходит
ещё и на VPS. Упавшие цели не мешают выкладке остальных и перечисляются в итоге. Кросс-сборка под
macOS берёт osxcross из `$OSXCROSS` (по умолчанию `/mnt/02F6E7F6F6E7E7BD/osxcross`, SDK 12.3 из
fpcupdeluxe); `.app` собирается скриптом, т.к. бандлер Tauri на Linux его пропускает.
## Версия и трассируемость сборки
Чтобы по розданному APK однозначно находить, где регресс/косяк:

View file

@ -0,0 +1,26 @@
# БалДав: выкатка сборок под все ОС на роутер (по образцу nlve/build.sh) - выполнено
## Контекст
`./build.sh android -u` выкладывал только APK. Нужно: Android + ПК (Linux, Windows, macOS Intel и
Apple Silicon) в любой комбинации целей и флагов, под одним новым номером за запуск, с выкладкой
собранного на роутер `keenetic:/opt/share/www/baldav/`.
## Решения
1. Глобальная память (`~/.claude/CLAUDE.md`, раздел «macOS Cross Toolchain»): osxcross в
`/mnt/02F6E7F6F6E7E7BD/osxcross/`, SDK в `/home/chaiman/fpcupdeluxe/cross/lib/all-darwin/`.
2. osxcross достроен: SDK 12.3 упакован в `tarballs/`, сборка
`CC="clang --gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13" CXX="clang++ --gcc-install-dir=..." UNATTENDED=1 ./build.sh`
(у clang выбирается GCC 14 без libstdc++-14-dev). `rustup target add x86_64-apple-darwin aarch64-apple-darwin`.
3. `build.sh`:
- `update_version` раз за запуск; цели `android`, `linux|desktop`, `windows`, `macos`, `pc`, `all`,
`upload` (= `-u all`) в любой комбинации; флаги `-d` (debug всё), `-da` (debug только APK), `-i`, `-u`.
- Windows: `--target x86_64-pc-windows-gnu --config '{"bundle":{"targets":["nsis"]}}'`
(`--bundles` на Linux принимает только deb/rpm/appimage); нужен `icons/icon.ico`.
- macOS: `--no-bundle` + `.app` собирается скриптом (бандлер Tauri на Linux пропускает `app`), zip.
- Выкладка: файлы под фиксированными именами + `<файл>.build`; `BUILD_NUMBER` только вместе с APK
(OTA телефона); APK ещё и на VPS; упавшие цели не блокируют остальные.
4. Документация: README, PROJECT_MAP, лог сессии.
## Проверка
`./build.sh -da upload` -> сборка 506, 7 файлов на роутере, HTTP 200, номера сходятся.
Запуск на Windows/macOS не проверялся.

View file

@ -0,0 +1,25 @@
# Сессия 2026-10-19 - тёмные дороги на тёмной карте + диагностика незагруженных тайлов
## Задача 1: дороги не видны на тёмной карте
Тёмный фильтр (`invert(1) hue-rotate(180deg) brightness(1.5)`) висел только на
`.leaflet-tile-pane`; цвета дорог (под светлые тайлы) на инвертированных тайлах сливались.
Сделано (тот же фильтр на пан дорог, авто по классу `body.dark-map`, без перерисовки):
- styles.css: добавлено `body.dark-map .leaflet-roadsPane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }`.
- Экран записи: дороги уже в `roadsPane` (SVG) - правок не нужно, правило применилось само.
- Экран "Дороги" (`showRoadsMap`, canvas): добавлен пан `roadsPane` (zIndex 240) и
`L.canvas({pane:"roadsPane"})`-рендер, полилинии дорог рисуются через него -> тот же фильтр
накрывает и этот экран.
- Трек/маршрут/POI не трогали (пользователь просил дороги).
Проверка: синтаксис JS ок. На телефоне: тумблер Луна на записи и на экране "Дороги" - дороги
контрастны и на тёмной, и на светлой; трек/маршрут не инвертированы. (Живую проверку делает
пользователь.)
## Задача 2: незагруженные тайлы на 90-км треке (только диагностика, без правок)
Факты от пользователя: офлайн-карты ВЫКЛ, мобильный интернет в поездке БЫЛ. Значит код
(`tilecache.js` createTile) должен был тянуть тайлы с OSM - пустота при живой сети это
аномалия, нужны факты. bbox трека: lat 47.47-47.57, lon 40.08-40.48 (Ростовская обл.).
План диагностики (read-only): CDP в живой WebView (localStorage.baldav_offline_maps,
TileCache.count(), перебор IndexedDB baldav_tiles по bbox на зумах 13-16) + logcat при
открытии трека (идут ли фетчи на a.tile.openstreetmap.org и статус). Результат - отдельно.

View file

@ -0,0 +1,47 @@
# 2026-10-19 - выкатка на роутер сборок под все ОС
## Контекст
По образцу `upload` из соседнего проекта nlve (`build.sh`): одной командой собрать Android + ПК
(Linux, Windows, macOS Intel/Apple Silicon) под одним номером и выложить на роутер.
Работали по утверждённому плану: `docs/plans/2026-10-19-router-upload-all-os.md`.
## Действия
- `build.sh`: `update_version` срабатывает раз за запуск; цели `linux`/`desktop`, `windows`
(`x86_64-pc-windows-gnu`, NSIS), `macos` (osxcross, `--bundles app`, zip), `release`, `upload`;
`upload_router` (ssh по ключу, файлы первыми, `BUILD_NUMBER` последним), итог `finish` со списком упавших.
Выкладываются только файлы, собранные в этом запуске. `-u` у `android` не менялся (APK на роутер+VPS).
- osxcross в `/mnt/02F6E7F6F6E7E7BD/osxcross` не был собран (не было `target/` и SDK в `tarballs/`).
SDK 12.3 упакован из fpcupdeluxe (`cross/lib/all-darwin/MacOSX12.3.sdk`) в `tarballs/`.
Сборка osxcross с clang++ падала: clang выбирает GCC 14, а `libstdc++-14-dev` не установлен ->
собран с `CC=gcc CXX=g++`.
- `rustup target add x86_64-apple-darwin aarch64-apple-darwin`.
- AppImage, которого не было после сборки 0.1.499, при повторной упаковке собрался; причина
прошлого отсутствия не выяснена.
- Сборка osxcross с clang заработала с `--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13`
(с gcc падает cctools-port: «must compile with clang»).
- Tauri CLI на Linux не принимает `--bundles nsis/app` -> Windows через `--config '{"bundle":{"targets":["nsis"]}}'`;
macOS-бандл бандлер на Linux пропускает -> `--no-bundle` + `.app` собирается скриптом (Info.plist,
`Resources/data/products_seed.db`, `icon.icns` из `icon.png` записью ic07, NSBluetoothAlwaysUsageDescription).
- Для Windows нужен `src-tauri/icons/icon.ico` - сделан ImageMagick из `icon.png`.
- По запросу пользователя: любая комбинация целей и флагов за один запуск, номер растёт раз;
`-da` - debug только APK (на телефоне и на роутере debug-подписанный APK);
`BUILD_NUMBER` на роутере только вместе с APK (OTA телефона читает его, `MainActivity.kt:801-806`),
у каждого файла `<файл>.build`; что собралось - выкладывается, упавшее в итоге.
## Результат
`./build.sh -da upload`: сборка 506, выложено на роутер и APK на VPS:
baldav.apk 181.5 МБ, baldav-linux-amd64.deb 9.9, baldav-linux-x86_64.rpm 9.9,
baldav-linux-x86_64.AppImage 82.2, baldav-windows-x64-setup.exe 9.7, baldav-macos-x64.zip 9.3,
baldav-macos-arm64.zip 9.1.
## Проверка
- Роутер: все 7 файлов отдаются по HTTP (200), BUILD_NUMBER = 506, все `.build` = 506.
- `file`: PE (NSIS) с baldav.exe + WebView2Loader.dll + data/products_seed.db внутри;
Mach-O x86_64 (min 10.13) и arm64 (min 11.0, есть LC_CODE_SIGNATURE ad-hoc).
- Запуск на Windows и macOS не проверялся: машин с этими ОС здесь нет.
## Следующие шаги
- Проверить установщик на Windows и .app на macOS (Intel и Apple M). Сборки не подписаны:
на macOS первый запуск через ПКМ -> «Открыть».
- `build.sh` в `.gitignore` - в репозиторий не попадает.

View file

@ -0,0 +1,27 @@
# Сессия 2026-10-19 (доб.) - онлайн-долечивание пустых тайлов
## Причина (из диагностики ранее)
`createTile` (tilecache.js) при падении фетча не ретраит: Leaflet метит тайл
loaded-with-error и не перезапрашивает, пока тайл не пересоздан (панорама/зум/redraw/
новая карта). Поэтому дырки от плохого сигнала в поездке висели даже после возврата связи
(подтверждено: офлайн ВЫКЛ, OSM через VPN отдавал 200, но пустые тайлы не оживали).
## Сделано (фича по запросу пользователя)
tilecache.js - хилер:
- `probeOsm()` - navigator.onLine + один тайл 0/0/0.png (таймаут 6с) = OSM доступен.
- `startHealer(map, layer, pointsFn)` / `stopHealer()` - раз в 30с, если онлайн и не
offline-режим: собирает недостающие тайлы на ТЕКУЩЕМ зуме по видимой области + (если
pointsFn есть) вдоль маршрута (rotating cursor по длинному треку, чтобы не бурстить),
фетчит с OSM, кэширует, по заполнению `layer.redraw()`. Лимиты: троттл 300мс, ≤40
фетчей/цикл, ≤500 lookups/цикл - бережно к политике OSM (без массовой скачки).
Также немедленный проход по событию `online` и через 3с после открытия карты.
- Решения пользователя: зум - только текущий; охват - запись + просмотр.
main.js - подключение: хилер стартует на картах записи (pointsFn = точки текущей записи),
детали трека (точки трека), сравнения треков и экрана "Дороги" (оба - по видимой области,
pointsFn=null); глушится в `renderScreen` при уходе с экрана (и на map 'unload').
## Проверка
Синтаксис JS ок (tilecache.js, main.js). На телефоне: открыть трек с дырявыми тайлами при
живой сети - в течение ~30с дырки на текущем зуме заполняются; при офлайн-режиме докачки нет;
нагрузка на OSM ограничена троттлингом. Живую проверку делает пользователь.

View file

@ -0,0 +1,32 @@
# Сессия 2026-10-20 (доб.) - авто-реконнект ремня ЧСС
## Задача
Чтобы ночная запись не оборвалась при кратком обрыве BLE. Условие пользователя: НЕ долбиться
в ремень, если его не было при старте или его не подключали руками - реконнект только для
реально установленного и оборвавшегося соединения.
## Сделано (BleHrService.kt)
- Поля `lastAddress`, `connectedOnce`, `stopping`, `reconnectTries`, `maxReconnect=3`.
- Фреш-коннект (onStartCommand connect-ветка): `lastAddress=address; stopping=false;
connectedOnce=false; reconnectTries=0`.
- `onServicesDiscovered` (реально подключились): `connectedOnce=true; reconnectTries=0;
isRunning=true`.
- `onConnectionStateChange` DISCONNECTED: закрыть gatt; если `!stopping && connectedOnce &&
reconnectTries<max && adapter.isEnabled && lastAddress!=null` -> reconnectTries++,
`connectDevice(lastAddress)` через 8с; иначе stopSelf. Реконнект идёт через скан: ремень не
найден -> timeout-ветка stopSelf (не зацикливается на отсутствующем).
- `stopping=true` на ACTION_STOP, BT-off (ACTION_STATE_CHANGED), onDestroy - там реконнекта нет.
Начальный "скан не нашёл при старте -> stopSelf" не тронут: если ремня не было при старте,
сервис гаснет, ничего не долбит.
## Установка (важно)
Сборка БЕЗ -i, чтобы не убить идущую ночную запись. Порядок: пользователь жмёт Стоп (короткая
сессия финализируется из нативного лога), затем установка APK, затем свежий старт ночи уже с
реконнектом. Переустановка убивает процесс и чистит лог при следующем старте - поэтому не
ставить поверх идущей записи.
## Проверка
Компиляция Kotlin - в сборке. На устройстве (после установки): старт воркаута, снять/вернуть
ремень на 10-20с -> запись переподключается и продолжается; снять надолго -> через ~3 попытки
сервис гаснет. Живую проверку делает пользователь.

View file

@ -0,0 +1,36 @@
# Сессия 2026-10-20 - нативный лог ЧСС (запись воркаута переживает гаснущий экран)
## Причина (из разбора 21ч-прогона эксперимента)
Сессия-воркаут 1792436740262 (19.10 22:05 -> 20.10 17:27) в сохранёнке имела дырки 11.6ч,
НО пользователь видел живые цифры засыпая/просыпаясь/весь день. Проверка: шаг внутри
покрытия ровно 2.0с (это JS-поллинг), границы дыр = сон/утро/обед (экран гас). Вывод: ремень
и дисплей работали; молчал ПЕРСИСТ - историю сэмплов воркаута писал JS setInterval(2с),
который Android замораживает в фоне/при гаснущем экране. Нативная часть клала только ТЕКУЩЕЕ
значение (ble_hr_data.json, overwrite), истории не было.
## Сделано
Нативный лог истории ЧСС (как GPS-точки пишутся foreground-сервисом):
- Kotlin BleHrService: поле `logFile` = `ble_hr_log.jsonl`; в `writeData` каждая строка с
`"hr":` дописывается (appendText) в лог. Пишет сам foreground-сервис -> переживает
гаснущий экран.
- Rust ble.rs: команда `ble_read_hr_log` (читает лог), `ble_clear_hr` теперь чистит и лог.
Зарегистрировано в lib.rs invoke_handler.
- JS gps.js: `GPS._readHrLogSamples()` - парсит лог в [[relSec,bpm]] относительно startTime
(фильтр hr>0, rel>=0, дедуп по секунде, сортировка). На остановке воркаута сэмплы берутся
из лога, если он полнее JS-массива (fallback на JS-массив, если лог пуст). Чтение - ДО
`_stopHrMonitor` (который чистит лог). Лог чистится при старте воркаута (ble_clear_hr, 1453).
## Не делал (осознанно, по риску разовой ночной записи)
Авто-реконнект ремня при обрыве BLE - НЕ добавлял сейчас. Причина дыр была в заморозке JS,
а не в обрыве BLE (днём была длинная непрерывная связь). Ночью пользователь запишет сон -
это проверит, рвётся ли BLE на самом деле. Если нативный лог покажет обрыв среди ночи -
тогда добавим реконнект. Иначе он не нужен.
## Также отложено
Разделение склеенного велотрека 1792506450721 (3 поездки по orig_session_id: 17:27-17:57,
18:00-18:32, 18:44-18:49) - одобрено, но отложено ради срочного ночного фикса.
## Проверка
Синтаксис JS ок. Rust/Kotlin компиляция - в сборке. На телефоне: старт воркаута "зарядка
(пульс)", экран гаснет на ночь, утром стоп -> сэмплы должны быть сплошные (из нативного лога),
включая сон. Живую проверку делает пользователь ночью.

View file

@ -0,0 +1,33 @@
# Сессия 2026-10-20 (доб.) - наработка ремня ЧСС + разделение склейки (UI)
## Наработка ремня (считаем ресурс батареи сами)
Проблема: ремень >24ч пишет 100%, непонятно шагами по 25% или как. Нужно самим считать
"300 часов". Заряд (0x2A19) грубый/запаздывает - поэтому копим ВРЕМЯ реальной отдачи ЧСС.
- Kotlin BleHrService: `accrueUptime`/`writeUptimeFile` - на каждый замер ЧСС копят время
(только промежутки <10с, т.е. без разрывов) и шаги заряда в `strap_uptime.json`
(персистентно, НЕ чистится per-session; flush ~20с и принудительно при обрыве/disconnect).
- Rust: `ble_read_strap_uptime` (чтение), `ble_reset_strap_uptime` (сброс при зарядке).
Зарегистрированы в lib.rs.
- JS: в настройках (Пульсометр) строка "Ремень ЧСС: наработка" - заряд, Hч Mм с последней
зарядки, шаги заряда, кнопка "Заряжен" (сброс через appConfirm).
Счётчик считает С МОМЕНТА установки/сброса; прошедшие сутки задним числом не восстановить.
## Авто-реконнект ремня (из прошлого лога этой даты)
Отдельный коммит ранее: переподключение только УЖЕ установленного соединения (connectedOnce),
до 3 попыток, не долбится в отсутствующий/не запущенный вручную ремень.
## Разделение склейки (по образцу merge, из UI)
Пользователь склеил 3 велопоездки в одну (session 1792506450721) и пожалел; склейка растянула
время и перекрыла воркауты между кусками. Все точки хранят `orig_session_id` -> разъединяемо.
- db.js `unmergeGpsSession(sessionId)`: по distinct `orig_session_id` пересоздаёт исходные
сессии (id = исходные), ретегает точки (orig_session_id=NULL, seq=NULL), выставляет каждой
дату/start/end из её точек. `countMergedOrigins` - сколько исходных склеено (>1 = склейка).
- main.js: кнопка "Разделить склейку (N)" в деталях трека (после "Разрезать"), видна только
для склейки; по тапу - unmerge + пересчёт каждой (computeStats+finishGpsSession, MET-оценка;
точную по физике - кнопкой "Пересчитать" в каждой) + возврат к списку.
## Проверка
Синтаксис JS ок (db/gps/main/tilecache). Rust/Kotlin - в сборке. Собираем в одну сборку с
реконнектом; ставим после Стопа ночной записи (переустановка её прерывает, данные в бэкапе
`scratchpad/night_hr_log_backup.jsonl`). На устройстве: наработка растёт и показывается в
настройках; "Разделить склейку" даёт 3 трека; реконнект переживает снятие ремня на 10-20с.

View file

@ -0,0 +1,35 @@
# Сессия 2026-10-21 - база покоя из ночной записи + справочная калибровка ЧСС
## Ночь записалась чисто (нативный лог сработал)
Воркаут `1792529811087` (20.10 23:56 -> 21.10 09:20): 9.4ч непрерывно, 0 дыр. Нативный лог
ЧСС (foreground-сервис, ble_hr_log.jsonl) переживает гаснущий экран - прошлый 21ч-прогон имел
11.6ч дыр из-за заморозки JS-поллинга, теперь починено.
## База покоя
Истинный пульс сна **~48 (46-50)**, устойчивый 10-мин минимум 50 в 05:46, общий минимум 46.
Всплеск 112 в 05:00 = подъём в туалет. Прежняя прикидка 65-85 завышена.
## Прикидка моделей (разбор, объяснено пользователю словами)
Поездка 90км по-всякому: физика 2756, Keytel-net 4283, резерв-ЧСС/Карвонен (от покоя 48,
VO2max 56 по Uth) ~4841. Пульсовые ~1.5-1.8x физики. Якорь на покой поездку не уменьшил.
Сутки (сон этой ночью + работа 10-17 вчера, 16ч бодрств.): по пульсу ~2300 против веса
BMR1670 x1.2=2005 (x1.3=2172). Пульс завышает и день (+10-15%), и поездку. Чисто НЕ сходятся;
грубый множитель ~0.65 мирит обе точки (поездка 2756/4283=0.64; день 335/632=0.53..0.79).
Физика у велосипеда занижает (неэффективный односкоростной складной - реально жжёшь больше
идеала); пульс завышает (жара/обезвоживание/еда + неспособность отделить их от усилия).
## Сделано (reference-only, баланс не трогаем)
- Память `project_hr_calibration_experiment.md` дополнена результатами ночи; статус - база
получена. Напоминалка снята (state.json status=done).
- `gps.js`: `config.hrCalibFactor = 0.65` (грубый персональный множитель, правится вручную).
- `main.js` showSessionDetail блок "Калории": добавлена строка "ЧСС откалибр. (грубо)" =
Keytel x hrCalibFactor, рядом с "По ЧСС (Keytel)"; подпись "грубая прикидка". В баланс
по-прежнему идёт физика.
## Остатки (не срочно)
Собрать ещё 1-2 чистых дня для устаканивания множителя; скидка на переваривание (TEF);
батарея ремня - ждём первого шага вниз (9.4ч @ 100%, ноль шагов).
## Проверка
Синтаксис JS ок. На телефоне: открыть трек с ЧСС -> в "Калории" три числа (физика,
По ЧСС Keytel, ЧСС откалибр.); баланс дня не изменился. Живую проверку делает пользователь.

View file

@ -0,0 +1,42 @@
# 2026-10-22 - TEF, вид Руки/VR, толщина дорог по проездам, офлайн-POI в brouter
## Контекст
Работа по нескольким запросам подряд (без утверждённого заранее плана, правки по ходу).
## Сделано
### brouter-web (вне git приложения, staging + /var/www/brouter-web)
- Офлайн-POI: Overpass недоступен из сети, поэтому взят статический экстракт Geofabrik
south-fed-district, osmium вырезал POI в радиусе 150 км от НЭВЗ -> poi-nevz.geojson
(26.6k точек). Конвейер tools/poi/make_poi.sh + poi_reduce.py.
- POI подключены в brouter-web ИКОНКАМИ (emoji) и в РОДНОЙ список слоёв (addOverlay в
пойманный L.Control.Layers), а не отдельными кнопками. Покрытие и Высоты - тоже в родной
список; бегунки высот всплывают только при включённом слое. Ползунок ребра высот ограничен
1000 (в hm_mesh.py MAX_EDGE=1000, выше бесполезно - дыры это промежутки >1км без проездов).
### Приложение (этот коммит)
- Толщина дорог по проездам: rebuild_roads считает passes (входы в 2м-ячейку), толщина
линии = база × min(3, 1+0.2·(проезды−1)). Опционально (настройка, по умолчанию вкл).
- Вид «Руки/VR (пульс)» (activity_type="vr") - HR-only как зарядка, трекается отдельно.
GPS.isHrOnly заменил жёсткие проверки "workout".
- TEF (термический эффект пищи): галочка в профиле, 10% прихода, с сохранением поддержки
(база = BMR*actLevel*(1−tef), +tef*приход). Строка «расход: база + переваривание +
активность» в сводке, 7-дневный тренд тоже с TEF.
## Проверка
- brouter-web: CDP + скриншоты на https://192.168.1.18/brouter-web/ - слои в родном списке,
POI иконками, ползунок 1000, 4777 точек по дефолтным категориям.
- Приложение: сборка 0.1.513, CDP в WebView - tefFrac=0.1, isHrOnly('vr')=true, опция
"vr:Руки/VR (пульс)" в списке, сводка «расход: база 1807 + переваривание 194 + активность
624» = 2625 (математика сходится).
## Разбор
- Сегодняшняя VR-сессия (id 1792607289599, 66 мин): ЧСС средняя 93, макс 113, резерв ~33% -
лёгко-умеренная зона без пиков. По ЧСС адекватно судить ЗОНУ; абсолютные ккал Keytel для
рук/VR завышает (верхняя граница), для этого и завели отдельный вид + множитель 0.65.
- Эта сессия переведена workout -> vr по просьбе (UPDATE через собственное DB-соединение
приложения по CDP).
## Дальше (не делалось)
- TEF по составу БЖУ (сейчас фиксированные 10%).
- MET-строка для VR как перекрёстная сверка (основным оставлен ЧСС).

View file

@ -1279,9 +1279,13 @@ select {
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. */
/* Dark map: invert ONLY the tile layer (track, route, 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); }
/* Roads overlay was tuned for LIGHT tiles, so on inverted (dark) tiles it blended in.
Give the roads pane the SAME filter as the tiles so road colours shift identically and
stay contrasting. Roads live in `roadsPane` (record screen SVG + roads screen canvas). */
body.dark-map .leaflet-roadsPane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }
/* Darken the map backdrop too, else the light Leaflet container (#ddd) flashes bright
between/behind tiles while zooming or where a tile is missing. */
body.dark-map .leaflet-container { background: #1b1f24; }

View file

@ -1001,6 +1001,65 @@ async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime
return String(newId);
}
// Undo a merge: split a merged session back into its original sessions by orig_session_id
// (merge preserved it on every point). The current session keeps its own original group; each
// other orig_session_id gets its session row recreated (metadata copied). Points are retagged
// (orig_session_id cleared, seq cleared → timestamp order). Returns the list of resulting
// session id_strs (incl. the current one), or null if this session isn't a merge. The caller
// recomputes stats + times for each (computeStats + finishGpsSession).
async function unmergeGpsSession(sessionId) {
const s = await getGpsSession(sessionId);
if (!s) return null;
const rows = await select(
"SELECT DISTINCT CAST(orig_session_id AS TEXT) AS o FROM gps_points WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NOT NULL",
[s.id_str]
);
const origs = rows.map(r => r.o).filter(Boolean);
if (origs.length < 2) return null; // not a merge — nothing to split
const resultIds = [];
for (const oid of origs) {
if (oid !== s.id_str) {
await execute(
`INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id)
VALUES (CAST(?1 AS INTEGER), ?2, ?3, ?4, ?5, ?6, ?7)`,
[oid, s.date, s.start_time, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null]
);
await execute(
"UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), orig_session_id = NULL, seq = NULL WHERE session_id = CAST(?2 AS INTEGER) AND orig_session_id = CAST(?1 AS TEXT)",
[oid, s.id_str]
);
} else {
await execute(
"UPDATE gps_points SET orig_session_id = NULL, seq = NULL WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id = CAST(?1 AS TEXT)",
[s.id_str]
);
}
resultIds.push(oid);
}
// Fix each piece's date/times from its own points (merge had stretched them to the whole span).
for (const id of resultIds) {
const ts = await select("SELECT MIN(ts) AS a, MAX(ts) AS b FROM gps_points WHERE session_id = CAST(?1 AS INTEGER)", [id]);
const a = ts[0] && ts[0].a;
if (a) {
const hm = (t) => new Date(t).toTimeString().slice(0, 8);
await execute("UPDATE gps_sessions SET date = ?1, start_time = ?2, end_time = ?3 WHERE id = CAST(?4 AS INTEGER)",
[new Date(a).toISOString().slice(0, 10), hm(a), hm(ts[0].b), id]);
}
}
return resultIds;
}
// How many distinct original sessions are glued into this one (>1 ⇒ it's a merge that can be split).
async function countMergedOrigins(sessionId) {
const s = await getGpsSession(sessionId);
if (!s) return 0;
const r = await select(
"SELECT COUNT(DISTINCT orig_session_id) AS c FROM gps_points WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NOT NULL",
[s.id_str]
);
return (r[0] && r[0].c) || 0;
}
async function importGpxSession(session, points) {
await execute(
`INSERT INTO gps_sessions (date, start_time, end_time, activity_type,
@ -1389,6 +1448,8 @@ window.DB.getGpsSession = getGpsSession;
window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.mergeGpsSessions = mergeGpsSessions;
window.DB.unmergeGpsSession = unmergeGpsSession;
window.DB.countMergedOrigins = countMergedOrigins;
window.DB.splitGpsSession = splitGpsSession;
window.DB.importGpxSession = importGpxSession;
window.DB.saveGapFill = saveGapFill;
@ -1538,7 +1599,7 @@ async function getRoadPoints() {
while (true) {
const raw = await invoke("get_road_points", { offset, batchSize: BATCH });
const resp = JSON.parse(raw);
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b" });
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b", passes: p[3] || 1 });
console.log(`[getRoadPoints] batch offset=${offset} got=${resp.points.length} total_db=${resp.total}`);
if (!resp.has_more) break;
offset += BATCH;

View file

@ -14,6 +14,9 @@ GPS.config = {
airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
hrCalibFactor: 0.65, // грубый персональный множитель для справочной "ЧСС откалибр." (Keytel x этого). Пульс завышает ~в 1.5x vs физика/вес; 0.65 мирит по 2 точкам (поездка 90км + сидячий день). НЕ истина, для справки.
tefFrac: 0.10, // термический эффект пищи (TEF): доля прихода, уходящая на переваривание. 0 = выкл. Вводится С СОХРАНЕНИЕМ поддержки: база = BMR*actLevel*(1-tefFrac), затем +tefFrac*приход, так что на поддержке итог не меняется, но TEF масштабируется от реальной еды. actLevel 1.2 уже включал TEF — поэтому не прибавляем сверху, а перераспределяем.
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
@ -42,6 +45,11 @@ GPS.saveConfig = function() {
localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config));
};
// HR-only session kinds (no GPS, no track — just heart rate + timer): "workout" (зарядка)
// and "vr" (руки/VR/танцы). Both estimate kcal from HR (Keytel). Kept as a set so new
// HR-only kinds get the same no-GPS recording/detail path without touching every check.
GPS.isHrOnly = function(t) { return t === "workout" || t === "vr"; };
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
try {
@ -858,7 +866,7 @@ GPS._startHrMonitor = async function() {
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) {
if (GPS.isHrOnly(GPS.recording.activityType) && 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);
@ -881,6 +889,33 @@ GPS._stopHrMonitor = function() {
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
};
// Rebuild the workout's HR series from the NATIVE log (ble_hr_log.jsonl), which the
// foreground service appends to even with the screen off — unlike the JS poll, which
// Android freezes in the background (so a sleep-long workout kept only the on-screen bits).
// Returns [[relSec, bpm], …] relative to the recording start, sorted and de-duplicated,
// or [] if the log is empty/unavailable.
GPS._readHrLogSamples = async function() {
if (!GPS.recording.startTime) return [];
let raw = "";
try { raw = await window.__TAURI__.core.invoke("ble_read_hr_log"); } catch { return []; }
if (!raw) return [];
const startSec = GPS.recording.startTime.getTime() / 1000;
const out = [];
let lastRel = -1;
for (const line of raw.split("\n")) {
const s = line.trim();
if (!s) continue;
let o; try { o = JSON.parse(s); } catch { continue; } // skip a torn last line
if (!o || !(o.hr > 0) || o.ts == null) continue;
const rel = Math.round(o.ts - startSec);
if (rel < 0 || rel === lastRel) continue; // before start, or same-second dup
out.push([rel, o.hr]);
lastRel = rel;
}
out.sort((a, b) => a[0] - b[0]);
return out;
};
// 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.
@ -1427,16 +1462,16 @@ GPS.startRecording = async function(activityType, weightKg, profile) {
GPS.recording.startTime = null;
GPS.recording.weightKg = parseFloat(weightKg) || 80;
// HR-only "workout" (зарядка): no GPS service, no location, no track points — just the
// HR-only "workout"/"vr": 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") {
if (GPS.isHrOnly(GPS.recording.activityType)) {
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");
new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), GPS.recording.activityType);
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
// Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean.
try {
@ -1548,9 +1583,15 @@ GPS.stopRecording = async function() {
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 || [];
// HR-only workout/vr: no GPS to stop. Compute net kcal from HR, save samples, finalise.
if (GPS.isHrOnly(GPS.recording.activityType)) {
// Prefer the native HR log (complete through screen-off) over the JS poll's in-memory
// array (frozen in the background). Fall back to the poll array if the log is empty.
let samples = GPS.recording.hrSamples || [];
try {
const native = await GPS._readHrLogSamples();
if (native.length > samples.length) samples = native;
} catch (e) { console.error("[workout] native hr log read", e); }
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]);

View file

@ -250,6 +250,7 @@ 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; }
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
try { TileCache.stopHealer(); } catch {} // leaving a map screen stops online tile-healing; map screens restart it
let prevScreen = state.screen;
state.screen = name;
@ -354,10 +355,15 @@ async function renderToday(container) {
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;
let intake = Math.round(foodTotals.kcal ?? 0);
let actKcal = Math.round(actTotals.kcal ?? 0);
let expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null;
// TEF (термический эффект пищи): доля прихода на переваривание. Вводим с сохранением
// поддержки — базу урезаем на tefFrac, а TEF добавляем от реального прихода, поэтому на
// поддержке итог тот же, но в дефицит (ел меньше — переварил меньше) расход чуть ниже.
let tefFrac = Math.max(0, Math.min(0.3, GPS.config.tefFrac ?? 0));
let dailyBase = bmr ? Math.round(bmr * actLevel * (1 - tefFrac)) : null;
let tefKcal = Math.round(tefFrac * intake);
let expend = dailyBase != null ? dailyBase + tefKcal + actKcal : (actKcal > 0 ? actKcal + tefKcal : null);
let balance = expend != null ? intake - expend : null;
container.innerHTML = `
<div class="day-header">
@ -381,6 +387,10 @@ async function renderToday(container) {
</div>
</div>
${expend != null ? `<div class="expend-breakdown" style="text-align:center;font-size:11px;color:#9aa4ae;margin-top:-4px">
расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""}
</div>` : ""}
<div class="balance-trend" id="balance-trend"></div>
<div class="drink-bar-wrap" id="drink-bar-wrap"></div>
@ -559,7 +569,7 @@ async function renderToday(container) {
for (let d of food7) {
let dayIntake = foodMap[d.date] ?? 0;
let dayAct = actMap[d.date] ?? 0;
let dayBalance = dayIntake - (dailyBase + dayAct);
let dayBalance = dayIntake - ((dailyBase ?? 0) + Math.round(tefFrac * dayIntake) + dayAct);
balances.push(dayBalance);
}
if (balances.length === 0) return;
@ -2813,6 +2823,7 @@ async function renderGpsScreen(container) {
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
<option value="workout"${localStorage.getItem("baldav_gps_type")==="workout"?" selected":""}>Зарядка (пульс)</option>
<option value="vr"${localStorage.getItem("baldav_gps_type")==="vr"?" selected":""}>Руки/VR (пульс)</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Трекинг" : "Старт"}
@ -2876,10 +2887,10 @@ async function renderGpsScreen(container) {
}
// 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");
await ensureSystemReady(!!state.profile?.ble_device_addr, !GPS.isHrOnly(type));
// Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
let hrProfile = type === "workout" ? {
// Workout/VR (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
let hrProfile = GPS.isHrOnly(type) ? {
weightKg: wkg,
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
sex: state.profile?.sex || "m",
@ -3317,6 +3328,40 @@ function roadColor(atype) {
return c.roadColorBike || "#2266aa";
}
// Line thickness by how often a stretch was ridden (road_points.passes from rebuild_roads):
// base width x (1..3), +0.2 per extra pass, capped at 3x. Optional — disabled by the flag.
function roadsThickOn() { return localStorage.getItem("baldav_roads_thick") !== "0"; }
function roadWeight(passes, base) {
base = base || 3;
if (!roadsThickOn()) return base;
return base * Math.min(3, 1 + 0.2 * ((passes || 1) - 1));
}
// Draw one road segment (array of {lat,lon,passes}), split into runs of equal thickness so
// often-ridden parts read thicker. Each run bridges the boundary point so the line stays
// continuous across a thickness change. `extra`: { pane, renderer, base, opacity, smoothFactor }.
function drawRoadSegment(map, seg, color, out, extra) {
if (!seg || seg.length < 2) return;
extra = extra || {};
let base = extra.base || 3;
let opts = { color, opacity: extra.opacity != null ? extra.opacity : 0.6 };
if (extra.pane) opts.pane = extra.pane;
if (extra.renderer) opts.renderer = extra.renderer;
if (extra.smoothFactor != null) opts.smoothFactor = extra.smoothFactor;
if (!roadsThickOn()) {
out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight: base }).addTo(map));
return;
}
let run = [seg[0]];
let runW = roadWeight(seg[0].passes, base);
let flush = () => { if (run.length >= 2) out.push(L.polyline(run.map(p => [p.lat, p.lon]), { ...opts, weight: runW }).addTo(map)); };
for (let i = 1; i < seg.length; i++) {
let w = roadWeight(seg[i].passes, base);
if (Math.abs(w - runW) > 0.01) { flush(); run = [seg[i - 1], seg[i]]; runW = w; }
else run.push(seg[i]);
}
flush();
}
// Resolve once the Leaflet map has finished its initial layout, so layers added
// after this actually render (adding a polyline before the map is loaded/sized
// leaves it invisible until the next redraw). Awaits the real 'load' event — the
@ -4475,7 +4520,7 @@ function renderWorkoutDetail(session) {
let max = hrs.length ? Math.max(...hrs) : null;
let kcal = Math.round(session.total_kcal || 0);
let html = `
<div class="sd-section"><div class="sd-title">Зарядка · ${session.date} ${session.start_time ?? ""}</div>
<div class="sd-section"><div class="sd-title">${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
<div class="sd-stat"><span class="sd-val">${kcal} ккал</span><span class="sd-lbl">Нагрузка</span></div>
@ -4585,7 +4630,7 @@ async function renderRecordingInline(container) {
}
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; }
if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; }
container.innerHTML = `
<div class="rec-screen">
@ -4632,7 +4677,9 @@ async function renderRecordingInline(container) {
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along the ridden route whenever OSM is reachable.
TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon]));
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
@ -4855,19 +4902,13 @@ async function renderRecordingInline(container) {
let dlat = (pts[i].lat - prev.lat) * 111000;
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) {
if (seg.length >= 2) {
let color = roadColor(seg[0].atype);
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
}
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
seg = [];
}
}
seg.push(pts[i]);
}
if (seg.length >= 2) {
let color = roadColor(seg[0].atype);
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
}
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
};
btnWrap.appendChild(roadsBtn);
// Auto-load roads if was enabled
@ -5389,7 +5430,7 @@ async function showSessionDetail(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; }
if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
@ -5458,7 +5499,7 @@ async function showSessionDetail(sessionId) {
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
let duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
@ -5480,6 +5521,7 @@ async function showSessionDetail(sessionId) {
heightCm: parseFloat(state.profile?.height_cm) || 178,
};
let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null;
let hrKcalCalib = hasHr ? Math.round(hrKcal * (GPS.config.hrCalibFactor ?? 0.65)) : 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;
@ -5524,8 +5566,9 @@ async function showSessionDetail(sessionId) {
<div class="sd-stat"><span class="sd-val">${session.total_kcal ?? stats.kcal} ккал</span><span class="sd-lbl">${hasHr ? "Физика (в баланс)" : "Всего"}</span></div>
<div class="sd-stat"><span class="sd-val">${kcalMin} ккал/мин</span><span class="sd-lbl">Расход</span></div>
</div>
${hasHr ? `<div class="sd-row"><div class="sd-stat"><span class="sd-val">${hrKcal} ккал</span><span class="sd-lbl">По ЧСС (сравнение)</span></div></div>
<div class="prod-hint" style="text-align:center;margin-top:4px">В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.</div>` : ""}
${hasHr ? `<div class="sd-row"><div class="sd-stat"><span class="sd-val">${hrKcal} ккал</span><span class="sd-lbl">По ЧСС (Keytel)</span></div>
<div class="sd-stat"><span class="sd-val">${hrKcalCalib} ккал</span><span class="sd-lbl">ЧСС откалибр. (грубо)</span></div></div>
<div class="prod-hint" style="text-align:center;margin-top:4px">В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.</div>` : ""}
</div>
${elevations.length > 0 ? `
@ -5594,6 +5637,7 @@ async function showSessionDetail(sessionId) {
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
<button id="sd-split" class="btn-secondary full-width" style="margin-top:8px">Разрезать трек</button>
<button id="sd-unmerge" class="btn-secondary full-width" style="margin-top:8px;display:none">Разделить склейку</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
@ -5620,7 +5664,9 @@ async function showSessionDetail(sessionId) {
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along this track's route when OSM is reachable.
TileCache.startHealer(map, tileLayer, () => points.map(p => [p.lat, p.lon]));
addDarkMapControl(map); // dark-tiles toggle on the track view
POI.attach(map); // POI overlay + category filter
{ let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles
@ -6382,6 +6428,35 @@ async function showSessionDetail(sessionId) {
};
}
// Un-merge button: only for a merged session (points carry >1 orig_session_id). Splits it
// back into the original rides, recomputing each; shown only when it's actually a merge.
let unmergeBtn = document.querySelector("#sd-unmerge");
if (unmergeBtn) (async () => {
let n = 0; try { n = await DB.countMergedOrigins(sessionId); } catch {}
if (n < 2) return;
unmergeBtn.style.display = "";
unmergeBtn.textContent = `Разделить склейку (${n})`;
unmergeBtn.onclick = async () => {
if (!(await appConfirm(`Разделить трек обратно на ${n} исходные поездки? Статистика каждой пересчитается (точную по физике можно потом «Пересчитать» в каждой).`, "Разделить"))) return;
unmergeBtn.disabled = true; unmergeBtn.textContent = "Разделяю...";
try {
let ids = await DB.unmergeGpsSession(sessionId);
if (!ids) { showToast("Это не склейка"); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; return; }
let w = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
for (let id of ids) {
let pts = await DB.getGpsPoints(id);
if (!pts.length) continue;
let st = GPS.computeStats(pts, w, { activityType: session.activity_type });
let endT = new Date(pts[pts.length - 1].ts).toTimeString().slice(0, 5);
try { await DB.finishGpsSession(id, endT, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss, []); } catch (e) { console.error("[unmerge] recompute", e); }
}
showToast(`Разделено на ${ids.length} трека`);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1; history.back(); renderScreen("gps");
} catch (e) { showToast("Ошибка: " + (e.message || e)); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; }
};
})();
// Recalculate button
let recalcBtn = document.querySelector("#sd-recalc");
if (recalcBtn) recalcBtn.onclick = async () => {
@ -6575,7 +6650,7 @@ async function showCompareModal(sessionId, currentSession, currentPoints) {
return;
}
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
showModal(`
<h3>Сравнить с...</h3>
<div class="compare-list">
@ -6669,7 +6744,8 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
mapEl._leafletMap = map;
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
// Build speed lookup for older track
let olderCoords = olderPoints.map(p => [p.lat, p.lon]);
@ -7289,6 +7365,10 @@ async function renderSettings(container) {
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: бег</div></div>
<input id="s-road-color-run" type="color" value="${GPS.config.roadColorRun || '#e67e22'}" style="width:40px;height:28px;border:none;background:none;padding:0">
</div>
<div class="settings-item">
<div class="settings-item-left"><div class="settings-item-title">Толщина дорог по проездам</div><div class="settings-item-desc">Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)</div></div>
<input id="s-roads-thick" type="checkbox" ${localStorage.getItem("baldav_roads_thick") !== "0" ? "checked" : ""}>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Fused-локация (Google Play)</div>
@ -7389,6 +7469,13 @@ async function renderSettings(container) {
<div class="settings-item-desc">Не предлагать включить GPS при старте записи трека</div></div>
<input id="s-dontask-gps" type="checkbox" ${localStorage.getItem("baldav_dontask_gps") === "1" ? "checked" : ""} style="width:20px;height:20px;">
</label>
<div class="settings-item" id="s-strap-usage">
<div class="settings-item-left">
<div class="settings-item-title">Ремень ЧСС: наработка</div>
<div class="settings-item-desc" id="s-strap-usage-desc">—</div>
</div>
<button class="btn-secondary" id="s-strap-reset" style="padding:6px 12px;font-size:13px">Заряжен</button>
</div>
</div>
<div class="settings-section">
@ -7679,6 +7766,8 @@ async function renderSettings(container) {
GPS.config.roadColorBike = container.querySelector("#s-road-color-bike").value;
GPS.config.roadColorWalk = container.querySelector("#s-road-color-walk").value;
GPS.config.roadColorRun = container.querySelector("#s-road-color-run").value;
let roadsThickEl = container.querySelector("#s-roads-thick");
if (roadsThickEl) localStorage.setItem("baldav_roads_thick", roadsThickEl.checked ? "1" : "0");
let trackColorEl = container.querySelector("#s-track-color");
if (trackColorEl) GPS.config.trackColor = trackColorEl.value;
let fusedEl = container.querySelector("#s-use-fused");
@ -7828,6 +7917,33 @@ async function renderSettings(container) {
let dontAskGps = container.querySelector("#s-dontask-gps");
if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} };
// Strap usage readout: total HR-delivering time since last "charged" reset + battery steps,
// so we can judge real battery life against the claimed 300h (the % reading is coarse/laggy).
let strapDesc = container.querySelector("#s-strap-usage-desc");
let fillStrapUsage = async () => {
if (!strapDesc) return;
try {
let raw = await window.__TAURI__.core.invoke("ble_read_strap_uptime");
let u = JSON.parse(raw || "{}");
let sec = Math.round(u.active_sec || 0);
let h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
let bat = (u.bat != null) ? `${u.bat}%` : "?";
let steps = Array.isArray(u.steps) ? u.steps : [];
let since = u.since ? new Date(u.since * 1000).toLocaleDateString("ru-RU") : "—";
let stepStr = steps.length
? " · шаги: " + steps.map(s => `${s.bat}%`).join("->")
: " · шагов заряда нет (ещё 100%?)";
strapDesc.textContent = `заряд ${bat} · в работе ${h}ч ${m}м с ${since}${stepStr}`;
} catch (e) { strapDesc.textContent = "нет данных"; }
};
fillStrapUsage();
let strapReset = container.querySelector("#s-strap-reset");
if (strapReset) strapReset.onclick = async () => {
if (!(await appConfirm("Сбросить счётчик наработки ремня (отметить как заряженный)?", "Сбросить"))) return;
try { await window.__TAURI__.core.invoke("ble_reset_strap_uptime"); } catch {}
fillStrapUsage();
};
let bleScanBtn = container.querySelector("#s-ble-scan");
let bleResults = container.querySelector("#s-ble-results");
if (bleScanBtn) {
@ -8022,6 +8138,13 @@ function showProfileModal() {
<option value="1.725">Высокая (физический труд)</option>
</select>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-tef" type="checkbox" ${(GPS.config.tefFrac ?? 0) > 0 ? "checked" : ""}>
Учитывать переваривание (TEF 10%)
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.</div>
</div>
<div class="form-group">
<label>Норма воды (мл/день)</label>
<input id="m-water-norm" type="number" inputmode="numeric" value="${p.daily_water_ml ?? "2000"}" placeholder="2000" />
@ -8038,6 +8161,8 @@ function showProfileModal() {
await DB.setProfileValue("sex", selectedSex);
let activityLevel = overlay.querySelector("#m-activity-level").value;
await DB.setProfileValue("activity_level", activityLevel);
let tefEl = overlay.querySelector("#m-tef");
if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); }
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
await DB.setProfileValue("daily_water_ml", waterNorm);
@ -8674,7 +8799,8 @@ async function showRoadsMap() {
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);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
addDarkMapControl(map); // dark-tiles toggle on the roads map
POI.attach(map); // POI overlay + category filter
@ -8728,32 +8854,37 @@ async function showRoadsMap() {
return;
}
// Split points into segments by gap (>100m) and activity type
let segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
// Split points into segments by gap (>100m) and activity type. Keep point objects (with
// `passes`) so line thickness can follow how often each stretch was ridden.
let segments = []; // { pts: [{lat,lon,passes}], atype: "b"|"w" }
let seg = [];
let segType = pts[0]?.atype || "b";
for (let i = 0; i < pts.length; i++) {
let newType = pts[i].atype || "b";
if (seg.length > 0) {
let prev = seg[seg.length - 1];
let dlat = (pts[i].lat - prev[0]) * 111000;
let dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
let dlat = (pts[i].lat - prev.lat) * 111000;
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
seg = [];
segType = newType;
}
}
seg.push([pts[i].lat, pts[i].lon]);
seg.push(pts[i]);
}
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
// Draw: bike = blue, walk = brown
// Draw: bike = blue, walk = brown, thickness by pass count. Roads go in their own pane with
// a dedicated canvas renderer so the dark-map CSS filter (body.dark-map .leaflet-roadsPane)
// can invert/shift road colours the same way it does the tiles — keeps them visible on dark.
if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; }
let roadsRenderer = L.canvas({ pane: "roadsPane" });
let drawn = [];
let allCoords = [];
for (let s of segments) {
let color = roadColor(s.atype);
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);
drawRoadSegment(map, s.pts, roadColor(s.atype), drawn, { renderer: roadsRenderer, base: 4, opacity: 1, smoothFactor: 2.5 });
for (let p of s.pts) allCoords.push([p.lat, p.lon]);
}
if (allCoords.length > 0) {

View file

@ -216,5 +216,113 @@ TileCache.importFromSqlite = async function(path, onProgress) {
return imported;
};
// ---- Online tile healer ---------------------------------------------------
// The on-demand loader (createTile) never retries a tile whose fetch failed:
// Leaflet marks it loaded-with-error and won't re-request until the tile is
// re-created (pan out+back / zoom / redraw). So blanks from a bad moment stay
// blank even after the network recovers. The healer fixes that: periodically,
// when OSM is reachable, it re-fetches MISSING tiles (current zoom only) for the
// visible area and — during recording / track review — along the ridden route,
// caches them, and redraws so the holes fill. Respects offline mode. Gentle to
// OSM (one-tile probe, throttled fetches, capped per cycle) per their tile usage
// policy — we never bulk-download.
let _healTimer = null, _healMap = null, _healLayer = null, _healPointsFn = null;
let _healing = false, _healCursor = 0, _onlineHandler = null;
const HEAL_INTERVAL_MS = 30000, HEAL_THROTTLE_MS = 300;
const HEAL_MAX_FETCH = 40; // tiles fetched per cycle (≈1.3/s — modest)
const HEAL_MAX_SCAN = 500; // IndexedDB lookups per cycle (bounds CPU on long routes)
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// One small tile tells us OSM is reachable (navigator.onLine gates first — cheap).
TileCache.probeOsm = async function() {
if (!navigator.onLine) return false;
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), 6000);
const r = await fetch("https://a.tile.openstreetmap.org/0/0/0.png", { cache: "no-store", signal: c.signal });
clearTimeout(t);
return r.ok;
} catch { return false; }
};
function _lonLatToTile(lat, lon, z) {
const n = Math.pow(2, z);
return {
x: Math.floor((lon + 180) / 360 * n),
y: Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n),
};
}
// Tiles covering a polyline at zoom z (+1 ring around each point so the road's
// tiles, not just the exact centre, are covered), de-duplicated.
function _tileKeysForLatLngs(latlngs, z) {
const seen = new Set();
for (const ll of latlngs) {
const lat = ll[0], lon = ll[1];
if (lat == null || lon == null) continue;
const t = _lonLatToTile(lat, lon, z);
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) seen.add(`${z}/${t.x + dx}/${t.y + dy}`);
}
return [...seen];
}
function _tileKeysForBounds(bounds, z) {
const r = getTileRange(bounds, z);
const keys = [];
for (let x = r.xMin; x <= r.xMax; x++) for (let y = r.yMin; y <= r.yMax; y++) keys.push(`${z}/${x}/${y}`);
return keys;
}
async function _healCycle() {
if (_healing || TileCache.offlineMode || !_healMap) return;
_healing = true;
try {
if (!(await TileCache.probeOsm())) return;
const z = _healMap.getZoom();
// Visible tiles first (fills what you actually see), then the ridden route
// from a rotating cursor so a long track trickles across cycles, not in a burst.
let keys = _tileKeysForBounds(_healMap.getBounds(), z);
const pts = _healPointsFn && _healPointsFn();
if (pts && pts.length) {
const routeKeys = _tileKeysForLatLngs(pts, z);
if (_healCursor >= routeKeys.length) _healCursor = 0;
keys = keys.concat(routeKeys.slice(_healCursor), routeKeys.slice(0, _healCursor));
}
let fetched = 0, scanned = 0, advanced = 0;
for (const key of keys) {
if (fetched >= HEAL_MAX_FETCH || scanned >= HEAL_MAX_SCAN) break;
scanned++; advanced++;
if (await getTile(key)) continue; // already cached
try {
const r = await fetch(`https://a.tile.openstreetmap.org/${key}.png`, { cache: "no-store" });
if (r.ok) { await putTile(key, await r.blob()); fetched++; }
} catch { /* leave for a later cycle */ }
await _sleep(HEAL_THROTTLE_MS);
}
if (pts && pts.length) _healCursor += advanced; // continue the route next cycle
if (fetched && _healLayer && _healLayer.redraw) _healLayer.redraw();
} finally { _healing = false; }
}
// Start healing for a map. layer = the tile layer to redraw once holes fill.
// pointsFn() → [[lat,lon],…] of the ridden route (recording / track review), or
// null to heal only the visible area (e.g. the roads screen).
TileCache.startHealer = function(map, layer, pointsFn) {
TileCache.stopHealer();
_healMap = map; _healLayer = layer; _healPointsFn = pointsFn || null; _healCursor = 0;
_healTimer = setInterval(_healCycle, HEAL_INTERVAL_MS);
setTimeout(_healCycle, 3000); // first pass soon after the map opens
_onlineHandler = () => _healCycle(); // and immediately when the link returns
window.addEventListener("online", _onlineHandler);
if (map && map.on) map.on("unload", TileCache.stopHealer);
};
TileCache.stopHealer = function() {
if (_healTimer) { clearInterval(_healTimer); _healTimer = null; }
if (_onlineHandler) { window.removeEventListener("online", _onlineHandler); _onlineHandler = null; }
_healMap = _healLayer = _healPointsFn = null; _healCursor = 0;
};
window.TileCache = TileCache;
console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.496"
version = "0.1.513"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.496"
version = "0.1.513"
edition = "2021"
[lib]

View file

@ -20,6 +20,8 @@ import android.os.ParcelUuid
import android.util.Log
import java.io.File
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
class BleHrService : Service() {
@ -59,6 +61,19 @@ class BleHrService : Service() {
private var gatt: BluetoothGatt? = null
private var deviceType = "hr_standard"
private var dataFile: File? = null
private var logFile: File? = null // append-only HR history (ble_hr_log.jsonl); survives screen-off
private var lastAddress: String? = null // for auto-reconnect of a DROPPED connection
private var connectedOnce = false // a real connection (services discovered) happened — only then do we reconnect
private var stopping = false // user / BT-off asked to stop — never auto-reconnect
private var reconnectTries = 0
private val maxReconnect = 3 // ride out a brief drop, then give up (don't hammer an absent strap)
// Strap usage/battery accounting — persistent across sessions (strap_uptime.json), so we can
// compute real battery life ourselves and see the battery-level steps over time.
private var uptimeFile: File? = null
private var lastReadingMs = 0L // for accruing only time the strap was actually delivering HR
private var pendingActiveMs = 0L
private var lastUptimeFlushMs = 0L
private var lastBatLogged = -1
private var lastBattery: Int = -1 // strap charge %, -1 = unknown
private var scanResultsFile: File? = null
private var fitproWriteChar: BluetoothGattCharacteristic? = null
@ -75,6 +90,7 @@ class BleHrService : Service() {
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")
stopping = true // BT off: do not auto-reconnect
handler.removeCallbacksAndMessages(null)
disconnect()
writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""")
@ -98,6 +114,7 @@ class BleHrService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopping = true // user-initiated stop: do not auto-reconnect
disconnect()
stopSelf()
return START_NOT_STICKY
@ -115,6 +132,10 @@ class BleHrService : Service() {
isRunning = true
val appDataDir = filesDir.parentFile ?: filesDir
dataFile = File(appDataDir, "ble_hr_data.json")
logFile = File(appDataDir, "ble_hr_log.jsonl")
uptimeFile = File(appDataDir, "strap_uptime.json")
// fresh user-initiated connect: arm reconnect only after it actually connects
lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0
connectDevice(address)
}
return START_NOT_STICKY // connect is user-initiated; no sticky restart
@ -247,7 +268,19 @@ class BleHrService : Service() {
Log.d(TAG, "Disconnected")
isRunning = false
writeData("""{"status":"disconnected","ts":${System.currentTimeMillis()/1000}}""")
stopSelf()
try { g.close() } catch (e: Exception) { Log.e(TAG, "gatt close on drop: ${e.message}") }
gatt = null
// Reconnect ONLY a connection that was really established (connectedOnce) and that the
// user didn't stop. The reconnect goes through connectDevice's scan — if the strap is
// gone it won't be found and we stopSelf (so we never hammer an absent/never-present band).
if (!stopping && connectedOnce && reconnectTries < maxReconnect && adapter()?.isEnabled == true && lastAddress != null) {
reconnectTries++
Log.d(TAG, "Auto-reconnect $reconnectTries/$maxReconnect in 8s")
updateNotification("Переподключение...")
handler.postDelayed({ if (!stopping) connectDevice(lastAddress!!) }, 8000)
} else {
stopSelf()
}
}
}
@ -266,6 +299,8 @@ class BleHrService : Service() {
Log.d(TAG, "Auto-detected device type: $deviceType")
}
Log.d(TAG, "Services discovered, setting up for $deviceType")
connectedOnce = true; reconnectTries = 0 // real connection established — arm/refresh reconnect budget
isRunning = true
when (deviceType) {
"hr_standard" -> setupStandardHr(g)
"fitpro" -> setupFitPro(g)
@ -498,10 +533,53 @@ class BleHrService : Service() {
} catch (e: Exception) {
Log.e(TAG, "Write data failed", e)
}
// Append real HR readings (not status lines) to the history log. This runs in the
// foreground service, so it keeps recording with the screen off — the workout
// recorder rebuilds its samples from here (ble_read_hr_log) instead of the JS poll.
if (json.contains("\"hr\":")) {
try { logFile?.appendText(json + "\n") } catch (e: Exception) { Log.e(TAG, "Append hr log failed", e) }
accrueUptime()
}
}
// Accrue strap "actually delivering HR" time + battery-level steps into strap_uptime.json
// (persistent — NOT cleared per session). Flushed every ~20s (read-modify-write, small file).
private fun accrueUptime() {
val now = System.currentTimeMillis()
val dt = now - lastReadingMs
if (lastReadingMs > 0L && dt in 1..10000) pendingActiveMs += dt // only count gap-free stretches
lastReadingMs = now
if (now - lastUptimeFlushMs < 20000) return
writeUptimeFile()
}
// Persist accrued active time + battery step to strap_uptime.json (read-modify-write).
private fun writeUptimeFile() {
val now = System.currentTimeMillis()
lastUptimeFlushMs = now
val f = uptimeFile ?: return
try {
val obj = if (f.exists() && f.length() > 0) JSONObject(f.readText()) else JSONObject()
obj.put("active_sec", obj.optDouble("active_sec", 0.0) + pendingActiveMs / 1000.0)
if (!obj.has("since")) obj.put("since", now / 1000)
val bat = lastBattery
if (bat in 0..100) {
obj.put("bat", bat)
if (bat != lastBatLogged) {
val steps = obj.optJSONArray("steps") ?: JSONArray()
steps.put(JSONObject().put("ts", now / 1000).put("bat", bat))
obj.put("steps", steps)
lastBatLogged = bat
}
}
f.writeText(obj.toString())
pendingActiveMs = 0L
} catch (e: Exception) { Log.e(TAG, "uptime flush: ${e.message}") }
}
private fun disconnect() {
isRunning = false
try { writeUptimeFile() } catch (e: Exception) {} // persist the last accrued strap time
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}") }
@ -536,6 +614,7 @@ class BleHrService : Service() {
}
override fun onDestroy() {
stopping = true
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
disconnect()
super.onDestroy()

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -465,6 +465,44 @@ pub fn ble_get_hr(
{ android::read_hr_data(&app) }
}
// Full HR history for the current workout. The Kotlin foreground service appends every
// reading to ble_hr_log.jsonl (one {"hr","ts"[,"bat"]} per line) — that keeps logging with
// the screen off, unlike the JS poll which Android freezes in the background. The workout
// recorder reads this at finalize to rebuild a gap-free sample series. Returns "" if none.
#[tauri::command]
pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok(String::new()) }
#[cfg(target_os = "android")]
{ Ok(android::read_file(&app, "ble_hr_log.jsonl").unwrap_or_default()) }
}
// Strap usage accounting (strap_uptime.json): total time the strap actually delivered HR plus
// battery-level steps, accumulated by the foreground service across sessions (persistent — so we
// can compute real battery life ourselves). Returns "{}" if none yet.
#[tauri::command]
pub fn ble_read_strap_uptime(app: tauri::AppHandle) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok("{}".to_string()) }
#[cfg(target_os = "android")]
{ Ok(android::read_file(&app, "strap_uptime.json").unwrap_or_else(|| "{}".to_string())) }
}
// Reset the usage counter — call when the strap is put on charge / newly charged.
#[tauri::command]
pub fn ble_reset_strap_uptime(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
let fresh = format!("{{\"active_sec\":0,\"since\":{},\"steps\":[]}}", now);
let _ = std::fs::write(data_dir.join("strap_uptime.json"), &fresh);
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
let _ = std::fs::write(root.join("strap_uptime.json"), &fresh);
}
Ok(())
}
#[tauri::command]
pub fn ble_is_connected(
app: tauri::AppHandle,
@ -488,10 +526,14 @@ pub fn ble_is_connected(
pub fn ble_clear_hr(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
// Clear HR data file so stale values don't show on next session
// Clear HR data file so stale values don't show on next session, and truncate the
// native HR history log (the foreground service appends every reading there — see
// ble_read_hr_log) so a new workout starts from an empty log.
let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}");
let _ = std::fs::write(data_dir.join("ble_hr_log.jsonl"), "");
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
let _ = std::fs::write(root.join("ble_hr_data.json"), "{}");
let _ = std::fs::write(root.join("ble_hr_log.jsonl"), "");
}
#[cfg(not(target_os = "android"))]
{

View file

@ -265,59 +265,72 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
db.execute("DROP TABLE IF EXISTS road_points", []).ok();
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT)", [])
// `passes` = how many distinct times this ~2 m stretch was ridden (used for line thickness).
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT, passes INTEGER NOT NULL DEFAULT 1)", [])
.map_err(|e| format!("create: {e}"))?;
db.execute("CREATE INDEX idx_road_lat_lon ON road_points(lat, lon)", [])
.map_err(|e| format!("index: {e}"))?;
// 2m in degrees: lat ~0.000018, lon ~0.000027 at 47N
// ~2 m cell in degrees at 47N: lat ~0.000018, lon ~0.000027.
let d_lat: f64 = 0.000018;
let d_lon: f64 = 0.000027;
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
// activity — so pushed-across-a-field bits don't become a rideable road.
// activity — so pushed-across-a-field bits don't become a rideable road. session_id is
// read too, so a cell re-entered in a later session counts as another pass.
let mut read_stmt = db.prepare(
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END, p.session_id
FROM gps_points p
JOIN gps_sessions s ON s.id = p.session_id
WHERE p.speed_ms >= ?1
ORDER BY p.session_id, p.seq"
).map_err(|e| format!("prepare read: {e}"))?;
let points: Vec<(f64, f64, String)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default()))
let points: Vec<(f64, f64, String, i64)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default(), row.get::<_, i64>(3).unwrap_or(-1)))
}).map_err(|e| format!("query: {e}"))?
.filter_map(|r| r.ok())
.collect();
drop(read_stmt);
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
let total = points.len();
let _ = app.emit("roads_progress", (0u64, total as u64)); // start
let mut inserted = 0u32;
for (i, (lat, lon, atype)) in points.iter().enumerate() {
// Progress heartbeat every ~2000 points (JS fills the Rebuild button by %).
if i % 2000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM road_points WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4)",
rusqlite::params![lat - d_lat, lat + d_lat, lon - d_lon, lon + d_lon],
|r| r.get(0),
).unwrap_or(false);
if !exists {
db.execute(
"INSERT INTO road_points (lat, lon, atype) VALUES (?1, ?2, ?3)",
rusqlite::params![lat, lon, atype],
).map_err(|e| format!("insert: {e}"))?;
inserted += 1;
// Glue + count passes in memory: one cell per ~2 m grid square, first-seen geometry kept,
// `passes` incremented once per ENTRY into the cell (not per point, so a slow crawl that
// drops many points in one cell still counts as a single pass). Leaving and re-entering
// the cell — or a new session — is a fresh entry. Much faster than a per-point SQL EXISTS.
use std::collections::HashMap;
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
let mut cells: Vec<(f64, f64, String, u32)> = Vec::new(); // lat, lon, atype, passes
let mut prev_key: Option<(i64, i64)> = None;
let mut prev_session: i64 = i64::MIN;
for (i, (lat, lon, atype, session)) in points.iter().enumerate() {
if i % 20000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
let key = ((lat / d_lat).floor() as i64, (lon / d_lon).floor() as i64);
let entry = prev_key != Some(key) || *session != prev_session;
match index.get(&key) {
Some(&idx) => { if entry { cells[idx].3 += 1; } }
None => { index.insert(key, cells.len()); cells.push((*lat, *lon, atype.clone(), 1)); }
}
prev_key = Some(key);
prev_session = *session;
}
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
{
let mut ins = db.prepare("INSERT INTO road_points (lat, lon, atype, passes) VALUES (?1, ?2, ?3, ?4)")
.map_err(|e| format!("prepare insert: {e}"))?;
for (lat, lon, atype, passes) in cells.iter() {
ins.execute(rusqlite::params![lat, lon, atype, passes])
.map_err(|e| format!("insert: {e}"))?;
}
}
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
let inserted = cells.len();
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", total, inserted))
}
// Read road_points in batches — bypasses Tauri SQL plugin's ~28K row limit
@ -337,20 +350,28 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul
let total: u32 = db.query_row(
"SELECT count(*) FROM road_points", [], |r| r.get(0)
).unwrap_or(0);
let mut stmt = db.prepare(
"SELECT lat, lon, COALESCE(atype,'bike') FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
).map_err(|e| format!("prepare: {e}"))?;
// `passes` column exists only after a rebuild on this build; tolerate older DBs (passes=1).
let has_passes: bool = db.query_row(
"SELECT COUNT(*) FROM pragma_table_info('road_points') WHERE name='passes'", [], |r| r.get::<_, i64>(0)
).map(|n| n > 0).unwrap_or(false);
let sql = if has_passes {
"SELECT lat, lon, COALESCE(atype,'bike'), COALESCE(passes,1) FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
} else {
"SELECT lat, lon, COALESCE(atype,'bike'), 1 FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
};
let mut stmt = db.prepare(sql).map_err(|e| format!("prepare: {e}"))?;
let mut points = Vec::new();
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
let lat: f64 = row.get(0)?;
let lon: f64 = row.get(1)?;
let atype: String = row.get(2)?;
Ok((lat, lon, atype))
let passes: i64 = row.get(3)?;
Ok((lat, lon, atype, passes))
}).map_err(|e| format!("query: {e}"))?;
for row in rows {
let (lat, lon, atype) = row.map_err(|e| format!("row: {e}"))?;
let (lat, lon, atype, passes) = row.map_err(|e| format!("row: {e}"))?;
let t = if atype == "walk" { "w" } else if atype == "run" { "r" } else { "b" };
points.push(format!("[{:.7},{:.7},\"{}\"]", lat, lon, t));
points.push(format!("[{:.7},{:.7},\"{}\",{}]", lat, lon, t, passes));
}
let has_more = offset + batch_size < total;
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))
@ -1162,7 +1183,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, 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])
.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_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.496",
"version": "0.1.513",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"