Tire pressure Crr, lap comparison, auto climb segments, bike maintenance
Idea 1 — Tire pressure: - tire_pressure_bar field in bikes table and bike modal - Crr corrected by sqrt(3.0 / pressure), reference 3.0 bar - Passed through all opts chains (recording, recovery, recalculate) Idea 3 — Lap comparison: - "Сравнить с..." button in session detail - Shows sessions with same type and similar distance (+-30%) - Overlay: current track green/red (faster/slower), comparison cyan dashed - Nearest-neighbor speed matching between tracks - Summary: time and speed difference Idea 4 — Auto climb segments: - computeStats returns segments[] with type (climb/flat/descent) - Gradient > 2% = climb, < -2% = descent, else flat - Min 50m per segment, adjacent same-type merged - Stats per segment: distance, elevation, gradient, duration, speed - UI section in session detail with colored icons Idea 5 — Bike maintenance log: - bike_maintenance table (date, description, mileage, next_service_km) - Total mileage from GPS sessions per bike - Overdue indicator when current mileage >= next_service_km - Add/delete entries in bike modal - CRUD functions on window.DB
This commit is contained in:
parent
7d1e4b3069
commit
1d6efcd851
4 changed files with 576 additions and 7 deletions
|
|
@ -1597,3 +1597,113 @@ select {
|
|||
padding: 40px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Climb segments (session detail) */
|
||||
.climb-segments {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.segment-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
align-items: center;
|
||||
}
|
||||
.segment-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.segment-item.climb .seg-icon {
|
||||
color: #f44336;
|
||||
font-weight: 700;
|
||||
}
|
||||
.segment-item.descent .seg-icon {
|
||||
color: #4caf50;
|
||||
font-weight: 700;
|
||||
}
|
||||
.segment-item.flat .seg-icon {
|
||||
color: var(--muted);
|
||||
}
|
||||
.seg-icon {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.seg-info {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.seg-stats {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Bike maintenance list */
|
||||
.maint-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
}
|
||||
.maint-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.maint-overdue {
|
||||
background: rgba(244, 67, 54, 0.08);
|
||||
}
|
||||
.maint-info {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.maint-date {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
min-width: 40px;
|
||||
}
|
||||
.maint-desc {
|
||||
font-weight: 500;
|
||||
}
|
||||
.maint-km {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.maint-warn {
|
||||
color: #f44336;
|
||||
font-weight: 700;
|
||||
}
|
||||
.maint-del {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.maint-del:active {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* Compare session list */
|
||||
.compare-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compare-item {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.compare-item:active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(187, 134, 252, 0.05);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -706,12 +706,13 @@ async function saveBike(data) {
|
|||
}
|
||||
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)`,
|
||||
cog_teeth, gear_count, surface_type, notes, is_active, tire_pressure_bar)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?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]
|
||||
data.notes ?? null, data.is_active ? 1 : 0,
|
||||
data.tire_pressure_bar ?? null]
|
||||
);
|
||||
return r.lastInsertId;
|
||||
}
|
||||
|
|
@ -723,11 +724,12 @@ async function updateBike(id, data) {
|
|||
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`,
|
||||
notes=?8, is_active=?9, tire_pressure_bar=?10 WHERE id=?11`,
|
||||
[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]
|
||||
data.notes ?? null, data.is_active ? 1 : 0,
|
||||
data.tire_pressure_bar ?? null, id]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -739,6 +741,45 @@ async function updateSessionBike(sessionId, bikeId) {
|
|||
await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bike maintenance log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getBikeMaintenanceLog(bikeId) {
|
||||
return select(
|
||||
"SELECT * FROM bike_maintenance WHERE bike_id = ?1 ORDER BY date DESC",
|
||||
[bikeId]
|
||||
);
|
||||
}
|
||||
|
||||
async function addBikeMaintenance(bikeId, date, description, mileageKm, nextServiceKm, notes) {
|
||||
const r = await execute(
|
||||
`INSERT INTO bike_maintenance (bike_id, date, description, mileage_km, next_service_km, notes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
||||
[bikeId, date, description, mileageKm ?? null, nextServiceKm ?? null, notes ?? null]
|
||||
);
|
||||
return r.lastInsertId;
|
||||
}
|
||||
|
||||
async function deleteBikeMaintenance(id) {
|
||||
await execute("DELETE FROM bike_maintenance WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
async function getBikeTotalMileage(bikeId) {
|
||||
const rows = await select(
|
||||
"SELECT SUM(total_distance_m) as total FROM gps_sessions WHERE bike_id = ?1 AND total_distance_m IS NOT NULL",
|
||||
[bikeId]
|
||||
);
|
||||
const totalM = rows[0]?.total ?? 0;
|
||||
return Math.round(totalM / 100) / 10; // km, 1 decimal
|
||||
}
|
||||
|
||||
async function getBikeMaintenanceDue(bikeId) {
|
||||
const mileage = await getBikeTotalMileage(bikeId);
|
||||
const all = await getBikeMaintenanceLog(bikeId);
|
||||
return all.filter(e => e.next_service_km != null && mileage >= e.next_service_km);
|
||||
}
|
||||
|
||||
// Expose as globals — no ES module system, loaded via <script> tags.
|
||||
// main.js accesses these as window.DB.xxx
|
||||
window.DB = {};
|
||||
|
|
@ -805,6 +846,11 @@ window.DB.saveBike = saveBike;
|
|||
window.DB.updateBike = updateBike;
|
||||
window.DB.deleteBike = deleteBike;
|
||||
window.DB.updateSessionBike = updateSessionBike;
|
||||
window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog;
|
||||
window.DB.addBikeMaintenance = addBikeMaintenance;
|
||||
window.DB.deleteBikeMaintenance = deleteBikeMaintenance;
|
||||
window.DB.getBikeTotalMileage = getBikeTotalMileage;
|
||||
window.DB.getBikeMaintenanceDue = getBikeMaintenanceDue;
|
||||
|
||||
// JS-side migrations — safe for any DB state (checks before altering)
|
||||
async function runMigrations() {
|
||||
|
|
@ -928,6 +974,21 @@ async function runMigrations() {
|
|||
const gsCols = (await select("PRAGMA table_info(gps_sessions)")).map(r => r.name);
|
||||
if (!gsCols.includes("bike_id")) await execute("ALTER TABLE gps_sessions ADD COLUMN bike_id INTEGER");
|
||||
|
||||
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
|
||||
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);
|
||||
if (!bikeCols.includes("tire_pressure_bar")) await execute("ALTER TABLE bikes ADD COLUMN tire_pressure_bar REAL");
|
||||
|
||||
// --- Bike maintenance log table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS bike_maintenance (
|
||||
id INTEGER PRIMARY KEY,
|
||||
bike_id INTEGER NOT NULL REFERENCES bikes(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
mileage_km REAL,
|
||||
next_service_km REAL,
|
||||
notes TEXT
|
||||
)`);
|
||||
|
||||
// Seed default bike if table is empty
|
||||
const bikeCnt = await select("SELECT COUNT(*) as cnt FROM bikes");
|
||||
if (bikeCnt[0].cnt === 0) {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,11 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const CdA = GPS.config.airDrag ?? 0.6;
|
||||
const rho = GPS.config.airDensity ?? 1.225;
|
||||
const efficiency = (GPS.config.pedalEfficiency ?? 25) / 100;
|
||||
const Crr = opts?.surfaceCrr ?? 0.006; // rolling resistance (mixed surface default)
|
||||
// Rolling resistance: base value from surface type, corrected by tire pressure.
|
||||
// Crr is roughly inversely proportional to sqrt(pressure). Reference: 3.0 bar.
|
||||
const baseCrr = opts?.surfaceCrr ?? 0.006;
|
||||
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";
|
||||
|
|
@ -180,6 +184,92 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
|
||||
: 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),
|
||||
|
|
@ -189,6 +279,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
kcal: Math.round(totalKcal),
|
||||
duration: Math.round(duration),
|
||||
avgCadence,
|
||||
segments,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -355,6 +446,7 @@ GPS._persistState = function() {
|
|||
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,
|
||||
|
|
@ -423,6 +515,7 @@ GPS.recoverSession = async function() {
|
|||
activityType: s.activityType,
|
||||
bikeWeightKg: s.bikeWeightKg ?? 0,
|
||||
surfaceCrr: s.surfaceCrr,
|
||||
tirePressureBar: s.tirePressureBar ?? 3.0,
|
||||
chainringTeeth: s.chainringTeeth,
|
||||
cogTeeth: s.cogTeeth,
|
||||
wheelDiameterInch: s.wheelDiameterInch,
|
||||
|
|
@ -553,6 +646,7 @@ GPS.stopRecording = async function() {
|
|||
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,
|
||||
|
|
@ -580,6 +674,7 @@ GPS.stopRecording = async function() {
|
|||
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;
|
||||
|
|
@ -920,6 +1015,7 @@ GPS.recalculateSession = async function(sessionId) {
|
|||
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,
|
||||
|
|
@ -941,6 +1037,7 @@ GPS.getRecordingOpts = function() {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -1667,6 +1667,7 @@ async function renderGpsScreen(container) {
|
|||
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.tirePressureBar = bike.tire_pressure_bar ?? 3.0;
|
||||
GPS.recording.chainringTeeth = bike.chainring_teeth;
|
||||
GPS.recording.cogTeeth = bike.cog_teeth;
|
||||
GPS.recording.wheelDiameterInch = bike.wheel_diameter_inch;
|
||||
|
|
@ -2088,6 +2089,11 @@ async function showSessionDetail(sessionId) {
|
|||
if (points.length >= 2) {
|
||||
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
|
||||
}
|
||||
// Always compute segments from points (needed for the Segments section)
|
||||
if (!stats.segments && points.length >= 3) {
|
||||
const fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||||
stats.segments = fullStats.segments;
|
||||
}
|
||||
|
||||
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
|
||||
const rawSpeeds = [];
|
||||
|
|
@ -2188,9 +2194,30 @@ 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>` : ""}
|
||||
|
||||
${(stats.segments && stats.segments.length > 1) ? `
|
||||
<div class="sd-section">
|
||||
<div class="sd-title">Сегменты</div>
|
||||
<div class="climb-segments">
|
||||
${stats.segments.map(seg => {
|
||||
const icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-";
|
||||
const distStr = seg.distance >= 1000 ? (seg.distance / 1000).toFixed(1) + " км" : seg.distance + " м";
|
||||
const elevStr = seg.type === "climb" ? `+${seg.elevGain} м, ${seg.avgGradient}%`
|
||||
: seg.type === "descent" ? `-${seg.elevLoss} м, ${seg.avgGradient}%`
|
||||
: `+/-${Math.max(seg.elevGain, seg.elevLoss)} м`;
|
||||
const durStr = GPS.formatDuration(seg.duration);
|
||||
return `<div class="segment-item ${seg.type}">
|
||||
<span class="seg-icon">${icon}</span>
|
||||
<span class="seg-info">${distStr}, ${elevStr}</span>
|
||||
<span class="seg-stats">${durStr} | ${seg.avgSpeed} км/ч</span>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>` : ""}
|
||||
|
||||
${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-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
|
||||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</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>
|
||||
`, () => {});
|
||||
|
|
@ -2496,6 +2523,10 @@ async function showSessionDetail(sessionId) {
|
|||
};
|
||||
}
|
||||
|
||||
// Compare button
|
||||
const compareBtn = document.querySelector("#sd-compare");
|
||||
if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points);
|
||||
|
||||
// Recalculate button
|
||||
const recalcBtn = document.querySelector("#sd-recalc");
|
||||
if (recalcBtn) recalcBtn.onclick = async () => {
|
||||
|
|
@ -2524,6 +2555,186 @@ async function showSessionDetail(sessionId) {
|
|||
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lap comparison — overlay two tracks on the same map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function showCompareModal(sessionId, currentSession, currentPoints) {
|
||||
const dist = currentSession.total_distance_m || 0;
|
||||
const sessions = await DB.getGpsSessions(100);
|
||||
// Filter: same activity type, similar distance (+-30%), not the same session
|
||||
const similar = sessions.filter(s =>
|
||||
s.id !== sessionId &&
|
||||
s.activity_type === currentSession.activity_type &&
|
||||
s.total_distance_m != null &&
|
||||
s.total_distance_m > dist * 0.7 && s.total_distance_m < dist * 1.3
|
||||
);
|
||||
|
||||
if (similar.length === 0) {
|
||||
showModal(`<h3>Сравнение</h3><p style="color:var(--muted)">Нет похожих тренировок (та же активность, расстояние +-30%).</p>`, () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||||
showModal(`
|
||||
<h3>Сравнить с...</h3>
|
||||
<div class="compare-list">
|
||||
${similar.map(s => {
|
||||
const d = s.total_distance_m != null ? GPS.formatDistance(Math.round(s.total_distance_m)) : "?";
|
||||
const spd = s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : "";
|
||||
return `<div class="compare-item" data-sid="${s.id}">
|
||||
<div style="font-weight:600">${s.date} ${s.start_time ?? ""}</div>
|
||||
<div style="font-size:12px;color:var(--muted)">${d} | ${spd}</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>`, () => {});
|
||||
|
||||
setTimeout(() => {
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
if (!overlay) return;
|
||||
// Hide save/cancel buttons — this is a selection modal
|
||||
const btns = overlay.querySelector(".modal-btns");
|
||||
if (btns) btns.style.display = "none";
|
||||
|
||||
overlay.querySelectorAll(".compare-item").forEach(item => {
|
||||
item.onclick = async () => {
|
||||
const compId = parseInt(item.dataset.sid);
|
||||
// Close this modal, open comparison view
|
||||
overlay.remove();
|
||||
history.back();
|
||||
await showComparisonView(sessionId, compId, currentSession, currentPoints);
|
||||
};
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function showComparisonView(currentId, compareId, currentSession, currentPoints) {
|
||||
const compSession = await DB.getGpsSession(compareId);
|
||||
if (!compSession) return;
|
||||
const compPoints = GPS.filterNoise(await DB.getGpsPoints(compareId), compSession.activity_type);
|
||||
if (compPoints.length < 2 || currentPoints.length < 2) return;
|
||||
|
||||
// Compute duration diff
|
||||
const curDur = currentPoints.length >= 2
|
||||
? (new Date(currentPoints[currentPoints.length-1].ts) - new Date(currentPoints[0].ts)) / 1000 : 0;
|
||||
const compDur = compPoints.length >= 2
|
||||
? (new Date(compPoints[compPoints.length-1].ts) - new Date(compPoints[0].ts)) / 1000 : 0;
|
||||
const durDiff = curDur - compDur;
|
||||
const durDiffStr = (durDiff <= 0 ? "" : "+") + GPS.formatDuration(Math.abs(Math.round(durDiff)));
|
||||
const durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково";
|
||||
|
||||
const curAvg = currentSession.avg_speed_kmh ?? 0;
|
||||
const compAvg = compSession.avg_speed_kmh ?? 0;
|
||||
const spdDiff = Math.round((curAvg - compAvg) * 10) / 10;
|
||||
|
||||
showModal(`
|
||||
<h3>Сравнение треков</h3>
|
||||
<div class="sd-section">
|
||||
<div class="sd-title">Результат</div>
|
||||
<div class="sd-row">
|
||||
<div class="sd-stat"><span class="sd-val" style="color:${durDiff <= 0 ? "#4caf50" : "#f44336"}">${durLabel}</span><span class="sd-lbl">Время</span></div>
|
||||
</div>
|
||||
<div class="sd-row">
|
||||
<div class="sd-stat"><span class="sd-val">${curAvg} км/ч</span><span class="sd-lbl">Текущая средняя</span></div>
|
||||
<div class="sd-stat"><span class="sd-val">${compAvg} км/ч</span><span class="sd-lbl">Сравнение</span></div>
|
||||
<div class="sd-stat"><span class="sd-val" style="color:${spdDiff >= 0 ? "#4caf50" : "#f44336"}">${spdDiff > 0 ? "+" : ""}${spdDiff}</span><span class="sd-lbl">Разница</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sd-section">
|
||||
<div class="sd-title">Карта</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Фиолетовый — текущий, голубой — сравнение. Зелёные участки — быстрее, красные — медленнее.</div>
|
||||
<div id="compare-map" class="sd-map" style="height:300px"></div>
|
||||
</div>
|
||||
`, () => {});
|
||||
|
||||
setTimeout(() => {
|
||||
const mapEl = document.querySelector("#compare-map");
|
||||
if (!mapEl) return;
|
||||
|
||||
// Hide save/cancel
|
||||
const btns = mapEl.closest(".modal-overlay")?.querySelector(".modal-btns");
|
||||
if (btns) btns.style.display = "none";
|
||||
|
||||
L.Icon.Default.imagePath = "lib/";
|
||||
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, touchZoom: true, dragging: true });
|
||||
mapEl._leafletMap = map;
|
||||
addZoomDisplay(map);
|
||||
const CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
|
||||
// Draw comparison track (thin dashed cyan)
|
||||
const compCoords = compPoints.map(p => [p.lat, p.lon]);
|
||||
L.polyline(compCoords, { color: "#4dd0e1", weight: 3, dashArray: "8,6", opacity: 0.7 }).addTo(map);
|
||||
|
||||
// Build speed comparison: for each point on current track, find nearest on comparison track
|
||||
// Pre-compute comparison speeds for quick nearest-neighbor lookup
|
||||
const compSpeeds = [];
|
||||
for (let i = 0; i < compPoints.length; i++) {
|
||||
const spd = compPoints[i].speed_ms != null ? compPoints[i].speed_ms * 3.6 : 0;
|
||||
compSpeeds.push(spd);
|
||||
}
|
||||
|
||||
// For each current segment, find nearest comparison point and compare speeds
|
||||
// Draw colored polyline segments
|
||||
for (let i = 1; i < currentPoints.length; i++) {
|
||||
const cp = currentPoints[i];
|
||||
const cpPrev = currentPoints[i - 1];
|
||||
const curSpd = cp.speed_ms != null ? cp.speed_ms * 3.6 : 0;
|
||||
|
||||
// Find nearest comparison point (simple brute force — OK for <2000 points)
|
||||
let nearestIdx = 0, nearestDist = Infinity;
|
||||
for (let j = 0; j < compPoints.length; j++) {
|
||||
const d = GPS.distance(cp.lat, cp.lon, compPoints[j].lat, compPoints[j].lon);
|
||||
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
|
||||
}
|
||||
const compSpd = compSpeeds[nearestIdx];
|
||||
|
||||
// Color: green if faster, red if slower, white if similar (within 1 km/h)
|
||||
let segColor;
|
||||
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare — use default
|
||||
else if (curSpd > compSpd + 1) segColor = "#4caf50"; // faster
|
||||
else if (curSpd < compSpd - 1) segColor = "#f44336"; // slower
|
||||
else segColor = "#bb86fc"; // similar
|
||||
|
||||
L.polyline(
|
||||
[[cpPrev.lat, cpPrev.lon], [cp.lat, cp.lon]],
|
||||
{ color: segColor, weight: 5, opacity: 0.9 }
|
||||
).addTo(map);
|
||||
}
|
||||
|
||||
// Fit to both tracks
|
||||
const allCoords = currentPoints.map(p => [p.lat, p.lon]).concat(compCoords);
|
||||
if (allCoords.length > 1) {
|
||||
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
|
||||
}
|
||||
|
||||
// Fullscreen toggle
|
||||
let fsState = false;
|
||||
const fsBtn = L.control({ position: "topright" });
|
||||
let fsDiv;
|
||||
fsBtn.onAdd = () => {
|
||||
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
|
||||
fsDiv.innerHTML = "⛶";
|
||||
fsDiv.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (fsState) {
|
||||
fsState = false;
|
||||
mapEl.classList.remove("sd-map-full");
|
||||
fsDiv.innerHTML = "⛶";
|
||||
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
|
||||
} else {
|
||||
fsState = true;
|
||||
mapEl.classList.add("sd-map-full");
|
||||
fsDiv.innerHTML = "✕";
|
||||
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
|
||||
}
|
||||
};
|
||||
return fsDiv;
|
||||
};
|
||||
fsBtn.addTo(map);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log supplement modal (from Today screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -3040,6 +3251,10 @@ async function showBikeModal(bikeId) {
|
|||
<option value="gravel"${surfaceVal === "gravel" ? " selected" : ""}>Грунт</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Давление в шинах (бар)</label>
|
||||
<input id="m-bike-pressure" type="number" inputmode="decimal" step="0.1" value="${bike.tire_pressure_bar ?? 3.0}" placeholder="3.0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Активный велосипед</label>
|
||||
<div class="type-chips">
|
||||
|
|
@ -3047,6 +3262,21 @@ async function showBikeModal(bikeId) {
|
|||
<button class="type-chip ${!bike.is_active ? "selected" : ""}" id="m-bike-active-no" data-active="0">Нет</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isNew ? `
|
||||
<div class="sd-section" style="margin-top:20px">
|
||||
<div class="sd-title">Обслуживание</div>
|
||||
<div id="m-bike-mileage" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
||||
<div id="m-bike-maint-list"></div>
|
||||
<button id="m-bike-maint-add" class="btn-secondary full-width" style="margin-top:8px">+ Добавить</button>
|
||||
<div id="m-bike-maint-form" style="display:none;margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
|
||||
<div class="form-group"><label>Дата</label><input id="m-maint-date" type="date" value="${new Date().toISOString().slice(0,10)}" /></div>
|
||||
<div class="form-group"><label>Описание</label><input id="m-maint-desc" type="text" placeholder="Смазка цепи, подкачка шин..." /></div>
|
||||
<div class="form-group"><label>След. обслуживание через (км)</label><input id="m-maint-next" type="number" inputmode="decimal" step="1" placeholder="100" /></div>
|
||||
<div class="form-group"><label>Заметки</label><input id="m-maint-notes" type="text" placeholder="" /></div>
|
||||
<button id="m-maint-save" class="btn-primary full-width">Сохранить запись</button>
|
||||
</div>
|
||||
</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();
|
||||
|
|
@ -3058,6 +3288,7 @@ async function showBikeModal(bikeId) {
|
|||
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,
|
||||
tire_pressure_bar: parseFloat(overlay.querySelector("#m-bike-pressure").value) || null,
|
||||
is_active: overlay.querySelector("#m-bike-active-yes").classList.contains("selected"),
|
||||
};
|
||||
if (isNew) {
|
||||
|
|
@ -3095,6 +3326,76 @@ async function showBikeModal(bikeId) {
|
|||
};
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Bike maintenance section
|
||||
setTimeout(async () => {
|
||||
const mileageEl = document.querySelector("#m-bike-mileage");
|
||||
const listEl = document.querySelector("#m-bike-maint-list");
|
||||
const addBtn = document.querySelector("#m-bike-maint-add");
|
||||
const formEl = document.querySelector("#m-bike-maint-form");
|
||||
if (!listEl) return;
|
||||
|
||||
const totalKm = await DB.getBikeTotalMileage(bikeId);
|
||||
if (mileageEl) mileageEl.textContent = `Общий пробег: ${totalKm} км`;
|
||||
|
||||
async function renderMaintList() {
|
||||
const entries = await DB.getBikeMaintenanceLog(bikeId);
|
||||
if (entries.length === 0) {
|
||||
listEl.innerHTML = '<div style="font-size:13px;color:var(--muted);padding:8px 0">Нет записей</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = entries.map(e => {
|
||||
const overdue = e.next_service_km != null && totalKm >= e.next_service_km;
|
||||
const nextInfo = e.next_service_km != null ? `, след. при ${e.next_service_km} км` : "";
|
||||
const dateShort = e.date.slice(5).replace("-", ".");
|
||||
return `<div class="maint-item${overdue ? " maint-overdue" : ""}" data-mid="${e.id}">
|
||||
<div class="maint-info">
|
||||
<span class="maint-date">${dateShort}</span>
|
||||
<span class="maint-desc">${esc(e.description)}</span>
|
||||
${e.mileage_km != null ? `<span class="maint-km">(${e.mileage_km} км${nextInfo})</span>` : ""}
|
||||
${overdue ? '<span class="maint-warn">[!]</span>' : ""}
|
||||
</div>
|
||||
<button class="maint-del" data-mid="${e.id}">×</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
listEl.querySelectorAll(".maint-del").forEach(btn => {
|
||||
btn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
await DB.deleteBikeMaintenance(parseInt(btn.dataset.mid));
|
||||
await renderMaintList();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await renderMaintList();
|
||||
|
||||
if (addBtn) {
|
||||
addBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
formEl.style.display = formEl.style.display === "none" ? "block" : "none";
|
||||
};
|
||||
}
|
||||
|
||||
const saveBtn = document.querySelector("#m-maint-save");
|
||||
if (saveBtn) {
|
||||
saveBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const date = document.querySelector("#m-maint-date").value;
|
||||
const desc = document.querySelector("#m-maint-desc").value.trim();
|
||||
if (!desc) return;
|
||||
const nextKm = parseFloat(document.querySelector("#m-maint-next").value) || null;
|
||||
const notes = document.querySelector("#m-maint-notes").value.trim() || null;
|
||||
// next_service_km = current mileage + interval
|
||||
const nextServiceKm = nextKm != null ? Math.round((totalKm + nextKm) * 10) / 10 : null;
|
||||
await DB.addBikeMaintenance(bikeId, date, desc, totalKm, nextServiceKm, notes);
|
||||
document.querySelector("#m-maint-desc").value = "";
|
||||
document.querySelector("#m-maint-next").value = "";
|
||||
document.querySelector("#m-maint-notes").value = "";
|
||||
formEl.style.display = "none";
|
||||
await renderMaintList();
|
||||
};
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue