From d4dda3da0c7dc64a99e9e826dd521fb0eebacd59 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 19:26:20 +0300 Subject: [PATCH 01/13] 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. --- README.md | 2 +- .../2026-10-19_dark-roads-and-tile-diag.md | 25 +++++++++++++++++++ frontend/css/styles.css | 8 ++++-- frontend/js/main.js | 8 ++++-- 4 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 docs/session-logs/2026-10-19_dark-roads-and-tile-diag.md diff --git a/README.md b/README.md index 5f924cd..b180306 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`
`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. - **Де-джиттер трека** — сглаживание дрожащего 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). diff --git a/docs/session-logs/2026-10-19_dark-roads-and-tile-diag.md b/docs/session-logs/2026-10-19_dark-roads-and-tile-diag.md new file mode 100644 index 0000000..0a38ec9 --- /dev/null +++ b/docs/session-logs/2026-10-19_dark-roads-and-tile-diag.md @@ -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 и статус). Результат - отдельно. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index fbc4569..c229785 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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; } diff --git a/frontend/js/main.js b/frontend/js/main.js index 9b83673..18459bb 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -8748,11 +8748,15 @@ async function showRoadsMap() { } if (seg.length >= 2) segments.push({ coords: seg, atype: segType }); - // Draw: bike = blue, walk = brown + // Draw: bike = blue, walk = brown. 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 the dark map. + if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; } + let roadsRenderer = L.canvas({ pane: "roadsPane" }); 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 + L.polyline(s.coords, { color, weight: 4, opacity: 1, smoothFactor: 2.5, renderer: roadsRenderer }).addTo(map); // px-space simplify: fewer points zoomed out allCoords.push(...s.coords); } From 3f092cced04129521e918dc26df545fc6c6b933f Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 20:07:05 +0300 Subject: [PATCH 02/13] 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. --- PROJECT_MAP.md | 2 +- README.md | 2 +- docs/session-logs/2026-10-19_tile-healer.md | 27 +++++ frontend/js/main.js | 15 ++- frontend/js/tilecache.js | 108 ++++++++++++++++++++ 5 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 docs/session-logs/2026-10-19_tile-healer.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 006451f..1c0208b 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -26,7 +26,7 @@ | `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает ``, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | | `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) | -| `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 нутриента), полураспад | diff --git a/README.md b/README.md index b180306..2d17a55 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ## Возможности - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). -- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). +- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). Онлайн-долечивание пустых тайлов: раз в 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) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». diff --git a/docs/session-logs/2026-10-19_tile-healer.md b/docs/session-logs/2026-10-19_tile-healer.md new file mode 100644 index 0000000..22a796f --- /dev/null +++ b/docs/session-logs/2026-10-19_tile-healer.md @@ -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 ограничена троттлингом. Живую проверку делает пользователь. diff --git a/frontend/js/main.js b/frontend/js/main.js index 18459bb..0f7b96f 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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; @@ -4632,7 +4633,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); @@ -5620,7 +5623,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 @@ -6669,7 +6674,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]); @@ -8674,7 +8680,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 diff --git a/frontend/js/tilecache.js b/frontend/js/tilecache.js index 69ec187..a20b181 100644 --- a/frontend/js/tilecache.js +++ b/frontend/js/tilecache.js @@ -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); From 1d133d0067d66e73a1e018c6119161161d8b79db Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 22:47:44 +0300 Subject: [PATCH 03/13] 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. --- PROJECT_MAP.md | 5 +++- README.md | 11 ++++++++ .../2026-10-19_router-upload-all-os.md | 26 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 docs/session-logs/2026-10-19_router-upload-all-os.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 1c0208b..7556775 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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 APK на роутер+VPS, `upload` всех ОС на роутер (`upload_router`), `clean`/авто-чистка старых бандлов (`clean_bundles`) | | `BUILD_NUMBER` | Автоинкремент номера сборки | | `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) | | `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) | @@ -261,6 +261,9 @@ 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 release # все ПК +./build.sh upload # release APK + все ПК, один номер, -> keenetic:/opt/share/www/baldav ``` Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK. `clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке. diff --git a/README.md b/README.md index 2d17a55..84fa5c5 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,19 @@ ./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 release # linux + windows + macos +./build.sh upload # release APK + все ПК под одним номером -> роутер ``` +`upload` кладёт в `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_NUMBER` пишется последним. Если хоть +одна платформа не собралась - не выкладывается ничего. Кросс-сборка под macOS берёт osxcross из +`$OSXCROSS` (по умолчанию `/mnt/02F6E7F6F6E7E7BD/osxcross`, SDK 12.3 из fpcupdeluxe). + ## Версия и трассируемость сборки Чтобы по розданному APK однозначно находить, где регресс/косяк: diff --git a/docs/session-logs/2026-10-19_router-upload-all-os.md b/docs/session-logs/2026-10-19_router-upload-all-os.md new file mode 100644 index 0000000..397db79 --- /dev/null +++ b/docs/session-logs/2026-10-19_router-upload-all-os.md @@ -0,0 +1,26 @@ +# 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, при повторной упаковке собрался; причина + прошлого отсутствия не выяснена. + +## Результат +См. раздел «Проверка» ниже (дополняется по ходу). + +## Проверка + +## Следующие шаги From f2d39459e6a9d11a48ac5486aade131be2bdb854 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 23:05:54 +0300 Subject: [PATCH 04/13] 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. --- PROJECT_MAP.md | 8 +++++--- README.md | 21 ++++++++++++++------- src-tauri/icons/icon.ico | Bin 0 -> 99678 bytes 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src-tauri/icons/icon.ico diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 7556775..282944f 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -81,7 +81,7 @@ | Файл | Назначение | |------|------------| -| `build.sh` | Сборка: android/debug/release, ПК linux/windows/macos (кросс), sync frontend, версионирование (один бамп за запуск), -u upload APK на роутер+VPS, `upload` всех ОС на роутер (`upload_router`), `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 (сырые данные) | @@ -262,9 +262,11 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200 ./build.sh install -d # поставить уже собранный APK без пересборки ./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает) ./build.sh linux|windows|macos # ПК под одну ОС (windows - mingw, macos - osxcross, оба кросс) -./build.sh release # все ПК -./build.sh upload # release APK + все ПК, один номер, -> keenetic:/opt/share/www/baldav +./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 при следующей сборке. diff --git a/README.md b/README.md index 84fa5c5..8f49096 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,22 @@ ./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 release # linux + windows + macos -./build.sh upload # release APK + все ПК под одним номером -> роутер +./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 # любая комбинация целей и флагов ``` -`upload` кладёт в `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_NUMBER` пишется последним. Если хоть -одна платформа не собралась - не выкладывается ничего. Кросс-сборка под macOS берёт osxcross из -`$OSXCROSS` (по умолчанию `/mnt/02F6E7F6F6E7E7BD/osxcross`, SDK 12.3 из fpcupdeluxe). +Цели и флаги комбинируются как угодно; за запуск номер сборки растёт один раз и общий для всех +целей. `-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 его пропускает. ## Версия и трассируемость сборки diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5525e1cf4714ef4a497d1acbb3c9ae8490f03afd GIT binary patch literal 99678 zcmeI5dr(yO702)LlJ`?lK@b%b2}A}J1rdoNDk>%jBxpe~Drgk2DxoU4KEOw8nxv+h z=wwnyBk|G1#7DGR&9t5L57Rcyw)sxOAmcL)M|D2HE126yvzyKHk z17H9QfB`T72EYIq00UqE41fVJ00zJS7ytuc01SWuFaQR?02lxRU;qq&0WbgtzyKHk z17H9QfB`T72EYIq00UqE41fVJ00zJS7ytuc01SWuFaQR?02lxRU;qq&0WbgtzyKHk z17H9QfB`T72EYIq00UqE41fVJ00zJS7ytuc01SWuFaQR?02lxRU|?DHY~mXle5+(K znJ6S6kOqcEQA$ia%^W$NzMof4hbo%s?=3&Wy%D_N{t&L{|4rQZ`S?;%$~d~TbPqVt zF$eC_-=_DA?83UfuUl^F5J{kJ=Vf}@Hb2U4` zfQ}e&ru?tZe~^A(|Lh&Z~KjE+rosQ=Yf`=Gx;^&b=#?NI+~O-G==0ri(_1bgj%?R9DYp>5}x zww*J6?ydU&#+K7GX=u7b{mU|@K!2T-ec{6u{XaLqLT@)7q#u{8rV-I`4z-sUa1rA{ zy5ip%{k7Qb>ai?6Ustv}6CaKK9#82n<3FEmJ_Y@C6#Jcx|E`w%>Z~G?y29vX$s6r+ zXZfF#I+5O7y&t;ktjt?;>Q4QAeSB!e#1fSKKB~V~Z)6Wi1p_)|z?t&j)A;XiS3jeU z_q0r{a;ndNQPvF913F~D8T}oN|Gm5J5XA(CJG}mXXU%h9KxYi}hW^r?*iyLIq5kH= zMbO`%`kz_c?oj`EX}Qqfp!%P!+2K(CyrgvKZ&3XuwrlNud&gNLv!TBM_5b7ApHj3F z^L}s*pr3JHtq;hV_Mg4$zw=9W(Z>50l8ouwEA!T`?=7iE`S-%|Z>^y#&p&KF3jO=2 z{>!uHKzp5OFLi)3@!yp)FQ1h;p;-T^zq;QO`rkJ^6MF&Zu-xBG`|obczw8TJLK7!r2HUI-)01SWuFaQR?02lxRU;qq&0WbgtzyKHk z17H9QxW>S3zSr>AJ1#m*xA{Fr^!1G`r|E;{qxAlxN9lu}694Zn8;;Q@tuN8dw(~?g zFVXFGWB5XBk^8KVh>oN9@ctAZac4@5jHT282~;qA3_X%Fk4{x@qknFBm5jgx?feBD zd1xaA`UMaVx;T}>`vudA2_^LLrjuUpgP!a+nM@QJ7)r6Bk!p$#4kNi=yS0D(e0*tp z(Gt4VcEPJ-LQnRG1O(F4^BU+64F~CW_0Q6;S3E^KXD*{bVbR@uFgAW9eYEj}S2NI) z{b9`axtbkByDoE`a7m4K689S>&F^OapwK9KXU%i!d%U$?uxJ07#qAcm#WwN9u35{w z**}o&f4lLZ;p~_A_S%wNM7v*C?Up$I=O4UCb4TZNvp*|7h50{jIR5+j`P0J8X=E;3 zM6J`RXzk>3nw*qD{v7kP7%1_tZN|ffjDMPq_Kq>Z;j}vU0s5Ok>py#T%j;4%mfn-c z?ZpnSF85lz?kWBSa9v+AI+yBmOKDZk{ZyVYg<^suEbL49sAv6eY&qlg?ckp5m-QA8yfJ%79!Oys>hx zA^F$JxV@vVkB@pC{I9LXh<}G4+Tif|S8NXL7sUMliQFctrt4mQ-J_L%>Gyi8;Q(!& zUZb}4q`haIHWyY?JGb+G!qD+<|B7nEB;ZbdFfma(7Eng+BTNNd6syor}mn4zVTe&*JaQL`hWp200zJS z7ytucpm!PIegQnbLl4M2tt3tboGkOSBKgGT0q%R3d-xOn1Ovuppr`R@PmX)aKA$pH zpr!YDZ26Pcm%0B=`oi41ABOBd)tEP*;=*HiOn88LOyK=kW&B0`>?t(5Ayp} zS<}@%?|TOiSNFn`e(%tLAay^aLlw<#9e`uAsz%2xM)+5f;A5)EWeR8QLP%X@CgKDDwJ4>u`j>G)!g=&$*IddgTjvCvF1 z?j*L$Sd{FS?9MSXYyXSAvj6hMrH}F0K(IPSAp4TJbNpTQ`qB88@xwgc2QsN^3`q8W zm3^b#ZNKb2Ccc+3%fjTbJpUt?*5^N{jxBhiFaGBC^YkT`IW67fahJ}Rtlu#Vcf|vI zuW{QM&=%Tyf&rWE0FR$aKZSHlByqCzQ~2?T&BGJE@W$Ve`d&+Sn}2D~$oznJ*Bzn{ zx$PiztFzxBb-mnop)`Fm%}$-D&V!J)f%BigNdGXuqPAgLrdCnyggI2fd;2;&k3z09 zIzOLIGkNY#CeJOD-*>kC_5aUjaGSn}@`v54?y)U%R-Cm#@+$YAkU5zhl`Y?D|M9Oa U`x)~7w%E_muJ3Ka!CjyJ55vSx8~^|S literal 0 HcmV?d00001 From 10ff635192502d19a5bdf81aeda14835ec47f061 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 23:16:33 +0300 Subject: [PATCH 05/13] Session log and plan: builds for all OS uploaded to the router (build 506) --- docs/plans/2026-10-19-router-upload-all-os.md | 26 +++++++++++++++++++ .../2026-10-19_router-upload-all-os.md | 23 +++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-10-19-router-upload-all-os.md diff --git a/docs/plans/2026-10-19-router-upload-all-os.md b/docs/plans/2026-10-19-router-upload-all-os.md new file mode 100644 index 0000000..993e52c --- /dev/null +++ b/docs/plans/2026-10-19-router-upload-all-os.md @@ -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 не проверялся. diff --git a/docs/session-logs/2026-10-19_router-upload-all-os.md b/docs/session-logs/2026-10-19_router-upload-all-os.md index 397db79..ca677cb 100644 --- a/docs/session-logs/2026-10-19_router-upload-all-os.md +++ b/docs/session-logs/2026-10-19_router-upload-all-os.md @@ -18,9 +18,30 @@ - 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` - в репозиторий не попадает. From 85d5e4797208df6c1b721611a8559975d6bc0093 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 23:16:39 +0300 Subject: [PATCH 06/13] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c5a960e..f553ed2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.496" +version = "0.1.506" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e83bd7f..8f18857 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.496" +version = "0.1.506" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d021293..e6ac102 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.496", + "version": "0.1.506", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From ee47af44796b00a549c5ab193fa88a3311d0b589 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 20 Oct 2026 23:09:50 +0300 Subject: [PATCH 07/13] Workout HR: log samples natively so recording survives screen-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PROJECT_MAP.md | 4 +-- docs/session-logs/2026-10-20_native-hr-log.md | 36 +++++++++++++++++++ frontend/js/gps.js | 35 +++++++++++++++++- .../main/java/dev/baldav/app/BleHrService.kt | 8 +++++ src-tauri/src/ble.rs | 18 +++++++++- src-tauri/src/lib.rs | 2 +- 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 docs/session-logs/2026-10-20_native-hr-log.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 282944f..45ce42e 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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` чистит и его | | `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` по `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-массива | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) diff --git a/docs/session-logs/2026-10-20_native-hr-log.md b/docs/session-logs/2026-10-20_native-hr-log.md new file mode 100644 index 0000000..f884f68 --- /dev/null +++ b/docs/session-logs/2026-10-20_native-hr-log.md @@ -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 компиляция - в сборке. На телефоне: старт воркаута "зарядка +(пульс)", экран гаснет на ночь, утром стоп -> сэмплы должны быть сплошные (из нативного лога), +включая сон. Живую проверку делает пользователь ночью. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 1e6e8db..6480192 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -881,6 +881,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. @@ -1550,7 +1577,13 @@ GPS.stopRecording = async function() { // 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 || []; + // 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]); diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index fb9ec27..dd2c09b 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -59,6 +59,7 @@ 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 lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null @@ -115,6 +116,7 @@ class BleHrService : Service() { isRunning = true val appDataDir = filesDir.parentFile ?: filesDir dataFile = File(appDataDir, "ble_hr_data.json") + logFile = File(appDataDir, "ble_hr_log.jsonl") connectDevice(address) } return START_NOT_STICKY // connect is user-initiated; no sticky restart @@ -498,6 +500,12 @@ 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) } + } } private fun disconnect() { diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index d20d531..24ed2de 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -465,6 +465,18 @@ 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 { + #[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()) } +} + #[tauri::command] pub fn ble_is_connected( app: tauri::AppHandle, @@ -488,10 +500,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"))] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a8be8c..242647f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1162,7 +1162,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, 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_request_enable, read_state_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } From 9bc71b19c9345d98b51aca766eb8ff605f822918 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 20 Oct 2026 23:25:06 +0300 Subject: [PATCH 08/13] 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. --- PROJECT_MAP.md | 2 +- docs/session-logs/2026-10-20_hr-reconnect.md | 32 +++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 26 ++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 docs/session-logs/2026-10-20_hr-reconnect.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 45ce42e..05670d4 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -53,7 +53,7 @@ |------|-------|------------| | `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check | | `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | -| `BleHrService.kt` | ~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-массива | +| `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, не долбится в отсутствующий/не запущенный вручную ремень | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) diff --git a/docs/session-logs/2026-10-20_hr-reconnect.md b/docs/session-logs/2026-10-20_hr-reconnect.md new file mode 100644 index 0000000..b86a16c --- /dev/null +++ b/docs/session-logs/2026-10-20_hr-reconnect.md @@ -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 reconnectTries++, + `connectDevice(lastAddress)` через 8с; иначе stopSelf. Реконнект идёт через скан: ремень не + найден -> timeout-ветка stopSelf (не зацикливается на отсутствующем). +- `stopping=true` на ACTION_STOP, BT-off (ACTION_STATE_CHANGED), onDestroy - там реконнекта нет. + +Начальный "скан не нашёл при старте -> stopSelf" не тронут: если ремня не было при старте, +сервис гаснет, ничего не долбит. + +## Установка (важно) +Сборка БЕЗ -i, чтобы не убить идущую ночную запись. Порядок: пользователь жмёт Стоп (короткая +сессия финализируется из нативного лога), затем установка APK, затем свежий старт ночи уже с +реконнектом. Переустановка убивает процесс и чистит лог при следующем старте - поэтому не +ставить поверх идущей записи. + +## Проверка +Компиляция Kotlin - в сборке. На устройстве (после установки): старт воркаута, снять/вернуть +ремень на 10-20с -> запись переподключается и продолжается; снять надолго -> через ~3 попытки +сервис гаснет. Живую проверку делает пользователь. diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index dd2c09b..8ded889 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -60,6 +60,11 @@ class BleHrService : Service() { 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) private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null @@ -76,6 +81,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}}""") @@ -99,6 +105,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 @@ -117,6 +124,8 @@ class BleHrService : Service() { val appDataDir = filesDir.parentFile ?: filesDir dataFile = File(appDataDir, "ble_hr_data.json") logFile = File(appDataDir, "ble_hr_log.jsonl") + // 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 @@ -249,7 +258,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() + } } } @@ -268,6 +289,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) @@ -544,6 +567,7 @@ class BleHrService : Service() { } override fun onDestroy() { + stopping = true try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ } disconnect() super.onDestroy() From abc4e3cef0281b792c2a2f013c99c60601b450d5 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 20 Oct 2026 23:49:17 +0300 Subject: [PATCH 09/13] 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. --- PROJECT_MAP.md | 6 +- .../2026-10-20_strap-uptime-and-unmerge.md | 33 ++++++++++ frontend/js/main.js | 64 +++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 47 ++++++++++++++ src-tauri/src/ble.rs | 26 ++++++++ src-tauri/src/lib.rs | 2 +- 6 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 docs/session-logs/2026-10-20_strap-uptime-and-unmerge.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 05670d4..b7dc486 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,7 +22,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; ` +
+
+
Ремень ЧСС: наработка
+
—
+
+ +
@@ -7834,6 +7871,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) { diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index 8ded889..401e8e2 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -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() { @@ -65,6 +67,13 @@ class BleHrService : Service() { 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 @@ -124,6 +133,7 @@ class BleHrService : Service() { 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) @@ -528,11 +538,48 @@ class BleHrService : Service() { // 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}") } diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 24ed2de..1e33525 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -477,6 +477,32 @@ pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result { { 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 { + #[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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 242647f..e2145bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1162,7 +1162,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, 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_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"); } From 435a0dd27cf6e8ddecc702473fb2029784bd91c9 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 20 Oct 2026 23:49:17 +0300 Subject: [PATCH 10/13] Tracks: un-merge a merged session back into its original rides (UI button) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/js/db.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/frontend/js/db.js b/frontend/js/db.js index 6dbed57..364f7a0 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -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; From 1cb6efbee318139c091fee45641130a44355e7d0 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Wed, 21 Oct 2026 11:22:46 +0300 Subject: [PATCH 11/13] Track detail: reference calibrated-HR kcal figure (Keytel x personal factor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_hr-base-and-calib.md | 35 +++++++++++++++++++ frontend/js/gps.js | 1 + frontend/js/main.js | 6 ++-- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 docs/session-logs/2026-10-21_hr-base-and-calib.md diff --git a/docs/session-logs/2026-10-21_hr-base-and-calib.md b/docs/session-logs/2026-10-21_hr-base-and-calib.md new file mode 100644 index 0000000..b011e14 --- /dev/null +++ b/docs/session-logs/2026-10-21_hr-base-and-calib.md @@ -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, ЧСС откалибр.); баланс дня не изменился. Живую проверку делает пользователь. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 6480192..dbf8ea6 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -14,6 +14,7 @@ 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км + сидячий день). НЕ истина, для справки. altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS) walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч diff --git a/frontend/js/main.js b/frontend/js/main.js index 463cad6..2c9e41e 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5483,6 +5483,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; @@ -5527,8 +5528,9 @@ async function showSessionDetail(sessionId) {
${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"}
${kcalMin} ккал/минРасход
- ${hasHr ? `
${hrKcal} ккалПо ЧСС (сравнение)
-
В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.
` : ""} + ${hasHr ? `
${hrKcal} ккалПо ЧСС (Keytel)
+
${hrKcalCalib} ккалЧСС откалибр. (грубо)
+
В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.
` : ""} ${elevations.length > 0 ? ` From 76efc9238b3f3af2adbbf02e737ff9c3192859d5 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 22 Oct 2026 00:49:18 +0300 Subject: [PATCH 12/13] Roads overlay: glue repeated passes, thicken line by ride count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/js/db.js | 2 +- frontend/js/main.js | 77 +++++++++++++++++++++++++++++------------ src-tauri/src/lib.rs | 81 ++++++++++++++++++++++++++++---------------- 3 files changed, 108 insertions(+), 52 deletions(-) diff --git a/frontend/js/db.js b/frontend/js/db.js index 364f7a0..eda3781 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -1599,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; diff --git a/frontend/js/main.js b/frontend/js/main.js index 2c9e41e..b012414 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3318,6 +3318,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 @@ -4858,19 +4892,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 @@ -7327,6 +7355,10 @@ async function renderSettings(container) {
Цвет дорог: бег
+
+
Толщина дорог по проездам
Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)
+ +
Fused-локация (Google Play)
@@ -7724,6 +7756,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"); @@ -8801,36 +8835,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. 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 the dark map. + // 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, renderer: roadsRenderer }).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) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2145bd..08cd2b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -265,59 +265,72 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result= ?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)) From c7dd588f370dd886f1243441ddf25127422c5f97 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 22 Oct 2026 00:51:31 +0300 Subject: [PATCH 13/13] TEF in daily balance + separate HR-only "vr" (hands/VR) workout type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PROJECT_MAP.md | 8 ++-- README.md | 4 +- .../2026-10-22_tef-vr-roads-poi.md | 42 +++++++++++++++++++ frontend/js/gps.js | 19 ++++++--- frontend/js/main.js | 41 +++++++++++++----- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 8 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 docs/session-logs/2026-10-22_tef-vr-roads-poi.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index b7dc486..862a2c3 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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 из СГЛАЖЕННОЙ высоты diff --git a/README.md b/README.md index 8f49096..e226ee7 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). - **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). Онлайн-долечивание пустых тайлов: раз в 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). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. diff --git a/docs/session-logs/2026-10-22_tef-vr-roads-poi.md b/docs/session-logs/2026-10-22_tef-vr-roads-poi.md new file mode 100644 index 0000000..e586989 --- /dev/null +++ b/docs/session-logs/2026-10-22_tef-vr-roads-poi.md @@ -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 как перекрёстная сверка (основным оставлен ЧСС). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index dbf8ea6..6c4d35b 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -15,6 +15,8 @@ GPS.config = { 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 км/ч @@ -43,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 { @@ -859,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); @@ -1455,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 { @@ -1576,8 +1583,8 @@ 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") { + // 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 || []; diff --git a/frontend/js/main.js b/frontend/js/main.js index b012414..d6591b6 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -355,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 = `
@@ -382,6 +387,10 @@ async function renderToday(container) {
+ ${expend != null ? `
+ расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""} +
` : ""} +
@@ -560,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; @@ -2814,6 +2823,7 @@ async function renderGpsScreen(container) { +