Compare commits

...

65 commits

Author SHA1 Message Date
802b2cc6e9 Bump version (build.sh output) 2026-10-19 14:12:04 +03:00
eeca9a7d94 HR connect: one BT-enable prompt per start, only on user action
setHrOn gains an 'interactive' flag; it pops the OS enable-Bluetooth dialog
only on a user tap (record-screen toggle). Auto-calls at recording start no
longer re-request enable after the single ensureSystemReady prompt, so a
decline is not immediately followed by the system dialog and is not re-asked
until the next start.
2026-10-19 13:43:55 +03:00
13c61f506a BleHrService: survive Bluetooth being turned off (no native crash)
Single Handler for all postDelayed work, cancelled on teardown; a BT
ACTION_STATE_CHANGED receiver that cleanly stops the service on STATE_OFF;
try/catch around every native scan/connect/GATT call. Prevents the libc
destroyed-mutex abort when the adapter is disabled mid-connection.
2026-10-19 13:43:55 +03:00
1df24000a3 Track detail: HR chart as its own collapsible section + 'what if by HR' kcal comparison
- Speed/Elevation/Пульс are now collapsible <details> sections; the HR chart
  moved out of the Elevation block into its own 'Пульс' section (avg/max + chart),
  shown when the track has HR.
- Калории now also shows the Keytel HR-based kcal as a COMPARISON when HR is
  present ('По ЧСС (сравнение)'). The activity balance keeps the physics/MET
  number unchanged - this is only to see the difference.
2026-10-17 14:53:21 +03:00
87a7fccfcc Record screen: remove the status line + its black bar, tighten button padding, move ⛔/↪ off the zoom controls
- Drop the status line (and any text it showed) from the record screen; keep
  a no-op statusEl sink so existing writes don't crash. Removes the empty
  black bar above the buttons. Tighter .rec-bottom padding.
- The nogo (⛔) and reroute (↪) buttons had no position class, so they piled
  at the map top-left over the zoom controls. Give them stack slots.
2026-10-17 14:43:57 +03:00
69b36be591 Record buttons equal width (border-box + uniform padding); HR strap charge as a background fill on the HR toggle
- Стоп was narrower: buttons had different horizontal padding and weren't
  border-box, so flex-basis:0 didn't equalize them. Force box-sizing + one
  padding on all three .rec-bottom buttons.
- HR strap battery now paints the HR toggle's background bottom-up 0-100%
  (--bat), coloured green/amber/red by level (setHrBatteryFill), instead of
  a number; cleared when HR is toggled off.
2026-10-17 14:35:54 +03:00
e88c2756f7 Record screen: big digits in the top strip (match bottom), drop the black status-bar backdrop band and the extra control offset
Top strip time/temp/clock now use the same 27px bold as the bottom stats. The
old .rec-map-wrap::before status-bar backdrop (and the leaflet-top safe-area
margin) are gone - the top strip already backs the status bar, so that strip
showed as a black band between the bar and the map.
2026-10-17 14:30:42 +03:00
dffadf3cd8 Record screen: regroup stats (top strip time/temp/clock, bottom HR/dist/speed/alt), equal-width buttons; reset stale HR per session
1) Layout: a top strip shows training time | temperature | clock ('external'
   things); the bottom panel row is now HR, distance, speed, altitude (bigger);
   the separate HR bar is gone; the three controls (HR toggle, pause, stop)
   share the width equally instead of clustering right.
2) Clear currentHr/currentBleData at recording start (+ ble_clear_hr) so a
   stale bpm from the previous session no longer hangs on the display or gets
   written onto this session's points; show -- until a fresh reading.
2026-10-17 12:29:14 +03:00
0a4ffcb49d HR toggle: white icon+bpm on solid red when active (contrast)
Active state was red text on faint-red; use white on solid red.
2026-10-17 10:59:31 +03:00
553a4fcf80 Record screen: show the live HR number on the HR toggle button
The HR toggle (ex-wind button) showed only the heart+pulse icon. Now the
current bpm renders on it (onHrUpdate writes #rec-hr-num); it clears when HR
is toggled off, leaving just the grey icon.
2026-10-17 10:57:08 +03:00
2d0e51bd8d Prompt to enable Bluetooth/location at recording start + two 'don't ask' settings
User forgets to turn BT/GPS on. Modern Android won't let an app toggle them
silently, so prompt and act: BT via the system enable dialog, location via
the location-settings screen.

- ensureSystemReady(needBt, needGps) runs before startRecording: BT off ->
  confirm -> ble_request_enable; location off -> confirm -> open settings.
  Gated by two settings (baldav_dontask_bt / _gps); declining proceeds.
- GPS.isLocationEnabled polls a new Kotlin check (check_location writes
  location_state.json via LocationManager; read_state_file reads it);
  open_location_settings opens ACTION_LOCATION_SOURCE_SETTINGS.
- Settings: 'Не спрашивать про Bluetooth' / '... геолокацию' checkboxes.
2026-10-17 02:07:44 +03:00
f7ba059628 HR: auto-connect at recording start if a strap is paired; HR on/off toggle replaces the wind button
Earlier I over-corrected: 'don't scan when the strap is absent' became
'never auto-connect'. Restore conditional auto: at recording start (ride or
workout) connect the paired strap and poll; absent strap is safe now
(BleHrService scans briefly and stops - no drain). No paired strap -> HR off.

- gps.js: GPS.setHrOn(on) - connect+poll / disconnect+stop; start auto-ons it.
- Record screen: the inconvenient 'Ветер' button is gone; in its place an
  HR on/off toggle (square, heart+pulse icon; grey off, red on) -> setHrOn.

(Also corrected a stale assumption: the phone DOES have Google services.)
2026-10-17 01:45:02 +03:00
62434d6155 HR strap battery + low-charge voice/beep alerts at 10% and 5%
- BleHrService reads the standard Battery service (0x180F/0x2A19) 2.5s after
  the HR subscribe and every 60s; writes 'bat' into ble_hr_data.json.
- HrReading gains battery: Option<u8> (serde alias 'bat').
- gps.js: the HR poll captures reading.battery; _checkBatteryAlert speaks
  (speechSynthesis ru-RU) and beeps once at 10% and 5% (beep is the
  guaranteed cue where TTS is absent), resetting above 15%.
- Workout screen shows the strap charge in its status line.
2026-10-17 00:58:31 +03:00
f704498d11 Workout mode: HR-only recording (зарядка) without GPS
New 'Зарядка' activity type records a session with no GPS - no location
service, no watchPosition, no track points - just the heart-rate poll and a
timer, for indoor exercise. kcal comes from HR (Keytel gross minus BMR, net
like the rest of the app) and counts in the daily balance.

- gps.js: GPS.hrKcalNet; startRecording/stopRecording 'workout' branches;
  the HR poll appends [relSec,bpm] samples.
- db.js: gps_sessions.hr_samples column + setSessionHrSamples.
- main.js: 'Зарядка (пульс)' option; renderWorkoutInline (map-less live
  screen: big HR, timer, kcal, connect-sensor button); renderWorkoutDetail
  (HR chart by zones, load, avg/max).
- tools/hr_log.py: background HR logger (standard 0x180D, no pairing).

Sensor read from the desktop confirmed on a Coospo H808S (F7:72:74:A4:FB:07,
~1 Hz) without pairing.
2026-10-17 00:54:21 +03:00
dd1642b6e9 BLE HR: connect manually only, no auto-scan on every ride (battery)
Recording auto-called ble_connect each ride, and the Kotlin service held a
GATT connect to an absent band (radio busy) plus START_STICKY churn -
draining the battery when the band was away or Bluetooth off.

- gps.js _startHrMonitor: drop the auto ble_connect; keep only the cheap HR
  poll so a manual connect (now or mid-ride) lands HR in the track.
- Settings: a 'Подключить' button for the paired band, usable any time incl.
  during a ride. BT off -> ble_request_enable (system enable dialog); on ->
  ble_connect, start the poll if recording, report connected / not found.
- ble.rs: ble_request_enable command (+ handler registration).
- MainActivity: ble_request_enable -> ACTION_REQUEST_ENABLE (BLUETOOTH_CONNECT
  already in manifest).
- BleHrService: START_NOT_STICKY; if the 5s scan doesn't find the address,
  stopSelf instead of directConnect to an absent device.
2026-10-02 14:03:16 +03:00
93908e3365 Gap-fill: bridge dropouts over your own ridden roads first, public BRouter only as fallback
fillGaps used the public BRouter shortest only, so a dropout where you rode
a trail/track absent from OSM was left unbridged (detour >2x or noroute),
even though your own track went straight through. Add opts.routeFn: the
detail view now tries rideRoute (the ride-graph over your tracks) first and
falls back to public BRouter only where you've never ridden. A ridden bridge
is trusted as-is (no detour-too-long rejection); public routing keeps that
guard. Clearer 'not bridged' message split by reason (public detour vs no
road data).
2026-09-30 00:23:28 +03:00
3e2a6e47f7 Progress on recompute/refine/roads-rebuild + remember map zoom
- .btn-progress fills a button's bg by --pct (setBtnProgress). Elevation
  refine drives it from fetchElevations network batches (0-80%) then save
  (85-100%); recompute from recalculateSession phase milestones; roads
  rebuild from a new Rust roads_progress event (rebuild_roads emits
  [current,total] every ~2000 points - first Rust->JS event in the app,
  adds use tauri::Emitter) so it no longer just hangs silently.
- recalc/refine now reopen the detail via reopenDetail (awaits the
  popstate) instead of the manual pop+history.back+render race.
- Track detail remembers its map view per session (_sdMapView, moveend) and
  restores it on reopen/return instead of re-fitting the whole track; the
  recording map persists its zoom in localStorage across screen switches.
2026-09-29 23:19:37 +03:00
fd178ad97d Track detail: await history.go popstates before re-render after gap-fill revert/save
reopenDetail fired history.go(-count) then re-rendered synchronously, so the
async map/chart build raced the popstate unwind: the pending popstates
desynced the back stack and bumped the detail render token, and the render
bailed at the token check - map and charts came up blank until the next
event repainted them. Make reopenDetail async and await the popstate(s)
before showSessionDetail, the same resolve-on-popstate pattern as closeThen
(no setTimeout/setInterval).
2026-09-29 16:03:52 +03:00
06cc77fdec Maps: trim tile retention, simplify big tracks by zoom, thin POIs when zoomed out
Canvas (prev commit) made the big-track map self-recover but the Chromium
compositor still hit 'tile memory limits exceeded' ~46x and 10k segments
still janked. Three cheap levers:
- tilecache: keepBuffer 2->1 and updateWhenZooming:false - fewer retained
  off-screen tiles in the compositor's raster budget on a high-DPI screen.
- track/roads polylines: smoothFactor 2.5 (Douglas-Peucker in pixels) - a
  10k-point track draws far fewer segments zoomed out, full detail zoomed in.
- POI: cap markers by zoom (z>=16:500, 15:200, 14:80, 13:30) instead of a
  flat 500 - a handful near min-zoom, everything only when zoomed in close.
2026-09-29 15:49:02 +03:00
834204a58a Maps: canvas renderer for heavy-vector maps (SVG blew Chromium tile memory, tiles vanished)
Viewing a big track (10865-pt, 60km) and zooming/panning threw
'cc/tiles/tile_manager tile memory limits exceeded, some content may not
draw' dozens of times - Chromium's compositor tile-memory cap. The track
is drawn by Leaflet's default SVG renderer, multi-layered (casing/core +
per-1%-slope segments), so the giant SVG layer exceeds the raster budget
and the map tiles stop drawing until a force-stop resets the compositor.

Set preferCanvas:true on the heavy-vector view maps - showSessionDetail,
showRoadsMap (many road polylines, also reported laggy), showComparisonView,
showRouteDetail. Canvas draws into one viewport-bounded canvas instead of
thousands of SVG paths. Left the recording map on SVG (rotate:true;
leaflet-rotate barely supports Canvas) and the route editor (line clicks).
2026-09-29 13:20:53 +03:00
74f3ec4d6e Startup: batch the seq repair (per-point loop made cold start ~15s, every boot)
The startup seq repair (runMigrations) assigned seq to NULL-seq sessions
one UPDATE per point. Merge/split leave seq=NULL by design, so recent
merged tracks (10865 + 3614 points) meant ~15000 sequential IPC writes on
every launch - and for the big sessions the writes did not persist before
the next launch re-ran the whole thing. That was the slow start.

Replace it with the batched CASE re-sequence (chunks of 300, like
saveGapFill/saveSmoothedTrack): ~50 UPDATEs instead of ~15000. Verified on
device: repair of all 3 sessions dropped from ~15s to ~1.1s, seq now
persists so it is one-time (boot 2 does no repair, migrations 357ms), 0
NULL seq left, seq matches ts order (0 inversions).
2026-09-29 12:37:15 +03:00
2d91c06290 gitignore build.sh (holds deploy secret) and BUILD_NUMBER (local state) 2026-09-29 12:19:15 +03:00
dbb6c2ea9f Build: auto-clean stale Android bundles per build + clean target
Each android build now removes the regenerable AAB bundles
(intermediary_bundle, outputs/bundle) and gen APK copies once the final
APK is in binaries/; a manual 'clean' target does the same on demand.
Frees ~0.5G/cycle without touching the Rust incremental cache or
binaries/. (build.sh stays untracked - it holds a deploy secret; this
documents the change.)
2026-09-27 00:04:52 +03:00
0123d3562b Bump version (build.sh output) 2026-09-27 00:04:52 +03:00
d6fe47a924 Recording map: follow toggle to free-pan while riding
New ⊕ button on the recording screen toggles map auto-follow. When off,
the map stays put so you can look around when unsure where you are (off
the track, or the track leads where there is no road); new GPS points no
longer recentre it. Tapping it back on recentres on the current position
and resumes following. Default on per screen entry (not persisted).

Guards followMode around panTo/setView in onPoint/onBatchUpdate/
onStateChange. Independent of the compass freeze/rotate controls.
2026-09-26 23:44:18 +03:00
5da02c598a Docs: versions table to build 474 (deployed to router + VPS) 2026-09-22 22:11:38 +03:00
749064ce59 Bump version (build.sh output) 2026-09-22 21:09:13 +03:00
5f58eabcc5 Gap-fill: re-sequence the session by ts on save (filled points landed at the front on merged/split tracks with NULL seq) 2026-09-22 20:58:06 +03:00
514ff4edb3 Docs: versions table to build 473 (deployed to router + VPS) 2026-09-22 11:49:48 +03:00
862b1ffc96 Bump version (build.sh output) 2026-09-22 11:32:19 +03:00
5d24f8c3a3 Keep scroll position across same-screen re-renders (day nav, add food) instead of jumping to top 2026-09-22 11:31:37 +03:00
bb9f03131d Bump version (build.sh output) 2026-09-22 11:19:37 +03:00
dff9342c0d Balance/trend/charts follow the selected day; kcal weight is per-ride (stored) and settings weight auto-syncs to the latest weigh-in 2026-09-22 11:12:24 +03:00
6534d6601c Docs: bump versions table to build 471 (deployed to router + VPS) 2026-09-21 20:40:49 +03:00
3ba8751664 Bump version (build.sh output) 2026-09-21 20:31:34 +03:00
685151b76b Repeat analysis: sweep the hot-cell threshold and keep the most passes (out-and-back reps were lost when a twice-ridden approach inflated the reference) 2026-09-21 20:30:38 +03:00
c84767fc9e Today: 7-day balance trend really covers 7 days (-7 days is an 8-day inclusive range) 2026-09-21 20:11:10 +03:00
b379160ff5 Bump version (build.sh output) 2026-09-21 13:49:27 +03:00
c913cc530c DB restore: drop unreliable auto-restart, ask user to reopen the app (reliable, restore applies on next launch) 2026-09-21 13:48:47 +03:00
ea7c6e68a8 Bump version (build.sh output) 2026-09-21 13:45:36 +03:00
492c54990f DB restore: restart via AlarmManager (startActivity+exit raced, app just closed) 2026-09-21 13:44:54 +03:00
ae90b4368b Bump version (build.sh output) 2026-09-21 13:36:43 +03:00
9e0910a35f DB backup: drop data_dir writes, use native_root only (match gps_service_read_points pattern) 2026-09-21 13:36:17 +03:00
d4faed200d Bump version to 0.1.467 (build.sh output) 2026-09-21 13:22:50 +03:00
d0abb72aba DB backup: use native_root() for the app root (not app_data_dir parent chain) - the fix we already made for point files 2026-09-21 13:22:05 +03:00
5b3239258f Bump version to 0.1.466 (build.sh output) 2026-09-21 13:20:10 +03:00
bb8a739b7e DB backup: write export/staging next to baldav.db (app_data_dir can be the app root; ../.. hit unwritable /data/user) 2026-09-21 13:19:18 +03:00
263163cf52 Bump version to 0.1.465 (build.sh output) 2026-09-21 13:05:34 +03:00
682bee6e9a Full DB backup: export all data to a file and restore it (swap DB on next launch) 2026-09-21 13:03:32 +03:00
75eca0e501 Docs: current versions table in README; note router deploy of build 464 2026-09-21 12:43:49 +03:00
d6139a99f7 Docs: repeat-detection findings and interval backlog notes 2026-09-21 12:42:57 +03:00
db62d52650 Bump version to 0.1.464 (build.sh output) 2026-09-21 12:36:51 +03:00
15a2881c6d Split a track at a point; block merging tracks with different bikes 2026-09-21 12:36:03 +03:00
44bb08e81e Bump version to 0.1.463 (build.sh output) 2026-09-21 11:28:37 +03:00
ea462ad9c4 Repeat analysis: detect a segment ridden twice (minPasses 3 -> 2); count no longer blocks 2-rep sessions 2026-09-21 11:27:24 +03:00
c5386ae22b Bump version to 0.1.462 (build.sh output) 2026-09-18 00:09:14 +03:00
cfadad4690 Merge rides: long-press to multi-select tracks and combine them into one (reversible via orig_session_id) 2026-09-18 00:08:25 +03:00
4895cbb042 Bump version to 0.1.461 (build.sh output) 2026-09-17 23:49:12 +03:00
2ffaf1581d Repeat analysis: project onto the reference line (lateral drift no longer truncates a rep); pass time counts moving seconds only 2026-09-17 23:46:32 +03:00
de93f77c3a Bump version to 0.1.460 (build.sh output) 2026-09-17 23:20:37 +03:00
7d2581dc2d Repeat analysis: bridge a stop inside a pass, count 70%+ passes (dropped-seat rep now counts) 2026-09-17 23:17:47 +03:00
1a57f36621 Bump version to 0.1.459 (build.sh output) 2026-09-17 22:51:10 +03:00
0d8b5f97b9 Repeat analysis: pick the longest corridor traversal as reference (fix short segment) 2026-09-17 22:50:24 +03:00
b2defb4564 Bump version to 0.1.458 (build.sh output) 2026-09-17 22:12:18 +03:00
7717a73129 Repeat/interval analysis: auto-find repeated segments in one ride, table + last/first verdict 2026-09-17 22:11:10 +03:00
34 changed files with 2828 additions and 241 deletions

4
.gitignore vendored
View file

@ -30,5 +30,9 @@ chzn_session.json
binaries/
test.exe
# Build script holds a deploy secret (router password); build counter is local state
build.sh
BUILD_NUMBER
# Reference material kept locally, not in the repo
*.pdf

View file

@ -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` не осталось; `<select>` оставлены). `orient-dbg` (сенсор-оверлей на карте) — только при `window.__BALDAV_DEBUG`. `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea` |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`. `runMigrations` -> стартовый seq-репейр для NULL-seq сессий (merge/split создают их намеренно): батчевый `CASE` по 300 точек (как `saveGapFill`), а не UPDATE на точку - иначе ~15000 IPC на старте = ~15с и не досохранялось, гонялось каждый запуск |
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) |
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
@ -53,7 +53,7 @@
|------|-------|------------|
| `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check |
| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
| `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect |
| `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 |
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
### Инструменты (`tools/`)
@ -81,7 +81,7 @@
| Файл | Назначение |
|------|------------|
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS |
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS, `clean`/авто-чистка старых бандлов (`clean_bundles`) |
| `BUILD_NUMBER` | Автоинкремент номера сборки |
| `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) |
| `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) |
@ -142,7 +142,7 @@
### Экраны
- **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы)
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали)
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали), тоггл следования карты за позицией (`followMode`, кнопка `⊕` в `rec-map-btn` стеке; guard вокруг `panTo`/`setView` в `onPoint`/`onBatchUpdate`/`onStateChange`; выкл = свободный просмотр карты на ходу, повторный тап центрирует; не запоминается). Карты с тяжёлыми векторами (`showSessionDetail`, `showRoadsMap`, `showComparisonView`, `showRouteDetail`) создаются с `preferCanvas: true` — большой трек/дороги в дефолтном SVG превышают лимит памяти тайлов компоновщика Chromium на зуме (`tile_manager tile memory limits exceeded`) и тайлы гаснут до перезапуска; canvas рисует в один холст. НЕ на карте записи (leaflet-rotate плохо дружит с Canvas) и не в редакторе маршрута (клик по линии)
- **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10)
- **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог
@ -151,6 +151,9 @@
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
- Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save)
- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция). Склейка блокируется, если у выбранных треков разные `bike_id` (appAlert)
- Разрезание трека: кнопка `#sd-split` в `showSessionDetail` включает `#sd-map.split-mode`; в попапе каждой точки кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` (db.js): вторая половина (точки по id, чанки) в НОВУЮ сессию (id=Date.now(), копия date/activity/bike/source), `seq=NULL`, у старой правится end_time; пересчёт обоих кусков. Велик копируется, потом меняется вручную
- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`)
- Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
@ -179,6 +182,9 @@
| `ble_save_device` | Сохранение привязанного устройства |
| `import_tiles` | Чтение тайлового SQLite батчами (base64) |
| `download_file` | Скачивание бинарного файла по URL (без лимита размера) |
| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) |
| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД |
| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) |
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') |
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |
@ -254,8 +260,10 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200
./build.sh android # release APK (подпись debug.keystore)
./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA)
./build.sh install -d # поставить уже собранный APK без пересборки
./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает)
```
Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK.
`clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке.
## Отладка WebView (debug-сборки)

View file

@ -2,12 +2,30 @@
Персональный трекер здоровья и физической активности.
## Текущие версии (2026-09-22, build 474)
| Что | Версия |
|-----|--------|
| Приложение (BalDav) | **0.1.474**, build **474** |
| Выкачено на роутер (OTA) | build **474** - `http://192.168.1.1:88/baldav/baldav.apk` |
| Резерв OTA (VPS) | build **474** - `https://4aiman.top/baldav/baldav.apk` |
| Встроенный движок BRouter | 1.6.3 |
| Сид продуктов (`products_seed.db`) | 11 |
| Сборка | Tauri v2, Android arm64, debug-подпись |
Версия/номер сборки бампаются автоматически при `./build.sh android` (файлы `BUILD_NUMBER`,
`tauri.conf.json`, `Cargo.toml/lock`). Телефон сам проверяет обновление: сначала роутер
(LAN `192.168.1.1:88/baldav`), затем VPS - берёт `BUILD_NUMBER`, если больше локального,
предлагает скачать `baldav.apk`. Выкатка на роутер - существующим APK (без пересборки, чтобы
номер не уезжал) либо `./build.sh android -d -u` (соберёт новый номер и зальёт на роутер+VPS).
## Возможности
- **Дневник питания** — ~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).
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека.
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает 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 с коэффициентом активности.
- **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** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
@ -24,12 +42,17 @@
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
- **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется.
- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`).
- **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске.
- **Резервная копия всех данных** - в Настройках **Экспорт** сливает всю базу (`baldav.db`) в файл через SAF (`export_db` -> `VACUUM INTO` чистой сжатой копии, ~на треть меньше), **Импорт** выбирает файл, проверяет его (`PRAGMA quick_check` + наличие таблиц BalDav) и восстанавливает с ЗАМЕНОЙ: файл кладётся в `pending_restore.db`, приложение перезапускается, и Kotlin в `onCreate` (до открытия БД) свопит его на `baldav.db`. Секретов в базе нет. Зачем: переносить наработки между сборками (**debug <-> release** - разные подписи, переустановка стирает данные) без сторонних бэкаперов. Внутриприложенческой авто-установки APK намеренно нет - только экспорт/импорт данных.
- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет.
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
- **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается).
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
- **Вес, сон, добавки** — логирование с историей.

View file

@ -351,3 +351,299 @@ Aeropod). Трубка, направленная вперёд, плюс датч
Готовый код обоих методов варианта 1 -- в `tools/track/track.py`
(функция `wind_by_passes` и секция «ветер» в `report`), выше описан
алгоритмически.
---
# БЭКЛОГ: автоматический разбор повторов внутри одной поездки
Заказано пользователем 17 сентября 2026.
## Задача
Пользователь катает интервалы: приезжает к подъёму, проезжает его 5 раз,
скатываясь вниз между заездами, потом уезжает домой. Сейчас, чтобы разобрать
такую тренировку, приходится либо останавливать и заново стартовать трек
на каждом повторе (пользователь называет это бредом), либо разбирать
вручную задним числом.
Нужно: **один трек на всю тренировку, повторы находятся сами.** Пользователь
готов на отдельный режим «велотренировка», если так проще.
## Что должно получиться на выходе
Таблица повторов: номер, время, средняя скорость, каденс, мощность.
И главное число, по которому принимается решение о завтрашнем дне:
отношение последнего повтора к первому
Порог, выведенный из данных (11 и 17 сентября, по 5 повторов):
- последний быстрее или равен первому -- не на пределе, можно повторить завтра
- последний медленнее на 5-10% -- нормальная тяжёлая тренировка
- последний медленнее больше чем на 10% -- два дня спокойно
## Алгоритм, проверен вручную на этих данных
Повторы ищутся по самоподобию трека, без опорных точек от пользователя.
1. **Разложить точки поездки по ячейкам** ~10 м (сетка как в `heightmap.py`).
2. **Найти ячейки, посещённые больше одного раза**, и отбросить те, где
направление движения (`bearing_deg`) при посещениях различается больше
чем на 60 градусов: проезд вниз по той же дороге повтором не считается.
**Без проверки направления половина повторов теряется или считается
задом наперёд** -- проверено, это главная ловушка.
3. **Собрать самую длинную цепочку** таких ячеек, идущую подряд по ходу
движения. Это и есть повторяемый участок. Требовать длину не меньше
300 м и не меньше 3 проходов, иначе это перекрёсток у дома, а не подъём.
4. **Разметить проходы:** для каждого прохода записать вход и выход
по цепочке, время, длину.
5. **Нормировать время на общую длину:** у пользователя GPS даёт от 1138
до 1199 м на одном и том же подъёме, то есть 5% разброса. Без нормировки
сравнение повторов между собой врёт.
## Чего делать НЕ надо
**Не просить пользователя ставить метки.** Он специально сказал, что
останавливать и перезапускать трек не будет.
**Не опираться на высоту отдельного проезда.** Профиль повторяемого участка
брать из карты высот по многим проездам (см. соседний раздел): у одиночного
проезда GPS даёт ступеньки вида 0, 0, 0, 0, 8.5 м на ровном месте.
**Не считать мощность усреднением по точкам.** Усреднять по времени.
При усреднении по точкам отрезки чуть разной длины дают разный вес
медленным кускам, и на одинаковой скорости выходят разные ватты
(проверено: 17.9 км/ч -> 173 Вт и 18.1 км/ч -> 209 Вт на одном подъёме).
## Готовый код
Ручная версия детектора по двум опорным точкам -- в истории этой сессии,
в `tools/track/`. Она находит проходы правильно, но опорные точки задаются
руками. Автоматизировать надо шаги 1-3.
---
# БЭКЛОГ: массовое уточнение высот по всем трекам
Заказано пользователем 17 сентября 2026. Приоритет свой, не срочный.
## Что нужно
Пункт меню, который уточняет высоты **пакетно**: идёт от последнего трека
назад по времени и обрабатывает те, где `altitude_srtm_m` ещё не заполнена.
Пользователь запускает его вручную, когда сидит на проводном интернете.
Требования к исполнению, из общих правил проекта:
- показывать прогресс числами: сколько треков обработано, сколько осталось
- прерываемость: в поле связь рвут РЭБ и РКН, процесс должен переживать
обрыв и продолжаться с того места, где встал, а не начинать заново
- уже уточнённые треки не трогать повторно
## Зачем, с измерениями
Аудит всех 83 велопоездок показал, что **высота за май и июнь непригодна**:
май-июнь: набор в БД 0-23 м, расчётный по сглаженной высоте 50-207 м
июль-сентябрь: набор в БД и расчётный сходятся (168/138, 251/275, 227/235)
До 17 мая запись шла раз в 3-4 секунды при шуме высотомера 0.8 м между
соседними точками. Пример 10 мая (session_id 3): сырая высота скачет
`36 20 23 20 20 36 36 35 35 35 34 34 33 20 23 20 19 32 32 32 31`, то есть
приёмник бегает между двумя уровнями с размахом 16 м. Медианный фильтр
и скользящее среднее это приглаживают, но остаток даёт уклоны до 12.8%
на треке, где реального набора 14 м на 13 км.
Практическое следствие: **любой анализ по уклонам за май и июнь даёт
фальшивые результаты**, причём правдоподобные на вид. Построенная по этим
данным кривая «лучший удержанный момент на шатунах» показала май лучшим
месяцем сезона, хотя по мощности за 20 минут рост с мая по сентябрь
составил 1.12 -> 1.75 Вт/кг.
Отдельно найдены отказы высотомера внутри годных треков: 2 сентября
(session_id 1788361994788) после точки 4194 значение падает на 10 м
и **замерзает** -- 26.8, 26.9, 26.9, 26.9 шестьдесят точек подряд,
а после перерыва записи прыгает на 64.8 м с точностью 22 м. Уклоны
на этом участке выдуманы целиком.
## Признаки негодной высоты, годятся как автотест
Все три считаются по уже записанным данным, без сети:
1. `elevation_gain_m` в сессии и набор, пересчитанный по точкам, расходятся
больше чем вдвое в любую сторону
2. медиана модуля разности высоты между соседними точками выше 1.5 м
3. доля точек с шагом записи больше 8 секунд выше 15%
По этим признакам из 83 поездок отсеиваются 31. После уточнения высот
через SRTM проверку надо прогнать повторно: она же покажет, помогло ли.
## Объём работы, посчитан по базе
всего точек 418 465
уже уточнено 47 420 (11.3%, 12 сессий из 170)
осталось 371 045
запросов по 100 3 711
Размер пачки 100 задан в `frontend/js/main.js` около строки 3085 и совпадает
с потолком OpenTopoData.
## Что надо поправить ДО массового прогона
**Откат по цепочке источников менять не надо.** Он сделан намеренно:
лучше получить хоть какую-то высоту, чем никакой. Проблема не в откате,
а в том, что после него не остаётся следа, кто именно ответил.
**1. Нет колонки источника.** В `gps_points` есть `altitude_srtm_m`, но нет
поля, откуда это значение пришло. При работающем откате база неизбежно
становится смесью источников, и без этой колонки отличить их потом нельзя
никак. Нужна колонка (ALTER TABLE, без пересоздания), заполняемая при записи.
Это единственное обязательное изменение.
**2. Откат должен быть виден пользователю.** Сейчас он сообщается только
строкой в консоли (`[gpxz] http 403`). Достаточно показать в конце итог
вида «уточнено 2314 точек: GPXZ 0, open-elevation 0, open-meteo 2314».
**3. Обработка 429 и 402 отдельно от сетевой ошибки.** По лимиту
останавливаться до завтра, по обрыву повторять. Иначе при массовом прогоне
упёршийся лимит молча отправит весь остаток базы к запасному источнику.
## Чего ждать и чего не ждать
Copernicus GLO-30 это сетка 30 м. На подъём длиной 200-400 м приходится
7-13 отсчётов, этого хватает. На короткий рамп 60-100 м приходится 2-3
отсчёта, форму по ним не восстановить. То есть массовое уточнение закроет
затяжные подъёмы и общий набор, но НЕ закроет короткие рампы, на которых
пользователь срывается. Для них нужен либо барометр, либо разовый замер
профиля конкретного подъёма угломером.
## Лимиты GPXZ против объёма
Тарифы с их сайта: Evaluation бесплатно и 100 запросов в сутки (помечен
«testing only»), Small 99 USD/мес и 2500 в сутки, Large 249 USD/мес и 7500.
3711 запросов при 100/сутки = 37 дней
3711 запросов при 2500/сутки = 1.5 дня
Отдельно: одна поездка это медиана 19 запросов, но максимум 133. То есть
**длинная поездка не влезает в бесплатный суточный лимит даже одна.**
Если массовый прогон делать на бесплатном ключе, он обязан быть
возобновляемым и считать свой суточный расход сам.
## Вариант без лимитов: свой Open Topo Data
GPXZ на своём сайте прямо пишет, что открытый форк их API-сервера
называется Open Topo Data. Размер пачки 100, уже зашитый в коде,
взят как раз из его потолка, то есть **протокол совместим и менять
в клиенте нужен только базовый URL** (сейчас `https://api.gpxz.io/v1/...`
захардкожен в `frontend/js/main.js` около строки 3091).
Считано по базе: все треки пользователя укрываются **двумя градусными
тайлами 1x1**. Для GLO-30 это порядка сотни мегабайт на диске, разово
скачивается по проводу.
Что это даёт против платного тарифа:
- лимита нет вообще, весь бэклог прогоняется за один вечер
- в поле сеть не нужна: у пользователя её глушат, и это постоянная
проблема всех сетевых зависимостей приложения
- тот же софт и тот же набор данных, значит результаты сопоставимы
с уже уточнёнными 12 сессиями, если там был GPXZ
Минус: нужен хост, на котором это крутится, и разовая возня с датасетом.
## Но своего хоста может и не понадобиться
Автор Open Topo Data держит бесплатный публичный инстанс
`api.opentopodata.org`. Его ограничения:
100 точек на запрос -- ровно тот размер пачки, что уже в коде
1 запрос в секунду -- нужен троттлинг, сейчас пауз между пачками нет
1000 запросов в сутки -- в десять раз больше бесплатного GPXZ
весь бэклог 3711 запросов -> 3.7 суток, 62 минуты чистого времени,
то есть ~15 минут работы в день
Сравнение по срокам на тот же объём:
GPXZ Evaluation (бесплатно) 37.1 дней
Open Topo Data публичный 3.7 дней
GPXZ Small (99 USD/мес) 1.5 дня
**Ключ не нужен вообще**, значит отпадает и вопрос, укладывается ли
массовый прогон личного архива в пометку «testing only» бесплатного
тарифа GPXZ.
### Выбор датасета, и почему это не мелочь
На публичном инстансе **нет Copernicus GLO-30**, на котором работает GPXZ.
Из глобальных 30-метровых доступны `mapzen`, `aster30m`, `srtm30m`.
`eudem25m` (25 м, лучшая точность) покрывает только страны EEA, регион
пользователя в его extent не входит.
Отсюда два следствия:
1. Датасет будет **не тот**, которым уточнены нынешние 12 сессий.
Смешивать нельзя, и колонка источника из пункта 2 выше становится
не пожеланием, а обязательной: без неё база превратится в винегрет
из трёх рельефов.
2. Разумный выбор по умолчанию `srtm30m` (USGS): у `aster30m` известны
шумы и артефакты, `mapzen` это композит из нескольких источников,
то есть внутри одного датасета точность неоднородна.
Хорошая проверка после прогона: 12 уже уточнённых сессий уточнить заново
новым датасетом в отдельную колонку и сравнить набор высоты. Разойдутся
сильно - значит выбор датасета важнее, чем кажется, и это надо знать
до того, как на этих высотах строить разбор поездок.
## ПРОВЕРЕНО: в базе лежит не то, что все думали
Проверка сделана, и она важнее всего остального в этом разделе.
**Ключ GPXZ не работает и, судя по всему, не работал никогда.** Запрос
к `api.gpxz.io` возвращает HTTP 403 с телом `error code: 1010`
и заголовком `Server: cloudflare`. Ответ **одинаковый с ключом и без
ключа**, то есть запрос режет Cloudflare по подписи клиента, до всякой
проверки ключа. Ключ при этом валидный на вид: 28 символов, префикс `ak_`.
**Те 12 «уточнённых» сессий уточнены open-meteo.** Это третий, последний
запасной источник в цепочке `GPXZ -> open-elevation -> open-meteo`.
Доказательство: опорные целые значения в `altitude_srtm_m` совпадают
с ответом open-meteo точка в точку и расходятся с `srtm30m` на 2-3 м.
**Дробные значения в базе создаёт приложение, а не источник.** Записанная
последовательность `39.000, 38.857, 38.714, 38.571, 38.429, 38.286,
38.143, 38.000` это `39 - k/7`, линейная интерполяция между двумя целыми
отсчётами. Все внешние DEM (srtm30m, aster30m, mapzen, srtm90m,
open-elevation, open-meteo) отдают **целые метры**. То есть дроби
в колонке выглядят как высокая точность, а на деле это прямая, натянутая
между узлами сетки в 30-90 м.
Практическое следствие для любого будущего разбора: **нельзя считать
уклон по `altitude_srtm_m` на коротких кусках.** Интерполяция даёт
гладкий правдоподобный профиль там, где данных нет вообще.
### Что из этого следует для задачи
1. Тихий откат (пункт 1 выше) не гипотетическая опасность, а уже
случившийся факт: база заполнена последним источником в цепочке,
и об этом никто не знал.
2. Колонка источника (пункт 2) обязательна.
3. Отказ надо показывать пользователю, а не писать в консоль.
### Покупать ли GPXZ: нет
С карты покрытия на их сайте: лидар 0.5-2 м у GPXZ есть только в США,
западной и северной Европе и Новой Зеландии. Регион пользователя
в жёлтой зоне, где по их же легенде 30 м, а по их тексту база
из Copernicus 30m. То есть платный GPXZ дал бы Copernicus 30m вместо
бесплатного SRTM 30m: другая вертикальная точность, **та же
горизонтальная сетка 30 м**.
А упирается всё именно в горизонтальную: подъём длиной 60-100 м, на
котором пользователь срывается, это 2-3 отсчёта на любой тридцатиметровой
сетке. Ни один DEM его не разрешит, ни платный, ни бесплатный.
Измерено на одном подъёме (1153 м, 229 точек, первый заезд 16 сентября):
источники расходятся между собой до 2-3% уклона на сотню метров, что
того же порядка, что шум GPS. `mapzen` при этом отдаёт числа, побитово
совпадающие с `srtm30m`.
Вывод: брать бесплатный `srtm30m` с публичного Open Topo Data.

View file

@ -0,0 +1,76 @@
# План: кнопка тоггла "следование карты за позицией" на экране записи
## Context (зачем)
Во время записи карта на экране трекинга постоянно снэпится (`panTo`) на текущую GPS-позицию
при каждой новой точке. Кейс пользователя: отклонился от трека или трек ведёт туда, где дороги
нет, надо осмотреться по карте вокруг - но карта тут же прыгает обратно на позицию, подвигать
её невозможно. Нужна кнопка-тумблер: нажал - карта перестаёт следовать за позицией (можно
свободно двигать/смотреть), нажал снова - карта центрируется на текущей позиции и следование
возобновляется.
Решения по UX (согласованы с пользователем):
- Кнопка - плитка 32x32 в общем правом ряду кнопок карты, с символом (не словом), активное
состояние подсвечивается (как freeze/roads). Активна = следование включено.
- Только кнопка-тумблер. Драг карты пальцем следование НЕ выключает (строго как в запросе).
- Повторный тап (включение следования) сразу центрирует карту на текущей позиции.
- Следование по умолчанию включено при каждом заходе на экран (в localStorage не храним).
## Где сейчас следование (frontend/js/main.js, функция renderRecordingInline)
Автоследование = центрирование вида карты на текущей точке. Происходит в:
- `GPS.recording.onPoint`, ~4840: `map.panTo(ll)` - основное следование при записи.
- `GPS.recording.onPoint` состояние waiting_gps, ~4857: `map.setView([p.lat,p.lon],15)`.
- `GPS.recording.onStateChange` "ready", ~4814: `map.setView([p.lat,p.lon],16)` - разовый locate.
- `GPS.recording.onBatchUpdate`, ~5005: `map.panTo(last)` - следование при пакетном апдейте.
Маркер позиции (`updateMe`/`refreshMe`) вид карты НЕ трогает - при выключенном следовании
точка продолжит обновляться на своём месте, просто карта не будет прыгать. Компас/поворот
(`compassFrozen`, `applyBearing`) ортогональны следованию - `panTo` на bearing не влияет.
## Фикс (frontend/js/main.js, только renderRecordingInline)
1. Флаг: рядом с `compassFrozen`/`rotOffset` (~4470-4477) добавить `let followMode = true;`.
2. Кнопка: по образцу `freezeBtn`/`roadsBtn` (~4624-4676) создать `followBtn`
(`class="rec-map-btn rec-follow-btn"`, символ-цель, `title="Следить за позицией"`),
добавить в `btnWrap`, сразу выставить `followBtn.classList.add("active")` (followMode=true).
onclick:
- `followMode = !followMode;`
- `followBtn.classList.toggle("active", followMode);`
- при включении - центрировать на текущей точке: взять последнюю точку
`GPS.recording.points` (как в onBatchUpdate: сортировка по ts, последняя) и `map.panTo`.
3. Загардить все 4 места следования условием `if (followMode)`:
- ~4840 `map.panTo(ll)` -> `if (followMode) map.panTo(ll);`
- ~4857 waiting_gps `map.setView(...)` -> обернуть в `if (followMode)` (return оставить).
- ~4814 "ready" `map.setView(...)` -> обернуть в `if (followMode)`.
- ~5005 `map.panTo(last)` -> `if (followMode) map.panTo(last);`
## CSS (frontend/css/styles.css)
После `.rec-dark-toggle { right:8px; bottom:238px; }` (~1248) добавить позицию для новой кнопки
в следующем свободном слоте стека: `.rec-follow-btn { right: 8px; bottom: 276px; }`.
Базовый вид и `.active` уже есть у `.rec-map-btn` / `.rec-map-btn.active` - переиспользуем.
## Символ кнопки
Плитка-цель, читается как "центрировать на мне": glyph круг-с-точкой/крест-в-круге
(например "⊕" плюс-в-круге или "◎" bullseye) - выбрать широко отрисовываемый.
Смысл несёт `title` (текст). Активная подсветка = следование включено.
## Регрессии (проверить рядом)
- Freeze-кнопка компаса (`compassFrozen`) и поворот карты - не задеты (следование = только
центр вида, поворот = bearing). Убедиться, что обе кнопки работают независимо.
- Маркер позиции `updateMe` при выключенном следовании продолжает двигаться (карта стоит).
- Второй, отдельный экран `setupLiveUpdates` (мини-карта gpsMap, ~5030, свой panTo 5039/5055)
НЕ трогаем - это превью в списке, у него нет ряда кнопок. Пользователь просил "экран
трекинга" = renderRecordingInline.
## Верификация
- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh`): открыть экран записи, начать запись
(или подсадить точки), проверить:
1) следование по умолчанию включено, кнопка подсвечена, карта идёт за точкой;
2) тап -> подсветка гаснет, карту можно свободно двигать, новые точки её не возвращают;
3) тап -> карта центрируется на текущей позиции, подсветка загорается, следование снова идёт.
Скрин каждого состояния.
- Сборка APK + телефон (по явной просьбе): в реальной записи проехать/постоять, отжать кнопку,
подвигать карту, вернуть кнопкой. Коммит ДО теста на телефоне, доработки - отдельным коммитом.
- README/PROJECT_MAP + лог сессии обновить вместе с коммитом.
## Не в этой задаче
Авто-выключение следования при драге карты (пользователь выбрал "только кнопка").
Хранение состояния следования между сессиями (по умолчанию всегда включено).

View file

@ -0,0 +1,107 @@
# Сессия 2026-09-17 - автоматический разбор повторов внутри поездки (интервалы)
## Контекст
Заказ из `docs/gps-model-findings.md` (раздел «БЭКЛОГ: автоматический разбор повторов»).
Пользователь катает интервалы: один подъём 5 раз за поездку, один трек, без стоп/старт.
Нужно: повторы находятся сами по самоподобию, таблица заездов и число «последний/первый».
Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md`
(в docs/plans не копировал - план короткий и одноразовый; здесь ссылка).
## Сделано
- `GPS.detectRepeats(points, opts)` (gps.js) - порт `tools/track/pers.py`, но эталонный
проход находится сам:
1. сетка ~10м (формула из `wind_by_passes`/`heightmap.py`);
2. горячие ячейки = >= minPasses (3) входов (заход = смена ключа ячейки);
3. эталон = ОДИН чистый проход: от первой горячей ячейки, добавляя новые, до первого
возврата к более ранней (склейка кругов давала абсурд - см. баги);
4. проходы = порт `passes()`: позиция в seq растёт от старта (допуск назад 3, гэп 25),
покрытие >= 80%; старт-гвард `pos<=15%` не даёт спуску открыть заезд;
5. нормировка: сравнение по `L_seq / T_прохода` (снимает 5% разброса длины GPS).
Направление без явной проверки bearing: seq хранит первую (подъёмную) позицию ячейки,
спуск роняет позицию и закрывает заезд.
- `avgPower` в возврат `GPS.computeStats` (накопление `power*dt` в вело-ветке / время
педалирования) - для колонки «Вт».
- UI: секция-аккордеон «Разбор повторов» в `showSessionDetail` (только вело), таблица
(№/время/км-ч норм/каденс/Вт), вердикт последний/первый (зелёный >=100%, жёлтый >=90%,
красный <90%), подсветка участка на карте (бирюза `#00e5ff`). CSS в styles.css.
## Баги по пути (и фиксы)
- Эталон как «самый длинный горячий ран» склеивал 5 кругов -> segLength 4406м и заезды по
9с (норм. скорость 1762 км/ч). Фикс: эталон = один проход до первого возврата.
- Следующая точка в ТОЙ ЖЕ ячейке считалась «возвратом» и сразу обрывала seq (seq=1).
Фикс: `c===seq[last]` -> продолжить, не обрывать.
## Проверка
- Node-харнесс на `tools/track/baldav.db` (реальные данные):
- 11 сен (3214 тчк): участок 1577м, **5 заездов**, норм. скорости 15.0/17.1/17.8/17.9/17.5,
последний/первый 117%.
- 17 сен (3079 тчк): участок 1630м, **5 заездов**, 16.9/15.8/17.3/16.4/17.7, 105%.
Совпадает с находками («11 и 17 сентября, по 5 повторов»).
- Контроль: 5 обычных вело-поездок (8-23 км) + 2 короткие -> «повторов не найдено» (0 ложных).
- Десктоп (Xvfb): временно подсажена сессия 17 сен в дев-БД (бэкап + точечное удаление
после, БД байт-в-байт восстановлена). Секция рисуется, аккордеон работает, таблица 5 строк
совпадает с харнессом, вердикт зелёный 105%, участок подсвечен бирюзой на карте. Скрин.
- Далее: сборка APK + проверка на телефоне (реальные интервалы там же).
## Фикс после проверки на теле (трек 16 сен)
- Симптом (пользователь): на 16 сен в повторы попала только часть горки, 729м вместо 1200+,
на карте видно прямую, что идёт дальше.
- Причина: эталон брался с ПЕРВОЙ горячей ячейки. Поездка 16 сен стартует прямо в горячей
ячейке (подъезд частично совпал с коридором), проход с индекса 0 обрывался гэпом на 143й
точке -> 729м, хотя горячих ячеек ~183 (~1830м).
- Фикс: эталон = САМЫЙ ДЛИННЫЙ чистый проход (перебор всех стартов горячих ранов,
`buildTraversal`), не первый. Дёшево (стартов немного).
- Проверка: харнесс 16 сен -> участок 1572м (было 729), 3 полных заезда. 11/17 сен без
изменений (по 5). Контроль (5 обычных) - 0 ложных. Десктоп (Xvfb, подсажена 16 сен,
бэкап+удаление): подсветка на карте покрывает весь верхний коридор, таблица 1.57км/3 заезда,
вердикт 104%. По коридору 4 траверса, но первый 74% (начат выше низа) - отсечён правилом
80% (как pers.py MIN_FRAC), иначе нормировка длина/время завысила бы его скорость.
## Фикс 2: остановка внутри заезда (седло) + сшивка (трек 16 сен)
- Пользователь: первый заезд 16 сен был полным, но разорвался остановкой (опущенное седло,
ноги не крутили) - такие стопы <~минуты сшивать в один заезд, время стопа оставлять в
заезде как штраф.
- Проверка данных: остановка ЕСТЬ - idx 310-319, **89 секунд** сдвиг <15м. По reported speed
её не видно (fused показывал 8 км/ч), нашлась по СМЕЩЕНИЮ. Плюс у вершины трек первого
заезда уходит с общей линии в негорячие ячейки (GPS-разброс на 20 км/ч), из-за чего покрытие
вышло 74%.
- Сделано: (1) в разметке проходов - мост через короткое прерывание рядом с коридором
(`bridgeSec` 90с, `bridgeM` 60м) + сохранена терпимость к нулевым пропускам (gap<=25);
время простоя остаётся в длительности заезда -> честный штраф скорости. (2) Порог покрытия
`minFrac` 0.8 -> **0.7**: полный заезд, у которого GPS сполз с линии у вершины, засчитывается.
- Проверка (харнесс): 16 сен -> **4 заезда** (был 3), норм.скор [19.6, 21.3, 21.2, 23.8] -
первый самый медленный (штраф за 89с стоп), вердикт 121%. 11/17 сен без изменений (по 5),
контроль 5 обычных - 0 ложных при 0.7. Десктоп: 16 сен подсажен, таблица 4 строки.
## Фикс 3: проекция на опорную линию + время только в движении
- Пользователь: снос вбок не случайность (седло падало, подкручивал) - проецировать на
опорную линию, чтобы боковой снос не резал покрытие; остановки на краях заездов не считать
во время.
- Сделано: детект проходов переписан с «точного совпадения ячеек» на ПРОЕКЦИЮ. Опорная линия =
точки лучшего прохода с накопленной дистанцией; каждая точка поездки проецируется на неё
(ближайшая вершина через cell-индекс, допуск вбок `projTolM` 30м) -> прогресс в метрах.
Проход = прогресс растёт от низа до >= minFrac. Снос на 30м больше не роняет покрытие,
поэтому `minFrac` вернул к строгому 0.8 (как pers.py).
- Время заезда = только СЕКУНДЫ В ДВИЖЕНИИ (`movingSec`, по фактическому смещению >=0.8 м/с),
стопы на краях и в середине вырезаются. Нормировка норм.скор = длина/движ.время.
- Проверка (харнесс, minFrac 0.8): 16 сен -> 4 заезда, движ.время [281,268,271,252]с (89с стоп
вырезан из первого), норм.скор [20.1,21.1,20.9,22.5], посл/перв 112%. 11 сен 5× (16.6..18.8,
113%), 17 сен 5× (101%), контроль 5 обычных - 0 ложных, короткие - нет.
## Фикс 4: детекция при 2 заездах (порог 3 -> 2)
- Симптом (пользователь + разбор другого агента): при двух проездах туда-обратно детекции
нет по построению.
- Проверка по коду + харнесс: `minPasses` (по умолч. 3) работал И как порог «горячей» ячейки
(>=3 входов), И как финальный порог числа проходов. Урезанный до 2 заездов трек 16 сен:
порог 3 -> НЕ НАЙДЕНО, порог 2 -> найдено (2 заезда, 1555м). Подтверждено.
- Фикс: `minPasses` по умолчанию 2. Перекрёстки отсекаются длиной (`minLenM` 300м), не
счётчиком; обычная поездка «туда-обратно» (коммьют) не ловится, т.к. проходы считаются
монотонно в ОДНУ сторону (спуск/возврат start-guard отсекает).
- Проверка: 2-заездный -> 2×; 16 сен 4×, 11/17 по 5× (без изменений); 5 обычных поездок
(включая туда-обратно) - 0 ложных. Признаки из разбора агента (возвратный участок, время,
высота) не понадобились - длина+направление уже отсекают мусор. Снос вбок и дырки в цепочке
уже покрыты (проекция 30м в разметке проходов; gap>5 в эталонном проходе).
## Не в этой задаче
Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET);
п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы).

View file

@ -0,0 +1,41 @@
# Сессия 2026-09-18 - объединение поездок (склейка треков)
## Контекст
Трек 16 сен: реально 5 заездов, но первый оказался в отдельной сессии (запись
несколькими кусками). Нужна склейка: долгий тап = выделение, 2+ = объединить,
разрыв >5 мин по времени - предупреждать но не мешать, только склейка (разрез/вырез
были проблемными, позже). После склейки «Разбор повторов» должен увидеть все 5.
## Сделано
- Миграция: `gps_points.orig_session_id TEXT` - исходная сессия точки (для будущего
разделения; склейка обратима без потери данных).
- `DB.mergeGpsSessions(idStrs)` (db.js): сессии сортируются по времени, target = самая
ранняя; точки остальных переносятся (`session_id=target`, `orig_session_id=COALESCE(...)`),
end_time продлевается, лишние сессии удаляются. `seq=NULL` у объединённого трека -
иначе совпадающие seq (у каждой сессии были 1..N) перемешивают порядок в getGpsPoints.
Ids - строками с CAST в INTEGER (i64 corruption).
- UI (`renderGpsScreen`): долгий тап 500мс (touch+mouse) -> режим выделения (`.selected`,
плавающая `#gps-sel-bar`: «Выбрано N», «Объединить» (>=2), «Отмена»). Тап в режиме
переключает. Клик после long-press подавляется. Диалог `appConfirm` с пометкой об
обратимости; предупреждение о разрыве >5 мин. После склейки - `recalculateSession
(noDelete)`, тост, ре-рендер.
- CSS: `.gps-session-item.selected`, `.gps-sel-bar`, фикс `[hidden]` (правило `display:flex`
перебивало атрибут hidden - панель висела всегда).
## Баги по пути (и фиксы)
- Панель выделения показывалась всегда: `.gps-sel-bar{display:flex}` перебивал `hidden`.
Фикс: `.gps-sel-bar[hidden]{display:none}`.
- После склейки в приложении: участок 43 км, скорости 6745 км/ч. Причина: у трёх сессий
seq совпадали (1..N), `getGpsPoints` (ORDER BY seq) перемешивал треки. В node-тесте я
сортировал по ts и не поймал. Фикс: `seq=NULL` при склейке -> порядок по ts.
## Проверка (десктоп Xvfb, подсажены 3 сессии 16 сен, БД восстановлена из бэкапа)
- Node: склейка 3 сессий -> detectRepeats 5 заездов, 1572м, [21.4,20.1,21.1,20.9,22.5], 105%
(по отдельности: две малые - «нет повторов», большая - 4).
- В приложении: долгий тап выделил 11:38 (рамка+фон, «Выбрано 1»), тап добрал 11:50/12:04
(«Выбрано 3»), «Объединить» -> диалог -> 3 сессии стали одной (11:38, 19.89 км, суммарно).
Открыл: «Разбор повторов» = 5 заездов, совпало с node, каденс 88-93, вердикт 105%. Скрины.
- Далее: сборка APK + проверка на телефоне (реальный трек 16 сен там же).
## Не сделано (по просьбе - позже)
Разрезание и вырезание треков (были проблемы). Сейчас только склейка.

View file

@ -0,0 +1,54 @@
# Сессия 2026-09-21 (2) - экспорт/импорт всей БД (резервная копия)
## Контекст
Смена сборки debug<->release стирает данные (разные подписи -> переустановка). Решили:
НЕ делать внутриприложенческую авто-установку APK, а сделать полный бэкап/восстановление БД.
Записано в память: `project_no_self_install`. Секретов в базе нет.
## Сделано
- Rust (`lib.rs`):
- `db_and_root(app)` - резолв пути `baldav.db` + app-root (как в rebuild_roads).
- `export_db(filename)` - `VACUUM INTO` чистой копии в `app-root/export_db.tmp` (консистентно
при живом соединении, сжимает ~на треть), команда `exportdb:<filename>` в gps_command.txt.
- `import_db()` - валидирует `picked_import.db` (`PRAGMA quick_check`=='ok' + есть хоть одна
таблица из gps_sessions/products/profile), копирует в `pending_restore.db`, удаляет picked.
- `restart_app()` - команда `restartapp`.
- Все три в generate_handler.
- Kotlin (`MainActivity.kt`):
- `pendingDbFile` + `saveDbLauncher` (CreateDocument octet-stream, СТРИМ файла, не в память -
файл десятки МБ).
- Ветка `exportdb:` -> SAF-сохранение `export_db.tmp`.
- Ветка `restartapp` -> relaunch (getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK) +
`Runtime.exit(0)`.
- `onCreate` (сразу после appDataDir, ДО открытия БД): если есть `pending_restore.db` -
скопировать на `baldav.db`, удалить journal/wal/shm и pending. Тот же паттерн, что копия сида.
- JS (`main.js`): в Настройках блок «Резервная копия всех данных» с кнопками Экспорт/Импорт.
Экспорт -> `export_db`. Импорт -> confirm (заменит всё) -> `pickLocalFile` -> `import_db` ->
confirm перезапуска -> `restart_app`. Импорт только на Android.
## Проверка
- JS синтаксис ок. Автономный тест `VACUUM INTO` на десктоп-БД: 53МБ -> 36МБ, quick_check ok,
сессии на месте (валидная сжатая копия). Ядро экспорта подтверждено.
- SAF-сохранение, своп при onCreate и рестарт - только на устройстве (Kotlin), десктоп не
воспроизводит. Проверка на телефоне: экспорт -> файл-бэкап валиден; импорт другого бэкапа ->
данные заменились после рестарта. (Сборка bump до 0.1.465.)
## Замечания
- Сжатие gzip не делал: VACUUM INTO уже компактит; flate2 в зависимостях нет. Добавлю, если
нужно меньше.
- Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап
подтянется вверх, новый в старое приложение - лишние колонки безвредны.
## Проверка на теле + фиксы
- Экспорт сперва падал: `vacuum: unable to open database: /data/user/...`. Причина - я взял
корень как `app_data_dir().parent().parent()` (на Android app_data_dir САМ = корень -> ../..
уводит в непишемый `/data/user`). ЭТО УЖЕ ЧИНИЛИ функцией `native_root()`. Исправил на
`native_root()` (как save-команды/rebuild_roads), убрал data_dir-дубли. Записано в память
`reference_android_native_root`. Подтверждено на теле: корень `/data/user/0/dev.baldav.app`
писабелен, там baldav.db + точки + командные файлы -> native_root резолвится туда.
- Экспорт + импорт (восстановление) пользователь проверил на теле - работают.
- Авто-рестарт после импорта не срабатывал: `startActivity()+exit(0)` - гонка (процесс умирает
до подъёма активити). Исправлено: relaunch через AlarmManager+PendingIntent ПОСЛЕ смерти
процесса (ProcessPhoenix-паттерн) + finishAffinity.
## На роутере всё ещё 0.1.464 (build 464). Бэкап-билды на роутер не выкатывал.

View file

@ -0,0 +1,52 @@
# Сессия 2026-09-21 - разрезание трека + блокировка склейки разных великов
## Контекст
Три вопроса пользователя (сначала разобрал по коду, потом делал):
1. «Пересчитать» берёт велик СЕССИИ (не активный) - пользователя устраивает, не трогаем.
2. Хочет разрезать трек в точке смены передачи, чтобы разным кускам назначать свой велик
(два велика = один и тот же, разные передачи) - для точных мощности/каденса.
3. Опасение: склейка разных великов слепит всё в один велик. Решение (по просьбе): при
склейке треков с РАЗНЫМИ великами - предупреждать и НЕ склеивать (не хранить велик по точкам).
## Сделано
- **Блокировка склейки (D):** в обработчике `#gps-sel-merge` (main.js) - если у выбранных
сессий разные `bike_id` (Set по bike_id, null=none), `appAlert` «велосипеды разные, склеить
нельзя, задайте одинаковый и склеивайте» и return.
- **Разрезание (B):** `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)`
(db.js): вторая половина -> НОВАЯ сессия (id=Date.now(), копия date/activity/bike/source,
start=part2 время, end=старый end), точки part2 переносятся по id чанками (200), `seq=NULL`;
у старой end_time=part1. UI: кнопка `#sd-split` включает `#sd-map.split-mode`; в попапе точки
кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> подтверждение ->
splitGpsSession -> пересчёт обоих кусков (`recalculateSession noDelete`) -> назад в историю.
- Точку разреза выбирают ТАПОМ ПО ТОЧКЕ (хит-таргеты уже есть), а не кликом карты - маркеры
перехватывают клик, поэтому кнопка живёт в попапе.
## Проверка (десктоп Xvfb, подсажен склеенный трек 16 сен 1789547934760, 3614 тчк, +2й велик)
- Разрез: «Разрезать трек» -> тост + скролл к карте -> тап точки #3547 -> попап с «✂ Разрезать
здесь» -> диалог (точка/время/км) -> «Разрезать» -> в истории ДВЕ сессии 16-го (part1 3548
тчк 19.54км, part2 66 тчк 343м), обе bike, стата пересчитана. Скрины.
- Блокировка: part2 велик -> 2 (через SQL), выделил обе (долгий тап + тап), «Объединить» ->
предупреждение «велосипеды разные, склеить нельзя», склейки не произошло. Скрин.
- БД восстановлена из бэкапа (0 сессий 16 сен, 1 велик).
## Замечания
- Инъекция `INSERT ... SELECT *` тихо даёт 0 строк, если id сессии больше нет в pulled-БД
(tools/track/baldav.db пере-пулена, старая 1789549492046 уже склеена на телефоне в
1789547934760). Проверять существование id перед выводами.
- `Xvfb :99` снова умирал; `pgrep -f "Xvfb :99"` ловит собственную команду (ложно «жив») -
проверять сокет `/tmp/.X11-unix/X99`.
- part2 start_time берётся из ts точки (UTC), а поле сессии - локальное; мелкая косметическая
рассинхронизация времени (данные так хранятся), на функциональность не влияет.
## Выкатка на роутер (по просьбе)
- Залит СУЩЕСТВУЮЩИЙ `binaries/baldav-debug.apk` (0.1.464, build 464) на роутер keenetic
(LAN 192.168.1.1) в `/opt/share/www/baldav/baldav.apk` + `BUILD_NUMBER=464`, БЕЗ пересборки
(иначе номер уехал бы на 465). Проверено: файл 190101489 б, BUILD_NUMBER=464 на роутере.
Доступно: `http://192.168.1.1:88/baldav/baldav.apk`. Пароль роутера взят из build.sh, не
печатался. VPS-зеркало не трогал (просили только роутер).
- Текущие версии сведены в README (раздел «Текущие версии»): app 0.1.464/build 464, на роутере
464, BRouter 1.6.3, сид продуктов 11.
## Не сделано (по просьбе - позже/не нужно)
- Велик по точкам (вариант C) не делаем: пользователь просто не будет склеивать разные велики.
- Вырезание участка трека - позже.

View file

@ -0,0 +1,25 @@
# Сессия 2026-09-21 (3) - мелкие UI-фиксы
## #1 balance-trend показывал 8 дней вместо 7 (исправлено)
- Симптом: вверху страницы «Среднее за 8 дн.», а график ниже - за 7.
- Причина: `getDailyFoodTotals(7)` -> SQL `WHERE date >= date('now','-7 days')` - диапазон
включает оба конца (today-7 ... today) = **8 календарных дней**. Balance-trend показывал
`balances.length` (сырое число строк) = 8. График строит свой ряд, поэтому у него 7.
- Фикс точечный (не трогая общий запрос и график): в balance-trend `food7 = food7.slice(-7)`
- берём последние 7 дней. Корневой off-by-one в `getDailyFoodTotals` не трогал, чтобы не
сдвинуть график 30/90.
## #2 сегодняшние повторы не опознались (исправлено)
- Трек 1790002321789 (out-and-back A(47.4915,40.0817)<->B(47.4917,40.1025), ~1.5км, 5 раз
туда-обратно). `detectRepeats` -> «не найдено».
- Диагностика: горячих ячеек полно (197 с >=3 входами, макс 10). Эталон (самый длинный проход)
при пороге горячей ячейки **2** раздувается до **2190м** - тянет разово/дважды накатанный
подъезд и развороты. Полный заезд 1585м покрывает лишь 72% этого эталона (<80%), поэтому
засчитывался лишь 1 проход. При пороге 3+ эталон сжимается до ядра **1585м** и находятся все 5.
- Причина глубже минусов порога: жадный порог 2 (нужен для «2 заездов») ломает эталон, когда
подъезд накатан 2 раза.
- Фикс: `detectRepeats` теперь ПЕРЕБИРАЕТ порог горячей ячейки от 2 до макс.входов и берёт тот,
что даёт БОЛЬШЕ проходов (при равенстве - длиннее участок). `minPasses` -> только финальный
порог числа проходов (`minReps`), развязан с порогом горячей ячейки. Вынесено в `detectAt(T)`.
- Проверка (харнесс): СЕГОДНЯ 5× 1590м [16.8,17.6,17.1,17.1,16.8]; 11сен 5×, 17сен 5×,
16сен(склеен) 5×; 5 обычных поездок - 0 ложных. ~0.9с/сессия на десктопе (перебор порогов).

View file

@ -0,0 +1,35 @@
# Сессия 2026-09-22 - баланс/графики от выбранного дня + вес
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`.
## Часть 1 - окно от выбранного дня
- `getRecentWeights/getDailyFoodTotals/getDailyActivityTotals` (db.js) получили опциональный
`endDate` (по умолч. 'now'). Диапазон сменён с `date >= date('now','-N days')` на
`date > date(<end>,'-N days') AND date <= date(<end>)` - ровно N дней, кончая на endDate
(заодно чинит старый off-by-one N+1).
- `renderToday` (main.js): BMR верхнего баланса `getRecentWeights(7, state.date)`; тренд
`getDaily*(7, state.date)` (убрал костыль `slice(-7)`); `renderStatsCharts(...,refDate=state.date)`
- все три запроса графиков и чипы 30/90 теперь от выбранного дня.
- Единый BMR у баланс-графика оставлен (от веса выбранного дня); per-day BMR не делал.
## Часть 2 - вес
### 2a синк настроек
- `DB.addWeight`: после вставки `setProfileValue("weight_kg", latest.weight_kg)` - настройки
всегда = последнее взвешивание. Колбэк модалки веса (main.js) обновляет `state.latestWeight`
и `state.profile.weight_kg`.
### 2b вес на момент поездки
- Миграция `gps_sessions.rider_weight_kg REAL`. Новый сеттер `DB.setSessionRiderWeight` (отдельно
от finishGpsSession, её зовёт и пересчёт).
- Запись: при финализации трека (gps.js) сохраняется `GPS.recording.weightKg`.
- Резолв веса везде первым берёт `session.rider_weight_kg`: `recalculateSession` (recorded+manual),
`recomputeAllStats` (per-session в цикле), деталь трека (2 места), `recomputeStatsFor`, разбор
повторов. Старые сессии (NULL) -> latest weigh-in (без регресса).
## Ответ пользователю (вопрос)
Ккал брал вес из последнего взвешивания (не из настроек; настройки - запас). Теперь новые
поездки хранят вес на момент записи, пересчёт использует его; настройки авто-синкаются с весом.
## Проверка
- SQL-диапазон на десктоп-БД: `> date(end,'-7') AND <= date(end)` = ровно 7 дней, кончая на
выбранном дне (старый `>=` давал 8). Подтверждено.
- Дальше: десктоп (Xvfb) - листание дней двигает баланс/тренд/графики; вес-синк; затем APK+тело.

View file

@ -0,0 +1,27 @@
# Сессия 2026-09-22 (2) - достройка пропусков уходила «в начало»
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`.
## Симптом
Достройка GPS-пропусков (🧩) показывала превью нормально, но сохранённый трек тянул прямую
линию от старта к месту пропуска («возврат в начало»). На любом (недавнем) треке.
## Корень
`saveGapFill` (db.js) ставит достроенным точкам `seq = lastSeq` (seq последней РЕАЛЬНОЙ точки
перед пропуском). У склеенных/разрезанных треков реальные точки имеют `seq = NULL` (склейка/
разрез специально обнуляют seq -> порядок по ts). Тогда `lastSeq` остаётся 0, все достроенные
точки получают seq=0, а `getGpsPoints` (`ORDER BY COALESCE(seq,999999), ts`) ставит их ПЕРЕД
реальными (NULL→999999) -> прямая к началу. Подтверждено: единственный NULL-seq трек в БД -
склеенный 16.09; недавние треки пользователя склеены.
## Фикс
`saveGapFill`: после вставки достроенных точек - пере-нумерация seq ВСЕЙ сессии по ts (читаю
id `ORDER BY ts,id`, seq=порядковый номер, пакетный `CASE id WHEN..` чанками по 300, паттерн
из `saveSmoothedTrack`). Теперь getGpsPoints всегда в порядке ts независимо от исходного seq
(плотный/NULL/смешанный). Для плотных треков - no-op, для склеенных - чинит.
## Проверка
- SQL: у склеенного 16.09 реальные точки seq=NULL (seq=0 достроенной встала бы перед ними) -
корень подтверждён.
- Десктоп (Xvfb): подсадка склеенного трека -> 🧩 -> Сохранить -> переоткрыть: без прямой к
началу. Затем сборка + телефон, вытянуть сегодняшний трек.

View file

@ -0,0 +1,62 @@
# Сессия 2026-09-26 - тумблер "следование карты за позицией" на экране записи
Работали по утверждённому плану: `docs/plans/2026-09-26-map-follow-toggle.md`.
## Контекст
Во время записи карта на экране трекинга при каждой новой GPS-точке центрируется на текущей
позиции (`map.panTo`). Кейс: отклонился от трека или трек ведёт туда, где дороги нет, надо
осмотреться по карте - но она тут же прыгает обратно, подвигать её нельзя. Нужен тумблер:
нажал - карта перестаёт следовать (можно двигать), нажал снова - центрируется на позиции и
следование возобновляется.
Согласовано с пользователем (AskUserQuestion): кнопка - символ-плитка в общем ряду кнопок
карты (не слово), активная подсветка = слежу; только кнопка-тумблер (драг карты следование НЕ
выключает); повторный тап центрирует; состояние не запоминается между сессиями.
## Действия
- `frontend/js/main.js`, `renderRecordingInline`:
- добавлен флаг `let followMode = true;` рядом с `compassFrozen`/`rotOffset`;
- создана кнопка `followBtn` (`class="rec-map-btn rec-follow-btn active"`, символ `⊕`,
`title="Следить за позицией"`) по образцу `freezeBtn`; onclick тогглит `followMode` и класс
`active`, при включении центрирует `map.panTo` на последней точке `GPS.recording.points`
(сортировка по ts);
- 4 места автоследования загардены `if (followMode)`: `onPoint` `panTo` (~4840), `onPoint`
waiting_gps `setView` (~4857), `onStateChange` "ready" `setView` (~4814), `onBatchUpdate`
`panTo` (~5005).
- `frontend/css/styles.css`: `.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }`
(следующий слот стека после `.rec-dark-toggle` 238px). Базовый вид и `.active` -
переиспользован `.rec-map-btn` / `.rec-map-btn.active`.
- README: добавлен буллет "Слежение карты (тумблер ⊕)".
## Проверка
- `node -c frontend/js/main.js` - OK.
- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh` = tauri dev, без бампа версии): открыт экран
записи (Старт), кнопка `⊕` в правом стеке вверху, по умолчанию подсвечена (слежу). Тап ->
подсветка гаснет (слежу выкл). Тап -> подсветка возвращается (слежу вкл). JS-ошибок в логе
нет. Скрины: s_rec.png / s_rec_off.png / s_rec_on2.png в scratchpad.
- НЕ проверено на десктопе: живое поведение "карта не прыгает на новую точку при выкл / прыгает
при вкл" - под Xvfb настоящего GPS нет, экран в waiting_gps, поток точек не идёт. Логика -
тривиальный guard вокруг уже работающих `panTo`/`setView`. Реальная проверка потока - на
телефоне.
## Деплой build 475
`./build.sh -u -i -d android`: собран debug APK (0.1.475, 182М), залит на роутер
(http://192.168.1.1:88/baldav/baldav.apk, build 475) и VPS
(https://4aiman.top/baldav/baldav.apk, build 475), установлен на 320245779368 через
`adb install -r --user 0` - Success (подпись совпала, данные сохранены). Живую проверку тоггла
следования в реальной записи делает пользователь на телефоне.
## Чистка старых бандлов (build.sh)
Место забивалось регенерируемыми выхлопами Android-сборки. Добавлена `clean_bundles()` в
build.sh: удаляет `gen/android/app/build/intermediates/intermediary_bundle` +
`outputs/bundle` (AAB - мы раздаём APK) и `outputs/apk/*.apk`. НЕ трогает `src-tauri/target`
(14Г Rust-инкрементал, иначе полная пересборка) и `binaries/` (последние APK). Вызывается
авто в конце каждой android-сборки (после копии APK в binaries/) и вручную `./build.sh clean`.
Согласовано с пользователем: объём "только бандлы/APK", запуск "авто + ручная".
Разовый прогон: gen build 1.6G -> 1.0G, освобождено ~557 МБ.
## Следующие шаги
- Живая проверка тоггла `⊕` на телефоне: проехать/постоять, отжать, подвигать карту (новые
точки не возвращают), вернуть кнопкой (центрируется). Доработки - отдельным коммитом.
- Компас (freeze `X`) и поворот карты не задеты (следование = только центр вида, поворот =
bearing) - убедиться, что обе кнопки работают независимо.

View file

@ -0,0 +1,44 @@
# Сессия 2026-09-29 (3) - прогресс на кнопках, память масштаба карты
Продолжение дня (см. `2026-09-29_startup-slowness.md`). По списку задач пользователя.
## Что значат сообщения достройки (объяснение, без правок)
`GPS.fillGaps` (gps.js) чинит только реальные потери сигнала: разрыв > 60с И хорда > 150м.
Для такого разрыва спрашивает у BRouter дорожный путь между концами:
- "больше нужного" = `skipped-long`: дорога есть, но путь длиннее прямой более чем в 2 раза
(крюк). Значит ехал не по дороге (тропа/поле/грунт, которого нет в графе) - оставляем прямую.
- "нет данных" = `skipped-noroute`: BRouter не проложил вообще - нет дорожных данных на участке
(не скачан .rd5-сегмент региона / пусто). Мостить нечем.
Это не баг, а честный отказ достраивать заведомо кривым путём.
## Прогресс на долгих действиях (заливка фона кнопки по %)
- CSS `.btn-progress` (styles.css): фон кнопки заливается слева по `--pct` (ставится из JS через
CSS-переменную, не инлайн-стиль). Хелпер `setBtnProgress(el, pct)` (main.js).
- "Уточнить высоты" (`#sd-refine`): реальный % от сетевых батчей `fetchElevations(pts, onProgress)`
(0-80%), затем сохранение+пересчёт (85-100%). Сеть - api.gpxz.io / open-elevation /
open-meteo / opentopodata.
- "Пересчитать" (`#sd-recalc`): `GPS.recalculateSession(id, {onProgress})` шлёт честные вехи по
фазам (5 старт / 40 фильтр / 70 стата / 100 запись).
- "Перестроить" дороги (`#roads-rebuild`): Rust `rebuild_roads` эмитит `roads_progress`
[current,total] каждые ~2000 точек (в проекте раньше событий из Rust не было вообще - добавлен
`use tauri::Emitter`), JS слушает и заливает кнопку + пишет "Построение: cur / tot". Убирает
"просто подвисает".
## Гонка reopenDetail - распространил на recalc/refine
`#sd-recalc` и `#sd-refine` раньше делали тот же ручной `winStack.pop + history.back +
showSessionDetail` (гонка с пустой картой/графиками). Переведены на `reopenDetail` (ждёт
popstate) - фикс той же гонки.
## Память масштаба карты
- Деталь трека: `_sdMapView[sessionId]` хранит {центр, зум}, обновляется на moveend/zoomend; при
рендере, если вид сохранён - `setView` вместо `fitBounds`. Так после пересчёта/уточнения (reopen)
и при возврате к треку масштаб сохраняется, а не сбрасывается на весь трек. Первый показ - как
раньше, fitBounds.
- Карта записи (`renderRecordingInline`): зум хранится в localStorage `baldav_rec_zoom`
(сохраняется на zoomend, восстанавливается при создании карты вместо жёсткого 15). Так возврат
на экран трекинга держит масштаб.
## Проверка
- Синтаксис JS - ок. Сборка debug + телефон. Проверить: заливка кнопок идёт по %, дороги больше
не "висят молча"; после "Пересчитать"/"Уточнить" карта на том же масштабе; вернулся на запись -
масштаб прежний. (Задача 1 - фикс гонки reopenDetail - проверена через CDP отдельно.)

View file

@ -0,0 +1,115 @@
# Сессия 2026-09-29 - долгий старт БалДава + .gitignore
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (замерить -> починить).
## Задача
1. build.sh + BUILD_NUMBER в .gitignore (в build.sh пароль роутера, не место в git).
2. Разобраться, почему БалДав стал долго стартовать, и починить.
## .gitignore
Добавлены `build.sh` и `BUILD_NUMBER` (оба были untracked, не в игноре). Коммит 2d91c06.
## Диагностика старта (по требованию - сначала замер)
Собирается только debug (release не рассматриваем), значит причина не в профиле сборки, а в
работе на каждом старте. Замер на устройстве (adb am start -W + логи Tauri/Console, тайминг-логи
временно добавлял в init/lib.rs, потом убрал):
- `am start -W` TotalTime ~400ms (первый кадр - экран загрузки, быстро). Медленно - JS-init
ПОСЛЕ первого кадра, он не виден в TotalTime.
- В логах: `[db.js] Repairing seq for 3 sessions...` -> сессии 687 + 3614 + 10865 точек с
`seq IS NULL`. Репейр (db.js `runMigrations`, ~2019) делал **по одному UPDATE на точку** -
~15000 последовательных IPC-round-trip'ов в debug-Rust. По таймингам одна сессия 3614 =
~3.8с, сессия 10865 не досчитывалась.
- Ключевое (проверено через `run-as cat baldav.db` + sqlite3, journal_mode=delete, WAL ни при
чём): репейр гонялся на КАЖДОМ старте (2 подряд без действий пользователя) и для крупных
сессий seq НЕ сохранялся -> заново каждый запуск. Это и есть "долго": ~15с блокирующего
init'а на старте.
Причина "стал": недавние merge/split (свежие фичи) создали NULL-seq сессии на 10865 и 3614
точек; поточечный репейр давился на них.
## Фикс (frontend/js/db.js, репейр seq в runMigrations)
Поточечный цикл заменён на **батчевую пере-нумерацию** (CASE чанками по 300, паттерн
`saveGapFill`/`saveSmoothedTrack`): один UPDATE на ~300 точек вместо одного на точку. Порядок -
по ts (как и было). ~15000 invoke'ов -> ~50.
## Проверка на устройстве (build 477, debug)
- BOOT 1 (seq ещё NULL): репейр всех 3 сессий (15166 точек) за **~1.1с** (было ~15с+);
`[BOOT] migrations` = 1356ms.
- BOOT 2 (без действий между): "checking seq repair" есть, "Repairing seq" НЕТ - `nullSeqSessions`
пусто, репейр не запускался. `[BOOT] migrations` = **357ms**. seq сохранился, репейр стал
одноразовым.
- Дамп БД после чистого force-stop: 0 NULL seq во всей БД; сессия 1789547934760 - seq 1..3614,
инверсий по ts = 0 (порядок корректный, регрессии в отрисовке трека нет).
## Осталось / отдельно
- `import_seed_products` (lib.rs) на up-to-date-старте всё равно копирует сид 8.5МБ + гоняет
дедуп = ~259ms каждый старт. Это НЕ было причиной "долго" (репейр был), поэтому seed-опт не
делал: область products/dedup имела баги (FK), трогать без нужды рискованно. Вынес пользователю
на решение.
- Тайминг-обвязку (JS [BOOT], Rust [SEED-TIMING]) убрал; lib.rs/main.js вернулись к HEAD.
## Пропажа тайлов карты на большом треке (отдельный баг)
Симптом: смотришь трек 60 км (сессия 1790501377655, 10865 точек), зумишь/таскаешь - тайлы карты
пропадают, помогает только force-stop+старт.
Диагностика по живому logcat (пользователь воспроизвёл: открыл трек -> достроил -> сохранил ->
reopen -> убрал достройку -> зум/таскание; буфер предварительно увеличил до 16МБ и очистил):
- `E chromium: [ERROR:cc/tiles/tile_manager.cc:1046] tile memory limits exceeded, some content
may not draw` - 40+ раз ровно в окно зум/таскания. Это лимит памяти тайлов КОМПОНОВЩИКА
Chromium (не карты, не TileCache). Никаких OOM-килла, крахов рендера, CSP по тайлам.
- Причина: трек рисуется дефолтным SVG-рендерером Leaflet (`preferCanvas` нигде не задан), да
ещё многослойно (casing/core + раскраска по уклону мелкими сегментами). Гигантский SVG-слой на
весь трек при зуме превышает бюджет растровой памяти -> компоновщик перестаёт рисовать часть,
тайлы гаснут. Force-stop сбрасывает компоновщик. Проявляется на любом крупном треке/карте.
Фикс (frontend/js/main.js): `preferCanvas: true` на картах с тяжёлыми векторами (canvas рисует
в один вьюпортный холст вместо тысяч SVG-путей):
- `showSessionDetail` (деталь трека, исходный баг), `showRoadsMap` (карта дорог - много
полилиний, пользователь отметил подтормаживания), `showComparisonView` (два трека),
`showRouteDetail` (превью маршрута с раскраской).
- НЕ трогал: `renderRecordingInline` (там `rotate:true`, а leaflet-rotate почти не поддерживает
Canvas - 1 ссылка на L.Canvas против 5 на SVG; canvas+rotate рискован, отдельная проверка),
редактор/рисование маршрута (клик/тап по линии, малые полилинии), цель-маркер (без треков).
Проверка: воспроизвести зум/таскание на треке 60км и на карте дорог, снять logcat, убедиться,
что `tile memory limits exceeded` больше НЕ появляется, тайлы держатся.
### Итог проверки canvas (build 479) - помог частично
Пользователь воспроизвёл на 479: "подвисло, но восстановилось" (раньше - только force-stop).
Снял logcat + инспекция живой страницы через CDP (`test_recovery/cdp.js`,
`adb forward ... webview_devtools_remote_<pid>`):
- Canvas ПРИМЕНИЛСЯ: в DOM детали `svgPaths=0`, оверлей - canvas.
- Но `tile_manager tile memory limits exceeded` всё равно ~46 раз (столько же). Значит бюджет
растровых тайлов компоновщика забивают САМИ тайлы карты (высокий DPI: 1260 физ / 404 css ≈ 3x)
+ объём трека, а не SVG. Canvas дал живучесть (само восстанавливается), но джанк от 10865
сегментов остался.
### Догоняющие правки (по решению пользователя: урезать тайлы + меньше точек на зум-ауте + POI)
1. `tilecache.js` - у тайл-слоя `keepBuffer: 1` (было 2) и `updateWhenZooming: false`: меньше
офскрин-тайлов в памяти компоновщика, тайлы не грузятся в процессе зума.
2. `main.js` - `smoothFactor: 2.5` на главных полилиниях трека (деталь, `showSessionDetail`) и
на сегментах карты дорог (`showRoadsMap`). smoothFactor - допуск Дугласа-Пекера в ПИКСЕЛЯХ,
поэтому сам прореживает: при зум-ауте точки сходятся в пиксели и выбрасываются (меньше
сегментов), при зум-ине - полная детализация. Ровно "меньше точек на сильном зум-ауте".
3. `poi.js` - кап маркеров POI масштабируется зумом (z>=16: 500, 15: 200, 14: 80, 13: 30):
на сильном зум-ауте показываем горстку ближайших, а не всё подряд.
Проверка: воспроизвести тот же сценарий, снять logcat - ожидаем, что `tile memory limits
exceeded` резко упадёт/исчезнет, джанк уйдёт; POI на малом зуме - мало маркеров.
Результат (build 480): пользователь - "всё ок".
## Баг: после "убрать достройку" пусто (карта+графики) до следующего действия
Симптом: revert достройки -> графики и карта пропадают, из экрана трека не выкидывает,
появляются только после следующего действия ("Отмена"). Стек окон + гонка.
Корень: `reopenDetail` (main.js:1176) стрелял `history.go(-count)` (асинхронные popstate) и
СРАЗУ синхронно `showSessionDetail`. showSessionDetail пушит свою history-запись, потом
отложенные popstate от go(-count) раскручивают уже ЕЁ и десинхронят стек; при fallthrough в
renderScreen второй showSessionDetail бампает `_sessionDetailRender`, и первый async-рендер
карты/графиков бросается на проверке токена (5411) -> пусто до следующего события. Ровно тот
же класс, что чинил `closeThen()` (комментарий там про это).
Фикс: `reopenDetail` сделан async и ДОЖИДАЕТСЯ popstate от history.go перед showSessionDetail -
через Promise + слушатель popstate (как `closeThen`), без setTimeout/setInterval.
Проверка: revert на устройстве, по [SD]-логам один прогон showSessionDetail, карта+графики
сразу на месте.

View file

@ -0,0 +1,30 @@
# Сессия 2026-09-30 - достройка пропусков предпочитает проеханные дороги
## Проблема (замечание пользователя)
Достройка отказывалась мостить разрыв там, где реально проезжал (есть два проезда), с
сообщением "больше нужного"/"нет данных". Причина: `GPS.fillGaps` звал только публичный BRouter
"shortest" по OSM и ничего не знал о собственных треках пользователя. В проекте уже есть router
по проеханным дорогам (`rideRoute`, `get_ride_graph`), которым пользуется редактор маршрутов, но
достройка к нему не была подключена. Отсюда: где ехал по тропе/грунту (нет в OSM) - публичный
BRouter либо в объезд > 2x, либо noroute.
## Фикс
- `GPS.fillGaps(points, opts)` (gps.js): добавлен `opts.routeFn(a,b) -> {points, ridden}`; дефолт -
публичный BRouter. Для `ridden:true` (путь по проеханным дорогам) НЕ применяется гвард "объезд
> 2x" - проеханный путь это факт, а не крюк (`rideRoute` и так ограничивает себя 2.5x). Каждому
разрыву в `gaps[]` пишется `ridden`.
- Место вызова (main.js, деталь трека): `routeFn` = `rideRoute` сначала, публичный BRouter
fallback. Так: есть моя дорога -> по ней; нет -> публичное; фэйл (в объезд / нет данных)
остаётся, но с понятным сообщением.
- Сообщение (drawGapPatches + панель): скипы считаются раздельно (skipped-long / skipped-noroute)
и словами: "Не достроено: N (нет твоей проеханной дороги; K — публичный маршрут уводит в объезд,
L — нет дорожных данных)."
## Оговорка
Если участок проезжен ТОЛЬКО в этой поездке (где сам разрыв - точек нет), в ride-графе его нет,
идёт fallback на публичный BRouter (как раньше). Ожидаемо: не ехал по другим - нельзя достроить.
## Проверка
Синтаксис ок. Собрать + телефон: на воскресном 60-км нажать 🧩, убедиться, что разрывы по
проеханным дорогам теперь достраиваются (голубым по реальной дороге), а сообщение по недостроенным
- понятное. Отдельно: где не ехал - остаётся честный отказ.

View file

@ -0,0 +1,41 @@
# Сессия 2026-10-02 - BLE-пульсометр: подключение вручную, без авто-поиска
По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`.
## Проблема
Расход батареи: при КАЖДОМ старте записи `GPS._startHrMonitor` безусловно звал `ble_connect`, а
Kotlin `BleHrService` при отсутствии браслета всё равно делал `directConnect` (держит радио) +
сервис `START_STICKY`. Если браслета нет рядом или BT выключен - расход впустую.
## Поведение (по указанию пользователя)
1. BT включён: НЕ искать/коннектиться автоматически. Подключение к сопряжённому браслету - только
вручную, кнопкой в настройках, доступной в любой момент (в т.ч. во время записи). Нажал ->
пульс пишется в трек с этого момента.
2. BT выключен: ничего не делать; при ручной попытке - системный запрос включить BT.
## Правки
- `frontend/js/gps.js` `_startHrMonitor`: убран авто `ble_connect`. Остался только дешёвый опрос
`ble_get_hr` (2с -> `currentHr`), чтобы ручное подключение (сейчас или позже по ходу записи)
начало класть пульс в трек.
- `frontend/js/main.js` настройки "Пульсометр (BLE)": кнопка **"Подключить"** (видна, когда
устройство сопряжено). Обработчик: `ble_check_bluetooth`; `off` -> `ble_request_enable` +
подсказка; `on` -> `ble_connect(addr,type)`; если идёт запись - поднять опрос
`GPS._startHrMonitor()`; короткий опрос статуса ~6с -> "подключён"/"браслет не найден".
- `src-tauri/src/ble.rs`: команда `ble_request_enable` (android -> send_command
"ble_request_enable"); зарегистрирована в `lib.rs` invoke_handler.
- `MainActivity.kt`: ветка `ble_request_enable` -> `startActivity(ACTION_REQUEST_ENABLE)` под
`ensureBlePermissions()`. Разрешение BLUETOOTH_CONNECT в манифесте уже есть.
- `BleHrService.kt`: `START_STICKY` -> `START_NOT_STICKY` (scan/connect; нет churn после убийства);
`connectDevice` - если скан 5с НЕ нашёл адрес, `stopSelf` вместо `directConnect` к отсутствующему
устройству (именно directConnect держал радио).
## Коммит
Одним коммитом (JS+Rust+Kotlin - одна связная фича: кнопка зовёт новую команду; дробить на
нерабочие промежутки смысла нет).
## Проверка (устройство, debug)
- Запись без браслета / BT off: нет "Поиск BLE", нет коннекта (logcat BleHrService молчит). GPS ок.
- BT off -> "Подключить": системный запрос включить BT.
- BT on, браслет надет -> "Подключить" (в т.ч. во время записи): подключается, heart_rate пишется
в точки с момента подключения.
- BT on, браслета нет -> "Подключить": "браслет не найден", сервис остановился (не висит).

View file

@ -0,0 +1,30 @@
# Сессия 2026-10-17 (2) - попап "включи BT/гео" при старте записи + 2 настройки "не спрашивать"
## Задача
Пользователь забывает включать Bluetooth и GPS. Нужно: при старте записи, если выключено -
предложить включить; дать отказаться; 2 отдельные настройки "не спрашивать".
Факт-чек: телефон С Google-сервисами (ранее ошибочно решил, что без). Тихо включать BT/гео
приложение на target 36 не может (ОС), поэтому - попап + действие: BT через системный диалог
включения, гео через экран настроек геолокации.
## Сделано
- Rust `read_state_file(name)` (allowlist "location_state.json") - читает ответ Kotlin из
native_root. Зарегистрирован.
- Kotlin MainActivity: команды `check_location` (пишет `location_state.json` {enabled} через
LocationManager.isLocationEnabled) и `open_location_settings` (ACTION_LOCATION_SOURCE_SETTINGS).
- `gps.js` `GPS.isLocationEnabled()` - дёргает check_location, читает state-файл; true/false или
null (неизвестно - тогда не спрашивать).
- `main.js` `ensureSystemReady(needBt, needGps)` - перед startRecording: BT off ->
appConfirm "Включить?" -> `ble_request_enable`; гео off -> appConfirm "Открыть настройки?" ->
`open_location_settings`. Гейтится настройками `baldav_dontask_bt` / `baldav_dontask_gps`.
Вызов в кнопке записи: needBt = есть привязанный датчик, needGps = не зарядка.
- Настройки (раздел Пульсометр): 2 чекбокса "Не спрашивать про Bluetooth" / "...геолокацию".
Ограничение (честно): авто-ВЫКЛ BT/гео после тренировки приложение сделать не может (ОС).
Тихое включение - тоже; поэтому попап + один тап.
## Проверка
Синтаксис JS ок. На телефоне: выключить BT и гео, старт записи трека -> два попапа по очереди
(Включить BT / Открыть настройки гео); отказ -> запись идёт как есть; включить чекбоксы "не
спрашивать" -> попапы не показываются.

View file

@ -0,0 +1,66 @@
# Сессия 2026-10-17 - HR-датчик Coospo H808S: чтение + режим "Зарядка" без GPS
## Датчик прочитан с ПК (без спаривания)
`F7:72:74:A4:FB:07` (`808S 0026275`), стандарт 0x180D/0x2A37. `tools/ble_probe.py`
подключается без pair(), живой пульс ~1/сек (+ RR). `tools/hr_log.py` - фоновый логгер
(epoch,bpm в файл, авто-переподключение). Адаптер ПК - hci1 (один). Подробности в памяти
project-hr-sensor.
## Режим "Зарядка" (workout) - запись без GPS, только пульс + нагрузка
Нужен был режим трекинга без геолокации (домашняя зарядка): меряет пульс и ккал, GPS не
включает и не пишет.
- `frontend/js/gps.js`:
- `GPS.hrKcalNet(samples, profile)` - нетто-ккал по пульсу (Keytel gross минус BMR/мин,
как и остальная активность). samples = [[relSec,bpm]].
- `startRecording(type, wkg, profile)` - ветка `workout`: НЕ запускает нативный GPS-сервис,
watchPosition, поллинг точек. Создаёт сессию (`activity_type="workout"`), запускает HR-опрос.
- HR-опрос (`_startHrMonitor`) при workout пушит `[relSec,bpm]` в `GPS.recording.hrSamples`.
- `stopRecording` ветка workout: считает ккал (Keytel), avg/max пульс, сохраняет `hr_samples`
(JSON) + `finishGpsSession(dist=0, kcal)`.
- `frontend/js/db.js`: миграция `gps_sessions.hr_samples TEXT`; `setSessionHrSamples`.
- `frontend/js/main.js`:
- выпадашка записи: "Зарядка (пульс)".
- `renderWorkoutInline` - экран записи без карты: крупный пульс, таймер, живые ккал,
средний/макс, кнопка "Подключить пульсометр" (с проверкой BT), Стоп -> деталь.
- `renderWorkoutDetail` - деталь без карты: график пульса (цвет по HR-зонам), время,
нагрузка (ккал), средний/макс, удалить.
- `frontend/css/styles.css`: `.workout-screen` и пр.
Ккал зарядки идёт в дневной баланс автоматически (`getDailyActivityTotals` суммирует
`gps_sessions.total_kcal`). `recalculateSession` для workout - no-op (точек нет, возвращает null),
так что "Пересчитать всё" не затирает HR-ккал.
## Заряд ремня ЧСС + голос/писк на 10% и 5% (сделано)
- Kotlin `BleHrService`: читает 0x180F/0x2A19 (readBattery) через 2.5с после подписки и раз в
60с; `lastBattery` пишется в `ble_hr_data.json` как `"bat":N`. onCharacteristicRead (две
перегрузки под API 33+/старый).
- Rust `HrReading`: поле `battery: Option<u8>` (alias "bat").
- JS `gps.js`: поллинг ловит `reading.battery` -> `GPS.recording.currentBattery` +
`GPS._checkBatteryAlert`: на 10% и 5% ОДИН раз - голос (`speechSynthesis` ru-RU) + писк
(Web Audio, гарантированный сигнал, т.к. TTS на de-Googled ROM может не быть). Сброс флагов
при заряде > 15% (перезарядили/заменили).
- Экран зарядки показывает "заряд N%" в статусе.
- Заметка: аудио в WebView может требовать user-gesture; старт записи - это жест, должно
играть. Проверить на устройстве.
## Правка после теста: авто-ЧСС на старте + тоггл ВКЛ/ВЫКЛ вместо "Ветер"
Я перестарался: по просьбе "не искать, если браслета нет" убрал автоконнект ВООБЩЕ. Нужно было
оставить авто с проверкой наличия. Исправлено:
- `GPS.setHrOn(on)` (gps.js): ON - проверяет BT (off -> запрос включения), коннектит привязанный
датчик, запускает поллинг; OFF - стоп поллинг + disconnect. Безопасно: BleHrService при
отсутствии браслета коротко сканит и `stopSelf` (драйна нет).
- Старт записи (и GPS, и зарядки): если датчик привязан - авто `setHrOn(true)`. Так "надел
датчик -> начал -> подключился сам", без ручного коннекта каждый раз. Нет привязанного - ЧСС off.
- Экран записи: кнопка **"Ветер" убрана** (неудобна), на её месте **тоггл ЧСС** (квадрат с
сердечком+линией пульса; серый = выкл, красный = вкл), дёргает `GPS.setHrOn`.
- Ручная кнопка "Подключить" в настройках остаётся.
Факт-чек (я ошибся ранее): на телефоне Google-сервисы ЕСТЬ (`com.google.android.gms`, Play
Store) - значит системный диалог включения гео через Play Services доступен. Авто-вкл BT/гео по
запросу (попап + 2 настройки "не спрашивать") - отдельная задача, теперь подтверждено выполнимо.
## Проверка
Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите
пульсометр"), подключить датчик -> пульс крупно + ккал растут; Стоп -> деталь с графиком.
Нужен BT на телефоне + привязанный датчик. (Сейчас BT на телефоне выключен.)

View file

@ -0,0 +1,54 @@
# Сессия 2026-10-19 - краш при выключении Bluetooth + один запрос на включение вместо нескольких
## Контекст
Пользователь: "при выключении блютуса балдав падает", и попапы про включение BT лезут
повторно ("если запретил - не просить до нового трекинга; просить ОДИН раз, а не 20").
Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (финальный
вариант; сохранён в docs/plans не был - план короткий, воспроизведён в этом логе).
## Разбор (две независимые причины)
1. Краш. Крэш-буфер телефона: нативный abort libc FORTIFY "pthread_mutex_lock called on a
destroyed mutex" (pid 19579), не Kotlin-исключение. Причина: живой GATT/scanner трогают
уже после выключения BT-адаптера. В `BleHrService.kt` не было приёмника состояния BT, а
отложенные `scanner.stopScan`, fitpro-ретриггер и периодические хендлеры продолжали дёргать
GATT. Точный фрейм без tombstone не прибивался - фикс сделан defense-in-depth (решение
пользователя: сразу защита + проверка воспроизведением).
2. Двойной запрос. `ensureSystemReady` (main.js) спрашивает один раз через appConfirm, но
`startRecording` авто-зовёт `setHrOn(true)`, а тот при BT off САМ дёргал нативный
`ble_request_enable` (gps.js:899), игнорируя отказ -> второе (системное) окно сразу после
отказа, и так каждый старт.
## Сделано (2 коммита)
Коммит 1 (краш, Kotlin `BleHrService.kt`):
- Единый `handler = Handler(Looper.getMainLooper())`; все postDelayed через него;
`handler.removeCallbacksAndMessages(null)` в `disconnect()` и `onDestroy()`.
- `BroadcastReceiver` на `BluetoothAdapter.ACTION_STATE_CHANGED`: на STATE_TURNING_OFF/OFF -
снять хендлеры, disconnect, `writeData(status:bt_off)`, stopSelf. Снятие в onDestroy в try.
- try/catch на всех нативных BLE-вызовах: startScan/stopScan/connectGatt/discoverServices/
writeCharacteristic/setCharacteristicNotification/writeDescriptor; directConnect проверяет
adapter.isEnabled.
Коммит 2 (попапы, JS):
- `GPS.setHrOn(on, interactive = false)` (gps.js): системное окно `ble_request_enable` только
при `interactive === true`. Авто-режим при BT off: hrOn=false, не подключаться, без окна.
- Авто-вызовы на старте (gps.js 1433 зарядка, 1462 трек) остаются `setHrOn(true)` (без
interactive) -> единственный авто-запрос при старте = один appConfirm в ensureSystemReady.
- Ручной тоггл ЧСС (main.js:5148) -> `setHrOn(..., true)`: одно системное окно на тап.
Ручное "Подключить" (настройки) не трогали - оно и так user-initiated.
## Проверка
Синтаксис JS ок (node -c обоих файлов). На телефоне (сборка `./build.sh -i -d android`):
1) ЧСС подключён, выключить BT в записи -> app НЕ падает, ЧСС гаснет, трек идёт; в logcat
`BleHR` виден STATE_OFF и чистый stop, нет нового FATAL/FORTIFY.
2) BT off, старт трека -> ровно одно окно "Включить Bluetooth?"; отказ -> системное окно НЕ
появляется, до конца записи повторов нет; новый старт -> снова один вопрос.
3) Тап по тогглу ЧСС при BT off -> одно системное окно.
4) Регрессии рядом: зарядка с ЧСС считает kcal; "Не спрашивать" гасят свои запросы;
"Подключить" в настройках работает на ходу.
## Отдельно (read-only, без правок)
Разобрал `wind_marks` в 5 треках из 228: отметки встречного ветра нигде не используются
(коммент в коде - "ground truth для оценки ветра из трека", которой в коде нет), и
эмпирически сигнал противоречив (в 2 из 4 велотреков "в ветер" скорость даже выше). Вывод:
пользы нет; кнопки ветра уже нет, новые треки пишут пустой wind_marks, помех нет - оставили
как есть.

View file

@ -714,6 +714,34 @@ select {
.full-width { width: 100%; }
/* HR-only workout (зарядка) recording screen: big heart rate, timer, kcal, no map. */
.workout-screen { padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.wk-hr-big { font-size: 92px; font-weight: 700; line-height: 1; color: var(--primary); }
.wk-hr-lbl { color: var(--muted); margin-top: -4px; }
.wk-stats { display: flex; gap: 18px; margin: 14px 0; flex-wrap: wrap; justify-content: center; }
.wk-stat { display: flex; flex-direction: column; align-items: center; min-width: 64px; }
.wk-val { font-size: 22px; font-weight: 600; }
.wk-lbl { font-size: 12px; color: var(--muted); }
.wk-status { color: var(--muted); font-size: 13px; text-align: center; min-height: 18px; margin-bottom: 6px; }
.workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; }
/* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */
.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 4px;
/* Strap battery as a background fill, bottom-up, 0-100% (--bat), coloured by level (--bat-color). */
background-image: linear-gradient(to top, var(--bat-color, transparent) var(--bat, 0%), transparent var(--bat, 0%)); }
.rec-hr-toggle svg { display: block; }
.rec-hr-toggle.active { color: #fff; } /* HR on = white icon + bpm (over the battery fill) */
.rec-hr-num { font-size: 15px; font-weight: 700; min-width: 0; }
.rec-hr-num:empty { display: none; }
/* Progress fill for long-running action buttons: the background fills left-to-right by
--pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps
the button's own background. Used by recompute / elevation-refine / roads-rebuild. */
.btn-progress {
background-image: linear-gradient(90deg, var(--primary) var(--pct, 0%), rgba(0,0,0,0) var(--pct, 0%));
transition: background-image 0.15s linear;
}
/* Products screen */
.products-screen, .stub-screen, .settings-screen {
padding: 16px;
@ -1246,6 +1274,11 @@ select {
.rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; }
.rec-route-toggle { right: 8px; bottom: 200px; }
.rec-dark-toggle { right: 8px; bottom: 238px; }
.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }
/* nogo (⛔) and reroute (↪) had no position class — they piled at the map's top-left over the
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. */
body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }
@ -1283,20 +1316,9 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
cursor: pointer;
}
/* Keep Leaflet zoom controls below the status bar on the recording map. */
.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); }
/* Status-bar backdrop. The recording map is edge-to-edge (draws behind the system
status bar), so the bar had no backing and its icons were unreadable over the map
tiles. Paint a solid strip the height of the top inset — like the header on every
other screen — so the status bar stays visible. Controls already start below it. */
.rec-map-wrap::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: var(--safe-area-top, 0px);
background: var(--surface);
z-index: 1100;
pointer-events: none;
}
/* The top strip (.rec-topbar) now sits above the map and already backs the status bar, so the
map no longer draws under the status bar — no backdrop strip, and controls start at the map top. */
.rec-map-wrap .leaflet-top { margin-top: 0; }
.rec-screen.rot-90 .rec-map-wrap::before,
.rec-screen.rot-270 .rec-map-wrap::before { display: none; }
.rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; }
@ -1379,11 +1401,21 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
}
.rec-val {
font-size: 22px;
font-size: 27px;
font-weight: 700;
color: var(--on-surface);
}
/* Top strip (external info): training time (left), temperature (centre), clock (right).
Same big digits as the bottom stats (.rec-val). */
.rec-topbar {
display: flex; justify-content: space-between; align-items: baseline;
padding: 4px 16px; padding-top: calc(4px + var(--safe-area-top));
background: var(--surface);
}
.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); }
.rec-topbar .rec-top-mid { color: var(--muted); }
.rec-lbl {
font-size: 10px;
color: var(--muted);
@ -1401,24 +1433,16 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
.rec-bottom {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 8px 12px;
padding-bottom: calc(8px + var(--safe-area-bottom));
align-items: stretch;
gap: 8px;
padding: 6px 8px;
padding-bottom: calc(6px + var(--safe-area-bottom));
background: var(--surface);
}
.rec-bottom .rec-status {
font-size: 12px;
color: var(--muted);
flex: 1;
}
.rec-bottom .gps-rec {
padding: 12px 32px;
flex-shrink: 0;
}
/* All three controls (HR toggle, pause, stop) share the width equally — no clustering right.
border-box + one padding so the different per-button paddings don't make them unequal. */
.rec-bottom > button { flex: 1 1 0; box-sizing: border-box; min-width: 0; padding: 12px 8px; }
.gps-header {
padding: 10px 12px;
@ -1542,6 +1566,18 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
font-weight: 600;
}
/* Repeat / interval analysis table + verdict */
.repeats-head { font-size: 12px; color: var(--on-bg); opacity: 0.8; margin-bottom: 8px; }
.repeats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.repeats-tbl th, .repeats-tbl td { padding: 5px 6px; text-align: right; border-bottom: 1px solid var(--border); }
.repeats-tbl th:first-child, .repeats-tbl td:first-child { text-align: left; }
.repeats-tbl th { color: var(--primary); font-weight: 600; }
.repeats-verdict { margin-top: 10px; padding: 10px; border-radius: var(--radius); text-align: center; line-height: 1.4; }
.repeats-verdict .rv-num { font-size: 22px; font-weight: 700; }
.repeats-verdict.ok { background: rgba(46, 160, 67, 0.18); color: #4caf50; }
.repeats-verdict.warn { background: rgba(255, 179, 0, 0.16); color: #ffb300; }
.repeats-verdict.bad { background: rgba(207, 102, 121, 0.18); color: #cf6679; }
.sd-row {
display: flex;
gap: 8px;
@ -1728,6 +1764,45 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
border-color: var(--primary);
}
.gps-session-item.selected {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 20%, var(--surface));
box-shadow: inset 0 0 0 1px var(--primary);
}
/* Floating action bar for multi-select merge (sits above the bottom tab bar) */
.gps-sel-bar {
position: fixed;
left: 0; right: 0;
bottom: calc(56px + var(--safe-area-bottom));
z-index: 90;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--surface);
border-top: 1px solid var(--border);
box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
}
.gps-sel-bar[hidden] { display: none; }
/* "Split here" button inside a point popup — visible only when the map is in split mode */
.split-here {
display: inline-block;
margin-top: 6px;
padding: 4px 10px;
background: #ff9800;
color: #000;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
#sd-map:not(.split-mode) .split-here { display: none; }
.gps-sel-bar #gps-sel-count { flex: 1; font-size: 14px; }
.gps-sel-bar button { padding: 8px 14px; }
.gps-session-head {
display: flex;
justify-content: space-between;

View file

@ -48,6 +48,9 @@ async function addWeight(date, time, kg) {
"INSERT INTO weight_log (date, time, weight_kg) VALUES (?1, ?2, ?3)",
[date, time, kg]
);
// Keep the settings weight in sync with the latest weigh-in (today's if there is one).
const latest = await getLatestWeight();
if (latest && latest.weight_kg != null) await setProfileValue("weight_kg", latest.weight_kg);
}
async function deleteWeight(id) {
@ -65,13 +68,16 @@ async function getLatestWeight() {
return rows.length ? rows[0] : null;
}
async function getRecentWeights(days) {
// `days` calendar days ENDING at endDate (default today). Range is (end-days, end] so it is
// exactly `days` days (the old `>= date('now','-N')` gave N+1) and follows the selected day.
async function getRecentWeights(days, endDate) {
const end = endDate || "now";
return select(
`SELECT date, MIN(weight_kg) as weight_kg
FROM weight_log
WHERE date >= date('now', ?1)
WHERE date > date(?2, ?1) AND date <= date(?2)
GROUP BY date ORDER BY date ASC`,
[`-${days ?? 30} days`]
[`-${days ?? 30} days`, end]
);
}
@ -680,25 +686,28 @@ async function getActivityTotalsForDate(date) {
return { kcal: manualKcal + gpsKcal, minutes: manual[0]?.minutes ?? 0 };
}
async function getDailyFoodTotals(days) {
// N calendar days ending at endDate (default today), range (end-N, end] = exactly N days.
async function getDailyFoodTotals(days, endDate) {
const end = endDate || "now";
return select(
`SELECT date, SUM(kcal) as kcal, SUM(protein_g) as protein_g,
SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g
FROM food_log WHERE date >= date('now', ?1)
FROM food_log WHERE date > date(?2, ?1) AND date <= date(?2)
GROUP BY date ORDER BY date ASC`,
[`-${days ?? 30} days`]
[`-${days ?? 30} days`, end]
);
}
async function getDailyActivityTotals(days) {
async function getDailyActivityTotals(days, endDate) {
const offset = `-${days ?? 30} days`;
const end = endDate || "now";
return select(
`SELECT date, SUM(kcal) as kcal FROM (
SELECT date, kcal FROM activity_log WHERE date >= date('now', ?1)
SELECT date, kcal FROM activity_log WHERE date > date(?2, ?1) AND date <= date(?2)
UNION ALL
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?2) AND total_kcal IS NOT NULL
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date > date(?2, ?1) AND date <= date(?2) AND total_kcal IS NOT NULL
) GROUP BY date ORDER BY date ASC`,
[offset, offset]
[offset, end]
);
}
@ -801,6 +810,24 @@ async function saveGapFill(sessionId, points) {
}
await execute(`INSERT INTO gps_points (session_id, ts, lat, lon, seq, estimated) VALUES ${rowSql.join(",")}`, vals);
}
// Re-sequence the WHOLE session by ts so getGpsPoints (ORDER BY seq,ts) returns the true
// order. Without this, on merged/split tracks the real points have seq=NULL, the inserted
// gap-fill points get seq=0, and they sort to the FRONT — a straight line from the start to
// the gap ("returns to the beginning"). Re-numbering by ts is a no-op for dense tracks and
// fixes NULL/mixed ones. Batched CASE update (like saveSmoothedTrack) to beat the IPC/bind cost.
let ordered = await select("SELECT id FROM gps_points WHERE session_id=?1 ORDER BY ts ASC, id ASC", [sessionId]);
let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit
for (let i = 0; i < ordered.length; i += SEQCHUNK) {
let chunk = ordered.slice(i, i + SEQCHUNK);
let when = [], ids = [], params = [];
for (let k = 0; k < chunk.length; k++) {
params.push(chunk[k].id); let idi = params.length;
params.push(i + k + 1); let sqi = params.length;
when.push(`WHEN ?${idi} THEN ?${sqi}`);
ids.push(`?${idi}`);
}
await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params);
}
await execute("UPDATE gps_sessions SET gap_filled=1 WHERE id=?1", [sessionId]);
return toInsert.length;
}
@ -914,6 +941,66 @@ async function deleteGpsSession(id) {
await execute("DELETE FROM gps_sessions WHERE id = ?1", [id]);
}
// Merge several sessions into one. Points from every session move into the EARLIEST
// (by start time) session; each moved point keeps its original session in
// orig_session_id (so a future "split" can undo this), the target's end_time extends to
// the latest, and the other session rows are deleted. Ids are text (large i64 read back
// off-by-one), passed as strings and CAST to INTEGER for the id/session_id columns.
// Returns the surviving (target) session id_str. Caller recomputes stats afterwards.
async function mergeGpsSessions(idStrs) {
if (!Array.isArray(idStrs) || idStrs.length < 2) return null;
const sessions = [];
for (const id of idStrs) { const s = await getGpsSession(id); if (s) sessions.push(s); }
if (sessions.length < 2) return null;
const ord = (s) => (s.date || "1970-01-01") + "T" + (s.start_time || "00:00:00");
sessions.sort((a, b) => ord(a) < ord(b) ? -1 : ord(a) > ord(b) ? 1 : 0);
const target = sessions[0];
const targetId = target.id_str;
const endOf = (s) => (s.date || "1970-01-01") + "T" + (s.end_time || s.start_time || "00:00:00");
const latestEnd = sessions.reduce((m, s) => endOf(s) > endOf(m) ? s : m, target).end_time;
// Tag the target's own points too, so orig_session_id is complete for a later split.
await execute("UPDATE gps_points SET orig_session_id = CAST(?1 AS TEXT) WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NULL", [targetId]);
for (const s of sessions.slice(1)) {
await execute(
"UPDATE gps_points SET orig_session_id = COALESCE(orig_session_id, CAST(session_id AS TEXT)), session_id = CAST(?1 AS INTEGER) WHERE session_id = CAST(?2 AS INTEGER)",
[targetId, s.id_str]
);
await execute("DELETE FROM gps_sessions WHERE id = CAST(?1 AS INTEGER)", [s.id_str]);
}
if (latestEnd) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [latestEnd, targetId]);
// Each source session numbered its points seq 1..N, so after the move the seq values
// collide and "ORDER BY seq" would interleave the tracks (measured: a 43 km "segment"
// and 6745 km/h passes). Clear seq so getGpsPoints falls back to timestamp order, which
// is the true order across the merged tracks.
await execute("UPDATE gps_points SET seq = NULL WHERE session_id = CAST(?1 AS INTEGER)", [targetId]);
return targetId;
}
// Split one session in two at a chosen point. The second part (points whose ids are in
// part2Ids) moves to a NEW session that copies the original's date / activity / bike /
// source; the first part keeps the original session. Bike stays the same on both halves -
// the rider then reassigns the bike on a half and recomputes if the gear differed. Times
// are "HH:MM:SS". Returns the new (second-part) session id_str. Caller recomputes both.
async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime) {
if (!Array.isArray(part2Ids) || part2Ids.length === 0) return null;
const s = await getGpsSession(sessionId);
if (!s) return null;
const newId = Date.now();
await execute(
`INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
[newId, s.date, part2StartTime, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null]
);
let CHUNK = 200;
for (let i = 0; i < part2Ids.length; i += CHUNK) {
let ch = part2Ids.slice(i, i + CHUNK);
let ph = ch.map((_, k) => "?" + (k + 2)).join(",");
await execute(`UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), seq = NULL WHERE id IN (${ph})`, [newId, ...ch]);
}
if (part1EndTime) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [part1EndTime, sessionId]);
return String(newId);
}
async function importGpxSession(session, points) {
await execute(
`INSERT INTO gps_sessions (date, start_time, end_time, activity_type,
@ -1012,6 +1099,18 @@ async function updateSessionBike(sessionId, bikeId) {
await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]);
}
// Store the rider's weight at ride time (set once when a recording finalises). A separate
// setter from finishGpsSession, which recompute also calls — recompute must NOT overwrite it.
async function setSessionRiderWeight(sessionId, kg) {
if (kg == null || isNaN(kg)) return;
await execute("UPDATE gps_sessions SET rider_weight_kg = ?1 WHERE id = ?2", [kg, sessionId]);
}
// HR-only "workout" session: store the heart-rate time series as JSON [[relSec,bpm],...].
async function setSessionHrSamples(sessionId, samplesJson) {
await execute("UPDATE gps_sessions SET hr_samples = ?1 WHERE id = ?2", [samplesJson, sessionId]);
}
// Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}].
// Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline.
async function updatePointsSrtm(rows) {
@ -1289,6 +1388,8 @@ window.DB.getGpsSessionsForDate = getGpsSessionsForDate;
window.DB.getGpsSession = getGpsSession;
window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.mergeGpsSessions = mergeGpsSessions;
window.DB.splitGpsSession = splitGpsSession;
window.DB.importGpxSession = importGpxSession;
window.DB.saveGapFill = saveGapFill;
window.DB.revertGapFill = revertGapFill;
@ -1313,6 +1414,8 @@ window.DB.saveBike = saveBike;
window.DB.updateBike = updateBike;
window.DB.deleteBike = deleteBike;
window.DB.updateSessionBike = updateSessionBike;
window.DB.setSessionRiderWeight = setSessionRiderWeight;
window.DB.setSessionHrSamples = setSessionHrSamples;
window.DB.updatePointsSrtm = updatePointsSrtm;
window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog;
window.DB.addBikeMaintenance = addBikeMaintenance;
@ -1516,6 +1619,7 @@ async function runMigrations() {
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads)
if (!gpsCols.includes("orig_session_id")) await execute("ALTER TABLE gps_points ADD COLUMN orig_session_id TEXT"); // set on merge = the session this point first belonged to (lets a future split undo the merge)
// --- Cooking methods table ---
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
@ -1634,6 +1738,8 @@ async function runMigrations() {
if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses
if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate)
if (!gsCols.includes("gap_filled")) await execute("ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0"); // BRouter gap-fill applied (reversible)
if (!gsCols.includes("rider_weight_kg")) await execute("ALTER TABLE gps_sessions ADD COLUMN rider_weight_kg REAL"); // rider weight at ride time; recompute uses it (NULL on old sessions -> latest weigh-in)
if (!gsCols.includes("hr_samples")) await execute("ALTER TABLE gps_sessions ADD COLUMN hr_samples TEXT"); // HR-only "workout" sessions (no GPS): JSON [[relSec,bpm],...]
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);
@ -1925,13 +2031,26 @@ async function runMigrations() {
);
if (nullSeqSessions.length > 0) {
console.log(`[db.js] Repairing seq for ${nullSeqSessions.length} sessions...`);
for (const row of nullSeqSessions) {
const pts = await select(
"SELECT rowid, ts FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, rowid ASC",
for (let row of nullSeqSessions) {
// Batched CASE update (like saveGapFill/saveSmoothedTrack): one UPDATE per ~300 points
// instead of one per point. The per-point loop was ~15000 awaited IPC round-trips on
// merged/split tracks (seq=NULL by design) — many seconds of startup, EVERY boot, and
// for the big sessions it did not even persist before the next launch re-ran it.
let pts = await select(
"SELECT id FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, id ASC",
[row.session_id]
);
for (let i = 0; i < pts.length; i++) {
await execute("UPDATE gps_points SET seq = ?1 WHERE rowid = ?2", [i + 1, pts[i].rowid]);
let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit
for (let i = 0; i < pts.length; i += SEQCHUNK) {
let chunk = pts.slice(i, i + SEQCHUNK);
let when = [], ids = [], params = [];
for (let k = 0; k < chunk.length; k++) {
params.push(chunk[k].id); let idi = params.length;
params.push(i + k + 1); let sqi = params.length;
when.push(`WHEN ?${idi} THEN ?${sqi}`);
ids.push(`?${idi}`);
}
await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params);
}
console.log(`[db.js] Repaired seq for session ${row.session_id}: ${pts.length} points`);
}

View file

@ -91,6 +91,141 @@ GPS.detectWalk = function(points, opts) {
return out;
};
// Auto-detect repeated segments within ONE ride (interval training: the same climb
// ridden N times, one track, no stop/restart). The repeated corridor is found by
// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run
// where position along a reference segment rises monotonically), but the reference
// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride
// revisits), not given as road_points ids.
// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }.
// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST
// (uphill) position, so coasting back down makes position fall and ends the pass, and
// the "must start near seq beginning" guard stops a descent from opening a new pass.
GPS.detectRepeats = function(points, opts) {
opts = opts || {};
let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap)
let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long
let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate)
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment
let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long
let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor
let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor
let backM = opts.backM ?? 30; // progress may dip this far without ending the pass
let n = points ? points.length : 0;
if (n < 20) return { found: false };
// Grid cell key per point.
let key = new Array(n);
for (let i = 0; i < n; i++) {
let la = points[i].lat, lo = points[i].lon;
if (la == null || lo == null) { key[i] = null; continue; }
key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
}
// Entries per cell: count each time the track enters a cell (key changes into it).
let visits = new Map();
for (let i = 0; i < n; i++) {
if (key[i] == null) continue;
if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1);
}
let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v;
if (maxEntries < 2) return { found: false };
// Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor).
// Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot
// cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where
// progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a
// small backstep. Pass time = moving seconds only (stops trimmed).
let detectAt = (hotThr) => {
let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr;
let buildTraversal = (start) => {
let seq = [], order = new Map(), end = start;
for (let k = start, gap = 0; k < n; k++) {
let c = key[k];
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
gap = 0;
if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell
if (order.has(c)) break; // revisit of an earlier cell -> traversal complete
order.set(c, seq.length); seq.push(c); end = k;
}
let len = 0;
for (let k = start + 1; k <= end; k++)
len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
return { seq, len, start, end };
};
let best = null;
for (let k = 0; k < n; k++) {
if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start
let t = buildTraversal(k);
if (!best || t.len > best.len) best = t;
}
if (!best || best.seq.length < 3 || best.len < minLenM) return null;
let rLat = [], rLon = [], cum = [0];
for (let k = best.start; k <= best.end; k++) {
rLat.push(points[k].lat); rLon.push(points[k].lon);
if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon));
}
let refLen = cum[cum.length - 1];
if (rLat.length < 3 || refLen < minLenM) return null;
let refCell = new Map();
for (let v = 0; v < rLat.length; v++) {
let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell);
(refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v);
}
let rad = Math.ceil(projTolM / cell);
let prog = new Array(n).fill(null);
for (let i = 0; i < n; i++) {
if (points[i].lat == null) continue;
let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell);
let bv = -1, bd = projTolM;
for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) {
let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue;
for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } }
}
if (bv >= 0) prog[i] = cum[bv];
}
let passes = [];
let p = 0;
while (p < n) {
if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; }
let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0;
while (q < n - 1) {
q++;
if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; }
let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000;
let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon);
if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue;
break;
}
if ((hi - lo) >= refLen * minFrac) {
let mov = 0;
for (let k = p + 1; k <= anchor; k++) {
let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000;
let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded
}
passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) });
}
p = Math.max(q, anchor) + 1;
}
return { segLength: Math.round(refLen), passes };
};
// A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the
// reference, inflating it so a full rep no longer covers minFrac (measured on a real
// out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to
// the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment).
let bestRes = null;
for (let T = 2; T <= Math.min(maxEntries, 20); T++) {
let r = detectAt(T);
if (!r || r.passes.length < minReps) continue;
if (!bestRes || r.passes.length > bestRes.passes.length ||
(r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r;
}
if (!bestRes) return { found: false };
return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes };
};
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
// Only touch the value if it's still the old default; respect a user-set threshold.
@ -246,6 +381,9 @@ GPS.computeStats = function(points, weightKg, opts) {
let totalKcal = 0;
const basalPerMin = (1.0 * 3.5 * wKg) / 200;
// Mechanical work while pedalling, for avgPower output (used by the repeat/interval
// analysis to compare passes). Averaged over pedalling time, like tools/track pers.py.
let mechWorkJ = 0, powerSec = 0;
// Cadence tracking
let totalPedalRevs = 0;
@ -407,6 +545,7 @@ GPS.computeStats = function(points, weightKg, opts) {
// 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest
// BMR already covers — the empirically measured cost.
const physicsKcal = Math.max(0, power) * dt / efficiency / 4184;
mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power
const vKmh = v * 3.6;
if (vKmh >= 5) {
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
@ -474,6 +613,8 @@ GPS.computeStats = function(points, weightKg, opts) {
const avgCadence = (hasGearData && pedalingSeconds > 0)
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
: null;
// Average mechanical power (W) over pedalling time, null if never pedalled.
const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null;
// ---------------------------------------------------------------------------
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
@ -570,6 +711,7 @@ GPS.computeStats = function(points, weightKg, opts) {
kcal: Math.round(totalKcal),
duration: Math.round(duration),
avgCadence,
avgPower,
segments,
walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs)
walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance)
@ -680,6 +822,19 @@ GPS.requestBackgroundLocation = async function() {
} catch (e) { console.log("[GPS] reqbgloc:", e); }
};
// Is the OS location toggle ON (not just the permission)? Kotlin checks LocationManager and
// writes location_state.json; we read it back. Returns true/false, or null if undeterminable
// (desktop / no answer) — callers must not prompt on null.
GPS.isLocationEnabled = async function() {
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_location" });
await new Promise(r => setTimeout(r, 300));
let raw = await window.__TAURI__.core.invoke("read_state_file", { name: "location_state.json" });
if (!raw) return null;
return !!JSON.parse(raw).enabled;
} catch { return null; }
};
// BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses.
// HR keeps measuring even when rider is stationary.
GPS._startHrMonitor = async function() {
@ -689,24 +844,23 @@ GPS._startHrMonitor = async function() {
const type = await DB.getProfileValue("ble_device_type");
if (!addr || !type) { console.log("[HR] No paired device"); return; }
const connected = await window.__TAURI__.core.invoke("ble_is_connected");
if (!connected) {
console.log("[HR] Connecting to", addr, type);
// Just send connect — Kotlin handles scan+connect internally
try {
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
} catch (e) {
console.log("[HR] Connect failed:", e);
}
}
// Start polling HR regardless — data will appear when device connects
console.log("[HR] Starting HR poll");
// Do NOT auto-connect/scan here. That ran a BLE scan+connect on every ride even when the
// band was absent or Bluetooth was off, holding the radio and draining the battery for
// nothing. Connecting is MANUAL now (Settings -> "Подключить", works mid-ride too). We only
// run the cheap HR poll (reads a local file), so a manual connect now or later starts
// feeding HR into the track via currentHr.
console.log("[HR] Starting HR poll (connect is manual)");
GPS.recording.hrPollInterval = setInterval(async () => {
try {
const reading = await window.__TAURI__.core.invoke("ble_get_hr");
if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); }
if (reading && reading.hr > 0) {
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) {
GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]);
}
if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading);
}
} catch {}
@ -727,6 +881,80 @@ GPS._stopHrMonitor = function() {
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
};
// 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.
//
// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then
// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start
// pass interactive=false: if BT is off they just leave HR off (no second popup after the single
// start-time prompt in ensureSystemReady), and a later manual connect still works.
GPS.setHrOn = async function(on, interactive = false) {
GPS.recording.hrOn = !!on;
if (!on) {
GPS._stopHrMonitor();
try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {}
return false;
}
const addr = await DB.getProfileValue("ble_device_addr");
const type = await DB.getProfileValue("ble_device_type");
if (!addr) { GPS.recording.hrOn = false; return false; }
try {
const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (bt === "off") {
// Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't
// connect with the radio off — leave HR off so the toggle shows grey, not a fake "on".
if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} }
GPS.recording.hrOn = false;
return false;
}
if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} }
} catch {}
try { await GPS._startHrMonitor(); } catch {}
return true;
};
// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%.
// Resets when the strap reads healthy again (recharged / battery replaced).
GPS._batAlerted = {};
GPS._checkBatteryAlert = function(bat) {
if (bat == null || bat < 0 || bat > 100) return;
if (bat > 15) { GPS._batAlerted = {}; return; }
for (let thr of [10, 5]) {
if (bat <= thr && !GPS._batAlerted[thr]) {
GPS._batAlerted[thr] = true;
GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`);
}
}
};
GPS._speakOrBeep = function(text) {
let spoke = false;
try {
if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") {
let u = new SpeechSynthesisUtterance(text);
u.lang = "ru-RU"; u.rate = 0.95;
window.speechSynthesis.speak(u);
spoke = true;
}
} catch {}
// Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue).
try { GPS._beep(spoke ? 1 : 3); } catch {}
};
GPS._beep = function(times) {
times = times || 1;
let ctx = new (window.AudioContext || window.webkitAudioContext)();
let one = (i) => {
let o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = 880; o.type = "sine";
o.connect(g); g.connect(ctx.destination);
g.gain.value = 0.25;
let t0 = ctx.currentTime + i * 0.35;
o.start(t0); o.stop(t0 + 0.2);
};
for (let i = 0; i < times; i++) one(i);
setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400);
};
// Read points from native service file
// Parse raw JSONL (one native point per line) into sorted point objects.
GPS._parseNativePoints = function(raw) {
@ -1160,15 +1388,65 @@ GPS.recoverSession = async function() {
}
};
GPS.startRecording = async function(activityType, weightKg) {
// Net kcal from a heart-rate series (Keytel 2005 gross, minus BMR for the same time so it is
// consistent with the app's NET model — BMR is already booked in the daily balance).
// samples: [[relSec, bpm], ...]. profile: {weightKg, age, sex ("m"/"f"), heightCm}.
GPS.hrKcalNet = function(samples, profile) {
if (!samples || samples.length < 2) return 0;
profile = profile || {};
const W = parseFloat(profile.weightKg) || 75, A = parseInt(profile.age) || 38;
const H = parseFloat(profile.heightCm) || 178, male = (profile.sex || "m") === "m";
const bmrMin = (10 * W + 6.25 * H - 5 * A + (male ? -5 : 161)) / 1440; // Mifflin -> kcal/min
const grossPerMin = hr => male
? (-55.0969 + 0.6309 * hr + 0.1988 * W + 0.2017 * A) / 4.184
: (-20.4022 + 0.4472 * hr - 0.1263 * W + 0.0740 * A) / 4.184;
let kcal = 0;
for (let i = 1; i < samples.length; i++) {
const dtMin = Math.max(0, samples[i][0] - samples[i-1][0]) / 60;
if (dtMin <= 0 || dtMin > 1) continue; // skip > 1 min gaps (disconnect)
const net = grossPerMin((samples[i-1][1] + samples[i][1]) / 2) - bmrMin;
if (net > 0) kcal += net * dtMin;
}
return Math.round(kcal);
};
GPS.startRecording = async function(activityType, weightKg, profile) {
if (GPS.recording.active) return;
if (!navigator.geolocation) throw new Error("Geolocation not available");
GPS.recording.active = true;
GPS.recording.activityType = activityType || "bike";
GPS.recording.points = [];
GPS.recording.hrSamples = [];
GPS.recording.hrProfile = profile || null;
// Clear any HR left over from the PREVIOUS session (else a stale bpm hangs on the display and
// gets written onto this session's points). Only a fresh reading this session re-fills it.
GPS.recording.currentHr = null;
GPS.recording.currentBleData = null;
GPS.recording.currentBattery = null;
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
GPS.recording.startTime = null;
GPS.recording.weightKg = parseFloat(weightKg) || 80;
// HR-only "workout" (зарядка): 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") {
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");
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
// Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean.
try {
const hrAddr = await DB.getProfileValue("ble_device_addr");
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
} catch (e) { console.error("[HR] start error:", e); }
return;
}
if (!navigator.geolocation) throw new Error("Geolocation not available");
GPS.recording.state = "waiting_gps";
GPS.recording.sessionId = null;
GPS.recording.pauseStart = null;
@ -1187,8 +1465,13 @@ GPS.startRecording = async function(activityType, weightKg) {
// Start native foreground service (Android)
await GPS._startNativeService();
// Start BLE HR monitor (auto-connect to paired device)
try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); }
// HR: default ON if a strap is paired — auto-connect + poll at start (the record screen has an
// on/off toggle). Safe: BleHrService scans briefly and stops if the strap isn't present, so no
// constant scanning/drain when it's absent. No paired strap -> HR stays off.
try {
const hrAddr = await DB.getProfileValue("ble_device_addr");
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
} catch (e) { console.error("[HR] start error:", e); }
// Poll native service output file
if (GPS.recording.nativeService) {
@ -1264,6 +1547,26 @@ GPS.stopRecording = async function() {
GPS.recording.active = false;
if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer);
// HR-only workout: no GPS to stop. Compute net kcal from HR, save samples, finalise.
if (GPS.recording.activityType === "workout") {
const samples = GPS.recording.hrSamples || [];
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]);
const avgHr = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
const maxHr = hrs.length ? Math.max(...hrs) : null;
const sid = GPS.recording.sessionId;
GPS._stopHrMonitor();
if (sid) {
try { await DB.setSessionHrSamples(sid, JSON.stringify(samples)); } catch (e) { console.error("[workout] save samples", e); }
// Reuse finishGpsSession: distance 0, kcal from HR; avg/max HR ride the speed columns' siblings? No —
// store them nowhere extra; detail derives avg/max from hr_samples. Just distance 0 + kcal.
try { await DB.finishGpsSession(sid, new Date().toTimeString().slice(0, 5), 0, kcal, null, null, 0, 0); } catch (e) { console.error("[workout] finish", e); }
}
return { sessionId: sid, workout: true, kcal, durationSec, avgHr, maxHr, samples };
}
GPS._stopHrMonitor();
if (GPS.recording.watchId != null) {
@ -1339,6 +1642,8 @@ GPS.stopRecording = async function() {
stats.elevGain, stats.elevLoss, recPauses
);
try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); }
// Persist the rider weight used for this ride, so a later recompute keeps ride-time weight.
try { await DB.setSessionRiderWeight(GPS.recording.sessionId, GPS.recording.weightKg); } catch (e) { console.error("[GPS] rider weight save failed:", e); }
// DB save confirmed — now safe to delete the points file
if (nativePointsFile) {
try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {}
@ -1454,6 +1759,13 @@ GPS.fillGaps = async function(points, opts) {
let minChord = opts.minChordM ?? 150; // and only when the endpoints are genuinely far apart
let ratio = opts.maxRatio ?? 2; // road > 2× chord = big detour, likely wrong → keep straight
let minGain = opts.minGainM ?? 40; // road ≈ chord = nothing to gain → keep straight
// Router for one gap: the caller can prefer already-ridden roads (returns {points, ridden:true}
// when the bridge runs over the user's own tracks); default is the public BRouter shortest.
// A ridden path is what actually happened, so it is NOT subject to the "detour too long" guard.
let routeFn = opts.routeFn || (async (a, b) => {
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false };
});
let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let out = [];
let gaps = [];
@ -1465,16 +1777,17 @@ GPS.fillGaps = async function(points, opts) {
let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon);
if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging
let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime();
let routed = [], status = "skipped-noroute", addedM = 0;
let routed = [], status = "skipped-noroute", addedM = 0, ridden = false;
try {
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
let r = await routeFn(a, b);
let rp = (r && r.points) || [];
ridden = !!(r && r.ridden);
if (rp.length >= 3) {
let rlen = 0;
for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
addedM = Math.max(0, rlen - chord);
if (rlen > chord * ratio) {
status = "skipped-long"; // road detoured too far — keep the chord
if (!ridden && rlen > chord * ratio) {
status = "skipped-long"; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is)
} else if (addedM < minGain) {
status = "skipped-nogain"; // road ≈ straight — nothing to bridge
} else {
@ -1488,7 +1801,7 @@ GPS.fillGaps = async function(points, opts) {
}
}
} catch (e) { status = "skipped-noroute"; }
gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord });
gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord, ridden });
}
return { points: out, gaps };
};
@ -1766,6 +2079,9 @@ GPS.exportGPX = async function(sessionId) {
// ---------------------------------------------------------------------------
GPS.recalculateSession = async function(sessionId, opts) {
opts = opts || {};
// Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer.
const onProg = (typeof opts.onProgress === "function") ? opts.onProgress : () => {};
onProg(5);
const session = await DB.getGpsSession(sessionId);
if (!session) return null;
const points = await DB.getGpsPoints(sessionId);
@ -1807,7 +2123,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
// Compute stats and finish
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
const stats = GPS.computeStats(sorted, weightKg, {
@ -1818,6 +2134,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
});
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
onProg(100);
return stats;
}
@ -1872,6 +2189,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
}
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
}
onProg(40);
// Interpolate gaps where accelerometer shows movement
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
@ -1930,10 +2248,10 @@ GPS.recalculateSession = async function(sessionId, opts) {
}
}
// Get user weight: latest weight log, or profile, or 80
// Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80.
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
// Get bike if linked to session, otherwise use active bike
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
@ -1943,6 +2261,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
// Use cleaned sorted array, not original points
const sessPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
onProg(70);
const stats = GPS.computeStats(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
@ -1961,6 +2280,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss, sessPauses
);
onProg(100);
return stats;
};
@ -1997,7 +2317,8 @@ GPS.recomputeAllStats = async function(onProgress) {
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
const bulkPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
const stats = GPS.computeStats(points, weightKg, {
const sessW = parseFloat(session.rider_weight_kg) || weightKg; // ride-time weight, else the fallback
const stats = GPS.computeStats(points, sessW, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,

File diff suppressed because it is too large Load diff

View file

@ -49,7 +49,11 @@
function render() {
group.clearLayers();
if (!shown || map.getZoom() < MINZOOM || !POI._data) return;
let z = map.getZoom();
if (!shown || z < MINZOOM || !POI._data) return;
// Fewer markers the further you zoom out: a wide view spans too many POIs to read (and to
// draw). Scale the cap with zoom - a handful near MINZOOM, the full CAP once zoomed in.
let cap = z >= 16 ? CAP : z >= 15 ? 200 : z >= 14 ? 80 : 30;
let b = map.getBounds(), n = 0;
for (let i = 0; i < POI._data.length; i++) {
let p = POI._data[i];
@ -59,7 +63,7 @@
let c = CATS[p.c] || { l: p.c };
m.bindPopup((p.n ? "<b>" + p.n + "</b><br>" : "") + c.l);
group.addLayer(m);
if (++n >= CAP) break;
if (++n >= cap) break;
}
}
map.on("moveend zoomend", render);

View file

@ -127,6 +127,13 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
// Custom Leaflet TileLayer that checks IndexedDB first
TileCache.createLayer = function() {
return L.TileLayer.extend({
options: {
// Keep fewer off-screen tile rings and don't reload tiles mid-zoom: on a high-DPI
// screen the compositor's raster-tile budget (cc/tiles "tile memory limits exceeded")
// is filled by the map tiles themselves, so trimming retained tiles keeps content drawing.
keepBuffer: 1, // default 2
updateWhenZooming: false, // defer tile loads until the zoom gesture settles
},
createTile: function(coords, done) {
const tile = document.createElement("img");
const key = `${coords.z}/${coords.x}/${coords.y}`;

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

@ -7,10 +7,15 @@ import android.app.PendingIntent
import android.app.Service
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import java.io.File
@ -31,6 +36,9 @@ class BleHrService : Service() {
val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
// Standard Battery Service (strap charge)
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
// FitPro Nordic UART
val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d")
@ -51,15 +59,40 @@ class BleHrService : Service() {
private var gatt: BluetoothGatt? = null
private var deviceType = "hr_standard"
private var dataFile: File? = null
private var lastBattery: Int = -1 // strap charge %, -1 = unknown
private var scanResultsFile: File? = null
private var fitproWriteChar: BluetoothGattCharacteristic? = null
private var fitproGatt: BluetoothGatt? = null
// One handler for all postDelayed work, so teardown can cancel every pending callback at once
// (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack).
private val handler = Handler(Looper.getMainLooper())
// When the user turns Bluetooth off, stop touching GATT immediately and shut the service down.
private val btStateReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
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")
handler.removeCallbacksAndMessages(null)
disconnect()
writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""")
stopSelf()
}
}
}
private fun adapter(): BluetoothAdapter? =
(getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) }
catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -72,7 +105,7 @@ class BleHrService : Service() {
ACTION_SCAN -> {
startForeground(NOTIFICATION_ID, buildNotification("Поиск BLE..."))
scanDevices()
return START_STICKY
return START_NOT_STICKY // don't let Android restart us into an empty null-intent churn
}
else -> {
val address = intent?.getStringExtra(EXTRA_ADDRESS)
@ -84,7 +117,7 @@ class BleHrService : Service() {
dataFile = File(appDataDir, "ble_hr_data.json")
connectDevice(address)
}
return START_STICKY
return START_NOT_STICKY // connect is user-initiated; no sticky restart
}
}
}
@ -134,10 +167,10 @@ class BleHrService : Service() {
}
}
scanner.startScan(callback)
try { scanner.startScan(callback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); writeScanResults("[]"); stopSelf(); return }
// Stop after 5 seconds
android.os.Handler(mainLooper).postDelayed({
scanner.stopScan(callback)
handler.postDelayed({
try { scanner.stopScan(callback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
writeScanResults("[${results.joinToString(",")}]")
Log.d(TAG, "Scan complete: ${results.size} devices")
// Don't stopSelf() — connect intent may follow immediately
@ -170,8 +203,9 @@ class BleHrService : Service() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (result.device.address == address) {
Log.d(TAG, "Found target device in scan, connecting...")
scanner.stopScan(this)
gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
try { scanner.stopScan(this) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
try { gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) }
catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") }
}
}
override fun onScanFailed(errorCode: Int) {
@ -179,21 +213,28 @@ class BleHrService : Service() {
directConnect(adapter, address)
}
}
scanner.startScan(scanCallback)
try { scanner.startScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); stopSelf(); return }
// Timeout: if device not found in 5 seconds, try direct connect
android.os.Handler(mainLooper).postDelayed({
scanner.stopScan(scanCallback)
handler.postDelayed({
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
if (gatt == null) {
Log.d(TAG, "Scan timeout, trying direct connect to $address")
directConnect(adapter, address)
// Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to
// an absent device (that keeps the radio busy and drains the battery). Give up;
// the user re-taps "Подключить" once the band is on.
Log.d(TAG, "Scan timeout, device $address not found - stopping")
writeData("""{"status":"not_found","ts":${System.currentTimeMillis()/1000}}""")
stopSelf()
}
}, 5000)
}
private fun directConnect(adapter: BluetoothAdapter, address: String) {
val device = adapter.getRemoteDevice(address)
Log.d(TAG, "Direct connecting to $address ($deviceType)")
gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
if (!adapter.isEnabled) { Log.e(TAG, "directConnect: adapter off"); stopSelf(); return }
try {
val device = adapter.getRemoteDevice(address)
Log.d(TAG, "Direct connecting to $address ($deviceType)")
gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() }
}
private val gattCallback = object : BluetoothGattCallback() {
@ -201,7 +242,7 @@ class BleHrService : Service() {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.d(TAG, "Connected, discovering services...")
updateNotification("Подключён, поиск сервисов...")
g.discoverServices()
try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") }
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "Disconnected")
isRunning = false
@ -256,6 +297,15 @@ class BleHrService : Service() {
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
onCharacteristicChanged(g, char, char.value)
}
// Battery level read (0x2A19). Two overloads: API 33+ passes the value, older reads char.value.
override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (char.uuid == BATTERY_LEVEL && value.isNotEmpty()) { lastBattery = value[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") }
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) {
val v = char.value
if (char.uuid == BATTERY_LEVEL && v != null && v.isNotEmpty()) { lastBattery = v[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") }
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
Log.d(TAG, "onDescriptorWrite status=$status")
@ -273,13 +323,32 @@ class BleHrService : Service() {
if (service == null) { Log.e(TAG, "HR service not found"); return }
val char = service.getCharacteristic(HR_MEASUREMENT_UUID)
if (char == null) { Log.e(TAG, "HR characteristic not found"); return }
g.setCharacteristicNotification(char, true)
val desc = char.getDescriptor(CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(desc)
}
try {
g.setCharacteristicNotification(char, true)
val desc = char.getDescriptor(CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(desc)
}
} catch (e: Exception) { Log.e(TAG, "HR notify setup: ${e.message}"); return }
Log.d(TAG, "Standard HR setup complete")
// Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s.
val readBat = object : Runnable {
override fun run() {
if (!isRunning) return
readBattery(g)
handler.postDelayed(this, 60000)
}
}
handler.postDelayed(readBat, 2500)
}
private fun readBattery(g: BluetoothGatt) {
try {
val bs = g.getService(BATTERY_SERVICE) ?: return
val bc = bs.getCharacteristic(BATTERY_LEVEL) ?: return
g.readCharacteristic(bc)
} catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") }
}
private val gattQueue = mutableListOf<() -> Unit>()
@ -298,7 +367,7 @@ class BleHrService : Service() {
fun onGattOperationComplete() {
gattBusy = false
android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100)
handler.postDelayed({ processGattQueue() }, 100)
}
private fun setupFitPro(g: BluetoothGatt) {
@ -311,16 +380,18 @@ class BleHrService : Service() {
// Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE
enqueueGatt {
g.setCharacteristicNotification(notifyChar, true)
val desc = notifyChar.getDescriptor(CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(desc)
Log.d(TAG, "FitPro: writing CCCD descriptor")
} else {
Log.d(TAG, "FitPro: no CCCD, skipping")
onGattOperationComplete()
}
try {
g.setCharacteristicNotification(notifyChar, true)
val desc = notifyChar.getDescriptor(CCCD_UUID)
if (desc != null) {
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(desc)
Log.d(TAG, "FitPro: writing CCCD descriptor")
} else {
Log.d(TAG, "FitPro: no CCCD, skipping")
onGattOperationComplete()
}
} catch (e: Exception) { Log.e(TAG, "FitPro notify setup: ${e.message}"); onGattOperationComplete() }
}
enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) }
enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) }
@ -333,37 +404,36 @@ class BleHrService : Service() {
// Re-trigger measurement every 25 seconds
fitproWriteChar = writeChar
fitproGatt = g
android.os.Handler(mainLooper).also { h ->
val retrigger = object : Runnable {
override fun run() {
if (!isRunning) return
Log.d(TAG, "FitPro: re-trigger measurement")
fitproGatt?.let { gg -> fitproWriteChar?.let { wc ->
writeCommand(gg, wc, FITPRO_MEASURE_START)
android.os.Handler(mainLooper).postDelayed({
writeCommand(gg, wc, FITPRO_MEASURE_OLD)
}, 300)
}}
h.postDelayed(this, 25000)
}
val retrigger = object : Runnable {
override fun run() {
if (!isRunning) return
Log.d(TAG, "FitPro: re-trigger measurement")
fitproGatt?.let { gg -> fitproWriteChar?.let { wc ->
writeCommand(gg, wc, FITPRO_MEASURE_START)
handler.postDelayed({
writeCommand(gg, wc, FITPRO_MEASURE_OLD)
}, 300)
}}
handler.postDelayed(this, 25000)
}
h.postDelayed(retrigger, 25000)
}
handler.postDelayed(retrigger, 25000)
}
private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
// NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms
android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200)
} else {
@Suppress("DEPRECATION")
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@Suppress("DEPRECATION")
g.writeCharacteristic(char)
android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
} else {
@Suppress("DEPRECATION")
char.value = data
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@Suppress("DEPRECATION")
g.writeCharacteristic(char)
}
} catch (e: Exception) { Log.e(TAG, "writeCommand: ${e.message}") }
// NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms
handler.postDelayed({ onGattOperationComplete() }, 200)
}
private fun parseStandardHr(data: ByteArray) {
@ -378,8 +448,9 @@ class BleHrService : Service() {
}
if (hr <= 0) return
val ts = System.currentTimeMillis() / 1000
writeData("""{"hr":$hr,"ts":$ts}""")
Log.d(TAG, "HR: $hr")
val batJson = if (lastBattery in 0..100) ""","bat":$lastBattery""" else ""
writeData("""{"hr":$hr,"ts":$ts$batJson}""")
Log.d(TAG, "HR: $hr bat: $lastBattery")
}
private fun parseFitProData(data: ByteArray) {
@ -430,10 +501,13 @@ class BleHrService : Service() {
}
private fun disconnect() {
gatt?.disconnect()
gatt?.close()
gatt = null
isRunning = false
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}") }
gatt = null
fitproGatt = null
fitproWriteChar = null
}
private fun createNotificationChannel() {
@ -462,6 +536,7 @@ class BleHrService : Service() {
}
override fun onDestroy() {
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
disconnect()
super.onDestroy()
}

View file

@ -25,6 +25,7 @@ class MainActivity : TauriActivity() {
private var commandObserver: FileObserver? = null
private var pendingSaveContent: String? = null
private var pendingImageBytes: ByteArray? = null
private var pendingDbFile: File? = null
private var backWebView: WebView? = null
// Disable WryActivity's generated back handler: its mWebView.goBack() does NOT
@ -78,6 +79,22 @@ class MainActivity : TauriActivity() {
pendingImageBytes = null
}
// SAF "create document" for the full DB backup — streams the staged file (can be tens of MB)
// straight to the picked location instead of loading it into memory.
private val saveDbLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri: Uri? ->
val src = pendingDbFile
if (uri != null && src != null && src.exists()) {
try {
contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } }
Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)")
src.delete()
} catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) }
}
pendingDbFile = null
}
// Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND).
private fun shareImage(bytes: ByteArray, filename: String) {
try {
@ -127,6 +144,22 @@ class MainActivity : TauriActivity() {
val appDataDir = filesDir.parentFile ?: filesDir
Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}")
// Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW,
// before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear
// any stale journal so the restored file is the single source of truth.
try {
val pending = File(appDataDir, "pending_restore.db")
if (pending.exists() && pending.length() > 0) {
val dbFile = File(appDataDir, "baldav.db")
pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } }
File(appDataDir, "baldav.db-journal").delete()
File(appDataDir, "baldav.db-wal").delete()
File(appDataDir, "baldav.db-shm").delete()
pending.delete()
Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)")
}
} catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) }
// Create command files if not exist (FileObserver needs them)
val cmdFile = File(appDataDir, "gps_command.txt")
if (!cmdFile.exists()) cmdFile.createNewFile()
@ -474,6 +507,26 @@ class MainActivity : TauriActivity() {
intent.action = BleHrService.ACTION_STOP
startService(intent)
}
cmd == "ble_request_enable" -> {
Log.d("BalDav", "Requesting Bluetooth enable")
if (ensureBlePermissions()) {
try { startActivity(Intent(android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
catch (e: Exception) { Log.e("BalDav", "BT enable request failed: ${e.message}") }
}
}
cmd == "check_location" -> {
val lm = getSystemService(LOCATION_SERVICE) as? LocationManager
val enabled = when {
lm == null -> false
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> lm.isLocationEnabled
else -> lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
File(appDataDir, "location_state.json").writeText("""{"enabled":$enabled}""")
}
cmd == "open_location_settings" -> {
try { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
catch (e: Exception) { Log.e("BalDav", "open location settings: ${e.message}") }
}
cmd == "check_update" -> {
checkForUpdate(appDataDir)
}
@ -515,6 +568,33 @@ class MainActivity : TauriActivity() {
}
}
}
cmd.startsWith("exportdb:") -> {
// exportdb:<filename> — SAF-save the DB copy Rust wrote to export_db.tmp.
val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" }
val f = File(appDataDir, "export_db.tmp")
if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) }
else Log.e("BalDav", "exportdb: export_db.tmp missing")
}
cmd == "restartapp" -> {
// Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate.
// startActivity()+exit() races (the new activity doesn't come up before the process
// dies — measured: app just closed, no restart). Schedule the relaunch via AlarmManager
// so it fires AFTER this process is gone (the ProcessPhoenix approach).
Log.d("BalDav", "Restarting app (DB restore)")
try {
val intent = packageManager.getLaunchIntentForPackage(packageName)
if (intent != null) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
val piFlags = android.app.PendingIntent.FLAG_CANCEL_CURRENT or
(if (Build.VERSION.SDK_INT >= 23) android.app.PendingIntent.FLAG_IMMUTABLE else 0)
val pi = android.app.PendingIntent.getActivity(this, 0, intent, piFlags)
val am = getSystemService(ALARM_SERVICE) as android.app.AlarmManager
am.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 300, pi)
}
} catch (e: Exception) { Log.e("BalDav", "restart schedule failed", e) }
finishAffinity()
Runtime.getRuntime().exit(0)
}
}
}

View file

@ -25,6 +25,8 @@ pub struct HrReading {
pub timestamp: u64,
#[serde(default)]
pub device: String,
#[serde(alias = "bat", default)]
pub battery: Option<u8>,
}
// Shared state for BLE operations
@ -438,6 +440,21 @@ pub async fn ble_disconnect(
}
}
// Ask the OS to turn Bluetooth on (system ACTION_REQUEST_ENABLE dialog). BLUETOOTH_CONNECT is
// already granted, so no extra permission. Called when the user taps "Подключить" with BT off.
#[tauri::command]
pub async fn ble_request_enable(
app: tauri::AppHandle,
) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok("desktop: no-op".to_string()) }
#[cfg(target_os = "android")]
{
android::send_command(&app, "ble_request_enable")?;
Ok("Requesting enable".to_string())
}
}
#[tauri::command]
pub fn ble_get_hr(
app: tauri::AppHandle,

View file

@ -2,6 +2,8 @@
use tauri_plugin_sql::{Migration, MigrationKind};
#[allow(unused_imports)]
use tauri::Manager;
#[allow(unused_imports)]
use tauri::Emitter; // app.emit(...) for progress events (Tauri v2)
use serde::Serialize;
#[allow(unused, dead_code)]
@ -226,6 +228,14 @@ fn read_points_file(app: tauri::AppHandle, filename: String) -> Result<String, S
}
}
// Read a small app state file (allowlisted) written by Kotlin, e.g. the location on/off state.
#[tauri::command]
fn read_state_file(app: tauri::AppHandle, name: String) -> Result<String, String> {
if name != "location_state.json" { return Err("bad name".to_string()); }
let p = native_root(&app).join(&name);
if p.exists() { std::fs::read_to_string(&p).map_err(|e| e.to_string()) } else { Ok(String::new()) }
}
// Download binary file from URL to local path (for tile DB import from router)
// read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads
#[tauri::command]
@ -284,8 +294,12 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
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 (lat, lon, atype) in &points {
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],
@ -302,6 +316,7 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
}
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
}
@ -624,6 +639,65 @@ fn read_file_base64(path: String) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
}
// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path.
// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc
// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the
// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the
// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do.
fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
let root = native_root(app);
let db = root.join("baldav.db");
Ok((db, root))
}
// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to
// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open.
#[tauri::command]
fn export_db(app: tauri::AppHandle, filename: String) -> Result<String, String> {
let (db_path, root) = db_and_root(&app)?; // root = native_root: the writable app dir Kotlin watches
if !db_path.exists() { return Err("baldav.db не найдена".into()); }
let out = root.join("export_db.tmp");
let _ = std::fs::remove_file(&out);
{
let conn = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?;
}
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
let _ = std::fs::write(root.join("gps_command.txt"), format!("exportdb:{}", filename));
Ok(format!("{{\"size\":{}}}", size))
}
// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db.
// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok;
// caller then restarts the app.
#[tauri::command]
fn import_db(app: tauri::AppHandle) -> Result<String, String> {
let (_db, root) = db_and_root(&app)?; // root = native_root: Kotlin wrote picked_import.db here
let picked = root.join("picked_import.db");
if !picked.exists() { return Err("файл не выбран".into()); }
{
let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?;
let ok: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0)).map_err(|e| format!("проверка: {e}"))?;
if ok != "ok" { return Err(format!("база повреждена: {ok}")); }
let has: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile')",
[], |r| r.get(0)).map_err(|e| format!("схема: {e}"))?;
if has < 1 { return Err("это не похоже на базу BalDav".into()); }
}
let pending = root.join("pending_restore.db");
std::fs::copy(&picked, &pending).map_err(|e| format!("подготовка: {e}"))?;
let _ = std::fs::remove_file(&picked);
Ok("staged".into())
}
// Ask Kotlin to restart the app process (used after import_db to load the restored DB).
#[tauri::command]
fn restart_app(app: tauri::AppHandle) -> Result<String, String> {
let (_db, root) = db_and_root(&app)?; // root = native_root, the dir Kotlin's FileObserver watches
let _ = std::fs::write(root.join("gps_command.txt"), "restartapp");
Ok("ok".into())
}
// Read tiles from a SQLite file in batches for import into IndexedDB
#[tauri::command]
fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result<String, String> {
@ -1088,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, 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])
.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])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

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

51
tools/hr_log.py Normal file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Фоновый логгер ЧСС с BLE HR-датчика (стандарт 0x180D / 0x2A37, напр. Coospo H808S).
Пишет строки 'epoch,bpm' в указанный файл, пока процесс не убьют. Авто-переподключение
при обрыве. Спаривание не требуется.
Использование: python3 tools/hr_log.py <BLE-addr> <logfile>
"""
import asyncio, sys, time
from bleak import BleakClient, BleakScanner
ADDR = sys.argv[1]
LOG = sys.argv[2]
HR_CHR = "00002a37-0000-1000-8000-00805f9b34fb"
def make_cb(logf):
def cb(_, data: bytearray):
flags = data[0]
bpm = int.from_bytes(data[1:3], "little") if flags & 1 else data[1]
logf.write(f"{time.time():.1f},{bpm}\n")
logf.flush()
return cb
async def main():
logf = open(LOG, "a")
logf.write(f"# {time.time():.1f} start addr={ADDR}\n")
logf.flush()
while True:
try:
dev = await BleakScanner.find_device_by_address(ADDR, timeout=15.0)
if dev is None:
logf.write(f"# {time.time():.1f} not_found, retry\n")
logf.flush()
await asyncio.sleep(3)
continue
async with BleakClient(dev) as cli:
logf.write(f"# {time.time():.1f} connected\n")
logf.flush()
await cli.start_notify(HR_CHR, make_cb(logf))
while cli.is_connected:
await asyncio.sleep(1)
logf.write(f"# {time.time():.1f} disconnected, reconnect\n")
logf.flush()
except Exception as e:
logf.write(f"# {time.time():.1f} err {e}\n")
logf.flush()
await asyncio.sleep(3)
asyncio.run(main())