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:
4aiman 2026-05-25 02:09:50 +03:00
commit 4740bdbe11
3 changed files with 455 additions and 29 deletions

View file

@ -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.

View file

@ -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");

View file

@ -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">&#x203A;</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
// ---------------------------------------------------------------------------