Bike profiles, physics-based kcal, session recalculation
Bike profiles: - bikes table: name, weight, wheel, chainring/cog teeth, surface type - CRUD in settings screen, active bike indicator - Default bike: 12kg, 26", 42/18, mixed surface - bike_id linked to gps_sessions Physics-based kcal (replaces MET*0.97): - Per-segment power calculation: gravity + rolling (Crr) + aero (CdA) - Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost - Power <= 0 = coasting: basal metabolic rate only - Walk/run: proper MET table from Compendium of Physical Activities - Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal) - Smoothed altitude kept for displayed elevGain/Loss only Session recalculation: - "Recalculate" button on session detail page - Uses current weight + bike settings, re-reads all points - Bike selector per session (change bike -> auto recalculate) Other fixes: - User weight fallback chain: weight_log -> profile -> 80kg - Theoretical cadence from gear ratio + wheel circumference - Crash recovery preserves bike data
This commit is contained in:
parent
62cdd7740f
commit
4740bdbe11
3 changed files with 455 additions and 29 deletions
|
|
@ -672,6 +672,63 @@ async function importGpxSession(session, points) {
|
|||
return sid;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bikes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getBikes() {
|
||||
return select("SELECT * FROM bikes ORDER BY is_active DESC, name");
|
||||
}
|
||||
|
||||
async function getBike(id) {
|
||||
const rows = await select("SELECT * FROM bikes WHERE id = ?1", [id]);
|
||||
return rows.length ? rows[0] : null;
|
||||
}
|
||||
|
||||
async function getActiveBike() {
|
||||
const rows = await select("SELECT * FROM bikes WHERE is_active = 1 LIMIT 1");
|
||||
return rows.length ? rows[0] : null;
|
||||
}
|
||||
|
||||
async function saveBike(data) {
|
||||
if (data.is_active) {
|
||||
await execute("UPDATE bikes SET is_active = 0 WHERE is_active = 1");
|
||||
}
|
||||
const r = await execute(
|
||||
`INSERT INTO bikes (name, weight_kg, wheel_diameter_inch, chainring_teeth,
|
||||
cog_teeth, gear_count, surface_type, notes, is_active)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
|
||||
[data.name, data.weight_kg ?? null, data.wheel_diameter_inch ?? 26,
|
||||
data.chainring_teeth ?? null, data.cog_teeth ?? null,
|
||||
data.gear_count ?? null, data.surface_type ?? "mixed",
|
||||
data.notes ?? null, data.is_active ? 1 : 0]
|
||||
);
|
||||
return r.lastInsertId;
|
||||
}
|
||||
|
||||
async function updateBike(id, data) {
|
||||
if (data.is_active) {
|
||||
await execute("UPDATE bikes SET is_active = 0 WHERE is_active = 1");
|
||||
}
|
||||
await execute(
|
||||
`UPDATE bikes SET name=?1, weight_kg=?2, wheel_diameter_inch=?3,
|
||||
chainring_teeth=?4, cog_teeth=?5, gear_count=?6, surface_type=?7,
|
||||
notes=?8, is_active=?9 WHERE id=?10`,
|
||||
[data.name, data.weight_kg ?? null, data.wheel_diameter_inch ?? 26,
|
||||
data.chainring_teeth ?? null, data.cog_teeth ?? null,
|
||||
data.gear_count ?? null, data.surface_type ?? "mixed",
|
||||
data.notes ?? null, data.is_active ? 1 : 0, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteBike(id) {
|
||||
await execute("DELETE FROM bikes WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
async function updateSessionBike(sessionId, bikeId) {
|
||||
await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]);
|
||||
}
|
||||
|
||||
// Expose as globals — no ES module system, loaded via <script> tags.
|
||||
// main.js accesses these as window.DB.xxx
|
||||
window.DB = {};
|
||||
|
|
@ -731,6 +788,13 @@ window.DB.getGpsSession = getGpsSession;
|
|||
window.DB.getGpsPoints = getGpsPoints;
|
||||
window.DB.deleteGpsSession = deleteGpsSession;
|
||||
window.DB.importGpxSession = importGpxSession;
|
||||
window.DB.getBikes = getBikes;
|
||||
window.DB.getBike = getBike;
|
||||
window.DB.getActiveBike = getActiveBike;
|
||||
window.DB.saveBike = saveBike;
|
||||
window.DB.updateBike = updateBike;
|
||||
window.DB.deleteBike = deleteBike;
|
||||
window.DB.updateSessionBike = updateSessionBike;
|
||||
|
||||
// JS-side migrations — safe for any DB state (checks before altering)
|
||||
async function runMigrations() {
|
||||
|
|
@ -871,6 +935,37 @@ async function runMigrations() {
|
|||
}
|
||||
} catch (e) { console.error("[db.js] egg unit seeding error:", e); }
|
||||
|
||||
// --- Bikes table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS bikes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
weight_kg REAL,
|
||||
wheel_diameter_inch REAL,
|
||||
chainring_teeth INTEGER,
|
||||
cog_teeth INTEGER,
|
||||
gear_count INTEGER,
|
||||
surface_type TEXT DEFAULT 'mixed',
|
||||
notes TEXT,
|
||||
is_active INTEGER DEFAULT 0
|
||||
)`);
|
||||
|
||||
// Add bike_id column to gps_sessions
|
||||
if (!(await hasColumn("gps_sessions", "bike_id"))) {
|
||||
await execute("ALTER TABLE gps_sessions ADD COLUMN bike_id INTEGER");
|
||||
}
|
||||
|
||||
// Seed default bike if table is empty
|
||||
const bikeCnt = await select("SELECT COUNT(*) as cnt FROM bikes");
|
||||
if (bikeCnt[0].cnt === 0) {
|
||||
await execute(
|
||||
`INSERT INTO bikes (name, weight_kg, wheel_diameter_inch, chainring_teeth,
|
||||
cog_teeth, surface_type, is_active)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
||||
["Мой велосипед", 12, 26, 42, 18, "mixed", 1]
|
||||
);
|
||||
console.log("[db.js] Seeded default bike");
|
||||
}
|
||||
|
||||
console.log("[db.js] recipe migrations done, checking seq repair...");
|
||||
|
||||
// Repair old tracks: assign seq to points where it's NULL, ordered by timestamp.
|
||||
|
|
|
|||
|
|
@ -32,23 +32,13 @@ GPS.distance = function(lat1, lon1, lat2, lon2) {
|
|||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kcal formula (FitoTrack-style MET based)
|
||||
// kcal = minutes * (MET * 3.5 * weight_kg) / 200
|
||||
// MET = speed_kmh * 0.97 (capped at minimum 1)
|
||||
// + elevation_gain_m (1 kcal per meter ascent — rough estimate)
|
||||
// ---------------------------------------------------------------------------
|
||||
GPS.calcKcal = function(minutes, avgSpeedKmh, weightKg, elevGainM) {
|
||||
const met = Math.max(avgSpeedKmh * 0.97, 1);
|
||||
const base = minutes * (met * 3.5 * weightKg) / 200;
|
||||
return Math.round(base + (elevGainM ?? 0));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
if (points.length < 2) return { distance: 0, elevGain: 0, elevLoss: 0, avgSpeed: 0, maxSpeed: 0, kcal: 0, duration: 0 };
|
||||
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 };
|
||||
|
||||
// Sort by seq (if present) or timestamp to fix out-of-order points from native service
|
||||
const sorted = points.slice().sort((a, b) => {
|
||||
|
|
@ -71,13 +61,34 @@ GPS.computeStats = function(points, weightKg) {
|
|||
return cnt > 0 ? sum / cnt : alt;
|
||||
});
|
||||
|
||||
// Physics constants
|
||||
const g = 9.81;
|
||||
const CdA = 0.6; // typical upright position drag area (m^2)
|
||||
const rho = 1.225; // air density at sea level (kg/m^3)
|
||||
const efficiency = 0.25; // human mechanical efficiency (~25%)
|
||||
const Crr = opts?.surfaceCrr ?? 0.006; // rolling resistance (mixed surface default)
|
||||
const mTotal = (weightKg ?? 80) + (opts?.bikeWeightKg ?? 0);
|
||||
const isBike = (opts?.activityType ?? "bike") === "bike";
|
||||
|
||||
let totalKcal = 0;
|
||||
// Basal metabolic rate per minute (MET=1 sitting, formula: MET * 3.5 * kg / 200)
|
||||
const basalPerMin = (1.0 * 3.5 * (weightKg ?? 80)) / 200;
|
||||
|
||||
// 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;
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const p = sorted[i], prev = sorted[i - 1];
|
||||
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
|
||||
distance += d;
|
||||
|
||||
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
|
||||
speeds.push(dt > 0 ? d / dt : 0);
|
||||
const v = dt > 0 ? d / dt : 0; // m/s
|
||||
speeds.push(v);
|
||||
|
||||
// Elevation from smoothed altitude, with 1m dead zone
|
||||
if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) {
|
||||
|
|
@ -85,6 +96,61 @@ GPS.computeStats = function(points, weightKg) {
|
|||
if (diff > 1) elevGain += diff;
|
||||
else if (diff < -1) elevLoss += Math.abs(diff);
|
||||
}
|
||||
|
||||
// Kcal calculation per segment
|
||||
if (dt <= 0 || dt > 300) continue; // skip pauses > 5min
|
||||
|
||||
if (v < 0.3) {
|
||||
// Standing still: only basal metabolic rate
|
||||
totalKcal += basalPerMin * (dt / 60);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBike) {
|
||||
// Gradient from RAW altitude (smoothed altitude kills real slopes and underestimates kcal)
|
||||
const rawAlt = sorted[i].altitude ?? sorted[i].altitude_gps_m ?? 0;
|
||||
const rawAltPrev = sorted[i-1].altitude ?? sorted[i-1].altitude_gps_m ?? 0;
|
||||
const altDiff = rawAlt - rawAltPrev;
|
||||
const gradient = d > 2 ? altDiff / d : 0; // rise/run
|
||||
const sinG = gradient; // for small angles sin ~= gradient
|
||||
const cosG = Math.sqrt(1 - sinG * sinG);
|
||||
|
||||
// Power required (watts): gravity + rolling resistance + aerodynamic drag
|
||||
const pGravity = mTotal * g * sinG;
|
||||
const pRolling = Crr * mTotal * g * cosG;
|
||||
const pAero = 0.5 * CdA * rho * v * v;
|
||||
const power = v * (pGravity + pRolling + pAero);
|
||||
|
||||
if (power > 0) {
|
||||
// Pedaling: convert mechanical power to metabolic energy
|
||||
const mechEnergy = power * dt; // joules
|
||||
const metabEnergy = mechEnergy / efficiency; // joules (total metabolic cost)
|
||||
totalKcal += metabEnergy / 4184; // convert to kcal
|
||||
|
||||
// Cadence: wheel revolutions / gear ratio = pedal revolutions
|
||||
if (hasGearData && wheelCirc > 0) {
|
||||
const wheelRevs = d / wheelCirc;
|
||||
totalPedalRevs += wheelRevs / gearRatio;
|
||||
pedalingSeconds += dt;
|
||||
}
|
||||
} else {
|
||||
// Coasting downhill: 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 * (weightKg ?? 80)) / 200;
|
||||
}
|
||||
}
|
||||
|
||||
// 9-point smoothed speeds for maxSpeed (kills spikes from GPS noise)
|
||||
|
|
@ -101,11 +167,14 @@ GPS.computeStats = function(points, weightKg) {
|
|||
const first = new Date(sorted[0].ts);
|
||||
const last = new Date(sorted[sorted.length - 1].ts);
|
||||
const duration = (last - first) / 1000;
|
||||
const minutes = duration / 60;
|
||||
const avgSpeed = duration > 0 ? (distance / duration) : 0;
|
||||
const avgSpeedKmh = avgSpeed * 3.6;
|
||||
const maxSpeedKmh = maxSpeed * 3.6;
|
||||
const kcal = GPS.calcKcal(minutes, avgSpeedKmh, weightKg ?? 80, elevGain);
|
||||
|
||||
// Average cadence (RPM) — only if gear data was provided and there was pedaling
|
||||
const avgCadence = (hasGearData && pedalingSeconds > 0)
|
||||
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
|
||||
: null;
|
||||
|
||||
return {
|
||||
distance: Math.round(distance),
|
||||
|
|
@ -113,8 +182,9 @@ GPS.computeStats = function(points, weightKg) {
|
|||
elevLoss: Math.round(elevLoss),
|
||||
avgSpeed: Math.round(avgSpeedKmh * 10) / 10,
|
||||
maxSpeed: Math.round(maxSpeedKmh * 10) / 10,
|
||||
kcal,
|
||||
kcal: Math.round(totalKcal),
|
||||
duration: Math.round(duration),
|
||||
avgCadence,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -278,6 +348,12 @@ GPS._persistState = function() {
|
|||
manualPaused: GPS.recording.manualPaused,
|
||||
pauses: GPS.recording.pauses || [],
|
||||
pointCount: GPS.recording.points.length,
|
||||
bikeId: GPS.recording.bikeId ?? null,
|
||||
bikeWeightKg: GPS.recording.bikeWeightKg ?? null,
|
||||
surfaceCrr: GPS.recording.surfaceCrr ?? null,
|
||||
chainringTeeth: GPS.recording.chainringTeeth ?? null,
|
||||
cogTeeth: GPS.recording.cogTeeth ?? null,
|
||||
wheelDiameterInch: GPS.recording.wheelDiameterInch ?? null,
|
||||
}));
|
||||
} catch {}
|
||||
};
|
||||
|
|
@ -339,9 +415,19 @@ GPS.recoverSession = async function() {
|
|||
// Finalize the session
|
||||
const pts = await DB.getGpsPoints(s.sessionId);
|
||||
if (pts.length > 0) {
|
||||
const stats = GPS.computeStats(pts, s.weightKg || 80);
|
||||
const recoverOpts = {
|
||||
activityType: s.activityType,
|
||||
bikeWeightKg: s.bikeWeightKg ?? 0,
|
||||
surfaceCrr: s.surfaceCrr,
|
||||
chainringTeeth: s.chainringTeeth,
|
||||
cogTeeth: s.cogTeeth,
|
||||
wheelDiameterInch: s.wheelDiameterInch,
|
||||
};
|
||||
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);
|
||||
// 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");
|
||||
}
|
||||
GPS._clearPersistedState();
|
||||
|
|
@ -458,7 +544,16 @@ GPS.stopRecording = async function() {
|
|||
return null;
|
||||
}
|
||||
|
||||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg);
|
||||
// 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,
|
||||
chainringTeeth: GPS.recording.chainringTeeth,
|
||||
cogTeeth: GPS.recording.cogTeeth,
|
||||
wheelDiameterInch: GPS.recording.wheelDiameterInch,
|
||||
};
|
||||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts);
|
||||
const endTime = new Date().toTimeString().slice(0, 5);
|
||||
|
||||
await DB.finishGpsSession(
|
||||
|
|
@ -467,12 +562,23 @@ GPS.stopRecording = async function() {
|
|||
stats.elevGain, stats.elevLoss
|
||||
);
|
||||
|
||||
// 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.chainringTeeth = null;
|
||||
GPS.recording.cogTeeth = null;
|
||||
GPS.recording.wheelDiameterInch = null;
|
||||
GPS._clearPersistedState();
|
||||
return { sessionId: sid, stats, pauses };
|
||||
};
|
||||
|
|
@ -761,5 +867,55 @@ GPS.exportGPX = async function(sessionId) {
|
|||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recalculate session stats using physics-based formula
|
||||
// ---------------------------------------------------------------------------
|
||||
GPS.recalculateSession = async function(sessionId) {
|
||||
const session = await DB.getGpsSession(sessionId);
|
||||
if (!session) return null;
|
||||
const points = await DB.getGpsPoints(sessionId);
|
||||
if (points.length < 2) return null;
|
||||
|
||||
// Get user weight: latest weight log, or profile, or 80
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = latestW?.weight_kg ?? 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 };
|
||||
|
||||
const stats = GPS.computeStats(points, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||||
chainringTeeth: bike?.chainring_teeth,
|
||||
cogTeeth: bike?.cog_teeth,
|
||||
wheelDiameterInch: bike?.wheel_diameter_inch,
|
||||
});
|
||||
|
||||
// Update session in DB
|
||||
await DB.finishGpsSession(sessionId,
|
||||
session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
||||
stats.elevGain, stats.elevLoss
|
||||
);
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
// 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,
|
||||
chainringTeeth: GPS.recording.chainringTeeth,
|
||||
cogTeeth: GPS.recording.cogTeeth,
|
||||
wheelDiameterInch: GPS.recording.wheelDiameterInch,
|
||||
};
|
||||
};
|
||||
|
||||
window.GPS = GPS;
|
||||
console.log("[gps.js] window.GPS ready");
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ function renderScreen(name, fromPopstate) {
|
|||
window._recBarTimer = setInterval(() => {
|
||||
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
|
||||
const pts = GPS.recording.points;
|
||||
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
|
||||
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||||
const sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
|
||||
const el = document.querySelector("#rec-bar-text");
|
||||
if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`;
|
||||
|
|
@ -1630,7 +1630,19 @@ async function renderGpsScreen(container) {
|
|||
} else {
|
||||
const type = container.querySelector("#gps-type").value;
|
||||
localStorage.setItem("baldav_gps_type", type);
|
||||
const wkg = state.latestWeight?.weight_kg ?? 80;
|
||||
const wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
|
||||
// Get active bike for bike sessions — store bike data in recording state
|
||||
if (type === "bike") {
|
||||
const bike = await DB.getActiveBike();
|
||||
if (bike) {
|
||||
GPS.recording.bikeId = bike.id;
|
||||
GPS.recording.bikeWeightKg = bike.weight_kg ?? 0;
|
||||
GPS.recording.surfaceCrr = ({asphalt: 0.004, mixed: 0.006, gravel: 0.008})[bike.surface_type] ?? 0.006;
|
||||
GPS.recording.chainringTeeth = bike.chainring_teeth;
|
||||
GPS.recording.cogTeeth = bike.cog_teeth;
|
||||
GPS.recording.wheelDiameterInch = bike.wheel_diameter_inch;
|
||||
}
|
||||
}
|
||||
await GPS.startRecording(type, wkg);
|
||||
renderScreen("recording");
|
||||
}
|
||||
|
|
@ -1673,7 +1685,7 @@ async function renderGpsScreen(container) {
|
|||
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
|
||||
const ext = file.name.split(".").pop().toLowerCase();
|
||||
console.log("[IMPORT] ext:", ext);
|
||||
const wkg = state.latestWeight?.weight_kg ?? 80;
|
||||
const wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
|
||||
try {
|
||||
if (ext === "gpx") {
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
|
|
@ -1766,7 +1778,7 @@ function renderRecordingInline(container) {
|
|||
|
||||
// Stats
|
||||
const pts = GPS.recording.points;
|
||||
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
|
||||
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||||
|
||||
// Show current state
|
||||
|
|
@ -1867,7 +1879,7 @@ function renderRecordingInline(container) {
|
|||
if (posMarker) map.removeLayer(posMarker);
|
||||
posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
|
||||
}
|
||||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg);
|
||||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||||
};
|
||||
}, 200);
|
||||
|
|
@ -1885,7 +1897,7 @@ function setupLiveUpdates(container) {
|
|||
gpsMap.panTo([p.lat, p.lon]);
|
||||
|
||||
const pts = GPS.recording.points;
|
||||
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
|
||||
const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||||
if (spdEl) {
|
||||
const curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
|
||||
|
|
@ -1902,7 +1914,7 @@ function setupLiveUpdates(container) {
|
|||
const coords = sorted.map(p => [p.lat, p.lon]);
|
||||
gpsTrailLine.setLatLngs(coords);
|
||||
if (coords.length) gpsMap.panTo(coords[coords.length - 1]);
|
||||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg);
|
||||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
|
||||
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
|
||||
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
|
||||
};
|
||||
|
|
@ -2040,7 +2052,7 @@ 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 ?? 80);
|
||||
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||||
// Compute duration from points regardless
|
||||
if (points.length >= 2) {
|
||||
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
|
||||
|
|
@ -2145,7 +2157,10 @@ async function showSessionDetail(sessionId) {
|
|||
<div class="chart-wrap" id="sd-elev-wrap"><canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||||
</div>` : ""}
|
||||
|
||||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт GPX</button>
|
||||
${session.bike_id ? `<div class="sd-section"><div class="sd-title">Велосипед</div><div class="sd-row"><div class="sd-stat"><span class="sd-val" id="sd-bike-name">...</span><span class="sd-lbl">Название</span></div></div></div>` : ''}
|
||||
${session.activity_type === 'bike' ? `<div class="sd-section"><div class="sd-row"><div class="form-group" style="width:100%;margin:0"><label>Велосипед</label><select id="sd-bike-select" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)"><option value="">-- Нет --</option></select></div></div></div>` : ''}
|
||||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:16px">Пересчитать</button>
|
||||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
|
||||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||||
`, () => {});
|
||||
|
||||
|
|
@ -2409,6 +2424,50 @@ async function showSessionDetail(sessionId) {
|
|||
};
|
||||
});
|
||||
|
||||
// Bike name display
|
||||
if (session.bike_id) {
|
||||
DB.getBike(session.bike_id).then(b => {
|
||||
const el = document.querySelector("#sd-bike-name");
|
||||
if (el && b) el.textContent = b.name;
|
||||
else if (el) el.textContent = "—";
|
||||
});
|
||||
}
|
||||
|
||||
// Bike selector (for bike sessions)
|
||||
const bikeSelect = document.querySelector("#sd-bike-select");
|
||||
if (bikeSelect) {
|
||||
DB.getBikes().then(bikes => {
|
||||
for (const b of bikes) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.id;
|
||||
opt.textContent = b.name + (b.is_active ? " [активный]" : "");
|
||||
if (session.bike_id && session.bike_id === b.id) opt.selected = true;
|
||||
bikeSelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
bikeSelect.onchange = async () => {
|
||||
const val = bikeSelect.value ? parseInt(bikeSelect.value) : null;
|
||||
await DB.updateSessionBike(sessionId, val);
|
||||
session.bike_id = val;
|
||||
};
|
||||
}
|
||||
|
||||
// Recalculate button
|
||||
const recalcBtn = document.querySelector("#sd-recalc");
|
||||
if (recalcBtn) recalcBtn.onclick = async () => {
|
||||
recalcBtn.textContent = "Пересчёт...";
|
||||
recalcBtn.disabled = true;
|
||||
const newStats = await GPS.recalculateSession(sessionId);
|
||||
recalcBtn.textContent = "Пересчитать";
|
||||
recalcBtn.disabled = false;
|
||||
if (newStats) {
|
||||
// Close modal and reopen with fresh data
|
||||
document.querySelector(".modal-overlay")?.remove();
|
||||
history.back();
|
||||
showSessionDetail(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const del = document.querySelector("#sd-delete");
|
||||
if (del) del.onclick = async () => {
|
||||
await DB.deleteGpsSession(sessionId);
|
||||
|
|
@ -2669,6 +2728,16 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Велосипеды</div>
|
||||
<div id="s-bikes-list"></div>
|
||||
<div class="settings-item" id="s-add-bike" style="cursor:pointer">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title" style="color:var(--primary)">+ Добавить велосипед</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Разрешения</div>
|
||||
<div class="settings-item">
|
||||
|
|
@ -2714,6 +2783,27 @@ async function renderSettings(container) {
|
|||
};
|
||||
}
|
||||
|
||||
// Bikes list
|
||||
async function loadBikesList() {
|
||||
const bikes = await DB.getBikes();
|
||||
const listEl = container.querySelector("#s-bikes-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = bikes.map(b => `
|
||||
<div class="settings-item" data-bike-id="${b.id}" style="cursor:pointer">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">${esc(b.name)}${b.is_active ? ' <span style="color:var(--primary);font-size:12px">[активный]</span>' : ''}</div>
|
||||
<div class="settings-item-desc">${b.weight_kg ? b.weight_kg + ' кг' : ''}${b.surface_type ? ' / ' + ({asphalt:'Асфальт',mixed:'Смешанный',gravel:'Грунт'}[b.surface_type] || b.surface_type) : ''}</div>
|
||||
</div>
|
||||
<span class="settings-arrow">›</span>
|
||||
</div>`).join("");
|
||||
listEl.querySelectorAll("[data-bike-id]").forEach(item => {
|
||||
item.onclick = () => showBikeModal(parseInt(item.dataset.bikeId));
|
||||
});
|
||||
}
|
||||
loadBikesList();
|
||||
|
||||
container.querySelector("#s-add-bike").onclick = () => showBikeModal(null);
|
||||
|
||||
// GPS permission check — works on both desktop and mobile
|
||||
const gpsStatus = container.querySelector("#s-gps-status");
|
||||
const gpsToggle = container.querySelector("#s-gps-toggle");
|
||||
|
|
@ -2817,6 +2907,91 @@ function showProfileModal() {
|
|||
}, 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bike profile modal
|
||||
// ---------------------------------------------------------------------------
|
||||
async function showBikeModal(bikeId) {
|
||||
const isNew = bikeId == null;
|
||||
const bike = isNew ? {} : await DB.getBike(bikeId);
|
||||
if (!isNew && !bike) return;
|
||||
|
||||
const surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" };
|
||||
const surfaceVal = bike.surface_type ?? "mixed";
|
||||
|
||||
showModal(`
|
||||
<h3>${isNew ? "Новый велосипед" : "Редактирование"}</h3>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input id="m-bike-name" type="text" value="${esc(bike.name ?? "")}" placeholder="Мой велосипед" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Вес велосипеда (кг)</label>
|
||||
<input id="m-bike-weight" type="number" inputmode="decimal" step="0.1" value="${bike.weight_kg ?? ""}" placeholder="12" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Диаметр колеса (дюймы)</label>
|
||||
<input id="m-bike-wheel" type="number" inputmode="decimal" step="0.5" value="${bike.wheel_diameter_inch ?? 26}" placeholder="26" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Передняя звезда (зубцы)</label>
|
||||
<input id="m-bike-chainring" type="number" inputmode="numeric" value="${bike.chainring_teeth ?? ""}" placeholder="42" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Задняя звезда (зубцы)</label>
|
||||
<input id="m-bike-cog" type="number" inputmode="numeric" value="${bike.cog_teeth ?? ""}" placeholder="18" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Покрытие</label>
|
||||
<select id="m-bike-surface" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||||
<option value="asphalt"${surfaceVal === "asphalt" ? " selected" : ""}>Асфальт</option>
|
||||
<option value="mixed"${surfaceVal === "mixed" ? " selected" : ""}>Смешанный</option>
|
||||
<option value="gravel"${surfaceVal === "gravel" ? " selected" : ""}>Грунт</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
<input id="m-bike-active" type="checkbox" ${bike.is_active ? "checked" : ""} />
|
||||
Активный велосипед
|
||||
</label>
|
||||
</div>
|
||||
${!isNew ? `<button id="m-del-bike" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить велосипед</button>` : ""}`,
|
||||
async overlay => {
|
||||
const name = overlay.querySelector("#m-bike-name").value.trim();
|
||||
if (!name) return false;
|
||||
const data = {
|
||||
name,
|
||||
weight_kg: parseFloat(overlay.querySelector("#m-bike-weight").value) || null,
|
||||
wheel_diameter_inch: parseFloat(overlay.querySelector("#m-bike-wheel").value) || 26,
|
||||
chainring_teeth: parseInt(overlay.querySelector("#m-bike-chainring").value) || null,
|
||||
cog_teeth: parseInt(overlay.querySelector("#m-bike-cog").value) || null,
|
||||
surface_type: overlay.querySelector("#m-bike-surface").value,
|
||||
is_active: overlay.querySelector("#m-bike-active").checked,
|
||||
};
|
||||
if (isNew) {
|
||||
await DB.saveBike(data);
|
||||
} else {
|
||||
await DB.updateBike(bikeId, data);
|
||||
}
|
||||
// Refresh the settings screen to show updated list
|
||||
renderScreen("settings");
|
||||
});
|
||||
|
||||
if (!isNew) {
|
||||
setTimeout(() => {
|
||||
const delBtn = document.querySelector("#m-del-bike");
|
||||
if (delBtn) {
|
||||
delBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
await DB.deleteBike(bikeId);
|
||||
document.querySelector(".modal-overlay")?.remove();
|
||||
history.back();
|
||||
renderScreen("settings");
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue