2026-05-13 10:21:24 +03:00
// GPS tracking module — recording, calculations, GPX import
// Exposes window.GPS
const GPS = { } ;
// ---------------------------------------------------------------------------
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Configurable thresholds (can be changed in settings)
GPS . config = {
minSpeedMs : 0.42 , // 1.5 km/h — below this = stationary
2026-07-07 00:28:27 +03:00
minAccuracyM : 50 , // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept.
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
minDistanceM : 1 , // skip points closer than this
2026-05-25 15:20:24 +03:00
maxSpeedKmh : 60 , // above this = GPS glitch, skip segment
2026-05-27 15:29:22 +03:00
gapThresholdSec : 30 , // gap longer than this = new track segment
Physics settings in UI, recipe modal cleanup, weight parseFloat fixes
Settings:
- Aero drag (CdA), air density, pedal efficiency now in GPS config
- Human-readable names: "Аэросопротивление", "Плотность воздуха", "КПД педалирования"
- Saved to localStorage, used in computeStats
Bug fixes:
- Recipe detail: overlay.remove without popHandler cleanup → now uses history.back()
- weightKg parseFloat in startRecording and importGPX (string "88" bug)
- profile.weight_kg stored as number not string
- getDailyActivityTotals: separate params for UNION ALL subqueries
Docs:
- PROJECT_MAP.md — full project structure, DB schema, screens, formulas
- README.md — overview, build instructions, data sources
2026-05-26 02:14:43 +03:00
airDrag : 0.6 , // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
airDensity : 1.225 , // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
pedalEfficiency : 25 , // КПД педалирования (%). Человек ~25%
2026-10-21 11:22:46 +03:00
hrCalibFactor : 0.65 , // грубый персональный множитель для справочной "ЧСС откалибр." (Keytel x этого). Пульс завышает ~в 1.5x vs физика/вес; 0.65 мирит по 2 точкам (поездка 90км + сидячий день). Н Е истина, для справки.
2026-10-22 00:51:31 +03:00
tefFrac : 0.10 , // термический эффект пищи (TEF): доля прихода, уходящая на переваривание. 0 = выкл. Вводится С СОХРАНЕНИЕМ поддержки: база = BMR*actLevel*(1-tefFrac), затем +tefFrac*приход, так что на поддержке итог не меняется, но TEF масштабируется от реальной еды. actLevel 1.2 уже включал TEF — поэтому не прибавляем сверху, а перераспределяем.
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
altSmoothHalf : 2 , // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
walkMaxSpeedKmh : 12 , // макс. скорость ходьбы/б е г а (выше = глитч GPS)
walkMinSpeedMs : 0.3 , // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
2026-09-14 12:14:04 +03:00
walkDetectKmh : 8 , // в вело-треке устойчивая скорость ниже = ведёшь/идёшь (не едешь)
walkMinRunSec : 60 , // минимум длины пешего куска, чтобы засчитать (не притормозил у столба)
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
bikeAvgSpeedKmh : 17 , // средняя скорость вело (для ручных маршрутов)
walkAvgSpeedKmh : 5 , // средняя скорость ходьбы
runAvgSpeedKmh : 10 , // средняя скорость б е г а
2026-07-07 00:28:27 +03:00
useFusedLocation : true , // fused-провайдер (Google Play services) — как Яндекс/Google Maps.
// raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть.
autoFinishIdleMin : 15 , // авто-завершить тренировку после N мин без движения (забыл «Конец»)
2026-08-22 22:08:58 +03:00
trackColor : '#bb86fc' , // цвет линии текущего/записанного трека (по умолчанию фиолетовый темы)
2026-07-31 22:11:55 +03:00
roadColorWalk : '#8B5E3C' , // цвет наложения проезженных/пройденных дорог: ходьба
roadColorBike : '#2266aa' , // ... вело
roadColorRun : '#e67e22' , // ... б е г
Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
2026-08-03 19:29:51 +03:00
gpxzKey : '' , // API-ключ GPXZ (точные высоты Copernicus GLO-30). Пусто = open-elevation (SRTM). Хранится только тут (localStorage), не в git.
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} ;
// Load saved config
try {
2026-07-07 00:28:27 +03:00
let saved = localStorage . getItem ( "baldav_gps_config" ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( saved ) Object . assign ( GPS . config , JSON . parse ( saved ) ) ;
} catch { }
GPS . saveConfig = function ( ) {
localStorage . setItem ( "baldav_gps_config" , JSON . stringify ( GPS . config ) ) ;
} ;
2026-10-22 00:51:31 +03:00
// HR-only session kinds (no GPS, no track — just heart rate + timer): "workout" (зарядка)
// and "vr" (руки/VR/танцы). Both estimate kcal from HR (Keytel). Kept as a set so new
// HR-only kinds get the same no-GPS recording/detail path without touching every check.
GPS . isHrOnly = function ( t ) { return t === "workout" || t === "vr" ; } ;
2026-07-07 00:28:27 +03:00
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
try {
if ( localStorage . getItem ( "baldav_fused_migrated" ) !== "1" ) {
GPS . config . useFusedLocation = true ;
localStorage . setItem ( "baldav_fused_migrated" , "1" ) ;
GPS . saveConfig ( ) ;
}
} catch { }
2026-09-14 12:14:04 +03:00
// Detect "walking" runs inside a ride (pushing the bike / on foot across a field): contiguous
// runs where the smoothed speed stays below a threshold for at least walkMinRunSec. The
// threshold is the single-speed push speed when gear data is given (30-rpm cadence), else
// config.walkDetectKmh — universal by speed, refined by gear. Points are assumed time-ordered
// (as read from DB). Returns a boolean array parallel to `points` (true = walking).
GPS . detectWalk = function ( points , opts ) {
opts = opts || { } ;
let n = points ? points . length : 0 ;
let out = new Array ( n ) . fill ( false ) ;
if ( n < 3 ) return out ;
let thrMs ;
let hasGear = opts . chainringTeeth && opts . cogTeeth && opts . wheelDiameterInch ;
if ( hasGear && ( opts . gearCount == null || opts . gearCount <= 1 ) ) {
let circ = Math . PI * opts . wheelDiameterInch * 0.0254 , ratio = opts . chainringTeeth / opts . cogTeeth ;
thrMs = ( 30 * circ * ratio ) / 60 ;
} else {
thrMs = ( GPS . config . walkDetectKmh ? ? 8 ) / 3.6 ;
}
let minRun = opts . walkMinRunSec ? ? ( GPS . config . walkMinRunSec ? ? 60 ) ;
let v = new Array ( n ) . fill ( 0 ) ;
for ( let i = 1 ; i < n ; i ++ ) {
let d = GPS . distance ( points [ i - 1 ] . lat , points [ i - 1 ] . lon , points [ i ] . lat , points [ i ] . lon ) ;
let dt = ( new Date ( points [ i ] . ts ) - new Date ( points [ i - 1 ] . ts ) ) / 1000 ;
let sp = points [ i ] . speed _ms ;
v [ i ] = ( sp != null && sp >= 0 ) ? sp : ( dt > 0 ? d / dt : 0 ) ;
}
let sm = v . map ( ( _ , i ) => { let s = 0 , c = 0 ; for ( let j = Math . max ( 0 , i - 2 ) ; j <= Math . min ( n - 1 , i + 2 ) ; j ++ ) { s += v [ j ] ; c ++ ; } return c ? s / c : 0 ; } ) ;
let i = 1 ;
while ( i < n ) {
if ( sm [ i ] > 0.15 && sm [ i ] < thrMs ) { // > 0.15 m/s excludes standing still
let j = i , dur = 0 ;
while ( j < n && sm [ j ] > 0.15 && sm [ j ] < thrMs ) { dur += Math . max ( 0 , ( new Date ( points [ j ] . ts ) - new Date ( points [ j - 1 ] . ts ) ) / 1000 ) ; j ++ ; }
if ( dur >= minRun ) for ( let k = i - 1 ; k < j ; k ++ ) out [ k ] = true ;
i = j ;
} else i ++ ;
}
return out ;
} ;
2026-09-17 22:11:10 +03:00
// 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
2026-09-21 20:30:38 +03:00
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
2026-09-17 23:17:47 +03:00
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
2026-09-21 20:30:38 +03:00
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
2026-09-17 22:11:10 +03:00
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 ; }
2026-09-21 20:30:38 +03:00
key [ i ] = Math . floor ( la * 111320 / cell ) + "," + Math . floor ( lo * 111320 * Math . cos ( la * Math . PI / 180 ) / cell ) ;
2026-09-17 22:11:10 +03:00
}
// 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 ) ;
}
2026-09-21 20:30:38 +03:00
let maxEntries = 0 ; for ( let v of visits . values ( ) ) if ( v > maxEntries ) maxEntries = v ;
if ( maxEntries < 2 ) return { found : false } ;
2026-09-17 22:11:10 +03:00
2026-09-21 20:30:38 +03:00
// 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 ;
2026-09-17 22:50:24 +03:00
}
2026-09-21 20:30:38 +03:00
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 ) ) ;
2026-09-17 23:46:32 +03:00
}
2026-09-21 20:30:38 +03:00
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 ) ;
2026-09-17 22:11:10 +03:00
}
2026-09-21 20:30:38 +03:00
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 ; } }
2026-09-17 23:46:32 +03:00
}
2026-09-21 20:30:38 +03:00
if ( bv >= 0 ) prog [ i ] = cum [ bv ] ;
2026-09-17 23:46:32 +03:00
}
2026-09-21 20:30:38 +03:00
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 ;
2026-09-17 22:11:10 +03:00
}
2026-09-21 20:30:38 +03:00
if ( ! bestRes ) return { found : false } ;
return { found : true , segLength : bestRes . segLength , passCount : bestRes . passes . length , passes : bestRes . passes } ;
2026-09-17 22:11:10 +03:00
} ;
2026-07-07 00:28:27 +03:00
// 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.
try {
if ( localStorage . getItem ( "baldav_minacc50_migrated" ) !== "1" ) {
if ( GPS . config . minAccuracyM <= 20 ) GPS . config . minAccuracyM = 50 ;
localStorage . setItem ( "baldav_minacc50_migrated" , "1" ) ;
GPS . saveConfig ( ) ;
}
} catch { }
2026-05-13 10:21:24 +03:00
// Haversine distance (meters)
// ---------------------------------------------------------------------------
2026-07-07 00:28:27 +03:00
// Compass bearing in degrees (0=N, clockwise) from point 1 to point 2.
GPS . bearing = function ( lat1 , lon1 , lat2 , lon2 ) {
let toRad = d => d * Math . PI / 180 ;
let f1 = toRad ( lat1 ) , f2 = toRad ( lat2 ) , dl = toRad ( lon2 - lon1 ) ;
let y = Math . sin ( dl ) * Math . cos ( f2 ) ;
let x = Math . cos ( f1 ) * Math . sin ( f2 ) - Math . sin ( f1 ) * Math . cos ( f2 ) * Math . cos ( dl ) ;
return ( Math . atan2 ( y , x ) * 180 / Math . PI + 360 ) % 360 ;
} ;
// Point [lat, lon] at `meters` distance along `bearingDeg` (0=N, CW) from a start point.
GPS . destination = function ( lat , lon , bearingDeg , meters ) {
let R = 6371000 , d = meters / R , br = bearingDeg * Math . PI / 180 ;
let f1 = lat * Math . PI / 180 , l1 = lon * Math . PI / 180 ;
let f2 = Math . asin ( Math . sin ( f1 ) * Math . cos ( d ) + Math . cos ( f1 ) * Math . sin ( d ) * Math . cos ( br ) ) ;
let l2 = l1 + Math . atan2 ( Math . sin ( br ) * Math . sin ( d ) * Math . cos ( f1 ) , Math . cos ( d ) - Math . sin ( f1 ) * Math . sin ( f2 ) ) ;
return [ f2 * 180 / Math . PI , ( ( l2 * 180 / Math . PI ) + 540 ) % 360 - 180 ] ;
} ;
2026-05-13 10:21:24 +03:00
GPS . distance = function ( lat1 , lon1 , lat2 , lon2 ) {
const R = 6371000 ;
const toRad = d => d * Math . PI / 180 ;
const dLat = toRad ( lat2 - lat1 ) ;
const dLon = toRad ( lon2 - lon1 ) ;
const a = Math . sin ( dLat / 2 ) * * 2 + Math . cos ( toRad ( lat1 ) ) * Math . cos ( toRad ( lat2 ) ) * Math . sin ( dLon / 2 ) * * 2 ;
return R * 2 * Math . atan2 ( Math . sqrt ( a ) , Math . sqrt ( 1 - a ) ) ;
} ;
// ---------------------------------------------------------------------------
// Compute session stats from array of {lat, lon, altitude, ts} points
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// weightKg = rider weight, opts = { bikeWeightKg, activityType, surfaceCrr,
// chainringTeeth, cogTeeth, wheelDiameterInch } (all optional)
2026-05-13 10:21:24 +03:00
// ---------------------------------------------------------------------------
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
GPS . computeStats = function ( points , weightKg , opts ) {
if ( points . length < 2 ) return { distance : 0 , elevGain : 0 , elevLoss : 0 , avgSpeed : 0 , maxSpeed : 0 , kcal : 0 , duration : 0 , avgCadence : null } ;
2026-05-13 10:21:24 +03:00
2026-08-16 20:31:12 +03:00
// Accuracy-threshold recompute: exclude real GPS fixes coarser than minAccuracyM
// (kept: no-accuracy imports and estimated fills). Non-destructive — this only
// affects the computed stats; every recompute path funnels through here.
points = GPS . filterByAccuracy ( points ) ;
if ( points . length < 2 ) return { distance : 0 , elevGain : 0 , elevLoss : 0 , avgSpeed : 0 , maxSpeed : 0 , kcal : 0 , duration : 0 , avgCadence : null } ;
2026-05-25 09:25:50 +03:00
// Always sort by timestamp — seq can be wrong if points arrived out of order during recording
const sorted = points . slice ( ) . sort ( ( a , b ) => new Date ( a . ts ) - new Date ( b . ts ) ) ;
Seed DB: +5ka (3103) +ION (1078) with micronutrients, GPS fixes, UI improvements
Products:
- 5ka scraper: recursive category BFS, 3103 products imported
- ION scraper (web.ion.ru): fetch-based, 1111 products with full vitamins/minerals
- Seed DB now 13480 products (magnit 8627, 5ka 3103, ion 1078, kdv 672)
- 13 micronutrient columns added: Na, K, Ca, Mg, P, Fe, vit A/B1/B2/B3/C/E, carotene
GPS fixes:
- computeStats sorts points by seq/ts before calculating (fixes wrong elevation/kcal)
- Trail restore on recording screen sorts points
- Old tracks with NULL seq repaired via migration (db.js)
- Activity type saved to localStorage, restored on GPS screen
UI:
- Track points invisible but tappable (popup with time/alt/speed)
- Charts: pointer-events:none on canvas when minimized (page scrolls)
- Navigation history stack (20 entries), back gesture doesn't close app
- Session list sort: added id DESC tiebreaker
2026-05-19 23:53:49 +03:00
2026-05-13 10:21:24 +03:00
let distance = 0 , elevGain = 0 , elevLoss = 0 ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
let elevRef = null ; // running reference for cumulative elevation hysteresis
2026-05-13 10:21:24 +03:00
const speeds = [ ] ; // m/s per segment
2026-09-07 13:53:40 +03:00
// Manual pauses (ms epoch): the rider hand-paused, so that stretch must NOT count
// (walked, not ridden) — unlike an automatic GPS gap in motion, which we keep.
// A segment that touches a pause interval is dropped and elevRef is reset (so the
// elevation delta across the pause is not banked as a climb).
const manualPauses = ( opts && opts . pauses ? opts . pauses : [ ] )
. map ( z => ( { s : z . startTs , e : z . endTs } ) ) . filter ( z => z . s != null && z . e != null ) ;
const inManualPause = ( aMs , bMs ) => manualPauses . some ( z => aMs < z . e && bMs > z . s ) ;
const MAX _GRADE = GPS . config . maxRealGradePct ? ? 1.0 ; // 1.0 = 100% = 45°; steeper adjacent alt step = glitch
let pausedSec = 0 ;
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
// 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise)
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
// Step 1: Extract raw altitudes and filter outliers.
// An outlier is a point that jumps far from its neighbors and returns back —
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
2026-07-07 00:28:27 +03:00
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI
Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and
work at the same time (one adapter, several LE connections). Verified on device against a PC
BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk.
- BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback,
ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and
auto-connects both HR (ble_device.json) and baro (baro_device.json) at start.
- Rust: ble_baro_disconnect, ble_save_baro_device.
- Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their
own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout.
- Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro).
- Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor.
- Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse);
parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns
to the tracks list instead of into details.
- tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing.
Emulator-verified only; real board still pending.
2026-10-30 22:46:26 +03:00
// Prefer the barometer (best for real climbs/descents) when present, then terrain (SRTM), then GPS.
const rawAlt = sorted . map ( p => p . altitude _baro _m ? ? p . altitude _srtm _m ? ? p . altitude ? ? p . altitude _gps _m ? ? null ) ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
const filteredAlt = rawAlt . slice ( ) ;
for ( let i = 1 ; i < filteredAlt . length - 1 ; i ++ ) {
if ( filteredAlt [ i ] == null ) continue ;
const prev = filteredAlt [ i - 1 ] , next = filteredAlt [ i + 1 ] ;
if ( prev == null || next == null ) continue ;
const jumpFromPrev = Math . abs ( filteredAlt [ i ] - prev ) ;
const jumpFromNext = Math . abs ( filteredAlt [ i ] - next ) ;
const neighborDiff = Math . abs ( next - prev ) ;
// If this point jumps >4m from BOTH neighbors, but neighbors are close to each other — outlier
if ( jumpFromPrev > 4 && jumpFromNext > 4 && neighborDiff < 3 ) {
filteredAlt [ i ] = ( prev + next ) / 2 ;
}
}
// Step 1b: Fix "stuck" altitude — same value repeated 5+ times = GPS frozen,
// replace with linear interpolation between surrounding real values
{
let i = 0 ;
while ( i < filteredAlt . length ) {
if ( filteredAlt [ i ] == null ) { i ++ ; continue ; }
let j = i + 1 ;
while ( j < filteredAlt . length && filteredAlt [ j ] != null
&& Math . abs ( filteredAlt [ j ] - filteredAlt [ i ] ) < 0.2 ) j ++ ;
if ( j - i >= 5 ) {
// Stuck run from i to j-1. Interpolate using values before i and after j-1
const before = i > 0 ? filteredAlt [ i - 1 ] : filteredAlt [ i ] ;
const after = j < filteredAlt . length ? filteredAlt [ j ] : filteredAlt [ j - 1 ] ;
for ( let k = i ; k < j ; k ++ ) {
const t = ( k - i + 1 ) / ( j - i + 1 ) ;
filteredAlt [ k ] = before + ( after - before ) * t ;
}
}
i = j ;
}
}
// Step 2: Smooth filtered altitude
const SMOOTH _HALF = GPS . config . altSmoothHalf ? ? 2 ;
const smoothedAlt = filteredAlt . map ( ( alt , i ) => {
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
if ( alt == null ) return null ;
let sum = 0 , cnt = 0 ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
for ( let j = Math . max ( 0 , i - SMOOTH _HALF ) ; j <= Math . min ( filteredAlt . length - 1 , i + SMOOTH _HALF ) ; j ++ ) {
if ( filteredAlt [ j ] != null ) { sum += filteredAlt [ j ] ; cnt ++ ; }
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
}
return cnt > 0 ? sum / cnt : alt ;
} ) ;
Physics settings in UI, recipe modal cleanup, weight parseFloat fixes
Settings:
- Aero drag (CdA), air density, pedal efficiency now in GPS config
- Human-readable names: "Аэросопротивление", "Плотность воздуха", "КПД педалирования"
- Saved to localStorage, used in computeStats
Bug fixes:
- Recipe detail: overlay.remove without popHandler cleanup → now uses history.back()
- weightKg parseFloat in startRecording and importGPX (string "88" bug)
- profile.weight_kg stored as number not string
- getDailyActivityTotals: separate params for UNION ALL subqueries
Docs:
- PROJECT_MAP.md — full project structure, DB schema, screens, formulas
- README.md — overview, build instructions, data sources
2026-05-26 02:14:43 +03:00
// Physics constants (configurable in settings)
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const g = 9.81 ;
Physics settings in UI, recipe modal cleanup, weight parseFloat fixes
Settings:
- Aero drag (CdA), air density, pedal efficiency now in GPS config
- Human-readable names: "Аэросопротивление", "Плотность воздуха", "КПД педалирования"
- Saved to localStorage, used in computeStats
Bug fixes:
- Recipe detail: overlay.remove without popHandler cleanup → now uses history.back()
- weightKg parseFloat in startRecording and importGPX (string "88" bug)
- profile.weight_kg stored as number not string
- getDailyActivityTotals: separate params for UNION ALL subqueries
Docs:
- PROJECT_MAP.md — full project structure, DB schema, screens, formulas
- README.md — overview, build instructions, data sources
2026-05-26 02:14:43 +03:00
const CdA = GPS . config . airDrag ? ? 0.6 ;
const rho = GPS . config . airDensity ? ? 1.225 ;
const efficiency = ( GPS . config . pedalEfficiency ? ? 25 ) / 100 ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
// Rolling resistance: base from surface type, corrected by tire pressure.
2026-09-07 15:50:19 +03:00
// If accelerometer data available, estimate Crr from vibration (accel RMS).
// Scale (lower bound of a back-solve on measured tracks — better to undercount):
// RMS < 3 → 0.004 smooth asphalt | 11-15 → 0.012 packed dirt
// 3-7 → 0.006 mixed | > 15 → 0.020 grass / rut / gravel
// 7-11 → 0.008 broken asphalt
// The old ceiling of 0.008 (gravel) undercounted rough surfaces ~3x: measured
// grass+tractor-rut needs 0.020-0.031, packed dirt 0.012. crrFromAccel maps both
// the session median (default Crr) and each point (ptCrr below) through this scale.
const crrFromAccel = ( rms ) => rms < 3 ? 0.004 : rms < 7 ? 0.006 : rms < 11 ? 0.008 : rms < 15 ? 0.012 : 0.020 ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
let baseCrr = opts ? . surfaceCrr ? ? 0.006 ;
const accelVals = sorted . map ( p => p . accel _rms ) . filter ( v => v != null && v > 0 ) ;
if ( accelVals . length > 10 ) {
accelVals . sort ( ( a , b ) => a - b ) ;
const medianAccel = accelVals [ Math . floor ( accelVals . length / 2 ) ] ;
2026-09-07 15:50:19 +03:00
baseCrr = crrFromAccel ( medianAccel ) ; // override with sensor-based estimate
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
}
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
const tirePressure = opts ? . tirePressureBar ? ? 3.0 ;
const Crr = baseCrr * Math . sqrt ( 3.0 / tirePressure ) ;
2026-05-25 03:00:47 +03:00
const wKg = parseFloat ( weightKg ) || 80 ; // ensure numeric (profile stores strings)
const mTotal = wKg + ( parseFloat ( opts ? . bikeWeightKg ) || 0 ) ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const isBike = ( opts ? . activityType ? ? "bike" ) === "bike" ;
let totalKcal = 0 ;
2026-05-25 03:00:47 +03:00
const basalPerMin = ( 1.0 * 3.5 * wKg ) / 200 ;
2026-09-17 22:11:10 +03:00
// 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 ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Cadence tracking
let totalPedalRevs = 0 ;
let pedalingSeconds = 0 ;
const hasGearData = opts ? . chainringTeeth && opts ? . cogTeeth && opts ? . wheelDiameterInch ;
const wheelCirc = hasGearData ? Math . PI * opts . wheelDiameterInch * 0.0254 : 0 ; // meters
const gearRatio = hasGearData ? opts . chainringTeeth / opts . cogTeeth : 0 ;
2026-09-07 15:50:19 +03:00
// Walking the bike (single-speed only). On a fixed ratio, below ~30 rpm cadence
// the bike can't stay upright — so a sustained sub-30-rpm speed means the rider
// is pushing it, not riding. speed = cadence * distance-per-crank-rev.
// distance-per-rev = wheelCirc * gearRatio; at 30 rpm → walkBikeMs m/s.
// Only for single-speed (gear_count 1 or unset): on a geared bike low speed proves
// nothing (accelerometer can't tell pushing from riding — measured). Sub-threshold
// segments are excluded from ride distance/speed/elevation so a pushed climb doesn't
// merge with the ridden one; the effort is counted as walking, not bike physics.
const gearCount = opts ? . gearCount ;
const singleSpeed = isBike && hasGearData && ( gearCount == null || gearCount <= 1 ) ;
const walkBikeMs = singleSpeed ? ( 30 * wheelCirc * gearRatio ) / 60 : 0 ;
let walkBikeSec = 0 , walkBikeDist = 0 ; // reported total (only runs ≥30s, see #7)
let wbRunSec = 0 , wbRunDist = 0 ; // current contiguous push run
Seed DB: +5ka (3103) +ION (1078) with micronutrients, GPS fixes, UI improvements
Products:
- 5ka scraper: recursive category BFS, 3103 products imported
- ION scraper (web.ion.ru): fetch-based, 1111 products with full vitamins/minerals
- Seed DB now 13480 products (magnit 8627, 5ka 3103, ion 1078, kdv 672)
- 13 micronutrient columns added: Na, K, Ca, Mg, P, Fe, vit A/B1/B2/B3/C/E, carotene
GPS fixes:
- computeStats sorts points by seq/ts before calculating (fixes wrong elevation/kcal)
- Trail restore on recording screen sorts points
- Old tracks with NULL seq repaired via migration (db.js)
- Activity type saved to localStorage, restored on GPS screen
UI:
- Track points invisible but tappable (popup with time/alt/speed)
- Charts: pointer-events:none on canvas when minimized (page scrolls)
- Navigation history stack (20 entries), back gesture doesn't close app
- Session list sort: added id DESC tiebreaker
2026-05-19 23:53:49 +03:00
for ( let i = 1 ; i < sorted . length ; i ++ ) {
2026-07-07 00:28:27 +03:00
let p = sorted [ i ] , prev = sorted [ i - 1 ] ;
let d = GPS . distance ( prev . lat , prev . lon , p . lat , p . lon ) ;
let dt = ( new Date ( p . ts ) - new Date ( prev . ts ) ) / 1000 ;
let v = dt > 0 ? d / dt : 0 ; // m/s
2026-09-07 13:53:40 +03:00
// Manual pause: exclude entirely (walked, not ridden). Reset elevRef so the
// elevation gained during the pause isn't banked on the next real segment.
let prevMs = new Date ( prev . ts ) . getTime ( ) , pMs = new Date ( p . ts ) . getTime ( ) ;
if ( inManualPause ( prevMs , pMs ) ) { pausedSec += Math . max ( 0 , dt ) ; elevRef = null ; continue ; }
2026-07-07 00:28:27 +03:00
let isEstimated = ( sorted [ i ] . accuracy >= 999 ) || sorted [ i ] . estimated ;
let segMaxMs = isBike ? ( GPS . config . maxSpeedKmh / 3.6 ) : ( GPS . config . walkMaxSpeedKmh / 3.6 ) ;
2026-07-31 12:58:34 +03:00
let gapThr = GPS . config . gapThresholdSec ? ? 30 ;
// Per-segment distance. Across a GPS gap (dt > gapThreshold — the fix rate
// dropped, or dead-reckoning) the straight chord under/over-counts, so estimate
// the path by reported speed x time, capped at the max speed. A tiny chord over
// a long gap means the rider was stopped and the reported speed is stale — trust
// the position. A fast jump WITHOUT a time gap (dt small, v > max) is a teleport
// glitch → drop it. The estimate depends on ts + speed only, so a track computes
// the same whether from live points (which carry an `estimated` flag) or DB rows
// (which don't) — live, Stop and recompute now agree.
let isSpike = dt > 0 && dt <= gapThr && v > segMaxMs ;
let isGap = dt > gapThr ;
let segDist ;
if ( isSpike ) {
segDist = 0 ;
} else if ( isGap ) {
let avgV = ( ( sorted [ i ] . speed _ms ? ? 0 ) + ( sorted [ i - 1 ] . speed _ms ? ? 0 ) ) / 2 ;
segDist = ( d < 20 ) ? d : Math . min ( avgV * dt , segMaxMs * dt ) ;
} else {
segDist = d ;
}
// A normal moving segment (continuous fix within the speed limit) drives the
// altitude and max-speed stats; gap and spike segments do not.
let normalMove = ! isGap && ! isSpike && dt > 0 ;
2026-09-07 15:50:19 +03:00
// Walking the bike (single-speed): a normal-fix segment slower than the 30-rpm
// speed. Exclude it from ride distance/speed/elevation (elevRef reset breaks the
// pushed-vs-ridden climb merge) and count the effort as walking, not bike physics.
// Per #7: exclude EVERY such point regardless of duration (brief brake-to-stops
// land here too — harmless, ~0 m and low cost); accumulate contiguous runs and
// only report those ≥30s as "walked" (a stop before a pothole isn't walking).
if ( walkBikeMs > 0 && normalMove && v > 0 && v < walkBikeMs ) {
wbRunSec += dt ; wbRunDist += segDist ;
elevRef = null ;
const wKmh = v * 3.6 ;
const wmet = wKmh < 4 ? 2.5 : wKmh < 5.5 ? 3.5 : wKmh < 7 ? 5.0 : 8.0 ;
totalKcal += ( dt / 60 ) * ( ( wmet - 1 ) * 3.5 * wKg ) / 200 ; // NET (BMR booked in daily balance)
continue ;
}
// Not (or no longer) pushing → flush a completed push run into the reported total.
if ( wbRunSec >= 30 ) { walkBikeSec += wbRunSec ; walkBikeDist += wbRunDist ; }
wbRunSec = 0 ; wbRunDist = 0 ;
2026-07-31 12:58:34 +03:00
if ( segDist > 0 ) distance += segDist ;
if ( normalMove ) {
2026-07-07 00:28:27 +03:00
speeds . push ( v ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Elevation: cumulative hysteresis against a running reference — NOT the diff
// between adjacent points. On dense tracks (e.g. 5000 pts / 16 km) the per-point
// change is < 1 m, so an adjacent-diff dead zone accumulates 0 even on real
// climbs. Accumulate the rise/fall from the reference and only bank it (and
// advance the reference) once it clears the 1 m dead zone.
if ( smoothedAlt [ i ] != null ) {
2026-09-07 13:53:40 +03:00
// Reject a physically impossible adjacent altitude step (glitch — incl. an
// Android no-fix 0.0): grade over the segment steeper than MAX_GRADE (45°).
// A smooth change to any value (e.g. a real 0 near a river) passes.
let prevAlt = smoothedAlt [ i - 1 ] ;
let glitch = prevAlt != null && segDist > 0 && Math . abs ( smoothedAlt [ i ] - prevAlt ) / segDist > MAX _GRADE ;
if ( ! glitch ) {
if ( elevRef == null ) elevRef = smoothedAlt [ i ] ;
let rise = smoothedAlt [ i ] - elevRef ;
if ( rise > 1 ) { elevGain += rise ; elevRef = smoothedAlt [ i ] ; }
else if ( rise < - 1 ) { elevLoss += - rise ; elevRef = smoothedAlt [ i ] ; }
}
2026-07-07 00:28:27 +03:00
}
2026-05-13 10:21:24 +03:00
}
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Kcal calculation per segment
2026-07-31 12:58:34 +03:00
if ( isSpike ) continue ;
if ( isGap || isEstimated ) {
// GPS gap / dead-reckoning: time-based walking MET (no reliable physics here).
2026-07-07 00:28:27 +03:00
let walkMet = 3.5 ; // moderate walking ~4-5 km/h
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
totalKcal += ( dt / 60 ) * ( walkMet * 3.5 * wKg ) / 200 ;
continue ;
}
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
if ( v < 0.3 ) {
// Standing still: only basal metabolic rate
totalKcal += basalPerMin * ( dt / 60 ) ;
continue ;
}
if ( isBike ) {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Gradient from SMOOTHED altitude (terrain-preferred, outlier-removed, smoothed)
// — using the raw filtered altitude let GPS-altitude noise inflate the gradient
// (and kcal) on un-refined tracks, making sessions inconsistent.
const altHere = smoothedAlt [ i ] ? ? filteredAlt [ i ] ? ? 0 ;
const altPrev = smoothedAlt [ i - 1 ] ? ? filteredAlt [ i - 1 ] ? ? 0 ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
const altDiff = altHere - altPrev ;
const gradient = d > 2 ? Math . max ( - 0.5 , Math . min ( 0.5 , altDiff / d ) ) : 0 ;
2026-05-25 03:00:47 +03:00
const sinG = gradient ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const cosG = Math . sqrt ( 1 - sinG * sinG ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
// Per-point Crr from accelerometer (if available), else session-wide Crr
const ptAccel = sorted [ i ] . accel _rms ;
const ptCrr = ( ptAccel != null && ptAccel > 0 )
2026-09-07 15:50:19 +03:00
? crrFromAccel ( ptAccel ) * Math . sqrt ( 3.0 / tirePressure )
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
: Crr ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Power required (watts): gravity + rolling resistance + aerodynamic drag
const pGravity = mTotal * g * sinG ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
const pRolling = ptCrr * mTotal * g * cosG ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const pAero = 0.5 * CdA * rho * v * v ;
const power = v * ( pGravity + pRolling + pAero ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Pedaling vs coasting. If the bike is freewheeling (you're NOT turning the
// pedals) it costs only basal — no matter the speed. How we know:
// - Physics proxy (what we use): power<=0 means the bike would roll at >=
// this speed unpowered — a downhill coast → basal. Otherwise → pedaling.
// (This proxy misses FLAT coasting, but that's rare and minor.)
// - cadence_rpm hook: reserved for a real crank cadence sensor (BLE) if one
// is ever added — that's the only reliable source. Deriving cadence from
// the phone IMU was tested on real 50Hz data and does NOT separate
// pedaling from coasting (~76-81% ≈ baseline), so it is not used.
const cad = sorted [ i ] . cadence _rpm ;
const pedaling = ( cad != null ) ? ( cad >= 20 ) : ( power > 0 ) ;
if ( pedaling ) {
// Both estimates NET (above resting): the daily balance already books BMR
// for the whole day (expend = BMR*activityLevel + activity, main.js), so
// activity kcal must be NET — never add basal here.
// 1. physics: mechanical work / drivetrain efficiency. At 15-18 km/h the
// mechanical power is only ~50-80 W, so on the flat this UNDERESTIMATES
// real metabolic cost ~2x — a lower bound; wins only on steep climbs.
// 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 ;
2026-09-17 22:11:10 +03:00
mechWorkJ += Math . max ( 0 , power ) * dt ; powerSec += dt ; // avg mechanical power
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
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 ;
const metNetKcal = ( dt / 60 ) * ( ( met - 1 ) * 3.5 * wKg ) / 200 ;
2026-09-07 15:50:19 +03:00
// KNOWN LIMITATION (measured, left as-is by decision): max() never SUMS the
// two, so on climbs — where physics wins big — the biological overhead the
// MET floor captures (balancing, core stabilisation, no external work) is
// dropped. Direction of the error is known: climbs are undercounted (whole-
// ride ~1.34x MET/physics, but flat 1.85x and climb 0.41x). A principled fix
// (physics/eff + overhead) needs calibration, so it is deferred, not applied.
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
totalKcal += Math . max ( physicsKcal , metNetKcal ) ;
} else {
totalKcal += physicsKcal ; // creeping < 5 km/h: negligible
}
// Cadence estimate from gearing (theoretical) for avgCadence output.
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
if ( hasGearData && wheelCirc > 0 ) {
const wheelRevs = d / wheelCirc ;
totalPedalRevs += wheelRevs / gearRatio ;
pedalingSeconds += dt ;
}
} else {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Coasting / freewheeling: only basal metabolic rate.
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
totalKcal += basalPerMin * ( dt / 60 ) ;
}
} else {
// Walk/run: MET-based from Compendium of Physical Activities
const speedKmh = v * 3.6 ;
let met ;
if ( speedKmh < 4 ) met = 2.5 ; // slow walk
else if ( speedKmh < 5.5 ) met = 3.5 ; // normal walk
else if ( speedKmh < 7 ) met = 5.0 ; // brisk walk
else if ( speedKmh < 9 ) met = 8.0 ; // jogging
else if ( speedKmh < 12 ) met = 10.0 ; // running
else if ( speedKmh < 16 ) met = 12.5 ; // fast running
else met = 15.0 ; // sprint
2026-05-25 03:00:47 +03:00
totalKcal += ( dt / 60 ) * ( met * 3.5 * wKg ) / 200 ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
}
2026-05-13 10:21:24 +03:00
}
2026-09-07 15:50:19 +03:00
// Flush a push run still open at the track's end.
if ( wbRunSec >= 30 ) { walkBikeSec += wbRunSec ; walkBikeDist += wbRunDist ; }
2026-05-13 10:21:24 +03:00
// 9-point smoothed speeds for maxSpeed (kills spikes from GPS noise)
let maxSpeed = 0 ;
for ( let i = 0 ; i < speeds . length ; i ++ ) {
let sum = 0 , cnt = 0 ;
for ( let j = Math . max ( 0 , i - 4 ) ; j <= Math . min ( speeds . length - 1 , i + 4 ) ; j ++ ) {
sum += speeds [ j ] ; cnt ++ ;
}
const smooth = sum / cnt ;
if ( smooth > maxSpeed ) maxSpeed = smooth ;
}
Seed DB: +5ka (3103) +ION (1078) with micronutrients, GPS fixes, UI improvements
Products:
- 5ka scraper: recursive category BFS, 3103 products imported
- ION scraper (web.ion.ru): fetch-based, 1111 products with full vitamins/minerals
- Seed DB now 13480 products (magnit 8627, 5ka 3103, ion 1078, kdv 672)
- 13 micronutrient columns added: Na, K, Ca, Mg, P, Fe, vit A/B1/B2/B3/C/E, carotene
GPS fixes:
- computeStats sorts points by seq/ts before calculating (fixes wrong elevation/kcal)
- Trail restore on recording screen sorts points
- Old tracks with NULL seq repaired via migration (db.js)
- Activity type saved to localStorage, restored on GPS screen
UI:
- Track points invisible but tappable (popup with time/alt/speed)
- Charts: pointer-events:none on canvas when minimized (page scrolls)
- Navigation history stack (20 entries), back gesture doesn't close app
- Session list sort: added id DESC tiebreaker
2026-05-19 23:53:49 +03:00
const first = new Date ( sorted [ 0 ] . ts ) ;
const last = new Date ( sorted [ sorted . length - 1 ] . ts ) ;
2026-09-07 15:50:19 +03:00
// Exclude manual-pause time and sustained bike-push time — both keep avgSpeed
// (distance/duration) a pure riding figure, consistent with the excluded distance.
const duration = Math . max ( 0 , ( last - first ) / 1000 - pausedSec - walkBikeSec ) ;
2026-05-13 10:21:24 +03:00
const avgSpeed = duration > 0 ? ( distance / duration ) : 0 ;
const avgSpeedKmh = avgSpeed * 3.6 ;
const maxSpeedKmh = maxSpeed * 3.6 ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Average cadence (RPM) — only if gear data was provided and there was pedaling
const avgCadence = ( hasGearData && pedalingSeconds > 0 )
? Math . round ( totalPedalRevs / ( pedalingSeconds / 60 ) )
: null ;
2026-09-17 22:11:10 +03:00
// Average mechanical power (W) over pedalling time, null if never pedalled.
const avgPower = powerSec > 0 ? Math . round ( mechWorkJ / powerSec ) : null ;
2026-05-13 10:21:24 +03:00
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
// ---------------------------------------------------------------------------
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
// Scans smoothed altitude. Merges adjacent segments of same type. Min 50m distance.
// ---------------------------------------------------------------------------
const segments = [ ] ;
if ( sorted . length >= 3 ) {
// Compute per-point gradient from smoothed altitude, accumulate into raw segments
const rawSegs = [ ] ; // { type, startIdx, endIdx }
let segStartIdx = 0 ;
let segType = "flat" ;
for ( let i = 1 ; i < sorted . length ; i ++ ) {
const d = GPS . distance ( sorted [ i - 1 ] . lat , sorted [ i - 1 ] . lon , sorted [ i ] . lat , sorted [ i ] . lon ) ;
if ( d < 1 ) continue ; // skip negligible distance
let grad = 0 ;
if ( smoothedAlt [ i ] != null && smoothedAlt [ i - 1 ] != null ) {
grad = ( smoothedAlt [ i ] - smoothedAlt [ i - 1 ] ) / d ;
}
let pType = "flat" ;
if ( grad > 0.02 ) pType = "climb" ;
else if ( grad < - 0.02 ) pType = "descent" ;
if ( pType !== segType && i > segStartIdx + 1 ) {
rawSegs . push ( { type : segType , startIdx : segStartIdx , endIdx : i - 1 } ) ;
segStartIdx = i - 1 ;
segType = pType ;
}
}
rawSegs . push ( { type : segType , startIdx : segStartIdx , endIdx : sorted . length - 1 } ) ;
// Merge adjacent segments of same type, and merge tiny segments (<50m) into neighbors
const merged = [ ] ;
for ( const seg of rawSegs ) {
if ( merged . length > 0 && merged [ merged . length - 1 ] . type === seg . type ) {
merged [ merged . length - 1 ] . endIdx = seg . endIdx ;
} else {
merged . push ( { ... seg } ) ;
}
}
// Absorb tiny segments into surrounding ones
const merged2 = [ ] ;
for ( let i = 0 ; i < merged . length ; i ++ ) {
const seg = merged [ i ] ;
// Calculate distance for this segment
let segDist = 0 ;
for ( let j = seg . startIdx + 1 ; j <= seg . endIdx ; j ++ ) {
segDist += GPS . distance ( sorted [ j - 1 ] . lat , sorted [ j - 1 ] . lon , sorted [ j ] . lat , sorted [ j ] . lon ) ;
}
if ( segDist < 50 && merged2 . length > 0 ) {
// Absorb into previous segment
merged2 [ merged2 . length - 1 ] . endIdx = seg . endIdx ;
} else {
merged2 . push ( { ... seg } ) ;
}
}
// Build final segment stats
for ( const seg of merged2 ) {
let segDist = 0 , segGain = 0 , segLoss = 0 ;
for ( let j = seg . startIdx + 1 ; j <= seg . endIdx ; j ++ ) {
segDist += GPS . distance ( sorted [ j - 1 ] . lat , sorted [ j - 1 ] . lon , sorted [ j ] . lat , sorted [ j ] . lon ) ;
if ( smoothedAlt [ j ] != null && smoothedAlt [ j - 1 ] != null ) {
const diff = smoothedAlt [ j ] - smoothedAlt [ j - 1 ] ;
if ( diff > 0 ) segGain += diff ;
else segLoss += Math . abs ( diff ) ;
}
}
const segDuration = ( new Date ( sorted [ seg . endIdx ] . ts ) - new Date ( sorted [ seg . startIdx ] . ts ) ) / 1000 ;
const segAvgSpeed = segDuration > 0 ? ( segDist / segDuration ) * 3.6 : 0 ;
const elevChange = seg . type === "climb" ? segGain : seg . type === "descent" ? segLoss : Math . max ( segGain , segLoss ) ;
const avgGradient = segDist > 0 ? ( elevChange / segDist ) * 100 : 0 ;
segments . push ( {
type : seg . type ,
startIdx : seg . startIdx ,
endIdx : seg . endIdx ,
distance : Math . round ( segDist ) ,
elevGain : Math . round ( segGain ) ,
elevLoss : Math . round ( segLoss ) ,
avgGradient : Math . round ( avgGradient * 10 ) / 10 ,
duration : Math . round ( segDuration ) ,
avgSpeed : Math . round ( segAvgSpeed * 10 ) / 10 ,
} ) ;
}
}
2026-05-13 10:21:24 +03:00
return {
distance : Math . round ( distance ) ,
elevGain : Math . round ( elevGain ) ,
elevLoss : Math . round ( elevLoss ) ,
avgSpeed : Math . round ( avgSpeedKmh * 10 ) / 10 ,
maxSpeed : Math . round ( maxSpeedKmh * 10 ) / 10 ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
kcal : Math . round ( totalKcal ) ,
2026-05-13 10:21:24 +03:00
duration : Math . round ( duration ) ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
avgCadence ,
2026-09-17 22:11:10 +03:00
avgPower ,
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
segments ,
2026-09-07 15:50:19 +03:00
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)
2026-05-13 10:21:24 +03:00
} ;
} ;
2026-07-31 22:11:55 +03:00
// ---------------------------------------------------------------------------
// Savitzky-Golay track smoothing (de-jitter). Quadratic least-squares fit of
// x(i) and y(i) over a window, evaluated at the centre, iterated. Removes GPS
// sideways jitter while keeping real corners better than a plain moving average.
// Returns NEW point objects with smoothed lat/lon; all other fields preserved.
// Endpoints anchored. window = points on each side, iters = passes.
// ---------------------------------------------------------------------------
GPS . smoothTrackSG = function ( points , window , iters ) {
const n = points . length ;
if ( n < 5 ) return points . map ( p => ( { ... p } ) ) ;
const W = Math . max ( 1 , window || 4 ) , IT = Math . max ( 1 , iters || 3 ) ;
const lat0 = points [ 0 ] . lat * Math . PI / 180 , mLat = 111320 , mLon = 111320 * Math . cos ( lat0 ) ;
let xy = points . map ( p => ( { x : ( p . lon - points [ 0 ] . lon ) * mLon , y : ( p . lat - points [ 0 ] . lat ) * mLat } ) ) ;
const det3 = m =>
m [ 0 ] [ 0 ] * ( m [ 1 ] [ 1 ] * m [ 2 ] [ 2 ] - m [ 1 ] [ 2 ] * m [ 2 ] [ 1 ] ) - m [ 0 ] [ 1 ] * ( m [ 1 ] [ 0 ] * m [ 2 ] [ 2 ] - m [ 1 ] [ 2 ] * m [ 2 ] [ 0 ] ) + m [ 0 ] [ 2 ] * ( m [ 1 ] [ 0 ] * m [ 2 ] [ 1 ] - m [ 1 ] [ 1 ] * m [ 2 ] [ 0 ] ) ;
const pass = a => {
const out = a . map ( p => ( { x : p . x , y : p . y } ) ) ;
for ( let i = 0 ; i < a . length ; i ++ ) {
const s = Math . max ( 0 , i - W ) , e = Math . min ( a . length - 1 , i + W ) ;
const fit = get => {
let S0 = 0 , S1 = 0 , S2 = 0 , S3 = 0 , S4 = 0 , T0 = 0 , T1 = 0 , T2 = 0 ;
for ( let j = s ; j <= e ; j ++ ) { const t = j - i , v = get ( a [ j ] ) ; S0 += 1 ; S1 += t ; S2 += t * t ; S3 += t * t * t ; S4 += t * t * t * t ; T0 += v ; T1 += v * t ; T2 += v * t * t ; }
const M = [ [ S0 , S1 , S2 ] , [ S1 , S2 , S3 ] , [ S2 , S3 , S4 ] ] , D = det3 ( M ) ;
if ( Math . abs ( D ) < 1e-9 ) return get ( a [ i ] ) ;
const M0 = [ [ T0 , M [ 0 ] [ 1 ] , M [ 0 ] [ 2 ] ] , [ T1 , M [ 1 ] [ 1 ] , M [ 1 ] [ 2 ] ] , [ T2 , M [ 2 ] [ 1 ] , M [ 2 ] [ 2 ] ] ] ;
return det3 ( M0 ) / D ;
} ;
out [ i ] = { x : fit ( p => p . x ) , y : fit ( p => p . y ) } ;
}
out [ 0 ] = a [ 0 ] ; out [ a . length - 1 ] = a [ a . length - 1 ] ;
return out ;
} ;
for ( let k = 0 ; k < IT ; k ++ ) xy = pass ( xy ) ;
return points . map ( ( p , i ) => ( { ... p , lat : points [ 0 ] . lat + xy [ i ] . y / mLat , lon : points [ 0 ] . lon + xy [ i ] . x / mLon } ) ) ;
} ;
2026-05-13 10:21:24 +03:00
// ---------------------------------------------------------------------------
// Speed smoothing — 3-point moving average
// ---------------------------------------------------------------------------
GPS . smoothSpeeds = function ( points ) {
const speeds = [ ] ;
for ( let i = 0 ; i < points . length ; i ++ ) {
if ( i === 0 || i === points . length - 1 ) {
speeds . push ( points [ i ] . speed _ms ? ? 0 ) ;
} else {
const a = points [ i - 1 ] . speed _ms ? ? 0 ;
const b = points [ i ] . speed _ms ? ? 0 ;
const c = points [ i + 1 ] . speed _ms ? ? 0 ;
speeds . push ( ( a + b + c ) / 3 ) ;
}
}
return speeds ;
} ;
// ---------------------------------------------------------------------------
// Live recording state
// ---------------------------------------------------------------------------
GPS . recording = {
active : false ,
sessionId : null ,
watchId : null ,
points : [ ] ,
startTime : null ,
activityType : "bike" ,
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
currentHr : null ,
currentBleData : null ,
hrPollInterval : null ,
2026-05-13 10:21:24 +03:00
} ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Recording states: waiting_gps → ready → recording (auto on first movement)
// Pause: speed < 1 m/s for 5+ seconds → stop counting distance/time
// Start native GPS service on Android (foreground service survives background)
GPS . _startNativeService = async function ( ) {
try {
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
// Per-activity speed limits
const type = GPS . recording . activityType ;
const isWalk = type === "walk" || type === "run" ;
const minSpd = isWalk ? GPS . config . walkMinSpeedMs : GPS . config . minSpeedMs ;
const maxSpd = isWalk ? ( GPS . config . walkMaxSpeedKmh / 3.6 ) : ( GPS . config . maxSpeedKmh / 3.6 ) ;
2026-07-07 00:28:27 +03:00
let fused = GPS . config . useFusedLocation ? 1 : 0 ;
let cmd = ` start: ${ minSpd } : ${ GPS . config . minAccuracyM } : ${ GPS . config . minDistanceM } : ${ maxSpd } : ${ fused } ` ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
await window . _ _TAURI _ _ . core . invoke ( "gps_service_command" , { cmd } ) ;
console . log ( "[GPS] Native service started with config:" , cmd ) ;
GPS . recording . nativeService = true ;
} catch ( e ) {
console . log ( "[GPS] No native service (desktop?), using WebView GPS" ) ;
GPS . recording . nativeService = false ;
}
} ;
GPS . _stopNativeService = async function ( ) {
try {
await window . _ _TAURI _ _ . core . invoke ( "gps_service_command" , { cmd : "stop" } ) ;
} catch { }
} ;
2026-07-07 00:28:27 +03:00
// Ask Android for background location ("Allow all the time") — required to record
// while the screen is off. On Android 11+ this opens the system location settings.
GPS . requestBackgroundLocation = async function ( ) {
try {
await window . _ _TAURI _ _ . core . invoke ( "gps_service_command" , { cmd : "reqbgloc" } ) ;
} catch ( e ) { console . log ( "[GPS] reqbgloc:" , e ) ; }
} ;
2026-10-17 02:07:44 +03:00
// 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 ; }
} ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
// 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 ( ) {
if ( GPS . recording . hrPollInterval ) return ;
try {
const addr = await DB . getProfileValue ( "ble_device_addr" ) ;
const type = await DB . getProfileValue ( "ble_device_type" ) ;
if ( ! addr || ! type ) { console . log ( "[HR] No paired device" ) ; return ; }
2026-10-02 14:03:16 +03:00
// 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)" ) ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
GPS . recording . hrPollInterval = setInterval ( async ( ) => {
try {
const reading = await window . _ _TAURI _ _ . core . invoke ( "ble_get_hr" ) ;
2026-10-17 00:58:31 +03:00
if ( reading && reading . battery != null ) { GPS . recording . currentBattery = reading . battery ; GPS . _checkBatteryAlert ( reading . battery ) ; }
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
if ( reading && reading . hr > 0 ) {
GPS . recording . currentHr = reading . hr ;
GPS . recording . currentBleData = reading ;
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
// HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading.
2026-10-22 00:51:31 +03:00
if ( GPS . isHrOnly ( GPS . recording . activityType ) && GPS . recording . startTime ) {
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
GPS . recording . hrSamples . push ( [ Math . round ( ( Date . now ( ) - GPS . recording . startTime . getTime ( ) ) / 1000 ) , reading . hr ] ) ;
}
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
if ( GPS . recording . onHrUpdate ) GPS . recording . onHrUpdate ( reading ) ;
}
} catch { }
} , 2000 ) ;
} catch ( e ) {
console . log ( "[HR] Monitor start failed:" , e ) ;
}
} ;
GPS . _stopHrMonitor = function ( ) {
if ( GPS . recording . hrPollInterval ) {
clearInterval ( GPS . recording . hrPollInterval ) ;
GPS . recording . hrPollInterval = null ;
}
GPS . recording . currentHr = null ;
GPS . recording . currentBleData = null ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
// Clear stale HR data file so next session doesn't show old values
try { window . _ _TAURI _ _ . core . invoke ( "ble_clear_hr" ) ; } catch { }
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
} ;
2026-10-20 23:09:50 +03:00
// Rebuild the workout's HR series from the NATIVE log (ble_hr_log.jsonl), which the
// foreground service appends to even with the screen off — unlike the JS poll, which
// Android freezes in the background (so a sleep-long workout kept only the on-screen bits).
// Returns [[relSec, bpm], …] relative to the recording start, sorted and de-duplicated,
// or [] if the log is empty/unavailable.
GPS . _readHrLogSamples = async function ( ) {
if ( ! GPS . recording . startTime ) return [ ] ;
let raw = "" ;
try { raw = await window . _ _TAURI _ _ . core . invoke ( "ble_read_hr_log" ) ; } catch { return [ ] ; }
if ( ! raw ) return [ ] ;
const startSec = GPS . recording . startTime . getTime ( ) / 1000 ;
const out = [ ] ;
let lastRel = - 1 ;
for ( const line of raw . split ( "\n" ) ) {
const s = line . trim ( ) ;
if ( ! s ) continue ;
let o ; try { o = JSON . parse ( s ) ; } catch { continue ; } // skip a torn last line
if ( ! o || ! ( o . hr > 0 ) || o . ts == null ) continue ;
const rel = Math . round ( o . ts - startSec ) ;
if ( rel < 0 || rel === lastRel ) continue ; // before start, or same-second dup
out . push ( [ rel , o . hr ] ) ;
lastRel = rel ;
}
out . sort ( ( a , b ) => a [ 0 ] - b [ 0 ] ) ;
return out ;
} ;
2026-10-17 01:45:02 +03:00
// 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.
2026-10-19 13:43:55 +03:00
//
// `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 ) {
2026-10-17 01:45:02 +03:00
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" ) ;
2026-10-19 13:43:55 +03:00
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 { } }
2026-10-17 01:45:02 +03:00
} catch { }
try { await GPS . _startHrMonitor ( ) ; } catch { }
return true ;
} ;
2026-10-17 00:58:31 +03:00
// 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 ) ;
} ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Read points from native service file
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Parse raw JSONL (one native point per line) into sorted point objects.
GPS . _parseNativePoints = function ( raw ) {
const points = [ ] ;
if ( ! raw ) return points ;
for ( const line of raw . trim ( ) . split ( "\n" ) ) {
if ( ! line ) continue ;
try {
const d = JSON . parse ( line ) ;
if ( d . lat == null || d . lon == null ) continue ; // skip marker/invalid lines
points . push ( {
lat : d . lat , lon : d . lon , altitude : d . alt ,
speed _ms : d . speed , accuracy : d . acc ,
bearing : ( d . bearing != null && d . bearing >= 0 ) ? d . bearing : null , // GPS course over ground
ts : new Date ( d . ts ) . toISOString ( ) ,
accel _rms : d . accel _rms , accel _max : d . accel _max ,
gyro _pitch : d . gyro _pitch , gyro _yaw : d . gyro _yaw ,
estimated : d . estimated || false ,
} ) ;
} catch { /* skip partial/corrupt lines */ }
}
points . sort ( ( a , b ) => new Date ( a . ts ) - new Date ( b . ts ) ) ;
return points ;
} ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . _readNativePoints = async function ( ) {
try {
const raw = await window . _ _TAURI _ _ . core . invoke ( "gps_service_read_points" ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
return GPS . _parseNativePoints ( raw ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} catch { return [ ] ; }
} ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// On-device track recovery. The foreground service writes every point to a
// native gps_points_<ms>.jsonl regardless of screen state, so these files are
// the source of truth for screen-off stretches the WebView missed.
//
// MATCHING — both a file and a session carry their own start timestamp, so we
// match them directly by id/date, never by "which session has points here":
// * The filename's <ms> is when the service started; a session's id is
// Date.now() at first movement. Empirically the file always starts a few
// seconds to ~25 min BEFORE its session (warmup before moving), same date.
// * A file belongs to session S iff S.date == file's date AND the file's start
// falls inside S's point window ±30 min. That rejects orphan recordings that
// left no session (e.g. the 07-05 files, hours from any session) and can
// never glue a file onto a foreign date.
//
// SAFETY (learned the hard way — a naive version corrupted real rides):
// * Only import file points whose own date == the session's date, so a stray
// glitched timestamp inside a correctly-matched file can't leak in.
// * recalculateSession is called with {noDelete:true}: recovery ADDS points, it
// must never trigger the destructive glitch/>24h filter that once wiped the
// real 07-22/07-24 rides.
GPS . recoverFromFiles = async function ( onProgress ) {
if ( GPS . recording . active ) return { error : "recording" } ;
const invoke = window . _ _TAURI _ _ . core . invoke ;
const sel = ( q , v ) => invoke ( "plugin:sql|select" , { db : "sqlite:baldav.db" , query : q , values : v || [ ] } ) ;
let files = [ ] ;
try { files = JSON . parse ( await invoke ( "list_points_files" ) ) ; }
catch ( e ) { console . error ( "[recover] list_points_files FAILED" , e ) ; }
console . log ( ` [recover] files= ${ files . length } ` ) ;
// Load every session's date + actual point window once. CAST id to TEXT — the
// SQL plugin corrupts large i64 ids returned as numbers.
let sessRows = [ ] ;
try {
sessRows = await sel (
"SELECT CAST(s.id AS TEXT) AS sid, s.date AS d, MIN(p.ts) AS mn, MAX(p.ts) AS mx " +
"FROM gps_sessions s JOIN gps_points p ON p.session_id = s.id GROUP BY s.id"
) ;
} catch ( e ) { console . error ( "[recover] session windows FAILED" , e ) ; }
const sessions = sessRows . map ( r => ( {
sid : r . sid , date : r . d ,
mn : new Date ( r . mn ) . getTime ( ) , mx : new Date ( r . mx ) . getTime ( ) ,
} ) ) ;
const dayOf = ( ms ) => new Date ( ms ) . toISOString ( ) . slice ( 0 , 10 ) ;
const NEAR _MS = 30 * 60 * 1000 ; // file start may lead the session by up to ~25 min
// The file the service is (or was last) writing — never delete it, a resumed
// session may still append to it.
let activeFile = "" ;
try { activeFile = ( await invoke ( "get_points_filename" ) ) || "" ; } catch { }
const results = [ ] ;
for ( let fi = 0 ; fi < files . length ; fi ++ ) {
const name = files [ fi ] ;
if ( onProgress ) { try { onProgress ( fi + 1 , files . length ) ; } catch { } }
// The file's start ms and (for new files) its owning session id live in the
// name: gps_points_<ms>.jsonl or gps_points_<ms>_s<sessionId>.jsonl.
const m = /gps_points_(\d+)(?:_s(\d+))?\.jsonl/ . exec ( name ) ;
if ( ! m ) { console . log ( ` [recover] f= ${ name } no id in name, skip ` ) ; continue ; }
const fileMs = Number ( m [ 1 ] ) ;
const boundSid = m [ 2 ] || null ; // service stamped the session id into the name
const fDate = dayOf ( fileMs ) ;
let match = null ;
if ( boundSid ) {
// Unambiguous binding: the file already names its session. Bind directly;
// if that session was deleted, leave the file unmatched (never inject).
match = sessions . find ( s => s . sid === boundSid ) || null ;
if ( ! match ) {
console . log ( ` [recover] f= ${ name } bound sid= ${ boundSid } but session gone — UNMATCHED ` ) ;
results . push ( { file : name , imported : 0 , unmatched : true } ) ;
continue ;
}
} else {
// Legacy files (no session id in name): match by id/date — same date, file
// start inside the session window ±30 min, nearest such session wins.
let bestGap = Infinity ;
for ( const s of sessions ) {
if ( s . date !== fDate ) continue ;
const gap = fileMs < s . mn ? s . mn - fileMs : ( fileMs > s . mx ? fileMs - s . mx : 0 ) ;
if ( gap <= NEAR _MS && gap < bestGap ) { bestGap = gap ; match = s ; }
}
if ( ! match ) {
console . log ( ` [recover] f= ${ name } start= ${ new Date ( fileMs ) . toISOString ( ) } UNMATCHED ` ) ;
results . push ( { file : name , imported : 0 , unmatched : true } ) ;
continue ;
}
}
const sid = match . sid ;
let raw = "" ;
try { raw = await invoke ( "read_points_file" , { filename : name } ) ; }
catch ( e ) { console . error ( ` [recover] read FAILED ${ name } ` , e ) ; continue ; }
const pts = GPS . _parseNativePoints ( raw ) ;
if ( pts . length < 2 ) { results . push ( { file : name , sessionId : sid , imported : 0 } ) ; continue ; }
// Only points on the matched session's own date may be imported (blocks a
// stray glitched timestamp inside an otherwise-correct file).
const sameDay = pts . filter ( p => p . ts . slice ( 0 , 10 ) === match . date ) ;
if ( ! sameDay . length ) { results . push ( { file : name , sessionId : sid , imported : 0 } ) ; continue ; }
const existing = await DB . getGpsPoints ( sid ) ;
// Dedup by TIME PROXIMITY, not exact ms. The native service and the WebView
// watchPosition record the SAME fixes with slightly different millisecond
// stamps, so exact-ms dedup keeps both → interleaved zig-zag inflates
// distance/kcal. Skip a native point if any existing point is within PROX ms;
// keep only points in genuine gaps (screen-off stretches the WebView missed).
const PROX = 2500 ;
const existMs = existing . map ( p => new Date ( p . ts ) . getTime ( ) ) . sort ( ( a , b ) => a - b ) ;
const nearExisting = ( t ) => {
let lo = 0 , hi = existMs . length - 1 ;
while ( lo <= hi ) { let mid = ( lo + hi ) >> 1 ; if ( existMs [ mid ] < t ) lo = mid + 1 ; else hi = mid - 1 ; }
let best = Infinity ;
if ( lo < existMs . length ) best = Math . min ( best , existMs [ lo ] - t ) ;
if ( lo > 0 ) best = Math . min ( best , t - existMs [ lo - 1 ] ) ;
return best <= PROX ;
} ;
const missing = sameDay . filter ( p => ! nearExisting ( new Date ( p . ts ) . getTime ( ) ) ) ;
let imported = 0 ;
if ( missing . length ) imported = await DB . addGpsPointsBulk ( sid , missing , existing . length ) ;
// Recalc so elevation/kcal fixes apply even when nothing new was imported.
// noDelete: recovery must NEVER run the destructive glitch/>24h filter.
try { await GPS . recalculateSession ( sid , { noDelete : true } ) ; }
catch ( e ) { console . error ( "[recover] recalc failed" , e ) ; }
// The file's points are now safely in the DB — delete it to clear the backlog
// clutter. Skip the active file. Orphan/unmatched files are kept (their points
// have no session, so deleting would lose data).
let removed = false ;
if ( name !== activeFile ) {
try { await invoke ( "delete_points_file" , { filename : name } ) ; removed = true ; }
catch ( e ) { console . warn ( ` [recover] delete failed ${ name } ` , e ) ; }
}
console . log ( ` [recover] f= ${ name } pts= ${ pts . length } match= ${ sid } existing= ${ existing . length } imported= ${ imported } removed= ${ removed } ` ) ;
results . push ( { file : name , sessionId : sid , imported , removed } ) ;
}
const removedN = results . filter ( r => r . removed ) . length ;
console . log ( ` [recover] DONE files= ${ results . length } imported= ${ results . reduce ( ( a , r ) => a + ( r . imported || 0 ) , 0 ) } removed= ${ removedN } ` ) ;
return { results , removed : removedN } ;
} ;
// Core point processing — used by both WebView GPS (watchPosition) and native
// service polling. `fromNative` = the point came from the native file via the
// poll. On Android the native file is the complete, screen-independent source of
// truth, so only native points are stored; watchPosition then just drives the
// live marker — the same fix is never written twice with different timestamps.
GPS . _processIncomingPoint = async function ( p , fromNative ) {
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( ! GPS . recording . active ) return ;
ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI
Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and
work at the same time (one adapter, several LE connections). Verified on device against a PC
BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk.
- BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback,
ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and
auto-connects both HR (ble_device.json) and baro (baro_device.json) at start.
- Rust: ble_baro_disconnect, ble_save_baro_device.
- Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their
own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout.
- Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro).
- Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor.
- Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse);
parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns
to the tracks list instead of into details.
- tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing.
Emulator-verified only; real board still pending.
2026-10-30 22:46:26 +03:00
// Attach barometric altitude to this point when the BLE baro sensor is live and anchored.
// Null without a barometer, so elevation stats fall back to GPS/SRTM exactly as before.
try {
if ( window . BARO && BARO . isLive ( ) ) {
let ba = BARO . altitude ( ) ;
if ( ba != null && isFinite ( ba ) ) p . altitude _baro _m = ba ;
}
} catch ( e ) { }
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// DB writes come only from the source of truth: native points on Android, or
// watchPosition on desktop (no native service).
const writeDb = ! ! fromNative || ! GPS . recording . nativeService ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// State: waiting_gps → ready (got first accurate fix)
if ( GPS . recording . state === "waiting_gps" ) {
GPS . recording . state = "ready" ;
if ( GPS . recording . onStateChange ) GPS . recording . onStateChange ( "ready" , p ) ;
}
// Manual pause — show position but don't process
if ( GPS . recording . manualPaused ) {
if ( GPS . recording . onPoint ) GPS . recording . onPoint ( p ) ;
return ;
}
2026-07-07 00:28:27 +03:00
// Determine if moving — same per-activity threshold as the native service
// (walk/run → walkMinSpeedMs, else minSpeedMs). 0 means "always moving":
// no auto-pause, no sticking on "начните движение".
let speed = p . speed _ms != null ? p . speed _ms : 0 ;
let mvType = GPS . recording . activityType ;
let effMinSpeed = ( mvType === "walk" || mvType === "run" ) ? GPS . config . walkMinSpeedMs : GPS . config . minSpeedMs ;
let isMoving = effMinSpeed <= 0 ? true : speed > effMinSpeed ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( ! isMoving && GPS . recording . lastMovingPoint ) {
const d = GPS . distance ( GPS . recording . lastMovingPoint . lat , GPS . recording . lastMovingPoint . lon , p . lat , p . lon ) ;
const dt = ( new Date ( p . ts ) - new Date ( GPS . recording . lastMovingPoint . ts ) ) / 1000 ;
if ( d > GPS . config . minDistanceM * 3 && dt < 5 && dt > 0 ) isMoving = true ;
}
// State: ready → recording (first movement detected)
if ( GPS . recording . state === "ready" && isMoving ) {
GPS . recording . state = "recording" ;
GPS . recording . startTime = new Date ( ) ;
const now = GPS . recording . startTime ;
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
try {
GPS . recording . sessionId = await DB . createGpsSession (
now . toISOString ( ) . slice ( 0 , 10 ) ,
now . toTimeString ( ) . slice ( 0 , 5 ) ,
GPS . recording . activityType
) ;
} catch ( e ) {
console . error ( "[GPS] CRITICAL: createGpsSession FAILED:" , e ) ;
// Show visible error — this is data loss
if ( typeof showToast === "function" ) showToast ( "DB: " + ( e . message || e ) ) ;
}
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Stamp the session id into the native file's name so recovery binds it
// unambiguously (the service renames gps_points_<ms>.jsonl -> ..._s<id>.jsonl).
if ( GPS . recording . sessionId != null ) {
try { await window . _ _TAURI _ _ . core . invoke ( "gps_set_session_id" , { sessionId : String ( GPS . recording . sessionId ) } ) ; }
catch ( e ) { console . warn ( "[GPS] gps_set_session_id failed (non-critical):" , e ) ; }
}
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . _persistState ( ) ;
if ( GPS . recording . onStateChange ) GPS . recording . onStateChange ( "recording" , p ) ;
}
// If not yet recording, just update position display
if ( GPS . recording . state !== "recording" ) {
if ( GPS . recording . onPoint ) GPS . recording . onPoint ( p ) ;
return ;
}
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
// Auto-pause detection — still save HR data during pause
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( ! isMoving ) {
if ( ! GPS . recording . pauseStart ) {
GPS . recording . pauseStart = new Date ( p . ts ) ;
}
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
// Save point with HR even during pause (no seq = excluded from distance/kcal)
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
if ( writeDb && GPS . recording . sessionId && GPS . recording . currentBleData ) {
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
DB . addGpsPoint ( GPS . recording . sessionId , p . ts , p . lat , p . lon , p . altitude , 0 , null , GPS . recording . currentBleData , p ) ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
}
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( GPS . recording . onPoint ) GPS . recording . onPoint ( p ) ;
return ;
}
// Resumed from auto-pause
if ( GPS . recording . pauseStart ) {
const pauseMs = new Date ( p . ts ) - GPS . recording . pauseStart ;
GPS . recording . totalPauseMs += pauseMs ;
GPS . recording . pauseStart = null ;
if ( GPS . recording . onStateChange ) GPS . recording . onStateChange ( "resumed" , p ) ;
}
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Save moving point with seq — only from the source of truth. watchPosition on
// Android skips the write (the poll stores the native equivalent), so distance
// and kcal aren't inflated by the same fix landing twice.
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . recording . lastMovingPoint = p ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
if ( writeDb ) {
GPS . recording . points . push ( p ) ;
GPS . recording . _pointSeq = ( GPS . recording . _pointSeq || 0 ) + 1 ;
if ( GPS . recording . sessionId ) {
await DB . addGpsPoint ( GPS . recording . sessionId , p . ts , p . lat , p . lon , p . altitude , p . speed _ms , GPS . recording . _pointSeq , GPS . recording . currentBleData , p ) ;
}
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
}
if ( GPS . recording . onPoint ) GPS . recording . onPoint ( p ) ;
} ;
2026-09-07 17:10:16 +03:00
// Headwind labelling (variant 5 as VALIDATION, not measurement): the rider taps
// to open a "wind in the face" interval and taps again to close it. Marks are
// stored per session ([{startTs, endTs}] ms epoch) and later compared against the
// from-track wind estimate — they are ground truth, they do NOT feed kcal/power.
// Returns true if a headwind interval is now open (button should show active).
GPS . toggleWindMark = function ( ) {
if ( ! GPS . recording . windMarks ) GPS . recording . windMarks = [ ] ;
if ( GPS . recording . _currentWind ) {
GPS . recording . _currentWind . endTs = Date . now ( ) ;
GPS . recording . _currentWind = null ;
} else {
GPS . recording . _currentWind = { startTs : Date . now ( ) , endTs : null } ;
GPS . recording . windMarks . push ( GPS . recording . _currentWind ) ;
}
GPS . _persistState ( ) ;
return GPS . recording . _currentWind != null ;
} ;
// Close an interval left open (rider forgot to tap off before Stop / recovery).
GPS . _closeOpenWindMark = function ( ) {
if ( GPS . recording . _currentWind && GPS . recording . _currentWind . endTs == null ) {
GPS . recording . _currentWind . endTs = Date . now ( ) ;
}
GPS . recording . _currentWind = null ;
} ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Persist recording state to localStorage for crash recovery
GPS . _persistState = function ( ) {
try {
localStorage . setItem ( "baldav_gps_active" , JSON . stringify ( {
sessionId : GPS . recording . sessionId ,
activityType : GPS . recording . activityType ,
weightKg : GPS . recording . weightKg ,
startTime : GPS . recording . startTime ? . toISOString ( ) ,
totalPauseMs : GPS . recording . totalPauseMs ,
manualPaused : GPS . recording . manualPaused ,
pauses : GPS . recording . pauses || [ ] ,
2026-09-07 17:10:16 +03:00
windMarks : GPS . recording . windMarks || [ ] ,
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
pointCount : GPS . recording . points . length ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
bikeId : GPS . recording . bikeId ? ? null ,
bikeWeightKg : GPS . recording . bikeWeightKg ? ? null ,
surfaceCrr : GPS . recording . surfaceCrr ? ? null ,
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
tirePressureBar : GPS . recording . tirePressureBar ? ? null ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
chainringTeeth : GPS . recording . chainringTeeth ? ? null ,
cogTeeth : GPS . recording . cogTeeth ? ? null ,
wheelDiameterInch : GPS . recording . wheelDiameterInch ? ? null ,
2026-09-07 15:50:19 +03:00
gearCount : GPS . recording . gearCount ? ? null ,
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} ) ) ;
} catch { }
} ;
GPS . _clearPersistedState = function ( ) {
localStorage . removeItem ( "baldav_gps_active" ) ;
} ;
// Manual pause/resume
GPS . pauseRecording = function ( ) {
if ( ! GPS . recording . active || GPS . recording . manualPaused ) return ;
GPS . recording . manualPaused = true ;
const last = GPS . recording . lastMovingPoint || GPS . recording . points [ GPS . recording . points . length - 1 ] ;
GPS . recording . _currentPause = {
startTs : Date . now ( ) ,
startLat : last ? . lat , startLon : last ? . lon ,
} ;
GPS . _persistState ( ) ;
if ( GPS . recording . onStateChange ) GPS . recording . onStateChange ( "manual_paused" ) ;
} ;
GPS . resumeRecording = function ( ) {
if ( ! GPS . recording . active || ! GPS . recording . manualPaused ) return ;
GPS . recording . manualPaused = false ;
const last = GPS . recording . lastMovingPoint || GPS . recording . points [ GPS . recording . points . length - 1 ] ;
if ( GPS . recording . _currentPause ) {
GPS . recording . _currentPause . endTs = Date . now ( ) ;
GPS . recording . _currentPause . endLat = last ? . lat ;
GPS . recording . _currentPause . endLon = last ? . lon ;
const dur = GPS . recording . _currentPause . endTs - GPS . recording . _currentPause . startTs ;
GPS . recording . totalPauseMs += dur ;
if ( ! GPS . recording . pauses ) GPS . recording . pauses = [ ] ;
GPS . recording . pauses . push ( GPS . recording . _currentPause ) ;
GPS . recording . _currentPause = null ;
}
GPS . _persistState ( ) ;
if ( GPS . recording . onStateChange ) GPS . recording . onStateChange ( "manual_resumed" ) ;
} ;
2026-07-07 00:28:27 +03:00
// True when an active session has had no movement for autoFinishIdleMin minutes —
// used to auto-finish a session the user forgot to stop, so the next outing is separate.
GPS . shouldAutoFinish = function ( ) {
if ( ! GPS . recording . active || GPS . recording . manualPaused ) return false ;
if ( ! GPS . recording . sessionId ) return false ; // no session yet (no movement)
let last = GPS . recording . lastMovingPoint ;
if ( ! last ) return false ;
let idleMs = Date . now ( ) - new Date ( last . ts ) . getTime ( ) ;
return idleMs > ( GPS . config . autoFinishIdleMin || 15 ) * 60000 ;
} ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Recover orphaned recording session on app startup
GPS . recoverSession = async function ( ) {
const saved = localStorage . getItem ( "baldav_gps_active" ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
if ( ! saved ) return false ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
try {
const s = JSON . parse ( saved ) ;
if ( ! s . sessionId ) { GPS . _clearPersistedState ( ) ; return ; }
2026-07-08 22:11:17 +03:00
// Import EVERY native point missing from the DB, deduped by the SET of existing
// timestamps — NOT "ts > maxDbTs". The live poll is screen-gated and leaves
// sparse points spread across the whole session, so the max DB ts can be the
// LAST point; "ts > maxDbTs" would then import nothing and the gaps stay.
2026-05-16 10:49:55 +03:00
const existingPts = await DB . getGpsPoints ( s . sessionId ) ;
2026-07-08 22:11:17 +03:00
const dbTs = new Set ( existingPts . map ( p => new Date ( p . ts ) . getTime ( ) ) ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
const nativePoints = await GPS . _readNativePoints ( ) ;
2026-05-16 10:49:55 +03:00
let imported = 0 ;
let seq = existingPts . length ;
for ( const p of nativePoints ) {
2026-07-08 22:11:17 +03:00
if ( dbTs . has ( new Date ( p . ts ) . getTime ( ) ) ) continue ;
2026-05-16 10:49:55 +03:00
seq ++ ;
2026-07-08 22:11:17 +03:00
await DB . addGpsPoint ( s . sessionId , p . ts , p . lat , p . lon , p . altitude , p . speed _ms , seq , null , p ) ;
2026-05-16 10:49:55 +03:00
imported ++ ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
}
// Finalize the session
const pts = await DB . getGpsPoints ( s . sessionId ) ;
if ( pts . length > 0 ) {
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const recoverOpts = {
activityType : s . activityType ,
bikeWeightKg : s . bikeWeightKg ? ? 0 ,
surfaceCrr : s . surfaceCrr ,
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
tirePressureBar : s . tirePressureBar ? ? 3.0 ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
chainringTeeth : s . chainringTeeth ,
cogTeeth : s . cogTeeth ,
wheelDiameterInch : s . wheelDiameterInch ,
2026-09-07 15:50:19 +03:00
gearCount : s . gearCount ,
2026-09-07 13:53:40 +03:00
pauses : s . pauses || [ ] ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
} ;
const stats = GPS . computeStats ( pts , s . weightKg || 80 , recoverOpts ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
await DB . finishGpsSession ( s . sessionId , new Date ( ) . toTimeString ( ) . slice ( 0 , 5 ) ,
2026-09-07 13:53:40 +03:00
stats . distance , stats . kcal , stats . avgSpeed , stats . maxSpeed , stats . elevGain , stats . elevLoss , s . pauses || [ ] ) ;
2026-09-07 17:10:16 +03:00
// Restore headwind marks captured before the crash (close a dangling open one)
if ( Array . isArray ( s . windMarks ) && s . windMarks . length ) {
for ( const w of s . windMarks ) if ( w && w . endTs == null ) w . endTs = new Date ( pts [ pts . length - 1 ] . ts ) . getTime ( ) ;
try { await DB . setSessionWindMarks ( s . sessionId , s . windMarks ) ; } catch ( e ) { console . error ( "[GPS] wind marks restore failed:" , e ) ; }
}
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Store bike_id if present
if ( s . bikeId ) await DB . updateSessionBike ( s . sessionId , s . bikeId ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
console . log ( "[GPS] Recovered session" , s . sessionId , "with" , pts . length , "points" ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
GPS . _clearPersistedState ( ) ;
await GPS . _stopNativeService ( ) ;
return true ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
}
GPS . _clearPersistedState ( ) ;
await GPS . _stopNativeService ( ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
return false ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} catch ( e ) {
console . error ( "[GPS] Recovery error:" , e ) ;
GPS . _clearPersistedState ( ) ;
}
} ;
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
// 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 ) {
2026-05-13 10:21:24 +03:00
if ( GPS . recording . active ) return ;
GPS . recording . active = true ;
GPS . recording . activityType = activityType || "bike" ;
GPS . recording . points = [ ] ;
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
GPS . recording . hrSamples = [ ] ;
GPS . recording . hrProfile = profile || null ;
2026-10-17 12:29:14 +03:00
// 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 { }
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . recording . startTime = null ;
Physics settings in UI, recipe modal cleanup, weight parseFloat fixes
Settings:
- Aero drag (CdA), air density, pedal efficiency now in GPS config
- Human-readable names: "Аэросопротивление", "Плотность воздуха", "КПД педалирования"
- Saved to localStorage, used in computeStats
Bug fixes:
- Recipe detail: overlay.remove without popHandler cleanup → now uses history.back()
- weightKg parseFloat in startRecording and importGPX (string "88" bug)
- profile.weight_kg stored as number not string
- getDailyActivityTotals: separate params for UNION ALL subqueries
Docs:
- PROJECT_MAP.md — full project structure, DB schema, screens, formulas
- README.md — overview, build instructions, data sources
2026-05-26 02:14:43 +03:00
GPS . recording . weightKg = parseFloat ( weightKg ) || 80 ;
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
2026-10-22 00:51:31 +03:00
// HR-only "workout"/"vr": no GPS service, no location, no track points — just the
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
// heart-rate poll + a timer. kcal comes from HR (Keytel) at finish.
2026-10-22 00:51:31 +03:00
if ( GPS . isHrOnly ( GPS . recording . activityType ) ) {
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
GPS . recording . state = "recording" ;
GPS . recording . startTime = new Date ( ) ;
GPS . recording . nativeService = false ;
GPS . recording . sessionId = null ;
try {
GPS . recording . sessionId = await DB . createGpsSession (
2026-10-22 00:51:31 +03:00
new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) , new Date ( ) . toTimeString ( ) . slice ( 0 , 5 ) , GPS . recording . activityType ) ;
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
} catch ( e ) { console . error ( "[workout] createGpsSession failed:" , e ) ; }
2026-10-17 01:45:02 +03:00
// 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 ) ; }
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
return ;
}
if ( ! navigator . geolocation ) throw new Error ( "Geolocation not available" ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . recording . state = "waiting_gps" ;
GPS . recording . sessionId = null ;
GPS . recording . pauseStart = null ;
GPS . recording . totalPauseMs = 0 ;
GPS . recording . lastMovingPoint = null ;
GPS . recording . manualPaused = false ;
GPS . recording . pauses = [ ] ;
GPS . recording . _currentPause = null ;
2026-09-07 17:10:16 +03:00
GPS . recording . windMarks = [ ] ; // headwind labels: [{startTs, endTs}] ms epoch
GPS . recording . _currentWind = null ; // open headwind interval, if any
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . recording . nativeService = false ;
GPS . recording . _pollTimer = null ;
2026-05-16 10:49:55 +03:00
GPS . recording . _lastProcessedTs = 0 ;
GPS . recording . _pointSeq = 0 ;
2026-05-13 10:21:24 +03:00
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Start native foreground service (Android)
await GPS . _startNativeService ( ) ;
2026-10-17 01:45:02 +03:00
// 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 ) ; }
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Poll native service output file
if ( GPS . recording . nativeService ) {
2026-05-16 21:21:13 +03:00
let polling = false ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . recording . _pollTimer = setInterval ( async ( ) => {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
console . log ( ` [poll] tick active= ${ GPS . recording . active } polling= ${ polling } hidden= ${ document . hidden } lastTs= ${ GPS . recording . _lastProcessedTs } ` ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( ! GPS . recording . active ) { clearInterval ( GPS . recording . _pollTimer ) ; return ; }
2026-05-16 21:21:13 +03:00
if ( polling ) return ; // skip if previous poll still running
polling = true ;
try {
const nativePoints = await GPS . _readNativePoints ( ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
console . log ( ` [poll] native= ${ nativePoints . length } firstTs= ${ nativePoints [ 0 ] ? . ts } lastNativeTs= ${ nativePoints [ nativePoints . length - 1 ] ? . ts } ` ) ;
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
let newCount = 0 ;
2026-07-07 23:45:11 +03:00
// Keep the heavy per-point path off (onPoint runs computeStats over all
// points + DB writes → O(n²) on a big backlog). Draw each imported point
// cheaply via onImportPoint so the track visibly GROWS as points transfer
// from the native file, and yield a frame every ~20 points so Leaflet
// repaints — this makes the screen-wake catch-up visible again.
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
const origOnPoint = GPS . recording . onPoint ;
2026-07-07 23:45:11 +03:00
GPS . recording . onPoint = null ;
2026-05-16 21:21:13 +03:00
for ( const p of nativePoints ) {
const pTs = new Date ( p . ts ) . getTime ( ) ;
if ( pTs <= GPS . recording . _lastProcessedTs ) continue ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
await GPS . _processIncomingPoint ( p , true ) ;
2026-05-16 21:21:13 +03:00
GPS . recording . _lastProcessedTs = pTs ;
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
newCount ++ ;
2026-07-07 23:45:11 +03:00
if ( GPS . recording . onImportPoint ) GPS . recording . onImportPoint ( p ) ;
2026-07-08 22:11:17 +03:00
// Yield a frame so the growing track repaints — but ONLY when visible.
// requestAnimationFrame never fires while the screen is off (page hidden),
// so awaiting it there would hang this loop forever and freeze the import.
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
if ( newCount % 20 === 0 && ! document . hidden ) {
console . log ( ` [poll] raf-wait at ${ newCount } ` ) ;
await new Promise ( r => requestAnimationFrame ( r ) ) ;
console . log ( ` [poll] raf-done at ${ newCount } ` ) ;
}
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
}
GPS . recording . onPoint = origOnPoint ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
console . log ( ` [poll] imported= ${ newCount } newLastTs= ${ GPS . recording . _lastProcessedTs } ` ) ;
2026-07-07 23:45:11 +03:00
// Final sync — marker, pan, course and stats from the full point set.
Alternative units, analytics charts, elevation smoothing, batch GPS update
Alternative food units (Phase 3a):
- serving_units table: шт, ст.л., ч.л., стакан, мл
- product_units junction: eggs linked to шт (60g)
- Unit selector chips in food modal after product selection
- Display "2 шт" instead of "120г" in food log when unit is set
- amount_g stays canonical, unit info is display metadata
Analytics (Phase 4):
- Stats section on Today screen with period selector (30d/90d)
- Weight trend chart (line, purple)
- Macros donut (protein/fat/carbs with kcal center text)
- Kcal balance bar chart (intake green vs expenditure red, includes BMR)
- Chart fullscreen + zoom support (reuses GPS chart patterns)
GPS improvements:
- Elevation smoothing: 5-point moving average + 1m dead zone
- Fixes inflated elevation gain (440m → ~70m for a real 70m route)
- Batch polyline update: poll loop suppresses per-point addLatLng,
calls onBatchUpdate to rebuild polyline from sorted points at once
- Eliminates visual jumps when app returns from background
Boot screen: removed verbose migration/GPS status labels,
shows "Загрузка..." only, updates text only during actual seed import
2026-05-23 17:12:19 +03:00
if ( newCount > 0 && GPS . recording . onBatchUpdate ) {
GPS . recording . onBatchUpdate ( ) ;
2026-05-16 21:21:13 +03:00
}
} catch ( e ) {
console . error ( "[GPS] poll error:" , e ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
}
2026-05-16 21:21:13 +03:00
polling = false ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} , 2000 ) ;
}
2026-05-13 10:21:24 +03:00
GPS . recording . watchId = navigator . geolocation . watchPosition (
async ( pos ) => {
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( pos . coords . accuracy > GPS . config . minAccuracyM ) return ;
await GPS . _processIncomingPoint ( {
2026-05-13 10:21:24 +03:00
lat : pos . coords . latitude ,
lon : pos . coords . longitude ,
altitude : pos . coords . altitude ,
speed _ms : pos . coords . speed ,
2026-05-14 14:17:57 +03:00
accuracy : pos . coords . accuracy ,
2026-07-07 00:28:27 +03:00
bearing : ( pos . coords . heading != null && ! isNaN ( pos . coords . heading ) ) ? pos . coords . heading : null ,
2026-05-13 10:21:24 +03:00
ts : new Date ( pos . timestamp ) . toISOString ( ) ,
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} ) ;
2026-05-13 10:21:24 +03:00
} ,
( err ) => {
console . error ( "GPS error:" , err . message ) ;
if ( GPS . recording . onError ) GPS . recording . onError ( err ) ;
} ,
2026-05-14 14:17:57 +03:00
{ enableHighAccuracy : true , maximumAge : 0 , timeout : 10000 }
2026-05-13 10:21:24 +03:00
) ;
} ;
GPS . stopRecording = async function ( ) {
if ( ! GPS . recording . active ) return null ;
GPS . recording . active = false ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( GPS . recording . _pollTimer ) clearInterval ( GPS . recording . _pollTimer ) ;
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
2026-10-22 00:51:31 +03:00
// HR-only workout/vr: no GPS to stop. Compute net kcal from HR, save samples, finalise.
if ( GPS . isHrOnly ( GPS . recording . activityType ) ) {
2026-10-20 23:09:50 +03:00
// Prefer the native HR log (complete through screen-off) over the JS poll's in-memory
// array (frozen in the background). Fall back to the poll array if the log is empty.
let samples = GPS . recording . hrSamples || [ ] ;
try {
const native = await GPS . _readHrLogSamples ( ) ;
if ( native . length > samples . length ) samples = native ;
} catch ( e ) { console . error ( "[workout] native hr log read" , e ) ; }
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
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 } ;
}
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
GPS . _stopHrMonitor ( ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
2026-05-13 10:21:24 +03:00
if ( GPS . recording . watchId != null ) {
navigator . geolocation . clearWatch ( GPS . recording . watchId ) ;
GPS . recording . watchId = null ;
}
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// Stop native service — read remaining points but DON'T delete the file yet
let nativePointsFile = null ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
console . log ( ` [stop] enter nativeService= ${ GPS . recording . nativeService } sessionId= ${ GPS . recording . sessionId } ` ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
if ( GPS . recording . nativeService ) {
2026-07-30 00:22:27 +03:00
// Import ONLY the un-polled tail: native points with ts beyond the last one
// the live poll already PROCESSED (_lastProcessedTs). The poll advances that
// marker for every native point it handles, including ones it intentionally
// dropped (stationary / auto-pause / wrong state). The old code re-scanned the
// whole file and re-added everything "not in the DB" — which raw-inserted those
// dropped points back, interleaving them into the clean track and inflating
// distance with zigzag. Screen-off case still works: the poll was frozen, so
// _lastProcessedTs stays old and the whole dark stretch is beyond it.
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
const nativePoints = await GPS . _readNativePoints ( ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
let stopImported = 0 ;
2026-07-07 23:45:11 +03:00
if ( GPS . recording . sessionId && nativePoints . length ) {
const existing = await DB . getGpsPoints ( GPS . recording . sessionId ) ;
let seq = existing . length ;
for ( const p of nativePoints ) {
2026-07-30 00:22:27 +03:00
const pTs = new Date ( p . ts ) . getTime ( ) ;
if ( pTs <= GPS . recording . _lastProcessedTs ) continue ;
2026-07-07 23:45:11 +03:00
seq ++ ;
await DB . addGpsPoint ( GPS . recording . sessionId , p . ts , p . lat , p . lon , p . altitude , p . speed _ms , seq , null , p ) ;
2026-07-30 00:22:27 +03:00
GPS . recording . _lastProcessedTs = pTs ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
stopImported ++ ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
}
2026-07-30 00:22:27 +03:00
console . log ( ` [stop] native= ${ nativePoints . length } existing= ${ existing . length } tail-imported= ${ stopImported } ` ) ;
2026-07-07 23:45:11 +03:00
GPS . recording . points = await DB . getGpsPoints ( GPS . recording . sessionId ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
} else {
console . log ( ` [stop] SKIP backfill: sessionId= ${ GPS . recording . sessionId } native= ${ nativePoints . length } ` ) ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
}
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// Remember the file name for cleanup after DB save
try {
nativePointsFile = await window . _ _TAURI _ _ . core . invoke ( "get_points_filename" ) ;
} catch { }
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
await GPS . _stopNativeService ( ) ;
}
if ( ! GPS . recording . sessionId ) {
return null ;
}
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Build opts for physics-based kcal if bike data is available
const recOpts = {
activityType : GPS . recording . activityType ,
bikeWeightKg : GPS . recording . bikeWeightKg ? ? 0 ,
surfaceCrr : GPS . recording . surfaceCrr ,
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
tirePressureBar : GPS . recording . tirePressureBar ? ? 3.0 ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
chainringTeeth : GPS . recording . chainringTeeth ,
cogTeeth : GPS . recording . cogTeeth ,
wheelDiameterInch : GPS . recording . wheelDiameterInch ,
2026-09-07 15:50:19 +03:00
gearCount : GPS . recording . gearCount ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
} ;
2026-09-07 13:53:40 +03:00
const recPauses = GPS . recording . pauses || [ ] ;
recOpts . pauses = recPauses ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const stats = GPS . computeStats ( GPS . recording . points , GPS . recording . weightKg , recOpts ) ;
2026-05-13 10:21:24 +03:00
const endTime = new Date ( ) . toTimeString ( ) . slice ( 0 , 5 ) ;
2026-09-07 17:10:16 +03:00
// Close an open headwind interval and persist all marks with the session.
GPS . _closeOpenWindMark ( ) ;
const recWindMarks = GPS . recording . windMarks || [ ] ;
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
try {
await DB . finishGpsSession (
GPS . recording . sessionId , endTime ,
stats . distance , stats . kcal , stats . avgSpeed , stats . maxSpeed ,
2026-09-07 13:53:40 +03:00
stats . elevGain , stats . elevLoss , recPauses
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
) ;
2026-09-07 17:10:16 +03:00
try { await DB . setSessionWindMarks ( GPS . recording . sessionId , recWindMarks ) ; } catch ( e ) { console . error ( "[GPS] wind marks save failed:" , e ) ; }
2026-09-22 11:12:24 +03:00
// 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 ) ; }
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// DB save confirmed — now safe to delete the points file
if ( nativePointsFile ) {
try { await window . _ _TAURI _ _ . core . invoke ( "delete_points_file" , { filename : nativePointsFile } ) ; } catch { }
}
} catch ( e ) {
console . error ( "[GPS] finishGpsSession FAILED — points file preserved:" , e ) ;
if ( typeof showToast === "function" ) showToast ( "Сохранение трека: " + ( e . message || e ) ) ;
}
2026-05-13 10:21:24 +03:00
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Store bike_id in session if present
if ( GPS . recording . bikeId ) {
await DB . updateSessionBike ( GPS . recording . sessionId , GPS . recording . bikeId ) ;
}
2026-05-13 10:21:24 +03:00
const sid = GPS . recording . sessionId ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
const pauses = GPS . recording . pauses || [ ] ;
2026-05-13 10:21:24 +03:00
GPS . recording . sessionId = null ;
GPS . recording . points = [ ] ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . recording . pauses = [ ] ;
GPS . recording . manualPaused = false ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
GPS . recording . bikeId = null ;
GPS . recording . bikeWeightKg = null ;
GPS . recording . surfaceCrr = null ;
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
GPS . recording . tirePressureBar = null ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
GPS . recording . chainringTeeth = null ;
GPS . recording . cogTeeth = null ;
GPS . recording . wheelDiameterInch = null ;
2026-09-07 15:50:19 +03:00
GPS . recording . gearCount = null ;
2026-09-07 17:10:16 +03:00
GPS . recording . windMarks = [ ] ;
GPS . recording . _currentWind = null ;
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
GPS . _clearPersistedState ( ) ;
return { sessionId : sid , stats , pauses } ;
2026-05-13 10:21:24 +03:00
} ;
// ---------------------------------------------------------------------------
// GPX import
// ---------------------------------------------------------------------------
GPS . parseGPX = function ( xmlString ) {
const parser = new DOMParser ( ) ;
const doc = parser . parseFromString ( xmlString , "application/xml" ) ;
const trkpts = doc . querySelectorAll ( "trkpt" ) ;
const points = [ ] ;
for ( const pt of trkpts ) {
const lat = parseFloat ( pt . getAttribute ( "lat" ) ) ;
const lon = parseFloat ( pt . getAttribute ( "lon" ) ) ;
const eleEl = pt . querySelector ( "ele" ) ;
const timeEl = pt . querySelector ( "time" ) ;
const spdEl = pt . querySelector ( "speed" ) ;
points . push ( {
lat , lon ,
altitude : eleEl ? parseFloat ( eleEl . textContent ) : null ,
ts : timeEl ? timeEl . textContent : null ,
speed : spdEl ? parseFloat ( spdEl . textContent ) : null ,
} ) ;
}
// Activity type from GPX <type> tag
const typeEl = doc . querySelector ( "type" ) ;
const actType = typeEl ? typeEl . textContent . toLowerCase ( ) : "other" ;
return { points , activityType : actType } ;
} ;
2026-07-31 22:11:55 +03:00
// Parse a GPX file as a REFERENCE ROUTE (a line to overlay on the map), not an
// activity. Accepts <rtept> (Yandex Map Constructor), <trkpt> or <wpt> — whichever
// the file uses. Returns { name, points:[{lat,lon}] }.
GPS . parseRouteGPX = function ( xmlString ) {
const parser = new DOMParser ( ) ;
const doc = parser . parseFromString ( xmlString , "application/xml" ) ;
const points = [ ] ;
for ( const tag of [ "rtept" , "trkpt" , "wpt" ] ) {
const els = doc . querySelectorAll ( tag ) ;
if ( els . length ) {
els . forEach ( pt => {
const lat = parseFloat ( pt . getAttribute ( "lat" ) ) ;
const lon = parseFloat ( pt . getAttribute ( "lon" ) ) ;
Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
2026-08-03 19:29:51 +03:00
if ( isNaN ( lat ) || isNaN ( lon ) ) return ;
const point = { lat , lon } ;
// Elevation is optional: tools like Komoot/BRouter emit <ele>, Yandex does not.
const eleEl = pt . querySelector ( "ele" ) ;
if ( eleEl ) { const e = parseFloat ( eleEl . textContent ) ; if ( ! isNaN ( e ) ) point . ele = e ; }
points . push ( point ) ;
2026-07-31 22:11:55 +03:00
} ) ;
break ; // first tag type that has points wins
}
}
let name = "" ;
const nameEl = doc . querySelector ( "metadata > name" ) || doc . querySelector ( "rte > name" )
|| doc . querySelector ( "trk > name" ) || doc . querySelector ( "name" ) ;
if ( nameEl ) name = ( nameEl . textContent || "" ) . trim ( ) ;
return { name , points } ;
} ;
2026-10-26 21:46:26 +03:00
// Parse a BRouter GeoJSON response (format=geojson / OsmTrack.formatAsGeoJson). Returns
// { name, points:[{lat,lon,ele}], dangers:[{lat,lon,type,tag}] }. Danger comes from the
// per-segment messages table (WayTags/NodeTags): fords (water), rough/abandoned tracks,
// and (informational) ferries — so the rider sees the "ад" BEFORE riding it. Geometry is
// identical to the GPX, so this can replace parseRouteGPX where tags are wanted.
GPS . parseRouteGeoJson = function ( jsonString ) {
let d ;
try { d = ( typeof jsonString === "string" ) ? JSON . parse ( jsonString ) : jsonString ; } catch { return null ; }
let feat = d && d . features && d . features [ 0 ] ;
if ( ! feat ) return null ;
let coords = ( feat . geometry && feat . geometry . coordinates ) || [ ] ;
let points = coords . map ( c => {
let p = { lon : c [ 0 ] , lat : c [ 1 ] } ;
if ( c . length > 2 && c [ 2 ] != null && isFinite ( c [ 2 ] ) ) p . ele = c [ 2 ] ;
return p ;
} ) ;
let name = ( feat . properties && feat . properties . name ) || "" ;
let dangers = [ ] ;
let msgs = feat . properties && feat . properties . messages ;
if ( Array . isArray ( msgs ) && msgs . length > 1 ) {
let hdr = msgs [ 0 ] ;
let li = hdr . indexOf ( "Longitude" ) , ai = hdr . indexOf ( "Latitude" ) ;
let wi = hdr . indexOf ( "WayTags" ) , ni = hdr . indexOf ( "NodeTags" ) ;
for ( let r = 1 ; r < msgs . length ; r ++ ) {
let row = msgs [ r ] ;
let wt = ( wi >= 0 ? row [ wi ] : "" ) || "" ;
let nt = ( ni >= 0 ? row [ ni ] : "" ) || "" ;
let both = wt + " " + nt ;
let type = null ;
if ( /\bford=(yes|stepping_stones)\b/ . test ( both ) ) type = "ford" ;
2026-10-26 22:07:41 +03:00
// likely (untagged) water crossing: a track/path/footway with a high BRouter
// river-class estimate and no bridge/tunnel. Catches fords OSM never tagged ford=yes
// (verified: a real ford showed estimated_river_class=5 on a track, no ford tag).
else if ( /\bhighway=(track|path|footway)\b/ . test ( wt ) && /\bestimated_river_class=(5|6|7|8|9|10)\b/ . test ( wt )
&& ! /\bbridge=/ . test ( wt ) && ! /\btunnel=/ . test ( wt ) ) type = "wetcross" ;
2026-10-26 21:46:26 +03:00
else if ( /\btracktype=grade[45]\b/ . test ( wt ) || /\bsurface=(mud|sand)\b/ . test ( wt )
|| /\btrail_visibility=(bad|horrible|no)\b/ . test ( wt )
|| /\bsac_scale=(demanding_mountain_hiking|alpine_hiking|demanding_alpine_hiking|difficult_alpine_hiking)\b/ . test ( wt )
|| /\bmtb:scale=[3-6]\b/ . test ( wt ) ) type = "rough" ;
else if ( /\broute=ferry\b/ . test ( wt ) ) type = "ferry" ;
if ( type && li >= 0 && ai >= 0 ) {
let lon = parseFloat ( row [ li ] ) / 1e6 , lat = parseFloat ( row [ ai ] ) / 1e6 ;
if ( isFinite ( lat ) && isFinite ( lon ) ) dangers . push ( { lat , lon , type , tag : ( wt || nt ) . trim ( ) } ) ;
}
}
}
return { name , points , dangers } ;
} ;
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
// Fill GPS dropouts along roads: for each REAL signal loss (a long time gap with a large
// chord), route the SHORTEST road path between the pre/post-gap points and splice the routed
// points in (flagged estimated). Deliberately conservative so normal slow/dense riding is
// NOT touched: only long gaps (dt > gapThresholdSec, default 60s) with a big chord
// (> minChordM, default 150m) qualify; a fill is kept only if the road neither detours too
// far (rlen <= maxRatio× chord) nor barely differs from the straight line (adds >= minGainM),
// since a road ≈ chord means the straight was already right ("+0.00 km" fills). Async
// (BRouter). Returns { points, gaps }:
2026-09-10 15:26:05 +03:00
// points — a NEW full track array with routed points spliced into filled gaps (each
// flagged estimated:true, with an interpolated ts across the gap so DB ordering by ts
// is correct and they don't collide on a.ts).
// gaps — one entry per DETECTED gap: { a, b, routed:[{lat,lon}], status, addedM, chordM }
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
// status: "filled" | "skipped-long" | "skipped-nogain" | "skipped-noroute".
2026-09-10 15:26:05 +03:00
// Lets the UI draw ONLY the gap patches (not the whole track) and report what failed.
2026-09-09 17:43:56 +03:00
GPS . fillGaps = async function ( points , opts ) {
opts = opts || { } ;
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
let gapThr = opts . gapThresholdSec ? ? 60 ; // only bridge a real signal loss, not a 30s hiccup
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
2026-09-30 00:23:28 +03:00
// 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 } ;
} ) ;
2026-09-10 15:26:05 +03:00
let sorted = points . slice ( ) . sort ( ( a , b ) => new Date ( a . ts ) - new Date ( b . ts ) ) ;
let out = [ ] ;
let gaps = [ ] ;
2026-09-09 17:43:56 +03:00
for ( let i = 0 ; i < sorted . length ; i ++ ) {
out . push ( sorted [ i ] ) ;
2026-09-10 15:26:05 +03:00
let a = sorted [ i ] , b = sorted [ i + 1 ] ;
2026-09-09 17:43:56 +03:00
if ( ! b ) continue ;
2026-09-10 15:26:05 +03:00
let dt = ( new Date ( b . ts ) - new Date ( a . ts ) ) / 1000 ;
let chord = GPS . distance ( a . lat , a . lon , b . lat , b . lon ) ;
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
if ( dt <= gapThr || chord < minChord ) continue ; // not a real dropout worth bridging
2026-09-10 15:26:05 +03:00
let ta = new Date ( a . ts ) . getTime ( ) , tb = new Date ( b . ts ) . getTime ( ) ;
2026-09-30 00:23:28 +03:00
let routed = [ ] , status = "skipped-noroute" , addedM = 0 , ridden = false ;
2026-09-09 17:43:56 +03:00
try {
2026-09-30 00:23:28 +03:00
let r = await routeFn ( a , b ) ;
let rp = ( r && r . points ) || [ ] ;
ridden = ! ! ( r && r . ridden ) ;
2026-09-10 15:26:05 +03:00
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 ) ;
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
addedM = Math . max ( 0 , rlen - chord ) ;
2026-09-30 00:23:28 +03:00
if ( ! ridden && rlen > chord * ratio ) {
status = "skipped-long" ; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is)
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
} else if ( addedM < minGain ) {
status = "skipped-nogain" ; // road ≈ straight — nothing to bridge
2026-09-10 15:26:05 +03:00
} else {
Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
2026-09-11 01:06:06 +03:00
status = "filled" ;
2026-09-10 15:26:05 +03:00
for ( let k = 1 ; k < rp . length - 1 ; k ++ ) {
let frac = k / ( rp . length - 1 ) ;
let ts = new Date ( ta + ( tb - ta ) * frac ) . toISOString ( ) ;
out . push ( { lat : rp [ k ] . lat , lon : rp [ k ] . lon , ts , estimated : true } ) ;
routed . push ( { lat : rp [ k ] . lat , lon : rp [ k ] . lon } ) ;
}
}
}
} catch ( e ) { status = "skipped-noroute" ; }
2026-09-30 00:23:28 +03:00
gaps . push ( { a : { lat : a . lat , lon : a . lon } , b : { lat : b . lat , lon : b . lon } , routed , status , addedM , chordM : chord , ridden } ) ;
2026-09-09 17:43:56 +03:00
}
2026-09-10 15:26:05 +03:00
return { points : out , gaps } ;
2026-09-09 17:43:56 +03:00
} ;
2026-05-13 10:21:24 +03:00
GPS . importGPX = async function ( xmlString , weightKg ) {
const { points , activityType } = GPS . parseGPX ( xmlString ) ;
if ( points . length < 2 ) throw new Error ( "GPX has fewer than 2 points" ) ;
Physics settings in UI, recipe modal cleanup, weight parseFloat fixes
Settings:
- Aero drag (CdA), air density, pedal efficiency now in GPS config
- Human-readable names: "Аэросопротивление", "Плотность воздуха", "КПД педалирования"
- Saved to localStorage, used in computeStats
Bug fixes:
- Recipe detail: overlay.remove without popHandler cleanup → now uses history.back()
- weightKg parseFloat in startRecording and importGPX (string "88" bug)
- profile.weight_kg stored as number not string
- getDailyActivityTotals: separate params for UNION ALL subqueries
Docs:
- PROJECT_MAP.md — full project structure, DB schema, screens, formulas
- README.md — overview, build instructions, data sources
2026-05-26 02:14:43 +03:00
const stats = GPS . computeStats ( points , parseFloat ( weightKg ) || 80 ) ;
2026-05-13 10:21:24 +03:00
const first = points [ 0 ] . ts ? new Date ( points [ 0 ] . ts ) : new Date ( ) ;
const last = points [ points . length - 1 ] . ts ? new Date ( points [ points . length - 1 ] . ts ) : new Date ( ) ;
const session = {
date : first . toISOString ( ) . slice ( 0 , 10 ) ,
start _time : first . toTimeString ( ) . slice ( 0 , 5 ) ,
end _time : last . toTimeString ( ) . slice ( 0 , 5 ) ,
activity _type : actType ( activityType ) ,
total _distance _m : stats . distance ,
total _kcal : stats . kcal ,
avg _speed _kmh : stats . avgSpeed ,
max _speed _kmh : stats . maxSpeed ,
elevation _gain _m : stats . elevGain ,
elevation _loss _m : stats . elevLoss ,
source : "gpx_import" ,
} ;
2026-05-14 14:17:57 +03:00
const dbPoints = points . map ( ( p , idx ) => ( {
ts : p . ts , lat : p . lat , lon : p . lon , altitude : p . altitude , speed : p . speed , seq : idx ,
2026-05-13 10:21:24 +03:00
} ) ) ;
return DB . importGpxSession ( session , dbPoints ) ;
} ;
// ---------------------------------------------------------------------------
// FitoTrack .ftb import (zip containing data.xml)
// ---------------------------------------------------------------------------
GPS . importFTB = async function ( arrayBuffer , weightKg ) {
const zip = await JSZip . loadAsync ( arrayBuffer ) ;
const xmlFile = zip . file ( "data.xml" ) ;
if ( ! xmlFile ) throw new Error ( "data.xml not found in .ftb" ) ;
const xmlStr = await xmlFile . async ( "string" ) ;
const parser = new DOMParser ( ) ;
const doc = parser . parseFromString ( xmlStr , "application/xml" ) ;
// Parse workouts
const workoutEls = doc . querySelectorAll ( "workouts > workouts" ) ;
const sampleEls = doc . querySelectorAll ( "samples > samples" ) ;
// Group samples by workoutId
const samplesByWorkout = { } ;
for ( const s of sampleEls ) {
const wid = s . querySelector ( "workoutId" ) ? . textContent ;
if ( ! wid ) continue ;
if ( ! samplesByWorkout [ wid ] ) samplesByWorkout [ wid ] = [ ] ;
samplesByWorkout [ wid ] . push ( {
lat : parseFloat ( s . querySelector ( "lat" ) ? . textContent ) ,
lon : parseFloat ( s . querySelector ( "lon" ) ? . textContent ) ,
altitude : parseFloat ( s . querySelector ( "elevationMSL" ) ? . textContent ) || parseFloat ( s . querySelector ( "elevation" ) ? . textContent ) || null ,
speed : parseFloat ( s . querySelector ( "speed" ) ? . textContent ) || null ,
ts : new Date ( parseInt ( s . querySelector ( "absoluteTime" ) ? . textContent ) ) . toISOString ( ) ,
} ) ;
}
let imported = 0 ;
for ( const w of workoutEls ) {
const el = tag => w . querySelector ( tag ) ? . textContent ;
const wid = el ( "id" ) ;
const samples = samplesByWorkout [ wid ] || [ ] ;
if ( samples . length < 2 ) continue ;
const startMs = parseInt ( el ( "start" ) ) ;
const endMs = parseInt ( el ( "end" ) ) ;
const startDate = new Date ( startMs ) ;
const endDate = new Date ( endMs ) ;
const session = {
date : startDate . toISOString ( ) . slice ( 0 , 10 ) ,
start _time : startDate . toTimeString ( ) . slice ( 0 , 5 ) ,
end _time : endDate . toTimeString ( ) . slice ( 0 , 5 ) ,
activity _type : ftbType ( el ( "workoutType" ) ) ,
total _distance _m : parseFloat ( el ( "length" ) ) || null ,
total _kcal : parseFloat ( el ( "calorie" ) ) || null ,
avg _speed _kmh : parseFloat ( el ( "avgSpeed" ) ) ? Math . round ( parseFloat ( el ( "avgSpeed" ) ) * 3.6 * 10 ) / 10 : null ,
max _speed _kmh : parseFloat ( el ( "topSpeed" ) ) ? Math . round ( parseFloat ( el ( "topSpeed" ) ) * 3.6 * 10 ) / 10 : null ,
elevation _gain _m : parseFloat ( el ( "ascent" ) ) || null ,
elevation _loss _m : parseFloat ( el ( "descent" ) ) || null ,
source : "fitotrack_import" ,
} ;
const dbPoints = samples . map ( ( p , idx ) => ( {
ts : p . ts , lat : p . lat , lon : p . lon , altitude : p . altitude , speed : p . speed , seq : idx ,
} ) ) ;
await DB . importGpxSession ( session , dbPoints ) ;
imported ++ ;
}
return imported ;
} ;
function ftbType ( raw ) {
const r = ( raw || "" ) . toLowerCase ( ) ;
if ( r . includes ( "cycl" ) || r . includes ( "bik" ) ) return "bike" ;
if ( r . includes ( "run" ) ) return "run" ;
if ( r . includes ( "walk" ) || r . includes ( "hik" ) ) return "walk" ;
return "other" ;
}
// ---------------------------------------------------------------------------
// Interpolate points where GPS gap > threshold (e.g. tunnel, signal loss)
// Adds intermediate points with linear lat/lon/altitude interpolation
// ---------------------------------------------------------------------------
GPS . interpolate = function ( points , maxGapSec = 30 ) {
if ( points . length < 2 ) return points ;
const result = [ points [ 0 ] ] ;
for ( let i = 1 ; i < points . length ; i ++ ) {
const prev = points [ i - 1 ] ;
const cur = points [ i ] ;
const prevT = new Date ( prev . ts ) . getTime ( ) ;
const curT = new Date ( cur . ts ) . getTime ( ) ;
const gapSec = ( curT - prevT ) / 1000 ;
if ( gapSec > maxGapSec ) {
// Insert interpolated points every ~3 seconds
const steps = Math . min ( Math . ceil ( gapSec / 3 ) , 100 ) ;
for ( let s = 1 ; s < steps ; s ++ ) {
const frac = s / steps ;
const t = prevT + ( curT - prevT ) * frac ;
result . push ( {
lat : prev . lat + ( cur . lat - prev . lat ) * frac ,
lon : prev . lon + ( cur . lon - prev . lon ) * frac ,
altitude : ( prev . altitude != null && cur . altitude != null )
? prev . altitude + ( cur . altitude - prev . altitude ) * frac : null ,
altitude _gps _m : ( prev . altitude _gps _m != null && cur . altitude _gps _m != null )
? prev . altitude _gps _m + ( cur . altitude _gps _m - prev . altitude _gps _m ) * frac : null ,
speed _ms : ( prev . speed _ms != null && cur . speed _ms != null )
? prev . speed _ms + ( cur . speed _ms - prev . speed _ms ) * frac : null ,
ts : new Date ( t ) . toISOString ( ) ,
interpolated : true ,
} ) ;
}
}
result . push ( cur ) ;
}
return result ;
} ;
// ---------------------------------------------------------------------------
// Filter GPS noise — remove points with unrealistic speed (>150 km/h)
// and break track into segments at large time gaps
// ---------------------------------------------------------------------------
// FitoTrack-style filter:
// 1. Skip points too close in time (<500ms)
// 2. Skip points with unrealistic speed (>maxSpeedKmh)
// 3. Skip points too close in distance (<minDistM) — configurable per activity
// Nearest-neighbor reorder: from current point, always jump to the closest
// unused point. No points removed. Untangles AGPS alternation.
// Identical to the debug version that worked.
// Reorder points: normal points go chronologically, AGPS points (with wrong
// timestamps) get inserted at their nearest spatial neighbor.
// Detection: a point is "AGPS-displaced" if it's far (>50m) from BOTH its
// chronological predecessor and successor — meaning its timestamp is wrong.
// Just return points as-is. Order comes from DB: seq (FTB XML order), then ts.
// This matches FitoTrack v15.6 which draws by insertion order without sorting.
2026-08-16 20:31:12 +03:00
// Drop real GPS fixes coarser than the accuracy threshold (GPS.config.minAccuracyM).
// This is the post-hoc "recompute with an accuracy threshold": NON-destructive —
// DB points are untouched, so raising the threshold brings points back. Points with
// no measured accuracy (imported GPX/FTB, older recordings) and estimated dead-
// reckoning fills (sentinel accuracy >= 999) are always kept.
GPS . filterByAccuracy = function ( points , threshold ) {
const t = ( threshold != null ) ? threshold : GPS . config . minAccuracyM ;
if ( ! t || t <= 0 ) return points ;
return points . filter ( p => {
const acc = ( p . accuracy _m != null ) ? p . accuracy _m : p . accuracy ;
if ( acc == null || acc >= 999 ) return true ; // no measure / estimated → keep
return acc <= t ;
} ) ;
} ;
2026-05-13 10:21:24 +03:00
GPS . filterNoise = function ( points , activityType ) {
2026-08-16 20:31:12 +03:00
return GPS . filterByAccuracy ( points ) ;
2026-05-13 10:21:24 +03:00
} ;
// Build polyline segments — break at gaps > maxGapSec
// Returns array of arrays of [lat,lon] for separate polylines
GPS . buildSegments = function ( points , maxGapSec = 120 ) {
if ( points . length === 0 ) return [ ] ;
const segments = [ [ [ points [ 0 ] . lat , points [ 0 ] . lon ] ] ] ;
for ( let i = 1 ; i < points . length ; i ++ ) {
const gap = ( new Date ( points [ i ] . ts ) - new Date ( points [ i - 1 ] . ts ) ) / 1000 ;
if ( gap > maxGapSec ) {
segments . push ( [ ] ) ;
}
segments [ segments . length - 1 ] . push ( [ points [ i ] . lat , points [ i ] . lon ] ) ;
}
return segments . filter ( s => s . length > 1 ) ;
} ;
function actType ( raw ) {
const r = ( raw || "" ) . toLowerCase ( ) ;
if ( r . includes ( "bik" ) || r . includes ( "cycl" ) ) return "bike" ;
if ( r . includes ( "run" ) ) return "run" ;
if ( r . includes ( "walk" ) || r . includes ( "hik" ) ) return "walk" ;
return "other" ;
}
// ---------------------------------------------------------------------------
// Format helpers
// ---------------------------------------------------------------------------
GPS . formatDuration = function ( seconds ) {
const h = Math . floor ( seconds / 3600 ) ;
const m = Math . floor ( ( seconds % 3600 ) / 60 ) ;
const s = Math . floor ( seconds % 60 ) ;
return h > 0 ? ` ${ h } : ${ String ( m ) . padStart ( 2 , "0" ) } : ${ String ( s ) . padStart ( 2 , "0" ) } `
: ` ${ m } : ${ String ( s ) . padStart ( 2 , "0" ) } ` ;
} ;
GPS . formatDistance = function ( meters ) {
2026-09-14 12:35:39 +03:00
return meters >= 1000 ? ( meters / 1000 ) . toFixed ( 2 ) + " км" : Math . round ( meters ) + " м" ;
2026-05-13 10:21:24 +03:00
} ;
2026-05-16 10:49:55 +03:00
// ---------------------------------------------------------------------------
// GPX Export
// ---------------------------------------------------------------------------
GPS . exportGPX = async function ( sessionId ) {
const session = await DB . getGpsSession ( sessionId ) ;
if ( ! session ) return ;
const points = await DB . getGpsPoints ( sessionId ) ;
if ( ! points . length ) return ;
const esc = s => String ( s ) . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) ;
const name = esc ( session . activity _type || "Track" ) + " " + esc ( session . date ) ;
2026-05-16 21:21:13 +03:00
const cfg = GPS . config ;
2026-05-16 10:49:55 +03:00
let gpx = ` <?xml version="1.0" encoding="UTF-8"?>
< gpx creator = "BalDav" version = "1.1" xmlns = "http://www.topografix.com/GPX/1/1" >
2026-05-16 21:21:13 +03:00
< metadata >
< name > $ { name } < / n a m e >
< time > $ { points [ 0 ] . ts } < / t i m e >
< desc > minSpeed = $ { ( cfg . minSpeedMs * 3.6 ) . toFixed ( 1 ) } kmh maxAcc = $ { cfg . minAccuracyM } m minDist = $ { cfg . minDistanceM } m points = $ { points . length } < / d e s c >
< / m e t a d a t a >
2026-05-16 10:49:55 +03:00
< trk >
< name > $ { name } < / n a m e >
< type > $ { esc ( session . activity _type || "other" ) } < / t y p e >
< trkseg >
` ;
for ( const p of points ) {
gpx += ` <trkpt lat=" ${ p . lat } " lon=" ${ p . lon } ">
` ;
if ( p . altitude _gps _m != null ) gpx += ` <ele> ${ p . altitude _gps _m } </ele> \n ` ;
if ( p . ts ) gpx += ` <time> ${ p . ts } </time> \n ` ;
if ( p . speed _ms != null ) gpx += ` <extensions><speed> ${ p . speed _ms } </speed></extensions> \n ` ;
gpx += ` </trkpt> \n ` ;
}
gpx += ` </trkseg>
< / t r k >
< / g p x > ` ;
2026-05-16 21:21:13 +03:00
// Save via Rust to Downloads folder
const time = ( session . start _time || "" ) . replace ( /:/g , "-" ) ;
const filename = ` baldav- ${ session . date } _ ${ time } - ${ session . activity _type || "track" } .gpx ` ;
try {
const path = await window . _ _TAURI _ _ . core . invoke ( "save_file" , { filename , content : gpx } ) ;
console . log ( "[GPX] Saved to:" , path ) ;
} catch ( e ) {
console . error ( "[GPX] Save error:" , e ) ;
}
2026-05-16 10:49:55 +03:00
} ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// ---------------------------------------------------------------------------
// Recalculate session stats using physics-based formula
// ---------------------------------------------------------------------------
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
GPS . recalculateSession = async function ( sessionId , opts ) {
opts = opts || { } ;
2026-09-29 23:19:37 +03:00
// Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer.
const onProg = ( typeof opts . onProgress === "function" ) ? opts . onProgress : ( ) => { } ;
onProg ( 5 ) ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const session = await DB . getGpsSession ( sessionId ) ;
if ( ! session ) return null ;
const points = await DB . getGpsPoints ( sessionId ) ;
if ( points . length < 2 ) return null ;
2026-05-25 15:20:24 +03:00
const invoke = ( q , v ) => window . _ _TAURI _ _ . core . invoke ( "plugin:sql|execute" , { db : "sqlite:baldav.db" , query : q , values : v } ) ;
2026-05-25 02:42:52 +03:00
const sorted = points . slice ( ) . sort ( ( a , b ) => new Date ( a . ts ) - new Date ( b . ts ) ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
2026-09-14 12:14:04 +03:00
// Persist the per-point "walking" flag (pushed bike / on foot inside a ride) for the roads
// overlay and the ridden-graph router. Not for walk/run sessions (all on foot already).
if ( session . activity _type !== "walk" && session . activity _type !== "run" ) {
try {
const wf = GPS . detectWalk ( sorted ) ;
const ids = sorted . filter ( ( p , i ) => wf [ i ] && p . id != null ) . map ( p => p . id ) ;
await DB . setWalkFlags ( sessionId , ids ) ;
} catch ( e ) { console . warn ( "[walk] persist failed" , e ) ; }
}
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
// Manual routes: skip glitch filtering (points are user-placed, not GPS).
// Just fix timestamps and recompute stats.
if ( session . source === "manual" ) {
const isWalkM = session . activity _type === "walk" || session . activity _type === "run" ;
const avgSpeedMs = isWalkM
? ( session . activity _type === "run" ? GPS . config . runAvgSpeedKmh : GPS . config . walkAvgSpeedKmh ) / 3.6
: GPS . config . bikeAvgSpeedKmh / 3.6 ;
let cumTime = 0 ;
const baseTs = new Date ( sorted [ 0 ] . ts ) . getTime ( ) ;
for ( let i = 0 ; i < sorted . length ; i ++ ) {
if ( i > 0 ) {
const d = GPS . distance ( sorted [ i - 1 ] . lat , sorted [ i - 1 ] . lon , sorted [ i ] . lat , sorted [ i ] . lon ) ;
cumTime += d / avgSpeedMs ;
}
const newTs = new Date ( baseTs + cumTime * 1000 ) . toISOString ( ) ;
await invoke ( "UPDATE gps_points SET ts=?1, speed_ms=?2, seq=?3 WHERE session_id=?4 AND id=?5" ,
[ newTs , avgSpeedMs , i + 1 , sessionId , sorted [ i ] . id ] ) ;
sorted [ i ] . ts = newTs ;
sorted [ i ] . speed _ms = avgSpeedMs ;
}
// Compute stats and finish
const latestW = await DB . getLatestWeight ( ) ;
const profile = await DB . getFullProfile ( ) ;
2026-09-22 11:12:24 +03:00
const weightKg = parseFloat ( session . rider _weight _kg ) || parseFloat ( latestW ? . weight _kg ) || parseFloat ( profile ? . weight _kg ) || 80 ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
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 , {
bikeWeightKg : bike ? . weight _kg ? ? 0 ,
activityType : session . activity _type ? ? "bike" ,
surfaceCrr : CRR _MAP [ bike ? . surface _type ] ? ? 0.006 ,
tirePressureBar : bike ? . tire _pressure _bar ? ? 3.0 ,
} ) ;
await DB . finishGpsSession ( sessionId , session . end _time ? ? session . start _time ,
stats . distance , stats . kcal , stats . avgSpeed , stats . maxSpeed , stats . elevGain , stats . elevLoss ) ;
2026-09-29 23:19:37 +03:00
onProg ( 100 ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
return stats ;
}
// --- Recorded sessions: glitch filtering ---
const isWalk = session . activity _type === "walk" || session . activity _type === "run" ;
const maxMs = ( isWalk ? GPS . config . walkMaxSpeedKmh : GPS . config . maxSpeedKmh ) / 3.6 ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Glitch filtering DELETES points. It is destructive and MUST be skipped for
// recovery (opts.noDelete): if a wrong-date file ever slips in, the ">24h"
// rule below would wipe the real track. Recovery only adds points, never deletes.
2026-05-25 15:20:24 +03:00
let deleted = 0 ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
if ( ! opts . noDelete ) {
// First pass: remove points with absurd timestamps (>1 day jump)
if ( sorted . length >= 3 ) {
const sessionStart = new Date ( sorted [ 0 ] . ts ) . getTime ( ) ;
const maxDuration = 24 * 3600 * 1000 ; // 24 hours max session
for ( let i = sorted . length - 1 ; i >= 0 ; i -- ) {
const t = new Date ( sorted [ i ] . ts ) . getTime ( ) ;
if ( t - sessionStart > maxDuration || t < sessionStart - 60000 ) {
await invoke ( "DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2" , [ sessionId , sorted [ i ] . ts ] ) ;
sorted . splice ( i , 1 ) ;
deleted ++ ;
}
}
}
// Second pass: speed + accel + altitude filter
for ( let i = 1 ; i < sorted . length ; i ++ ) {
const d = GPS . distance ( sorted [ i - 1 ] . lat , sorted [ i - 1 ] . lon , sorted [ i ] . lat , sorted [ i ] . lon ) ;
const dt = ( new Date ( sorted [ i ] . ts ) - new Date ( sorted [ i - 1 ] . ts ) ) / 1000 ;
const speedMs = dt > 0 ? d / dt : 0 ;
const accel = sorted [ i ] . accel _rms ;
// Altitude jump: >100m between consecutive points = impossible
const alt = sorted [ i ] . altitude _gps _m ;
const prevAlt = sorted [ i - 1 ] . altitude _gps _m ;
const altJump = ( alt != null && prevAlt != null ) ? Math . abs ( alt - prevAlt ) : 0 ;
// Glitch: speed over max, accel=0 but moving, fast+walking-accel, or >100m alt jump.
const isGlitch =
( dt > 0 && speedMs > maxMs ) ||
( accel != null && accel === 0 && speedMs > 3 ) ||
( isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8 ) ||
( altJump > 100 ) ;
if ( isGlitch ) {
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
await invoke ( "DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2" , [ sessionId , sorted [ i ] . ts ] ) ;
sorted . splice ( i , 1 ) ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
i -- ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
deleted ++ ;
}
}
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
if ( deleted ) console . log ( ` [GPS] Deleted ${ deleted } glitch points from session ${ sessionId } ` ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
}
2026-09-29 23:19:37 +03:00
onProg ( 40 ) ;
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router
GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
when accel confirms movement. Walk/run separate speed limits (configurable).
Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
GPS silent but accel shows movement. Locale.US fix for sensor JSON.
Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).
Manual routes: draw on map by tapping points, elevation from past tracks +
open-meteo API, realistic timestamps by activity speed. Manual sessions
skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.
Water tracking: water_pct field in product add/edit forms, searchProducts
returns water_pct, "ml" label for liquids, recipe water from ingredients.
BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.
BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.
Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.
Export/import: custom products + recipes + supplements in single JSON bundle.
Export all or single product. Dedup on import by name_lower + source.
OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
first (2s timeout), then VPS. Update URL from update_info.json.
Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
// Interpolate gaps where accelerometer shows movement
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
let interpolated = 0 ;
for ( let i = 1 ; i < sorted . length ; i ++ ) {
const dt = ( new Date ( sorted [ i ] . ts ) - new Date ( sorted [ i - 1 ] . ts ) ) / 1000 ;
if ( dt < 30 || dt > 3600 ) continue ; // only gaps 30s - 1h
const accelBefore = sorted [ i - 1 ] . accel _rms ;
const accelAfter = sorted [ i ] . accel _rms ;
// Both ends show movement → person was moving during gap
if ( accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2 ) {
// Add interpolated distance to total (will be counted by computeStats naturally
// since the gap points are adjacent and haversine gives straight-line distance)
// Mark gap as "moving" by ensuring speed_ms is set
interpolated ++ ;
}
}
if ( interpolated ) console . log ( ` [GPS] Found ${ interpolated } gaps with movement (accel-confirmed) ` ) ;
// Fix manual routes: timestamps are fake (1s apart), recalculate based on realistic speed
if ( session . source === "manual" || sorted . length >= 2 ) {
const t0 = new Date ( sorted [ 0 ] . ts ) . getTime ( ) ;
const tN = new Date ( sorted [ sorted . length - 1 ] . ts ) . getTime ( ) ;
const totalTimeMs = tN - t0 ;
// Compute total distance
let totalDist = 0 ;
for ( let i = 1 ; i < sorted . length ; i ++ ) {
totalDist += GPS . distance ( sorted [ i - 1 ] . lat , sorted [ i - 1 ] . lon , sorted [ i ] . lat , sorted [ i ] . lon ) ;
}
const impliedSpeedMs = totalTimeMs > 0 ? ( totalDist / ( totalTimeMs / 1000 ) ) : 0 ;
// If implied speed is absurd (>30 m/s for walk, >50 for bike) → fix timestamps
const saneMaxMs = isWalk ? 5 : 15 ; // 18 km/h walk, 54 km/h bike
if ( impliedSpeedMs > saneMaxMs && totalDist > 100 ) {
const avgSpeedMs = isWalk
? ( session . activity _type === "run" ? GPS . config . runAvgSpeedKmh : GPS . config . walkAvgSpeedKmh ) / 3.6
: GPS . config . bikeAvgSpeedKmh / 3.6 ;
let cumTime = 0 ;
const baseTs = new Date ( sorted [ 0 ] . ts ) . getTime ( ) ;
for ( let i = 0 ; i < sorted . length ; i ++ ) {
if ( i > 0 ) {
const d = GPS . distance ( sorted [ i - 1 ] . lat , sorted [ i - 1 ] . lon , sorted [ i ] . lat , sorted [ i ] . lon ) ;
cumTime += d / avgSpeedMs ;
}
const newTs = new Date ( baseTs + cumTime * 1000 ) . toISOString ( ) ;
await invoke ( "UPDATE gps_points SET ts = ?1, speed_ms = ?2, seq = ?3 WHERE session_id = ?4 AND ts = ?5" ,
[ newTs , avgSpeedMs , i + 1 , sessionId , sorted [ i ] . ts ] ) ;
sorted [ i ] . ts = newTs ;
sorted [ i ] . speed _ms = avgSpeedMs ;
}
console . log ( ` [GPS] Fixed manual route timestamps: ${ sorted . length } pts, avg ${ avgSpeedMs } m/s ` ) ;
} else if ( deleted > 0 ) {
// Just re-assign seq
for ( let i = 0 ; i < sorted . length ; i ++ ) {
await invoke ( "UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3" , [ i + 1 , sessionId , sorted [ i ] . ts ] ) ;
}
2026-05-25 02:42:52 +03:00
}
}
2026-09-22 11:12:24 +03:00
// Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80.
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
const latestW = await DB . getLatestWeight ( ) ;
const profile = await DB . getFullProfile ( ) ;
2026-09-22 11:12:24 +03:00
const weightKg = parseFloat ( session . rider _weight _kg ) || parseFloat ( latestW ? . weight _kg ) || parseFloat ( profile ? . weight _kg ) || 80 ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Get bike if linked to session, otherwise use active bike
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 } ;
2026-05-25 15:20:24 +03:00
// Use cleaned sorted array, not original points
2026-09-07 13:53:40 +03:00
const sessPauses = Array . isArray ( session . pauses ) ? session . pauses
: ( typeof session . pauses === "string" ? ( ( ) => { try { return JSON . parse ( session . pauses ) ; } catch { return [ ] ; } } ) ( ) : [ ] ) ;
2026-09-29 23:19:37 +03:00
onProg ( 70 ) ;
2026-05-25 15:20:24 +03:00
const stats = GPS . computeStats ( sorted , weightKg , {
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
bikeWeightKg : bike ? . weight _kg ? ? 0 ,
activityType : session . activity _type ? ? "bike" ,
surfaceCrr : CRR _MAP [ bike ? . surface _type ] ? ? 0.006 ,
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
tirePressureBar : bike ? . tire _pressure _bar ? ? 3.0 ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
chainringTeeth : bike ? . chainring _teeth ,
cogTeeth : bike ? . cog _teeth ,
wheelDiameterInch : bike ? . wheel _diameter _inch ,
2026-09-07 15:50:19 +03:00
gearCount : bike ? . gear _count ,
2026-09-07 13:53:40 +03:00
pauses : sessPauses ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
} ) ;
2026-09-07 13:53:40 +03:00
// Update session in DB (preserve pauses)
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
await DB . finishGpsSession ( sessionId ,
session . end _time ? ? session . start _time ,
stats . distance , stats . kcal , stats . avgSpeed , stats . maxSpeed ,
2026-09-07 13:53:40 +03:00
stats . elevGain , stats . elevLoss , sessPauses
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
) ;
2026-09-29 23:19:37 +03:00
onProg ( 100 ) ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
return stats ;
} ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Recompute ONLY the stored stats (distance / kcal / elevation / speeds) for
// every recorded session with the current computeStats. No point is added,
// deleted, or retimed — this touches just the 7 stat columns. Use it to refresh
// historical sessions after a formula change (the elevation-hysteresis and
// smoothed-gradient kcal fix left old sessions showing ~0 gain / inflated kcal).
GPS . recomputeAllStats = async function ( onProgress ) {
const invoke = window . _ _TAURI _ _ . core . invoke ;
const sel = ( q , v ) => invoke ( "plugin:sql|select" , { db : "sqlite:baldav.db" , query : q , values : v || [ ] } ) ;
let ids = [ ] ;
try { ids = ( await sel ( "SELECT CAST(id AS TEXT) AS sid FROM gps_sessions ORDER BY id" ) ) . map ( r => r . sid ) ; }
catch ( e ) { console . error ( "[recalc-all] list FAILED" , e ) ; return { error : String ( e ) } ; }
const latestW = await DB . getLatestWeight ( ) ;
const profile = await DB . getFullProfile ( ) ;
const weightKg = parseFloat ( latestW ? . weight _kg ) || parseFloat ( profile ? . weight _kg ) || 80 ;
const CRR _MAP = { asphalt : 0.004 , mixed : 0.006 , gravel : 0.008 } ;
let updated = 0 , skipped = 0 ;
for ( let i = 0 ; i < ids . length ; i ++ ) {
const sid = ids [ i ] ;
if ( onProgress ) { try { onProgress ( i + 1 , ids . length ) ; } catch { } }
try {
const session = await DB . getGpsSession ( sid ) ;
if ( ! session ) { skipped ++ ; continue ; }
// Manual routes are user-drawn estimates (no altitude, fake timestamps) —
// leave them untouched.
if ( session . source === "manual" ) { skipped ++ ; continue ; }
const points = await DB . getGpsPoints ( sid ) ;
if ( points . length < 2 ) { skipped ++ ; continue ; }
const bike = session . bike _id ? await DB . getBike ( session . bike _id ) : await DB . getActiveBike ( ) ;
2026-09-07 13:53:40 +03:00
const bulkPauses = Array . isArray ( session . pauses ) ? session . pauses
: ( typeof session . pauses === "string" ? ( ( ) => { try { return JSON . parse ( session . pauses ) ; } catch { return [ ] ; } } ) ( ) : [ ] ) ;
2026-09-22 11:12:24 +03:00
const sessW = parseFloat ( session . rider _weight _kg ) || weightKg ; // ride-time weight, else the fallback
const stats = GPS . computeStats ( points , sessW , {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
bikeWeightKg : bike ? . weight _kg ? ? 0 ,
activityType : session . activity _type ? ? "bike" ,
surfaceCrr : CRR _MAP [ bike ? . surface _type ] ? ? 0.006 ,
tirePressureBar : bike ? . tire _pressure _bar ? ? 3.0 ,
chainringTeeth : bike ? . chainring _teeth ,
cogTeeth : bike ? . cog _teeth ,
wheelDiameterInch : bike ? . wheel _diameter _inch ,
2026-09-07 15:50:19 +03:00
gearCount : bike ? . gear _count ,
2026-09-07 13:53:40 +03:00
pauses : bulkPauses ,
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
} ) ;
await DB . finishGpsSession ( sid , session . end _time ? ? session . start _time ,
2026-09-07 13:53:40 +03:00
stats . distance , stats . kcal , stats . avgSpeed , stats . maxSpeed , stats . elevGain , stats . elevLoss , bulkPauses ) ;
2026-09-14 12:14:04 +03:00
// Persist the per-point "walking" flag too (roads overlay + ridden-graph router).
if ( session . activity _type !== "walk" && session . activity _type !== "run" ) {
try {
const sw = points . slice ( ) . sort ( ( a , b ) => new Date ( a . ts ) - new Date ( b . ts ) ) ;
const wf = GPS . detectWalk ( sw ) ;
await DB . setWalkFlags ( sid , sw . filter ( ( p , k ) => wf [ k ] && p . id != null ) . map ( p => p . id ) ) ;
} catch ( e ) { console . warn ( "[walk] persist failed" , sid , e ) ; }
}
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
updated ++ ;
} catch ( e ) { console . error ( ` [recalc-all] ${ sid } failed ` , e ) ; skipped ++ ; }
}
console . log ( ` [recalc-all] DONE updated= ${ updated } skipped= ${ skipped } total= ${ ids . length } ` ) ;
return { updated , skipped , total : ids . length } ;
} ;
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
// Build opts from current recording state (for live stats during recording)
GPS . getRecordingOpts = function ( ) {
return {
activityType : GPS . recording . activityType ,
bikeWeightKg : GPS . recording . bikeWeightKg ? ? 0 ,
surfaceCrr : GPS . recording . surfaceCrr ,
Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure:
- tire_pressure_bar field in bikes table and bike modal
- Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar
- Passed through all opts chains (recording, recovery, recalculate)
Idea 3 — Lap comparison:
- "Сравнить с..." button in session detail
- Shows sessions with same type and similar distance (+-30%)
- Overlay: current track green/red (faster/slower), comparison cyan dashed
- Nearest-neighbor speed matching between tracks
- Summary: time and speed difference
Idea 4 — Auto climb segments:
- computeStats returns segments[] with type (climb/flat/descent)
- Gradient > 2% = climb, < -2% = descent, else flat
- Min 50m per segment, adjacent same-type merged
- Stats per segment: distance, elevation, gradient, duration, speed
- UI section in session detail with colored icons
Idea 5 — Bike maintenance log:
- bike_maintenance table (date, description, mileage, next_service_km)
- Total mileage from GPS sessions per bike
- Overdue indicator when current mileage >= next_service_km
- Add/delete entries in bike modal
- CRUD functions on window.DB
2026-05-27 16:45:33 +03:00
tirePressureBar : GPS . recording . tirePressureBar ? ? 3.0 ,
Bike profiles, physics-based kcal, session recalculation
Bike profiles:
- bikes table: name, weight, wheel, chainring/cog teeth, surface type
- CRUD in settings screen, active bike indicator
- Default bike: 12kg, 26", 42/18, mixed surface
- bike_id linked to gps_sessions
Physics-based kcal (replaces MET*0.97):
- Per-segment power calculation: gravity + rolling (Crr) + aero (CdA)
- Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost
- Power <= 0 = coasting: basal metabolic rate only
- Walk/run: proper MET table from Compendium of Physical Activities
- Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal)
- Smoothed altitude kept for displayed elevGain/Loss only
Session recalculation:
- "Recalculate" button on session detail page
- Uses current weight + bike settings, re-reads all points
- Bike selector per session (change bike -> auto recalculate)
Other fixes:
- User weight fallback chain: weight_log -> profile -> 80kg
- Theoretical cadence from gear ratio + wheel circumference
- Crash recovery preserves bike data
2026-05-25 02:09:50 +03:00
chainringTeeth : GPS . recording . chainringTeeth ,
cogTeeth : GPS . recording . cogTeeth ,
wheelDiameterInch : GPS . recording . wheelDiameterInch ,
} ;
} ;
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
// Filter outliers and smooth altitude array for chart display
GPS . filterAndSmoothAlt = function ( rawAlts ) {
const f = rawAlts . slice ( ) ;
// Outlier spikes
for ( let i = 1 ; i < f . length - 1 ; i ++ ) {
if ( f [ i ] == null ) continue ;
const prev = f [ i - 1 ] , next = f [ i + 1 ] ;
if ( prev == null || next == null ) continue ;
if ( Math . abs ( f [ i ] - prev ) > 4 && Math . abs ( f [ i ] - next ) > 4 && Math . abs ( next - prev ) < 3 ) {
f [ i ] = ( prev + next ) / 2 ;
}
}
// Stuck runs: same value 5+ times = GPS frozen → interpolate
let i = 0 ;
while ( i < f . length ) {
if ( f [ i ] == null ) { i ++ ; continue ; }
let j = i + 1 ;
while ( j < f . length && f [ j ] != null && Math . abs ( f [ j ] - f [ i ] ) < 0.2 ) j ++ ;
if ( j - i >= 5 ) {
const before = i > 0 ? f [ i - 1 ] : f [ i ] ;
const after = j < f . length ? f [ j ] : f [ j - 1 ] ;
for ( let k = i ; k < j ; k ++ ) {
const t = ( k - i + 1 ) / ( j - i + 1 ) ;
f [ k ] = before + ( after - before ) * t ;
}
}
i = j ;
}
// Smooth
const half = GPS . config . altSmoothHalf ? ? 2 ;
return f . map ( ( alt , idx ) => {
if ( alt == null ) return null ;
let sum = 0 , cnt = 0 ;
for ( let j = Math . max ( 0 , idx - half ) ; j <= Math . min ( f . length - 1 , idx + half ) ; j ++ ) {
if ( f [ j ] != null ) { sum += f [ j ] ; cnt ++ ; }
}
return cnt > 0 ? sum / cnt : alt ;
} ) ;
} ;
Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
2026-08-03 19:29:51 +03:00
// Elevation profile of a saved route (points {lat,lon,ele?}). Smooths the raw
// elevations (filterAndSmoothAlt kills SRTM vertical noise) so the grade — the
// thing that matters for a singlespeed rider — isn't spiky. Returns per-point
// cumulative distance (km), smoothed elevation, grade-to-next (%), plus ascent/
// descent (1m hysteresis) and the steepest segment.
GPS . routeElevStats = function ( points ) {
const n = points . length ;
const hasEle = points . some ( p => p . ele != null ) ;
const smoothed = GPS . filterAndSmoothAlt ( points . map ( p => ( p . ele != null ? p . ele : null ) ) ) ;
const cumKm = new Array ( n ) . fill ( 0 ) ;
for ( let i = 1 ; i < n ; i ++ ) {
cumKm [ i ] = cumKm [ i - 1 ] + GPS . distance ( points [ i - 1 ] . lat , points [ i - 1 ] . lon , points [ i ] . lat , points [ i ] . lon ) / 1000 ;
}
2026-10-26 20:55:11 +03:00
// Grade over a DISTANCE window (~one DEM cell), not between adjacent points. DEM
// elevations are quantised to 30m cells: dense points inside a cell share one value
// and the whole rise lands on the single boundary segment → a false steep spike.
// A windowed central difference spreads that step across the cell so the colour
// reflects the real grade. Window W metres (config gradeWindowM, default 30).
const W = GPS . config . gradeWindowM ? ? 30 ;
const cumM = cumKm . map ( k => k * 1000 ) ;
Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
2026-08-03 19:29:51 +03:00
const grades = new Array ( n ) . fill ( 0 ) ;
let maxGrade = 0 ;
for ( let i = 0 ; i < n - 1 ; i ++ ) {
2026-10-26 20:55:11 +03:00
const center = ( cumM [ i ] + cumM [ i + 1 ] ) / 2 ;
let a = i , b = i + 1 ;
while ( a > 0 && center - cumM [ a ] < W / 2 ) a -- ;
while ( b < n - 1 && cumM [ b ] - center < W / 2 ) b ++ ;
// pull window ends in off any null-elevation points
while ( a < i && smoothed [ a ] == null ) a ++ ;
while ( b > i + 1 && smoothed [ b ] == null ) b -- ;
const span = cumM [ b ] - cumM [ a ] ;
let g = 0 ;
if ( span > 1 && smoothed [ a ] != null && smoothed [ b ] != null ) {
g = ( ( smoothed [ b ] - smoothed [ a ] ) / span ) * 100 ;
}
Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
2026-08-03 19:29:51 +03:00
grades [ i ] = g ;
2026-10-26 20:55:11 +03:00
if ( span > 10 && Math . abs ( g ) > Math . abs ( maxGrade ) ) maxGrade = g ;
Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
2026-08-03 19:29:51 +03:00
}
let ascent = 0 , descent = 0 , ref = null ;
for ( let i = 0 ; i < n ; i ++ ) {
const e = smoothed [ i ] ;
if ( e == null ) continue ;
if ( ref == null ) { ref = e ; continue ; }
const rise = e - ref ;
if ( rise > 1 ) { ascent += rise ; ref = e ; }
else if ( rise < - 1 ) { descent += - rise ; ref = e ; }
}
return {
hasEle , smoothed , cumKm , grades ,
ascent : Math . round ( ascent ) , descent : Math . round ( descent ) ,
maxGrade : Math . round ( maxGrade * 10 ) / 10 ,
distanceKm : cumKm [ n - 1 ] || 0 ,
} ;
} ;
2026-05-13 10:21:24 +03:00
window . GPS = GPS ;
console . log ( "[gps.js] window.GPS ready" ) ;