baldav/PROJECT_MAP.md
4aiman 9a8ef04230 Hydration: water intake from the food log, drop the "+вода" button
Intake is what you already log (liquids count via water_pct), so there is
nothing to tap mid-ride and no guessing an amount. At session start JS seeds
the native deficit from the food log — need-so-far for the time of day minus
logged intake — via set_hydration_config(start_deficit_ml); the service then
accrues sweat on top. Removes the +вода button, hydration_drink and water_drink.txt.
2026-10-24 01:29:30 +03:00

282 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# PROJECT MAP — BalDav
Трекер здоровья и активности. Pure JS + Tauri 2 + SQLite. Без фреймворков и бандлеров.
## Статистика
| Метрика | Значение |
|---------|----------|
| Строк кода | ~22000 |
| Файлов исходников | 50+ |
| Язык | JS 70%, Rust 6%, Kotlin 12%, CSS 12% |
| Таблиц в БД | 21 |
| Экранов | 7 основных + 15 модалок |
| IPC команд (Tauri) | 14 |
| Библиотеки | Leaflet, Chart.js, Hammer.js, JSZip |
| Платформы | Android (Kotlin), Desktop (Tauri) |
## Структура файлов
### Frontend (`frontend/`)
| Файл | Строк | Назначение |
|------|-------|------------|
| `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`. `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` | ~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 нутриента), полураспад |
| `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим |
| `index.html` | 28 | Точка входа, порядок загрузки скриптов |
| `img/gps-placeholder.png` | 63KB | Пиксельарт заглушка при поиске GPS |
| `data/products_seed.db` | ~9MB | 13008 продуктов с меткой источника (magnit 7690, 5ka 3354, ion 1079, kdv 667, gridnev 217, manual 1). Импорт без плашки (HD/ashan/фастфуд) вычищен, seed_meta.version=11. Поиск — `searchProducts` порядко-независимый (AND по токенам). Чистка каталога: `cleanupUnlabeledProducts` (неиспользованные non-keep) |
**Библиотеки** (`lib/`): leaflet.js (1.9.4), leaflet-rotate.js (компас), chart.min.js, chartjs-plugin-zoom.min.js, hammer.min.js, jszip.min.js, zxing.js (@zxing/library UMD — офлайн-декодер DataMatrix для ЧЗ-сканера; нативный `BarcodeDetector` требует Google Play Services и падает "Barcode detection service unavailable" на de-Googled ROM)
### Бэкенд Rust (`src-tauri/`)
| Файл | Строк | Назначение |
|------|-------|------------|
| `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` | ~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` | — | Конфиг приложения, плагины, ресурсы |
### Android Kotlin (`src-tauri/gen/android/.../dev/baldav/app/`)
| Файл | Строк | Назначение |
|------|-------|------------|
| `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. Тикает `Hydration` каждые 10с |
| `Hydration.kt` | ~110 | Уровневое напоминание пить (синглтон на процесс, дедуп GPS+HR сервисов). `reset()` при старте сессии -> `tick()` сидит стартовый дефицит `startDeficitMl` (JS считает из лога: норма ко времени минус выпитое) + копит пот (`sweatMlPerHour`); сигнал (ALARM-канал) + вибро + уведомление при дефиците >= `alertMl`, повтор `reAlertMin`. Конфиг `hydration.json`. Вода - из залогированной жидкости, без кнопки. Rust: `set_hydration_config`. JS: `seedHydrationDeficit()` на старте записи |
| `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/`)
| Скрипт | Назначение |
|--------|------------|
| `5ka_scraper.user.js` | Tampermonkey скрейпер 5ka.ru (BFS категорий, Load JSON) |
| `magnit_scraper.user.js` | Tampermonkey скрейпер magnit.ru (NUXT_DATA парсинг) |
| `ion_scraper.user.js` | Tampermonkey скрейпер web.ion.ru (fetch, без перезагрузок) |
| `import_ion.js` | Импорт ION продуктов в seed DB |
| `import_with_dedup.js` | Импорт с дедупликацией по имени |
| `enrich_nutrients.js` | Обогащение микронутриентами из ION |
| `parse_magnit.js` | Парсер HTML каталога Магнит |
| `parse_kdv.js` | Парсер HTML каталога KDV |
| `download_tiles.js` | Скачивание тайлов карт для оффлайна |
| `pack_tiles.js` | Упаковка PNG тайлов региона в SQLite для импорта |
| `healthdiet_scraper.user.js` | Tampermonkey скрейпер health-diet.ru (КБЖУ + 37 нутриентов) |
| `import_healthdiet.js` | Импорт health-diet.ru JSON в seed DB (авто-бамп версии) |
| `fetch_openfoodfacts.js` | OpenFoodFacts API |
| `fuzzy_dedup.js` | Нечёткая дедупликация |
| `steep_segments.js` | Крутые участки вело-треков → GPX + brouter lonlats. Тянет `baldav.db` (run-as), уточняет высоты online (`--gpxz` Copernicus GLO-30 по ключу, дедуп по ~33м ячейкам + кэш; иначе open-elevation), сглаживание по дистанции, уклон на базе (`120`) с барьером GPS-гэпов, окно-ядро steepest + пространственный дедуп мест. Флаги: `--gpxz/--local/--gps`, `--min= --drop= --dry` |
| `chzn_export.sh` | Экспорт анонимной сессии приложения «Честный знак» с рут-девайса (adb) → `chzn_session.json` для импорта в BalDav. Читает `com.qrbonus.xml`, проверяет живым `refresh` |
### Корень
| Файл | Назначение |
|------|------------|
| `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 (сырые данные) |
### Разборы (docs/)
| Файл | Назначение |
|------|------------|
| `docs/gps-model-findings.md` | Находки по расчётной модели GPS (Crr/ккал/высота/ветер) — часть внедрена, см. лог 2026-09-07 |
| `docs/brouter-findings.md` | Разбор BRouter 1.6.3 (оффлайн-роутинг): данные `.rd5`, профили, малый набор высоты, 3 способа вызова + встраивание; под идеи «заполнение GPS-пропусков» и «оффлайн-планирование по точкам». Рекомендация — встраивание движка (jar 356 КБ) в Android-модуль |
## База данных
### Таблицы (Rust v1 миграция + JS миграции)
| Таблица | Назначение | Ключевые поля |
|---------|------------|---------------|
| `profile` | Key-value настройки | key, value |
| `products` | Продукты (13480) | name, kcal/protein/fat/carbs per 100g, витамины, минералы, nutrients_guessed |
| `food_log` | Дневник еды | date, time, product_id/recipe_id, amount_g, unit_id, unit_amount, ккал/БЖУ |
| `recipes` | Рецепты | name, cooking_method_id, servings, notes |
| `recipe_ingredients` | Ингредиенты рецептов | recipe_id, product_id, custom_name, amount_g, cooking_method_id |
| `cooking_methods` | Способы готовки (6 шт) | name, потери витаминов/БЖУ в % |
| `serving_units` | Единицы измерения (5 шт) | name (шт/ст.л./ч.л./стакан/мл), grams_equivalent |
| `product_units` | Привязка единиц к продуктам | product_id, unit_id, grams_per_unit |
| `weight_log` | Лог веса | date, time, weight_kg |
| `sleep_log` | Лог сна | date, bed/asleep/wake time, nap_minutes |
| `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 (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 |
| `rdi_standards` | Стандарты РСП (RU/EU/US) | code, name |
| `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), passes (число проездов ячейки, для толщины линии) |
### Seed DB (products_seed.db)
| Источник | Продуктов | Микронутриенты |
|----------|-----------|----------------|
| magnit | 8627 | нет |
| 5ka | 3103 | нет |
| ION | 1078 | витамины + минералы |
| kdv | 672 | нет |
| **Всего** | **13480** | 1398 обогащены (nutrients_guessed) |
## Экраны и навигация
### Нижний навбар (5 табов)
```
[Сегодня] [Продукты] [Трек] [Добавки] [Настройки]
```
### Экраны
- **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы)
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали), тоггл следования карты за позицией (`followMode`, кнопка `⊕` в `rec-map-btn` стеке; guard вокруг `panTo`/`setView` в `onPoint`/`onBatchUpdate`/`onStateChange`; выкл = свободный просмотр карты на ходу, повторный тап центрирует; не запоминается). Карты с тяжёлыми векторами (`showSessionDetail`, `showRoadsMap`, `showComparisonView`, `showRouteDetail`) создаются с `preferCanvas: true` — большой трек/дороги в дефолтном SVG превышают лимит памяти тайлов компоновщика Chromium на зуме (`tile_manager tile memory limits exceeded`) и тайлы гаснут до перезапуска; canvas рисует в один холст. НЕ на карте записи (leaflet-rotate плохо дружит с Canvas) и не в редакторе маршрута (клик по линии)
- **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10)
- **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог
### Модалки
- Еда: showFoodModal, showEditFoodModal, showFoodFromRecipeModal
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
- Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save)
- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция). Склейка блокируется, если у выбранных треков разные `bike_id` (appAlert)
- Разрезание трека: кнопка `#sd-split` в `showSessionDetail` включает `#sd-map.split-mode`; в попапе каждой точки кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` (db.js): вторая половина (точки по id, чанки) в НОВУЮ сессию (id=Date.now(), копия date/activity/bike/source), `seq=NULL`, у старой правится end_time; пересчёт обоих кусков. Велик копируется, потом меняется вручную
- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`)
- Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
## IPC команды (Rust → JS)
| Команда | Назначение |
|---------|------------|
| `import_seed_products` | Импорт продуктов из seed DB, прогресс-события |
| `get_safe_insets` | Отступы Android status/nav bar |
| `gps_service_command` | start/stop GPS + BLE команды (ble_scan/connect/disconnect) |
| `gps_service_read_points` | Чтение активного JSONL файла точек (через `native_root`) |
| `gps_set_session_id` | Передать id сессии сервису (пишет `gps_session.txt` → сервис дописывает `_s<id>` в имя файла) |
| `list_points_files` / `read_points_file` / `delete_points_file` | Список/чтение/удаление нативных `gps_points_*` (восстановление + чистка) |
| `get_points_filename` | Имя активного файла точек (по указателю `gps_live_points.jsonl`) |
| `debug_native_paths` | Диагностика путей нативных файлов |
| `save_file` | SAF диалог сохранения (GPX экспорт) |
| `get_app_info` | Версия, git hash, дата сборки, `debug` (cfg!(debug_assertions) — для глушения диагностики в release) |
| `get_update_info` | Проверка OTA обновления (BUILD_NUMBER с VPS) |
| `ble_scan` | Сканирование BLE устройств |
| `ble_scan_results` | Результаты скана (Android) |
| `ble_connect` | Подключение к BLE устройству |
| `ble_disconnect` | Отключение BLE |
| `ble_get_hr` | Последние данные HR/SpO2/BP |
| `ble_is_connected` | Статус подключения |
| `ble_check_bluetooth` | Состояние Bluetooth адаптера |
| `ble_save_device` | Сохранение привязанного устройства |
| `import_tiles` | Чтение тайлового SQLite батчами (base64) |
| `download_file` | Скачивание бинарного файла по URL (без лимита размера) |
| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) |
| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД |
| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) |
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп в памяти по сетке; пеший point (`walk=1`) → atype 'walk'; считает passes = входы в ячейку = число проездов) |
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |
## Формула ккал (GPS)
### Велосипед — НЕТТО-модель (суточный баланс = 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 из СГЛАЖЕННОЙ высоты
Crr (ручной surface_type): 0.004 асфальт / 0.006 смешанный / 0.008 грунт
Crr по акселерометру (crrFromAccel, если есть accel_rms >10 точек): <3=0.004, 3-7=0.006,
7-11=0.008, 11-15=0.012 (плотный грунт), >15=0.020 (трава/колея/щебень). Прежний потолок 0.008
занижал грунты ~3x (замер: трава требует 0.020-0.031). Мапится и медиана сессии, и каждая точка (ptCrr).
CdA = 0.6, rho = 1.225, efficiency = 0.25
Педалирование (power>0): kcal = max(физика, MET-нетто), физика = power*dt/(0.25*4184)
MET по скорости: <16км/ч=6.0, <19=6.8, <22=8.0, <25=10.0, <28=12.0, иначе 14.0
MET-нетто = (dt/60)*((MET-1)*3.5*weight)/200 (−1 MET покоя, он уже в BMR)
Floor только при v≥5км/ч (ниже — дрожание GPS, не езда)
ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ (по решению не правим, нужна калибровка): max() не СКЛАДЫВАЕТ —
на подъёмах физика выигрывает, а накладные (баланс/стабилизация корпуса, без внешней работы,
их ловит MET-этаж) теряются. Направление ошибки известно: подъёмы недосчитаны (по поездке ~1.34x
MET/физика, но равнина 1.85x, подъём 0.41x). Честный фикс = физика/КПД + накладные, отложен.
Накат / freewheel (power≤0, «велик катился бы сам»): kcal = базальный метаболизм
(детектор кручу/накат по IMU-каденсу проверен на 50Hz — сигнал слишком слаб, не используется; хук cadence_rpm — под BLE-датчик шатуна)
Набор высоты: кумулятивный гистерезис (elevRef, порог 1м) — на плотных треках per-point разница <1м даёт 0
ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ (по решению не правим): абсолютный набор по сырому GPS-альтиметру расходится
с независимым пересчётом в ОБЕ стороны (медиана разброса высоты между проездами одной точки ~10м).
Без модели рельефа (DEM) не разрешается. Реальный фикс — предзагрузка DEM по району дома по проводу
(кнопка «уточнить высоты» → GPXZ работает, но требует сети, а РЭБ/РКН глушат в поле). Пока: набор
имеет точность порядка десятков процентов, на расчёт мощности влияет слабо (там уклон по окну, ограничен сверху).
```
### Дистанция по сегментам (computeStats, gps.js ~189-235)
```
segMaxMs = maxSpeedKmh/3.6 (вело) или walkMaxSpeedKmh/3.6 (пеш); gapThr = config.gapThresholdSec (30)
шип (dt<=gapThr И v>segMaxMs) → сегмент не считать (телепорт без разрыва по времени)
разрыв(dt>gapThr) → segDist = min(avg(speed)*dt, segMaxMs*dt); если хорда<20м → берём хорду (стоял, скорость залипла)
норма (иначе) → segDist = хорда (haversine)
distance += segDist; высота/maxSpeed — только на «норме»; ккал в разрыве/estimated = walk-MET по времени
Зависит только от ts+speed (не от флага estimated) → live == «Стоп» == пересчёт. gapThreshold настраивается, применяется при пересчёте.
Ручная пауза (opts.pauses = [{startTs,endTs}] мс): сегмент, пересекающий паузу (prevMs<endTs И pMs>startTs),
исключается ЦЕЛИКОМ — ни дистанции, ни высоты, ни ккал, ни времени (pausedSec вычитается из duration), elevRef=null
(перепад, набранный пешком за паузу, не банкуется на первом сегменте после резюма). Авто-GPS-гэп (без паузы) — считается как раньше.
Глюк-гард высоты: соседний сглаженный перепад с грейдом > maxRealGradePct (1.0 = 100% = 45°) не банкуется и не двигает elevRef
(скачок/ступенька, в т.ч. Android no-fix 0.0). Плавный приход к любому значению (реальный 0 у реки) проходит.
Kotlin пишет "alt":null без фикса высоты (hasAltitude()), честный 0 с фиксом сохраняется. Паузы: колонка gps_sessions.pauses (JSON).
Пеший пуш велосипеда (только односкоростной, gear_count 1 или пусто + есть звезда/трещотка/колесо):
на фиксированной передаче каденс <30 об/мин = велик не едет, его ведут. Порог скорости walkBikeMs = 30*wheelCirc*gearRatio/60.
Сегмент медленнее порога (нормальный фикс, не гэп) исключается из ездовой дистанции/скорости/высоты (elevRef=null — пеший
подъём не склеивается с ездовым), усилие считается как ходьба (walk-MET нетто), не по физике. #7: исключается КАЖДАЯ такая точка
без оглядки на длительность (краткие торможения тоже, безвредно ~0м); в отчёт как «пешком» идут только сплошные пробеги ≥30с.
Возврат computeStats: walkBikeSec (время пуша ≥30с-пробегами), walkBikeMeters (пешая дистанция). opts.gearCount прокинут во все вызовы.
Метки ветра (разметка, НЕ расчёт): кнопка «Ветер» на экране записи — тап открывает интервал встречного ветра,
тап закрывает. GPS.toggleWindMark()/_closeOpenWindMark(); GPS.recording.windMarks=[{startTs,endTs}] мс, персист в localStorage,
запись в БД (DB.setSessionWindMarks) на финише + восстановление в recovery. Колонка gps_sessions.wind_marks (JSON). Это ground
truth для проверки оценки ветра по треку (tools/track/track.py, wind_by_passes) — на distance/kcal/power НЕ влияет. Голос — потом.
```
### Ходьба/бег — MET-таблица
```
<4 км/ч = 2.5 MET, 4-5.5 = 3.5, 5.5-7 = 5.0, 7-9 = 8.0, 9-12 = 10.0, 12-16 = 12.5, 16+ = 15.0
kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200
```
## Сборка
```bash
./build.sh -d -i android # debug APK + установка на телефон (для телефона всегда -d)
./build.sh android # release APK (подпись debug.keystore)
./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA)
./build.sh install -d # поставить уже собранный APK без пересборки
./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает)
./build.sh 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 при следующей сборке.
## Отладка WebView (debug-сборки)
Debug-APK инспектируется через Chrome DevTools Protocol — можно смотреть DOM/состояние и выполнять JS прямо на телефоне:
```bash
PID=$(adb shell pidof dev.baldav.app)
adb forward tcp:9222 localabstract:webview_devtools_remote_$PID
curl -s http://127.0.0.1:9222/json/list # webSocketDebuggerUrl страницы
```
`test_recovery/cdp.js` (gitignored) — минимальный CDP-клиент: `node cdp.js "<ws-url>" "<js-expr>"` (Runtime.evaluate, awaitPromise). Так был найден баг зависания `showSessionDetail` на 1-точечном треке (delete не привязывался — исполнение висело на `await whenMapReady` без вида карты).