baldav/frontend/js/gps.js

2558 lines
121 KiB
JavaScript
Raw Normal View History

// GPS tracking module — recording, calculations, GPX import
// Exposes window.GPS
const GPS = {};
// ---------------------------------------------------------------------------
// Configurable thresholds (can be changed in settings)
GPS.config = {
minSpeedMs: 0.42, // 1.5 km/h — below this = stationary
minAccuracyM: 50, // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept.
minDistanceM: 1, // skip points closer than this
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
gapThresholdSec: 30, // gap longer than this = new track segment
airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
hrCalibFactor: 0.65, // грубый персональный множитель для справочной "ЧСС откалибр." (Keytel x этого). Пульс завышает ~в 1.5x vs физика/вес; 0.65 мирит по 2 точкам (поездка 90км + сидячий день). НЕ истина, для справки.
tefFrac: 0.10, // термический эффект пищи (TEF): доля прихода, уходящая на переваривание. 0 = выкл. Вводится С СОХРАНЕНИЕМ поддержки: база = BMR*actLevel*(1-tefFrac), затем +tefFrac*приход, так что на поддержке итог не меняется, но TEF масштабируется от реальной еды. actLevel 1.2 уже включал TEF — поэтому не прибавляем сверху, а перераспределяем.
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 км/ч
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, // средняя скорость бега
useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps.
// raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть.
autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец»)
trackColor: '#bb86fc', // цвет линии текущего/записанного трека (по умолчанию фиолетовый темы)
roadColorWalk: '#8B5E3C', // цвет наложения проезженных/пройденных дорог: ходьба
roadColorBike: '#2266aa', // ... вело
roadColorRun: '#e67e22', // ... бег
gpxzKey: '', // API-ключ GPXZ (точные высоты Copernicus GLO-30). Пусто = open-elevation (SRTM). Хранится только тут (localStorage), не в git.
};
// Load saved config
try {
let saved = localStorage.getItem("baldav_gps_config");
if (saved) Object.assign(GPS.config, JSON.parse(saved));
} catch {}
GPS.saveConfig = function() {
localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config));
};
// HR-only session kinds (no GPS, no track — just heart rate + timer): "workout" (зарядка)
// and "vr" (руки/VR/танцы). Both estimate kcal from HR (Keytel). Kept as a set so new
// HR-only kinds get the same no-GPS recording/detail path without touching every check.
GPS.isHrOnly = function(t) { return t === "workout" || t === "vr"; };
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
try {
if (localStorage.getItem("baldav_fused_migrated") !== "1") {
GPS.config.useFusedLocation = true;
localStorage.setItem("baldav_fused_migrated", "1");
GPS.saveConfig();
}
} catch {}
// 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;
};
// Auto-detect repeated segments within ONE ride (interval training: the same climb
// ridden N times, one track, no stop/restart). The repeated corridor is found by
// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run
// where position along a reference segment rises monotonically), but the reference
// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride
// revisits), not given as road_points ids.
// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }.
// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST
// (uphill) position, so coasting back down makes position fall and ends the pass, and
// the "must start near seq beginning" guard stops a descent from opening a new pass.
GPS.detectRepeats = function(points, opts) {
opts = opts || {};
let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap)
let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long
let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate)
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment
let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long
let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor
let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor
let backM = opts.backM ?? 30; // progress may dip this far without ending the pass
let n = points ? points.length : 0;
if (n < 20) return { found: false };
// Grid cell key per point.
let key = new Array(n);
for (let i = 0; i < n; i++) {
let la = points[i].lat, lo = points[i].lon;
if (la == null || lo == null) { key[i] = null; continue; }
key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
}
// Entries per cell: count each time the track enters a cell (key changes into it).
let visits = new Map();
for (let i = 0; i < n; i++) {
if (key[i] == null) continue;
if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1);
}
let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v;
if (maxEntries < 2) return { found: false };
// Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor).
// Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot
// cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where
// progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a
// small backstep. Pass time = moving seconds only (stops trimmed).
let detectAt = (hotThr) => {
let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr;
let buildTraversal = (start) => {
let seq = [], order = new Map(), end = start;
for (let k = start, gap = 0; k < n; k++) {
let c = key[k];
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
gap = 0;
if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell
if (order.has(c)) break; // revisit of an earlier cell -> traversal complete
order.set(c, seq.length); seq.push(c); end = k;
}
let len = 0;
for (let k = start + 1; k <= end; k++)
len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
return { seq, len, start, end };
};
let best = null;
for (let k = 0; k < n; k++) {
if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start
let t = buildTraversal(k);
if (!best || t.len > best.len) best = t;
}
if (!best || best.seq.length < 3 || best.len < minLenM) return null;
let rLat = [], rLon = [], cum = [0];
for (let k = best.start; k <= best.end; k++) {
rLat.push(points[k].lat); rLon.push(points[k].lon);
if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon));
}
let refLen = cum[cum.length - 1];
if (rLat.length < 3 || refLen < minLenM) return null;
let refCell = new Map();
for (let v = 0; v < rLat.length; v++) {
let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell);
(refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v);
}
let rad = Math.ceil(projTolM / cell);
let prog = new Array(n).fill(null);
for (let i = 0; i < n; i++) {
if (points[i].lat == null) continue;
let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell);
let bv = -1, bd = projTolM;
for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) {
let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue;
for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } }
}
if (bv >= 0) prog[i] = cum[bv];
}
let passes = [];
let p = 0;
while (p < n) {
if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; }
let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0;
while (q < n - 1) {
q++;
if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; }
let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000;
let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon);
if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue;
break;
}
if ((hi - lo) >= refLen * minFrac) {
let mov = 0;
for (let k = p + 1; k <= anchor; k++) {
let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000;
let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded
}
passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) });
}
p = Math.max(q, anchor) + 1;
}
return { segLength: Math.round(refLen), passes };
};
// A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the
// reference, inflating it so a full rep no longer covers minFrac (measured on a real
// out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to
// the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment).
let bestRes = null;
for (let T = 2; T <= Math.min(maxEntries, 20); T++) {
let r = detectAt(T);
if (!r || r.passes.length < minReps) continue;
if (!bestRes || r.passes.length > bestRes.passes.length ||
(r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r;
}
if (!bestRes) return { found: false };
return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes };
};
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
// Only touch the value if it's still the old default; respect a user-set threshold.
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 {}
// Haversine distance (meters)
// ---------------------------------------------------------------------------
// 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];
};
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
// weightKg = rider weight, opts = { bikeWeightKg, activityType, surfaceCrr,
// chainringTeeth, cogTeeth, wheelDiameterInch } (all optional)
// ---------------------------------------------------------------------------
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 };
// 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 };
// 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));
let distance = 0, elevGain = 0, elevLoss = 0;
let elevRef = null; // running reference for cumulative elevation hysteresis
const speeds = []; // m/s per segment
// 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;
// 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise)
// 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.
// 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.
// 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);
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) => {
if (alt == null) return null;
let sum = 0, cnt = 0;
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++; }
}
return cnt > 0 ? sum / cnt : alt;
});
// Physics constants (configurable in settings)
const g = 9.81;
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.
// 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)];
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
}
const tirePressure = opts?.tirePressureBar ?? 3.0;
const Crr = baseCrr * Math.sqrt(3.0 / tirePressure);
const wKg = parseFloat(weightKg) || 80; // ensure numeric (profile stores strings)
const mTotal = wKg + (parseFloat(opts?.bikeWeightKg) || 0);
const isBike = (opts?.activityType ?? "bike") === "bike";
let totalKcal = 0;
const basalPerMin = (1.0 * 3.5 * wKg) / 200;
// Mechanical work while pedalling, for avgPower output (used by the repeat/interval
// analysis to compare passes). Averaged over pedalling time, like tools/track pers.py.
let mechWorkJ = 0, powerSec = 0;
// Cadence tracking
let totalPedalRevs = 0;
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;
// 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
for (let i = 1; i < sorted.length; i++) {
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
// 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; }
let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
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;
// 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;
if (segDist > 0) distance += segDist;
if (normalMove) {
speeds.push(v);
// 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) {
// 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]; }
}
}
}
// Kcal calculation per segment
if (isSpike) continue;
if (isGap || isEstimated) {
// GPS gap / dead-reckoning: time-based walking MET (no reliable physics here).
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;
}
if (v < 0.3) {
// Standing still: only basal metabolic rate
totalKcal += basalPerMin * (dt / 60);
continue;
}
if (isBike) {
// 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;
const altDiff = altHere - altPrev;
const gradient = d > 2 ? Math.max(-0.5, Math.min(0.5, altDiff / d)) : 0;
const sinG = gradient;
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)
? 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;
// 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;
const pAero = 0.5 * CdA * rho * v * v;
const power = v * (pGravity + pRolling + pAero);
// 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;
mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power
const vKmh = v * 3.6;
if (vKmh >= 5) {
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
const metNetKcal = (dt / 60) * ((met - 1) * 3.5 * wKg) / 200;
// 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.
totalKcal += Math.max(physicsKcal, metNetKcal);
} else {
totalKcal += physicsKcal; // creeping < 5 km/h: negligible
}
// Cadence estimate from gearing (theoretical) for avgCadence output.
if (hasGearData && wheelCirc > 0) {
const wheelRevs = d / wheelCirc;
totalPedalRevs += wheelRevs / gearRatio;
pedalingSeconds += dt;
}
} else {
// Coasting / freewheeling: only basal metabolic rate.
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
totalKcal += (dt / 60) * (met * 3.5 * wKg) / 200;
}
}
// Flush a push run still open at the track's end.
if (wbRunSec >= 30) { walkBikeSec += wbRunSec; walkBikeDist += wbRunDist; }
// 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;
}
const first = new Date(sorted[0].ts);
const last = new Date(sorted[sorted.length - 1].ts);
// 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);
const avgSpeed = duration > 0 ? (distance / duration) : 0;
const avgSpeedKmh = avgSpeed * 3.6;
const maxSpeedKmh = maxSpeed * 3.6;
// Average cadence (RPM) — only if gear data was provided and there was pedaling
const avgCadence = (hasGearData && pedalingSeconds > 0)
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
: null;
// Average mechanical power (W) over pedalling time, null if never pedalled.
const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null;
// ---------------------------------------------------------------------------
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
// 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,
});
}
}
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,
kcal: Math.round(totalKcal),
duration: Math.round(duration),
avgCadence,
avgPower,
segments,
walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs)
walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance)
};
};
// ---------------------------------------------------------------------------
// 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 }));
};
// ---------------------------------------------------------------------------
// 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",
currentHr: null,
currentBleData: null,
hrPollInterval: null,
};
// 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);
let fused = GPS.config.useFusedLocation ? 1 : 0;
let cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}:${fused}`;
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 {}
};
// 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); }
};
// Is the OS location toggle ON (not just the permission)? Kotlin checks LocationManager and
// writes location_state.json; we read it back. Returns true/false, or null if undeterminable
// (desktop / no answer) — callers must not prompt on null.
GPS.isLocationEnabled = async function() {
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_location" });
await new Promise(r => setTimeout(r, 300));
let raw = await window.__TAURI__.core.invoke("read_state_file", { name: "location_state.json" });
if (!raw) return null;
return !!JSON.parse(raw).enabled;
} catch { return null; }
};
// BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses.
// HR keeps measuring even when rider is stationary.
GPS._startHrMonitor = async function() {
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; }
// Do NOT auto-connect/scan here. That ran a BLE scan+connect on every ride even when the
// band was absent or Bluetooth was off, holding the radio and draining the battery for
// nothing. Connecting is MANUAL now (Settings -> "Подключить", works mid-ride too). We only
// run the cheap HR poll (reads a local file), so a manual connect now or later starts
// feeding HR into the track via currentHr.
console.log("[HR] Starting HR poll (connect is manual)");
GPS.recording.hrPollInterval = setInterval(async () => {
try {
const reading = await window.__TAURI__.core.invoke("ble_get_hr");
if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); }
if (reading && reading.hr > 0) {
GPS.recording.currentHr = reading.hr;
GPS.recording.currentBleData = reading;
// HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading.
if (GPS.isHrOnly(GPS.recording.activityType) && GPS.recording.startTime) {
GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]);
}
if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading);
}
} 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 {}
};
// Rebuild the workout's HR series from the NATIVE log (ble_hr_log.jsonl), which the
// foreground service appends to even with the screen off — unlike the JS poll, which
// Android freezes in the background (so a sleep-long workout kept only the on-screen bits).
// Returns [[relSec, bpm], …] relative to the recording start, sorted and de-duplicated,
// or [] if the log is empty/unavailable.
GPS._readHrLogSamples = async function() {
if (!GPS.recording.startTime) return [];
let raw = "";
try { raw = await window.__TAURI__.core.invoke("ble_read_hr_log"); } catch { return []; }
if (!raw) return [];
const startSec = GPS.recording.startTime.getTime() / 1000;
const out = [];
let lastRel = -1;
for (const line of raw.split("\n")) {
const s = line.trim();
if (!s) continue;
let o; try { o = JSON.parse(s); } catch { continue; } // skip a torn last line
if (!o || !(o.hr > 0) || o.ts == null) continue;
const rel = Math.round(o.ts - startSec);
if (rel < 0 || rel === lastRel) continue; // before start, or same-second dup
out.push([rel, o.hr]);
lastRel = rel;
}
out.sort((a, b) => a[0] - b[0]);
return out;
};
// Turn HR on/off during a recording (the toggle on the record screen). ON connects to the
// paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning /
// drain when the strap isn't there) and polls; OFF stops the poll and disconnects.
//
// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then
// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start
// pass interactive=false: if BT is off they just leave HR off (no second popup after the single
// start-time prompt in ensureSystemReady), and a later manual connect still works.
GPS.setHrOn = async function(on, interactive = false) {
GPS.recording.hrOn = !!on;
if (!on) {
GPS._stopHrMonitor();
try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {}
return false;
}
const addr = await DB.getProfileValue("ble_device_addr");
const type = await DB.getProfileValue("ble_device_type");
if (!addr) { GPS.recording.hrOn = false; return false; }
try {
const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (bt === "off") {
// Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't
// connect with the radio off — leave HR off so the toggle shows grey, not a fake "on".
if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} }
GPS.recording.hrOn = false;
return false;
}
if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} }
} catch {}
try { await GPS._startHrMonitor(); } catch {}
return true;
};
// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%.
// Resets when the strap reads healthy again (recharged / battery replaced).
GPS._batAlerted = {};
GPS._checkBatteryAlert = function(bat) {
if (bat == null || bat < 0 || bat > 100) return;
if (bat > 15) { GPS._batAlerted = {}; return; }
for (let thr of [10, 5]) {
if (bat <= thr && !GPS._batAlerted[thr]) {
GPS._batAlerted[thr] = true;
GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`);
}
}
};
GPS._speakOrBeep = function(text) {
let spoke = false;
try {
if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") {
let u = new SpeechSynthesisUtterance(text);
u.lang = "ru-RU"; u.rate = 0.95;
window.speechSynthesis.speak(u);
spoke = true;
}
} catch {}
// Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue).
try { GPS._beep(spoke ? 1 : 3); } catch {}
};
GPS._beep = function(times) {
times = times || 1;
let ctx = new (window.AudioContext || window.webkitAudioContext)();
let one = (i) => {
let o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = 880; o.type = "sine";
o.connect(g); g.connect(ctx.destination);
g.gain.value = 0.25;
let t0 = ctx.currentTime + i * 0.35;
o.start(t0); o.stop(t0 + 0.2);
};
for (let i = 0; i < times; i++) one(i);
setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400);
};
// Read points from native service file
// Parse raw JSONL (one native point per line) into sorted point objects.
GPS._parseNativePoints = function(raw) {
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;
};
GPS._readNativePoints = async function() {
try {
const raw = await window.__TAURI__.core.invoke("gps_service_read_points");
return GPS._parseNativePoints(raw);
} catch { return []; }
};
// 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) {
if (!GPS.recording.active) return;
// 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) {}
// 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;
// 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;
}
// 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;
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;
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));
}
// 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); }
}
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;
}
// Auto-pause detection — still save HR data during pause
if (!isMoving) {
if (!GPS.recording.pauseStart) {
GPS.recording.pauseStart = new Date(p.ts);
}
// Save point with HR even during pause (no seq = excluded from distance/kcal)
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);
}
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);
}
// 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.
GPS.recording.lastMovingPoint = p;
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);
}
}
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
};
// 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;
};
// 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 || [],
windMarks: GPS.recording.windMarks || [],
pointCount: GPS.recording.points.length,
bikeId: GPS.recording.bikeId ?? null,
bikeWeightKg: GPS.recording.bikeWeightKg ?? null,
surfaceCrr: GPS.recording.surfaceCrr ?? null,
tirePressureBar: GPS.recording.tirePressureBar ?? null,
chainringTeeth: GPS.recording.chainringTeeth ?? null,
cogTeeth: GPS.recording.cogTeeth ?? null,
wheelDiameterInch: GPS.recording.wheelDiameterInch ?? null,
gearCount: GPS.recording.gearCount ?? null,
}));
} 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");
};
// 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;
};
// 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;
try {
const s = JSON.parse(saved);
if (!s.sessionId) { GPS._clearPersistedState(); return; }
// 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.
const existingPts = await DB.getGpsPoints(s.sessionId);
const dbTs = new Set(existingPts.map(p => new Date(p.ts).getTime()));
const nativePoints = await GPS._readNativePoints();
let imported = 0;
let seq = existingPts.length;
for (const p of nativePoints) {
if (dbTs.has(new Date(p.ts).getTime())) continue;
seq++;
await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
imported++;
}
// Finalize the session
const pts = await DB.getGpsPoints(s.sessionId);
if (pts.length > 0) {
const recoverOpts = {
activityType: s.activityType,
bikeWeightKg: s.bikeWeightKg ?? 0,
surfaceCrr: s.surfaceCrr,
tirePressureBar: s.tirePressureBar ?? 3.0,
chainringTeeth: s.chainringTeeth,
cogTeeth: s.cogTeeth,
wheelDiameterInch: s.wheelDiameterInch,
gearCount: s.gearCount,
pauses: s.pauses || [],
};
const stats = GPS.computeStats(pts, s.weightKg || 80, recoverOpts);
await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5),
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, s.pauses || []);
// 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); }
}
// Store bike_id if present
if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId);
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;
}
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;
} catch (e) {
console.error("[GPS] Recovery error:", e);
GPS._clearPersistedState();
}
};
// Net kcal from a heart-rate series (Keytel 2005 gross, minus BMR for the same time so it is
// consistent with the app's NET model — BMR is already booked in the daily balance).
// samples: [[relSec, bpm], ...]. profile: {weightKg, age, sex ("m"/"f"), heightCm}.
GPS.hrKcalNet = function(samples, profile) {
if (!samples || samples.length < 2) return 0;
profile = profile || {};
const W = parseFloat(profile.weightKg) || 75, A = parseInt(profile.age) || 38;
const H = parseFloat(profile.heightCm) || 178, male = (profile.sex || "m") === "m";
const bmrMin = (10 * W + 6.25 * H - 5 * A + (male ? -5 : 161)) / 1440; // Mifflin -> kcal/min
const grossPerMin = hr => male
? (-55.0969 + 0.6309 * hr + 0.1988 * W + 0.2017 * A) / 4.184
: (-20.4022 + 0.4472 * hr - 0.1263 * W + 0.0740 * A) / 4.184;
let kcal = 0;
for (let i = 1; i < samples.length; i++) {
const dtMin = Math.max(0, samples[i][0] - samples[i-1][0]) / 60;
if (dtMin <= 0 || dtMin > 1) continue; // skip > 1 min gaps (disconnect)
const net = grossPerMin((samples[i-1][1] + samples[i][1]) / 2) - bmrMin;
if (net > 0) kcal += net * dtMin;
}
return Math.round(kcal);
};
GPS.startRecording = async function(activityType, weightKg, profile) {
if (GPS.recording.active) return;
GPS.recording.active = true;
GPS.recording.activityType = activityType || "bike";
GPS.recording.points = [];
GPS.recording.hrSamples = [];
GPS.recording.hrProfile = profile || null;
// Clear any HR left over from the PREVIOUS session (else a stale bpm hangs on the display and
// gets written onto this session's points). Only a fresh reading this session re-fills it.
GPS.recording.currentHr = null;
GPS.recording.currentBleData = null;
GPS.recording.currentBattery = null;
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
GPS.recording.startTime = null;
GPS.recording.weightKg = parseFloat(weightKg) || 80;
// HR-only "workout"/"vr": no GPS service, no location, no track points — just the
// heart-rate poll + a timer. kcal comes from HR (Keytel) at finish.
if (GPS.isHrOnly(GPS.recording.activityType)) {
GPS.recording.state = "recording";
GPS.recording.startTime = new Date();
GPS.recording.nativeService = false;
GPS.recording.sessionId = null;
try {
GPS.recording.sessionId = await DB.createGpsSession(
new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), GPS.recording.activityType);
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
// Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean.
try {
const hrAddr = await DB.getProfileValue("ble_device_addr");
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
} catch (e) { console.error("[HR] start error:", e); }
return;
}
if (!navigator.geolocation) throw new Error("Geolocation not available");
GPS.recording.state = "waiting_gps";
GPS.recording.sessionId = null;
GPS.recording.pauseStart = null;
GPS.recording.totalPauseMs = 0;
GPS.recording.lastMovingPoint = null;
GPS.recording.manualPaused = false;
GPS.recording.pauses = [];
GPS.recording._currentPause = null;
GPS.recording.windMarks = []; // headwind labels: [{startTs, endTs}] ms epoch
GPS.recording._currentWind = null; // open headwind interval, if any
GPS.recording.nativeService = false;
GPS.recording._pollTimer = null;
GPS.recording._lastProcessedTs = 0;
GPS.recording._pointSeq = 0;
// Start native foreground service (Android)
await GPS._startNativeService();
// HR: default ON if a strap is paired — auto-connect + poll at start (the record screen has an
// on/off toggle). Safe: BleHrService scans briefly and stops if the strap isn't present, so no
// constant scanning/drain when it's absent. No paired strap -> HR stays off.
try {
const hrAddr = await DB.getProfileValue("ble_device_addr");
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
} catch (e) { console.error("[HR] start error:", e); }
// Poll native service output file
if (GPS.recording.nativeService) {
let polling = false;
GPS.recording._pollTimer = setInterval(async () => {
console.log(`[poll] tick active=${GPS.recording.active} polling=${polling} hidden=${document.hidden} lastTs=${GPS.recording._lastProcessedTs}`);
if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; }
if (polling) return; // skip if previous poll still running
polling = true;
try {
const nativePoints = await GPS._readNativePoints();
console.log(`[poll] native=${nativePoints.length} firstTs=${nativePoints[0]?.ts} lastNativeTs=${nativePoints[nativePoints.length-1]?.ts}`);
let newCount = 0;
// 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.
const origOnPoint = GPS.recording.onPoint;
GPS.recording.onPoint = null;
for (const p of nativePoints) {
const pTs = new Date(p.ts).getTime();
if (pTs <= GPS.recording._lastProcessedTs) continue;
await GPS._processIncomingPoint(p, true);
GPS.recording._lastProcessedTs = pTs;
newCount++;
if (GPS.recording.onImportPoint) GPS.recording.onImportPoint(p);
// 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.
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}`);
}
}
GPS.recording.onPoint = origOnPoint;
console.log(`[poll] imported=${newCount} newLastTs=${GPS.recording._lastProcessedTs}`);
// Final sync — marker, pan, course and stats from the full point set.
if (newCount > 0 && GPS.recording.onBatchUpdate) {
GPS.recording.onBatchUpdate();
}
} catch (e) {
console.error("[GPS] poll error:", e);
}
polling = false;
}, 2000);
}
GPS.recording.watchId = navigator.geolocation.watchPosition(
async (pos) => {
if (pos.coords.accuracy > GPS.config.minAccuracyM) return;
await GPS._processIncomingPoint({
lat: pos.coords.latitude,
lon: pos.coords.longitude,
altitude: pos.coords.altitude,
speed_ms: pos.coords.speed,
accuracy: pos.coords.accuracy,
bearing: (pos.coords.heading != null && !isNaN(pos.coords.heading)) ? pos.coords.heading : null,
ts: new Date(pos.timestamp).toISOString(),
});
},
(err) => {
console.error("GPS error:", err.message);
if (GPS.recording.onError) GPS.recording.onError(err);
},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
);
};
GPS.stopRecording = async function() {
if (!GPS.recording.active) return null;
GPS.recording.active = false;
if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer);
// HR-only workout/vr: no GPS to stop. Compute net kcal from HR, save samples, finalise.
if (GPS.isHrOnly(GPS.recording.activityType)) {
// Prefer the native HR log (complete through screen-off) over the JS poll's in-memory
// array (frozen in the background). Fall back to the poll array if the log is empty.
let samples = GPS.recording.hrSamples || [];
try {
const native = await GPS._readHrLogSamples();
if (native.length > samples.length) samples = native;
} catch (e) { console.error("[workout] native hr log read", e); }
const kcal = GPS.hrKcalNet(samples, GPS.recording.hrProfile);
const durationSec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0;
const hrs = samples.map(s => s[1]);
const avgHr = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
const maxHr = hrs.length ? Math.max(...hrs) : null;
const sid = GPS.recording.sessionId;
GPS._stopHrMonitor();
if (sid) {
try { await DB.setSessionHrSamples(sid, JSON.stringify(samples)); } catch (e) { console.error("[workout] save samples", e); }
// Reuse finishGpsSession: distance 0, kcal from HR; avg/max HR ride the speed columns' siblings? No —
// store them nowhere extra; detail derives avg/max from hr_samples. Just distance 0 + kcal.
try { await DB.finishGpsSession(sid, new Date().toTimeString().slice(0, 5), 0, kcal, null, null, 0, 0); } catch (e) { console.error("[workout] finish", e); }
}
return { sessionId: sid, workout: true, kcal, durationSec, avgHr, maxHr, samples };
}
GPS._stopHrMonitor();
if (GPS.recording.watchId != null) {
navigator.geolocation.clearWatch(GPS.recording.watchId);
GPS.recording.watchId = null;
}
// Stop native service — read remaining points but DON'T delete the file yet
let nativePointsFile = null;
console.log(`[stop] enter nativeService=${GPS.recording.nativeService} sessionId=${GPS.recording.sessionId}`);
if (GPS.recording.nativeService) {
// 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.
const nativePoints = await GPS._readNativePoints();
let stopImported = 0;
if (GPS.recording.sessionId && nativePoints.length) {
const existing = await DB.getGpsPoints(GPS.recording.sessionId);
let seq = existing.length;
for (const p of nativePoints) {
const pTs = new Date(p.ts).getTime();
if (pTs <= GPS.recording._lastProcessedTs) continue;
seq++;
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
GPS.recording._lastProcessedTs = pTs;
stopImported++;
}
console.log(`[stop] native=${nativePoints.length} existing=${existing.length} tail-imported=${stopImported}`);
GPS.recording.points = await DB.getGpsPoints(GPS.recording.sessionId);
} else {
console.log(`[stop] SKIP backfill: sessionId=${GPS.recording.sessionId} native=${nativePoints.length}`);
}
// Remember the file name for cleanup after DB save
try {
nativePointsFile = await window.__TAURI__.core.invoke("get_points_filename");
} catch {}
await GPS._stopNativeService();
}
if (!GPS.recording.sessionId) {
return null;
}
// 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,
tirePressureBar: GPS.recording.tirePressureBar ?? 3.0,
chainringTeeth: GPS.recording.chainringTeeth,
cogTeeth: GPS.recording.cogTeeth,
wheelDiameterInch: GPS.recording.wheelDiameterInch,
gearCount: GPS.recording.gearCount,
};
const recPauses = GPS.recording.pauses || [];
recOpts.pauses = recPauses;
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts);
const endTime = new Date().toTimeString().slice(0, 5);
// Close an open headwind interval and persist all marks with the session.
GPS._closeOpenWindMark();
const recWindMarks = GPS.recording.windMarks || [];
try {
await DB.finishGpsSession(
GPS.recording.sessionId, endTime,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss, recPauses
);
try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); }
// Persist the rider weight used for this ride, so a later recompute keeps ride-time weight.
try { await DB.setSessionRiderWeight(GPS.recording.sessionId, GPS.recording.weightKg); } catch (e) { console.error("[GPS] rider weight save failed:", e); }
// DB save confirmed — now safe to delete the points file
if (nativePointsFile) {
try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {}
}
} catch (e) {
console.error("[GPS] finishGpsSession FAILED — points file preserved:", e);
if (typeof showToast === "function") showToast("Сохранение трека: " + (e.message || e));
}
// Store bike_id in session if present
if (GPS.recording.bikeId) {
await DB.updateSessionBike(GPS.recording.sessionId, GPS.recording.bikeId);
}
const sid = GPS.recording.sessionId;
const pauses = GPS.recording.pauses || [];
GPS.recording.sessionId = null;
GPS.recording.points = [];
GPS.recording.pauses = [];
GPS.recording.manualPaused = false;
GPS.recording.bikeId = null;
GPS.recording.bikeWeightKg = null;
GPS.recording.surfaceCrr = null;
GPS.recording.tirePressureBar = null;
GPS.recording.chainringTeeth = null;
GPS.recording.cogTeeth = null;
GPS.recording.wheelDiameterInch = null;
GPS.recording.gearCount = null;
GPS.recording.windMarks = [];
GPS.recording._currentWind = null;
GPS._clearPersistedState();
return { sessionId: sid, stats, pauses };
};
// ---------------------------------------------------------------------------
// 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 };
};
// 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"));
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);
});
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 };
};
// 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";
// 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";
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 };
};
// 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 }:
// 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 }
// status: "filled" | "skipped-long" | "skipped-nogain" | "skipped-noroute".
// Lets the UI draw ONLY the gap patches (not the whole track) and report what failed.
GPS.fillGaps = async function(points, opts) {
opts = opts || {};
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
// Router for one gap: the caller can prefer already-ridden roads (returns {points, ridden:true}
// when the bridge runs over the user's own tracks); default is the public BRouter shortest.
// A ridden path is what actually happened, so it is NOT subject to the "detour too long" guard.
let routeFn = opts.routeFn || (async (a, b) => {
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false };
});
let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let out = [];
let gaps = [];
for (let i = 0; i < sorted.length; i++) {
out.push(sorted[i]);
let a = sorted[i], b = sorted[i + 1];
if (!b) continue;
let dt = (new Date(b.ts) - new Date(a.ts)) / 1000;
let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon);
if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging
let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime();
let routed = [], status = "skipped-noroute", addedM = 0, ridden = false;
try {
let r = await routeFn(a, b);
let rp = (r && r.points) || [];
ridden = !!(r && r.ridden);
if (rp.length >= 3) {
let rlen = 0;
for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
addedM = Math.max(0, rlen - chord);
if (!ridden && rlen > chord * ratio) {
status = "skipped-long"; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is)
} else if (addedM < minGain) {
status = "skipped-nogain"; // road ≈ straight — nothing to bridge
} else {
status = "filled";
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"; }
gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord, ridden });
}
return { points: out, gaps };
};
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");
const stats = GPS.computeStats(points, parseFloat(weightKg) || 80);
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",
};
const dbPoints = points.map((p, idx) => ({
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
}));
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.
// 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;
});
};
GPS.filterNoise = function(points, activityType) {
return GPS.filterByAccuracy(points);
};
// 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) {
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : Math.round(meters) + " м";
};
// ---------------------------------------------------------------------------
// 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const name = esc(session.activity_type || "Track") + " " + esc(session.date);
const cfg = GPS.config;
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx creator="BalDav" version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${name}</name>
<time>${points[0].ts}</time>
<desc>minSpeed=${(cfg.minSpeedMs*3.6).toFixed(1)}kmh maxAcc=${cfg.minAccuracyM}m minDist=${cfg.minDistanceM}m points=${points.length}</desc>
</metadata>
<trk>
<name>${name}</name>
<type>${esc(session.activity_type || "other")}</type>
<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>
</trk>
</gpx>`;
// 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);
}
};
// ---------------------------------------------------------------------------
// Recalculate session stats using physics-based formula
// ---------------------------------------------------------------------------
GPS.recalculateSession = async function(sessionId, opts) {
opts = opts || {};
// Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer.
const onProg = (typeof opts.onProgress === "function") ? opts.onProgress : () => {};
onProg(5);
const session = await DB.getGpsSession(sessionId);
if (!session) return null;
const points = await DB.getGpsPoints(sessionId);
if (points.length < 2) return null;
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
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
// 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();
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);
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;
// 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.
let deleted = 0;
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);
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++;
}
}
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
}
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]);
}
}
}
// Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80.
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
// Get bike if linked to session, otherwise use active bike
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
// Use cleaned sorted array, not original points
const sessPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
onProg(70);
const stats = GPS.computeStats(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
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,
gearCount: bike?.gear_count,
pauses: sessPauses,
});
// Update session in DB (preserve pauses)
await DB.finishGpsSession(sessionId,
session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss, sessPauses
);
onProg(100);
return stats;
};
// 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();
const bulkPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
const sessW = parseFloat(session.rider_weight_kg) || weightKg; // ride-time weight, else the fallback
const stats = GPS.computeStats(points, sessW, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
chainringTeeth: bike?.chainring_teeth,
cogTeeth: bike?.cog_teeth,
wheelDiameterInch: bike?.wheel_diameter_inch,
gearCount: bike?.gear_count,
pauses: bulkPauses,
});
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses);
// 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); }
}
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 };
};
// 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,
tirePressureBar: GPS.recording.tirePressureBar ?? 3.0,
chainringTeeth: GPS.recording.chainringTeeth,
cogTeeth: GPS.recording.cogTeeth,
wheelDiameterInch: GPS.recording.wheelDiameterInch,
};
};
// 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;
});
};
// 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;
}
// 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);
const grades = new Array(n).fill(0);
let maxGrade = 0;
for (let i = 0; i < n - 1; i++) {
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;
}
grades[i] = g;
if (span > 10 && Math.abs(g) > Math.abs(maxGrade)) maxGrade = g;
}
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,
};
};
window.GPS = GPS;
console.log("[gps.js] window.GPS ready");