baldav/PROJECT_MAP.md
4aiman 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

40 KiB
Raw Blame History

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. 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 чистит и его
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
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-массива
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, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо»)
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)

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')
get_road_points Чтение road_points батчами (обход лимита SQL plugin)
get_ride_graph Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая walk=1), рёбра между последовательными точками сессии (вес = между узлами). {nodes,edges}

Формула ккал (GPS)

Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя)

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

Сборка

./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 прямо на телефоне:

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 без вида карты).