TEF in daily balance + separate HR-only "vr" (hands/VR) workout type

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

New activity_type "vr" (Руки/VR): HR-only like "workout" (no GPS, kcal
from HR), tracked separately. GPS.isHrOnly() replaces the hardcoded
"workout" checks so both kinds share the no-GPS record/detail path.
This commit is contained in:
4aiman 2026-10-22 00:51:31 +03:00
commit c7dd588f37
8 changed files with 94 additions and 26 deletions

View file

@ -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 из СГЛАЖЕННОЙ высоты

View file

@ -24,8 +24,8 @@
- **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). Онлайн-долечивание пустых тайлов: раз в 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). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.

View file

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

View file

@ -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 || [];

View file

@ -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 = `
<div class="day-header">
@ -382,6 +387,10 @@ async function renderToday(container) {
</div>
</div>
${expend != null ? `<div class="expend-breakdown" style="text-align:center;font-size:11px;color:#9aa4ae;margin-top:-4px">
расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""}
</div>` : ""}
<div class="balance-trend" id="balance-trend"></div>
<div class="drink-bar-wrap" id="drink-bar-wrap"></div>
@ -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) {
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
<option value="workout"${localStorage.getItem("baldav_gps_type")==="workout"?" selected":""}>Зарядка (пульс)</option>
<option value="vr"${localStorage.getItem("baldav_gps_type")==="vr"?" selected":""}>Руки/VR (пульс)</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Трекинг" : "Старт"}
@ -2877,10 +2887,10 @@ async function renderGpsScreen(container) {
}
// Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's
// choice: they can decline, and the two "Не спрашивать" settings suppress each prompt.
await ensureSystemReady(!!state.profile?.ble_device_addr, type !== "workout");
await ensureSystemReady(!!state.profile?.ble_device_addr, !GPS.isHrOnly(type));
// Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
let hrProfile = type === "workout" ? {
// Workout/VR (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
let hrProfile = GPS.isHrOnly(type) ? {
weightKg: wkg,
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
sex: state.profile?.sex || "m",
@ -4510,7 +4520,7 @@ function renderWorkoutDetail(session) {
let max = hrs.length ? Math.max(...hrs) : null;
let kcal = Math.round(session.total_kcal || 0);
let html = `
<div class="sd-section"><div class="sd-title">Зарядка · ${session.date} ${session.start_time ?? ""}</div>
<div class="sd-section"><div class="sd-title">${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
<div class="sd-stat"><span class="sd-val">${kcal} ккал</span><span class="sd-lbl">Нагрузка</span></div>
@ -4620,7 +4630,7 @@ async function renderRecordingInline(container) {
}
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; }
if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; }
container.innerHTML = `
<div class="rec-screen">
@ -5420,7 +5430,7 @@ async function showSessionDetail(sessionId) {
let session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
// HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path.
if (session.activity_type === "workout") { renderWorkoutDetail(session); return; }
if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
@ -5489,7 +5499,7 @@ async function showSessionDetail(sessionId) {
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
let duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
@ -6640,7 +6650,7 @@ async function showCompareModal(sessionId, currentSession, currentPoints) {
return;
}
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
showModal(`
<h3>Сравнить с...</h3>
<div class="compare-list">
@ -8128,6 +8138,13 @@ function showProfileModal() {
<option value="1.725">Высокая (физический труд)</option>
</select>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-tef" type="checkbox" ${(GPS.config.tefFrac ?? 0) > 0 ? "checked" : ""}>
Учитывать переваривание (TEF 10%)
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.</div>
</div>
<div class="form-group">
<label>Норма воды (мл/день)</label>
<input id="m-water-norm" type="number" inputmode="numeric" value="${p.daily_water_ml ?? "2000"}" placeholder="2000" />
@ -8144,6 +8161,8 @@ function showProfileModal() {
await DB.setProfileValue("sex", selectedSex);
let activityLevel = overlay.querySelector("#m-activity-level").value;
await DB.setProfileValue("activity_level", activityLevel);
let tefEl = overlay.querySelector("#m-tef");
if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); }
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
await DB.setProfileValue("daily_water_ml", waterNorm);

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

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