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:
4aiman 2026-05-27 16:45:33 +03:00
commit 1d6efcd851
4 changed files with 576 additions and 7 deletions

View file

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

View file

@ -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) {

View file

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

View file

@ -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 = "&#x26F6;";
fsDiv.onclick = (e) => {
e.stopPropagation();
if (fsState) {
fsState = false;
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
} else {
fsState = true;
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
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}">&times;</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);
}
}