GPS stats: exclude manual pauses; reject unrealistic altitude steps
Manual pause (hand-paused at foot of a climb, walked up, resumed) was counted as riding: computeStats bridged the GPS gap for distance and elevRef survived the gap, leaking the on-foot climb into elevation gain. - gps_sessions.pauses (JSON) now persists manual-pause intervals - computeStats(opts.pauses): a segment crossing a pause is excluded entirely (no distance/speed/elevation/kcal), elevRef reset, paused time subtracted from duration; automatic GPS gaps still interpolated - altitude glitch guard: an adjacent smoothed step steeper than maxRealGradePct (100% = 45deg) is not banked and does not move elevRef (covers Android no-fix 0.0); a smooth change to any value (a real 0 near a river) still passes - recorder writes "alt":null when hasAltitude() is false (real fixed 0 kept) - pauses threaded through live-stop, recalculateSession, recovery and bulk recompute - new-tracks only (old tracks never stored pauses; no manual segment-cut UI) Also: track-detail header shows a click-to-copy track ID.
This commit is contained in:
parent
86a9a1846b
commit
e9efb4fc6c
7 changed files with 178 additions and 20 deletions
|
|
@ -103,7 +103,7 @@
|
|||
| `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m |
|
||||
| `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы |
|
||||
| `supplement_log` | Приём добавок | date, supplement_id, dose_g |
|
||||
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер) |
|
||||
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз) |
|
||||
| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw |
|
||||
| `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json (`{lat,lon,ele?}`), created_at |
|
||||
| `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active |
|
||||
|
|
@ -198,6 +198,13 @@ segMaxMs = maxSpeedKmh/3.6 (вело) или walkMaxSpeedKmh/3.6 (пеш); gapTh
|
|||
норма (иначе) → segDist = хорда (haversine)
|
||||
distance += segDist; высота/maxSpeed — только на «норме»; ккал в разрыве/estimated = walk-MET по времени
|
||||
Зависит только от ts+speed (не от флага estimated) → live == «Стоп» == пересчёт. gapThreshold настраивается, применяется при пересчёте.
|
||||
|
||||
Ручная пауза (opts.pauses = [{startTs,endTs}] мс): сегмент, пересекающий паузу (prevMs<endTs И pMs>startTs),
|
||||
исключается ЦЕЛИКОМ — ни дистанции, ни высоты, ни ккал, ни времени (pausedSec вычитается из duration), elevRef=null
|
||||
(перепад, набранный пешком за паузу, не банкуется на первом сегменте после резюма). Авто-GPS-гэп (без паузы) — считается как раньше.
|
||||
Глюк-гард высоты: соседний сглаженный перепад с грейдом > maxRealGradePct (1.0 = 100% = 45°) не банкуется и не двигает elevRef
|
||||
(скачок/ступенька, в т.ч. Android no-fix 0.0). Плавный приход к любому значению (реальный 0 у реки) проходит.
|
||||
Kotlin пишет "alt":null без фикса высоты (hasAltitude()), честный 0 с фиксом сохраняется. Паузы: колонка gps_sessions.pauses (JSON).
|
||||
```
|
||||
|
||||
### Ходьба/бег — MET-таблица
|
||||
|
|
|
|||
56
docs/plans/2026-09-07-manual-pauses-altitude-glitches.md
Normal file
56
docs/plans/2026-09-07-manual-pauses-altitude-glitches.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# План: ручные паузы и глюки высоты в расчёте статистики трека
|
||||
|
||||
## Context (зачем)
|
||||
Две ошибки в `GPS.computeStats` (frontend/js/gps.js):
|
||||
1. **Ручная пауза считается как езда.** Пользователь внизу подъёма ставит паузу руками,
|
||||
идёт пешком, наверху снимает. `computeStats` через гэп всё равно считает дистанцию
|
||||
по позиции, а `elevRef` переживает гэп → первый сегмент после резюма сравнивается с
|
||||
высотой ДО паузы → перепад подъёма утекает в elevGain. Нужно: **ручную паузу
|
||||
исключать** (ни дистанции, ни перепада, ни времени, ни ккал), а **автоматический
|
||||
GPS-гэп в движении — считать как сейчас** (интерполяция).
|
||||
2. **Глюки высоты, включая честный 0.** Android при отсутствии фикса высоты пишет
|
||||
`location.altitude=0.0` → в данных 146 точек `altitude_gps_m=0`, которые как «реальная
|
||||
высота» дают ложные перепады. НО плавный приход к 0 у реки — реальная высота
|
||||
(пользователь часто у рек). Правило: отбрасывать **нереальный уклон** (скачок /
|
||||
грейд >45° = >100%), а плавные значения (в т.ч. 0) — оставлять.
|
||||
|
||||
Решения (по ответам): **только для НОВЫХ треков** (ручного «разрезания» старых не делаем).
|
||||
Паузы отслеживаются в `GPS.recording.pauses` (`[{startTs,endTs,startLat,...}]`, мс epoch),
|
||||
но в БД НЕ сохранялись — начали сохранять. Данные разбираются офлайн через `tools/track/`.
|
||||
|
||||
## Изменения (финал, как реализовано)
|
||||
1. **Persist пауз (БД).** Колонка `pauses TEXT` в `gps_sessions` тем же JS-путём ADD COLUMN
|
||||
под `PRAGMA table_info(gps_sessions)`-гардом (db.js ~1540). `finishGpsSession(...pauses)`
|
||||
получил аргумент `pauses`: `hasPauses = pauses !== undefined` → пишет `JSON.stringify(pauses||[])`,
|
||||
`undefined` оставляет колонку как есть. `getGpsSession` парсит `pauses` из JSON.
|
||||
2. **`computeStats(points, wKg, opts)` — исключение ручных пауз.** `opts.pauses` (мс epoch) →
|
||||
`inManualPause(aMs,bMs) = pauses.some(z => aMs<z.e && bMs>z.s)`. В сегментном цикле:
|
||||
`if (inManualPause(prevMs,pMs)) { pausedSec += dt; elevRef = null; continue; }` — целиком
|
||||
исключает сегмент (дистанция/скорость/высота/ккал), сбрасывает elevRef. `duration -= pausedSec`.
|
||||
3. **Фильтр высоты по уклону.** В банковании перепада: `glitch = prevAlt!=null && segDist>0 &&
|
||||
|smoothedAlt[i]-prevAlt|/segDist > MAX_GRADE` (`MAX_GRADE = config.maxRealGradePct ?? 1.0`
|
||||
= 100% = 45°). При glitch — не банковать и не двигать elevRef. Плавные изменения (0 у реки) проходят.
|
||||
Существующие фильтры (выброс >4м, «застревание», сглаживание) оставлены.
|
||||
4. **Запись высоты (Kotlin `GpsRecorderService.kt` ~465).** `altStr = if (location.hasAltitude())
|
||||
location.altitude.toString() else "null"` → `"alt":${altStr}`. Нет фикса → null (не 0). Идёт вперёд.
|
||||
5. **Проброс пауз через вызовы `computeStats`/`finishGpsSession`:** live-стоп (recPauses),
|
||||
`recalculateSession` recorded-ветка (sessPauses), recovery (`s.pauses||[]`),
|
||||
`recomputeAllStats` bulk (bulkPauses из session.pauses), `showSessionDetail` (main.js:
|
||||
`{pauses: session.pauses||[]}` + вычет pauseMs из duration).
|
||||
Ручная-маршрут ветка recalculateSession паузы не передаёт (у нарисованных маршрутов пауз нет).
|
||||
|
||||
## Файлы
|
||||
- `frontend/js/gps.js` — computeStats (паузы + фильтр уклона), проброс в finish/recalculate/recovery/stop/bulk.
|
||||
- `frontend/js/db.js` — миграция `pauses`, finishGpsSession, getGpsSession.
|
||||
- `frontend/js/main.js` — showSessionDetail: передать `session.pauses`, вычесть pauseMs.
|
||||
- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt` — alt=null без фикса.
|
||||
|
||||
## Верификация
|
||||
1. `node --check` на трёх JS — чисто.
|
||||
2. `./build.sh -d -i android` → короткая поездка с ручной паузой на склоне → в деталях трека
|
||||
участок паузы не учтён (нет дистанции/перепада), в БД `pauses` = JSON; треки с 0-глюками не раздуты,
|
||||
плавный 0 у реки — профиль цел.
|
||||
|
||||
## Не трогаем
|
||||
- Ручной инструмент «выделить/исключить участок» — отложено.
|
||||
- Старые треки ретроактивно не правим (паузы не были сохранены). Seed/продукты не трогаем.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Сессия 2026-09-07: ручные паузы и глюки высоты в статистике трека
|
||||
|
||||
## Контекст
|
||||
На последнем ~56км-треке подъём пройден пешком: внизу пауза руками, наверху снята. BalDav
|
||||
мостил GPS-гэп (дистанция + перепад высоты через elevRef, переживший гэп) как езду. Плюс
|
||||
Android пишет `altitude=0.0` без фикса высоты (146 точек в данных), что как «реальная высота»
|
||||
раздувает набор высоты. Пользователь часто у рек, где 0 может быть честным — нельзя тупо
|
||||
выкидывать 0. Правило: исключать нереальный уклон (>45°/>100%), плавные значения (в т.ч. 0) хранить.
|
||||
|
||||
## Действия (по плану docs/plans/2026-09-07-manual-pauses-altitude-glitches.md)
|
||||
Работали по утверждённому плану. Реализовано:
|
||||
- db.js: колонка `gps_sessions.pauses TEXT` (JS ADD COLUMN миграция), `finishGpsSession(...,pauses)`
|
||||
с `hasPauses`-гардом (undefined = не трогать колонку), `getGpsSession` парсит JSON.
|
||||
- gps.js `computeStats`: `opts.pauses` → `inManualPause`; сегмент, пересекающий паузу,
|
||||
исключается целиком (дистанция/скорость/высота/ккал), `elevRef=null`, `pausedSec` вычитается
|
||||
из duration. Глюк-гард высоты: соседний перепад с грейдом > `maxRealGradePct` (1.0=45°) не
|
||||
банкуется и не двигает elevRef. Проброс пауз во все вызовы: live-стоп, recalculateSession
|
||||
(recorded), recovery, recomputeAllStats (bulk), showSessionDetail.
|
||||
- main.js showSessionDetail: передаёт `session.pauses`, вычитает pauseMs из длительности.
|
||||
- Kotlin GpsRecorderService: `alt=null` без `hasAltitude()`, честный 0 с фиксом сохраняется.
|
||||
|
||||
## Результат
|
||||
Только для НОВЫХ треков (старые паузы не сохранялись — ретро-фикса нет; UI ручного разрезания
|
||||
отложен). Все 3 JS проходят `node --check`. PROJECT_MAP обновлён (computeStats-раздел + колонка pauses).
|
||||
|
||||
## Следующие шаги
|
||||
- `./build.sh -d -i android`, записать короткую поездку с ручной паузой на склоне, проверить:
|
||||
участок паузы не в статистике, `pauses` в БД = JSON, треки с 0-глюками не раздуты.
|
||||
|
|
@ -779,14 +779,19 @@ async function addGpsPointsBulk(sessionId, points, startSeq) {
|
|||
return inserted;
|
||||
}
|
||||
|
||||
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) {
|
||||
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) {
|
||||
// Guard against NaN — SQLite may reject or corrupt NaN values
|
||||
const safe = v => (v != null && !isNaN(v)) ? v : null;
|
||||
// pauses: array of manual-pause intervals; store as JSON. undefined = leave column as-is.
|
||||
const hasPauses = pauses !== undefined;
|
||||
const pausesJson = hasPauses ? JSON.stringify(pauses || []) : null;
|
||||
await execute(
|
||||
`UPDATE gps_sessions SET end_time=?1, total_distance_m=?2, total_kcal=?3,
|
||||
avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7
|
||||
avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7${hasPauses ? ", pauses=?9" : ""}
|
||||
WHERE id=?8`,
|
||||
[endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id]
|
||||
hasPauses
|
||||
? [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id, pausesJson]
|
||||
: [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -843,7 +848,10 @@ async function getGpsSessionsForDate(date) {
|
|||
|
||||
async function getGpsSession(id) {
|
||||
const rows = await select("SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE id = ?1", [id]);
|
||||
return rows.length ? rows[0] : null;
|
||||
if (!rows.length) return null;
|
||||
const s = rows[0];
|
||||
if (typeof s.pauses === "string") { try { s.pauses = JSON.parse(s.pauses); } catch { s.pauses = []; } }
|
||||
return s;
|
||||
}
|
||||
|
||||
async function getGpsPoints(sessionId) {
|
||||
|
|
@ -1529,6 +1537,7 @@ async function runMigrations() {
|
|||
if (!gsCols.includes("smoothed")) await execute("ALTER TABLE gps_sessions ADD COLUMN smoothed INTEGER DEFAULT 0");
|
||||
if (!gsCols.includes("smooth_window")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_window INTEGER");
|
||||
if (!gsCols.includes("smooth_iters")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_iters INTEGER");
|
||||
if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses
|
||||
|
||||
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
|
||||
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);
|
||||
|
|
|
|||
|
|
@ -111,6 +111,16 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
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 —
|
||||
|
|
@ -203,6 +213,11 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
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;
|
||||
|
|
@ -239,10 +254,17 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
// 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) {
|
||||
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]; }
|
||||
// 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]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,7 +375,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
|
||||
const first = new Date(sorted[0].ts);
|
||||
const last = new Date(sorted[sorted.length - 1].ts);
|
||||
const duration = (last - first) / 1000;
|
||||
const duration = Math.max(0, (last - first) / 1000 - pausedSec); // exclude manual-pause time
|
||||
const avgSpeed = duration > 0 ? (distance / duration) : 0;
|
||||
const avgSpeedKmh = avgSpeed * 3.6;
|
||||
const maxSpeedKmh = maxSpeed * 3.6;
|
||||
|
|
@ -991,10 +1013,11 @@ GPS.recoverSession = async function() {
|
|||
chainringTeeth: s.chainringTeeth,
|
||||
cogTeeth: s.cogTeeth,
|
||||
wheelDiameterInch: s.wheelDiameterInch,
|
||||
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);
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, s.pauses || []);
|
||||
// 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");
|
||||
|
|
@ -1171,6 +1194,8 @@ GPS.stopRecording = async function() {
|
|||
cogTeeth: GPS.recording.cogTeeth,
|
||||
wheelDiameterInch: GPS.recording.wheelDiameterInch,
|
||||
};
|
||||
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);
|
||||
|
||||
|
|
@ -1178,7 +1203,7 @@ GPS.stopRecording = async function() {
|
|||
await DB.finishGpsSession(
|
||||
GPS.recording.sessionId, endTime,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
||||
stats.elevGain, stats.elevLoss
|
||||
stats.elevGain, stats.elevLoss, recPauses
|
||||
);
|
||||
// DB save confirmed — now safe to delete the points file
|
||||
if (nativePointsFile) {
|
||||
|
|
@ -1710,6 +1735,8 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
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 []; } })() : []);
|
||||
const stats = GPS.computeStats(sorted, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
|
|
@ -1718,13 +1745,14 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
chainringTeeth: bike?.chainring_teeth,
|
||||
cogTeeth: bike?.cog_teeth,
|
||||
wheelDiameterInch: bike?.wheel_diameter_inch,
|
||||
pauses: sessPauses,
|
||||
});
|
||||
|
||||
// Update session in DB
|
||||
// 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
|
||||
stats.elevGain, stats.elevLoss, sessPauses
|
||||
);
|
||||
|
||||
return stats;
|
||||
|
|
@ -1760,6 +1788,8 @@ GPS.recomputeAllStats = async function(onProgress) {
|
|||
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 stats = GPS.computeStats(points, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
|
|
@ -1768,9 +1798,10 @@ GPS.recomputeAllStats = async function(onProgress) {
|
|||
chainringTeeth: bike?.chainring_teeth,
|
||||
cogTeeth: bike?.cog_teeth,
|
||||
wheelDiameterInch: bike?.wheel_diameter_inch,
|
||||
pauses: bulkPauses,
|
||||
});
|
||||
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses);
|
||||
updated++;
|
||||
} catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4541,14 +4541,16 @@ async function showSessionDetail(sessionId) {
|
|||
elevGain: Math.round(session.elevation_gain_m || 0),
|
||||
elevLoss: Math.round(session.elevation_loss_m || 0),
|
||||
duration: 0,
|
||||
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||||
// Compute duration from points regardless
|
||||
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
|
||||
// Manual-pause total (ms) to exclude from displayed duration
|
||||
let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0);
|
||||
// Compute duration from points regardless (minus manual pauses)
|
||||
if (points.length >= 2) {
|
||||
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
|
||||
stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000));
|
||||
}
|
||||
// Always compute segments from points (needed for the Segments section)
|
||||
if (!stats.segments && points.length >= 3) {
|
||||
let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||||
let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
|
||||
stats.segments = fullStats.segments;
|
||||
}
|
||||
|
||||
|
|
@ -4600,6 +4602,7 @@ async function showSessionDetail(sessionId) {
|
|||
|
||||
showModal(`
|
||||
<h3>${actName}</h3>
|
||||
<div id="sd-track-id" style="font-size:11px;color:var(--muted);margin:-6px 0 8px;cursor:pointer;user-select:all">ID: ${session.id}</div>
|
||||
|
||||
<div class="sd-section">
|
||||
<div class="sd-title">Время</div>
|
||||
|
|
@ -5158,6 +5161,10 @@ async function showSessionDetail(sessionId) {
|
|||
let exp = document.querySelector("#sd-export");
|
||||
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
|
||||
|
||||
// Track ID — click to copy to clipboard
|
||||
let idEl = document.querySelector("#sd-track-id");
|
||||
if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId);
|
||||
|
||||
// Chart fullscreen in modal — push to winStack, back collapses
|
||||
document.querySelectorAll(".chart-wrap").forEach(wrap => {
|
||||
wrap.onclick = () => {
|
||||
|
|
@ -7561,6 +7568,23 @@ function showToast(msg, duration) {
|
|||
setTimeout(hide, duration || 6000);
|
||||
}
|
||||
|
||||
// Copy text to clipboard (navigator.clipboard with a hidden-textarea fallback for
|
||||
// WebViews where the async API is unavailable). Optional toast on success.
|
||||
function copyToClipboard(text, toastMsg) {
|
||||
let ok = () => { if (toastMsg) showToast(toastMsg); };
|
||||
let fallback = () => {
|
||||
try {
|
||||
let ta = document.createElement("textarea");
|
||||
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||
document.execCommand("copy"); ta.remove(); ok();
|
||||
} catch (e) { showToast("Не удалось скопировать"); }
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(ok).catch(fallback);
|
||||
} else fallback();
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str ?? "")
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
|
|
|
|||
|
|
@ -460,7 +460,10 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
|
|||
|
||||
pointCount++
|
||||
val bearing = if (location.hasBearing()) location.bearing else -1f
|
||||
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}"""
|
||||
// No altitude fix -> write null, not 0.0: Android returns 0.0 when hasAltitude()
|
||||
// is false, and a fake 0 corrupts elevation stats. A real fixed 0 is kept.
|
||||
val altStr = if (location.hasAltitude()) location.altitude.toString() else "null"
|
||||
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${altStr},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}"""
|
||||
synchronized(bufferLock) {
|
||||
pointBuffer.add(line)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue