Desktop: synchronous track-detail render + text session ids

showSessionDetail rendered its map/charts/buttons inside setTimeout blocks
guarded by renderToken. On WebKitGTK the deferred render was voided, so the
track detail showed no map, charts, or fullscreen button (while the
synchronously-rendered roads view worked).

- Remove all setTimeout/requestAnimationFrame/IIFE from showSessionDetail;
  render map, charts and wire buttons synchronously right after the modal
  markup exists (like showRoadsMap). Early `return`s become `if` guards;
  goals/food-cost keep real `await`. Force layout (offsetHeight) + call
  invalidateSize so Leaflet reads the real container size in the modal.

- Fix large i64 session-id corruption from tauri-plugin-sql 2.4.0 (ids come
  back off by 1 as JS numbers). Read/pass session ids as text via
  CAST(id AS TEXT) AS id_str everywhere they are reused: Trek list, Today
  click, compare modal/view, goal-marker prev sessions, map default-center,
  and importGpxSession (whose points were orphaned by the corrupted id).
  SQLite coerces the string to the INTEGER column. Point ids (max ~135k)
  are small and unaffected.
This commit is contained in:
4aiman 2026-07-03 22:19:58 +03:00
commit 5d6025ea9c
2 changed files with 81 additions and 110 deletions

View file

@ -721,22 +721,24 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa
);
}
// NOTE: tauri-plugin-sql corrupts large i64 ids when returned as JS numbers
// (off by ±1). Always read/pass session ids via the text `id_str` column.
async function getGpsSessions(limit) {
return select(
"SELECT * FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1",
"SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1",
[limit ?? 50]
);
}
async function getGpsSessionsForDate(date) {
return select(
"SELECT * FROM gps_sessions WHERE date = ?1 ORDER BY start_time ASC",
"SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE date = ?1 ORDER BY start_time ASC",
[date]
);
}
async function getGpsSession(id) {
const rows = await select("SELECT * FROM gps_sessions WHERE id = ?1", [id]);
const rows = await select("SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
}
@ -760,9 +762,11 @@ async function importGpxSession(session, points) {
session.max_speed_kmh, session.elevation_gain_m, session.elevation_loss_m,
session.source ?? "gpx_import"]
);
// Get id of just-inserted session by matching unique fields
// Get id of just-inserted session by matching unique fields.
// CAST to TEXT — numeric i64 id is corrupted by the SQL plugin; use string
// sid for point inserts (SQLite coerces it to the INTEGER session_id column).
const rows = await select(
"SELECT id FROM gps_sessions WHERE date=?1 AND start_time=?2 AND source=?3 ORDER BY id DESC LIMIT 1",
"SELECT CAST(id AS TEXT) AS id FROM gps_sessions WHERE date=?1 AND start_time=?2 AND source=?3 ORDER BY id DESC LIMIT 1",
[session.date, session.start_time, session.source ?? "gpx_import"]
);
const sid = rows[0].id;

View file

@ -399,7 +399,7 @@ async function renderToday(container) {
let dist = s.total_distance_m ? (s.total_distance_m / 1000).toFixed(1) + " км" : "";
let kcal = s.total_kcal ? Math.round(s.total_kcal) + " кк" : "";
let dur = s.end_time && s.start_time ? s.start_time + "–" + s.end_time : s.start_time || "";
return `<li class="log-item" data-gps-session="${s.id}" style="cursor:pointer">
return `<li class="log-item" data-gps-session="${s.id_str ?? s.id}" style="cursor:pointer">
<span class="log-time">${dur}</span>
<span class="log-desc">${dist}${dist && kcal ? " / " : ""}${kcal}
${s.elevation_gain_m ? `<small>+${Math.round(s.elevation_gain_m)}м</small>` : ""}</span>
@ -593,15 +593,7 @@ async function renderToday(container) {
// GPS session clicks → open track details
container.querySelectorAll("[data-gps-session]").forEach(el => {
el.onclick = () => {
let sid = el.dataset.gpsSession;
renderScreen("recording");
// After rendering, trigger session detail view
setTimeout(() => {
let sessionEl = document.querySelector(`[data-session-id="${sid}"]`);
if (sessionEl) sessionEl.click();
}, 300);
};
el.onclick = () => showSessionDetail(el.dataset.gpsSession);
});
// Stats charts section
@ -2375,7 +2367,7 @@ async function renderGpsScreen(container) {
<div class="gps-sessions">
<h3>История</h3>
${sessions.length ? sessions.map(s => `
<div class="gps-session-item" data-sid="${s.id}">
<div class="gps-session-item" data-sid="${s.id_str ?? s.id}">
<div class="gps-session-head">
<span>${s.date} ${s.start_time ?? ""}</span>
<span class="gps-session-type">${s.activity_type ?? ""}</span>
@ -2423,12 +2415,10 @@ async function renderGpsScreen(container) {
}
};
// Session click → detail view
// Session click → detail view. Keep sid as a STRING (numeric id from the
// SQL plugin is corrupted off-by-1 for large i64 values).
container.querySelectorAll(".gps-session-item").forEach(item => {
item.onclick = () => {
let sid = parseInt(item.dataset.sid);
showSessionDetail(sid);
};
item.onclick = () => showSessionDetail(item.dataset.sid);
});
// Three-dot menu
@ -2583,7 +2573,7 @@ async function showAddGoalMarkerModal() {
// Try to get last GPS point from recent sessions
DB.getGpsSessions(1).then(async sessions => {
if (sessions.length > 0) {
let pts = await DB.getGpsPoints(sessions[0].id);
let pts = await DB.getGpsPoints(sessions[0].id_str ?? sessions[0].id);
if (pts.length > 0) {
let last = pts[pts.length - 1];
defaultLat = last.lat;
@ -3538,12 +3528,12 @@ async function showSessionDetail(sessionId) {
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
`, () => {});
// Render map
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
let mapEl = document.querySelector("#sd-map");
if (!mapEl || !points.length) return;
if (renderToken !== _sessionDetailRender) return;
// Render map
let mapEl = document.querySelector("#sd-map");
if (mapEl && points.length) {
void mapEl.offsetHeight; // force layout so Leaflet reads the real container size
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
mapEl._leafletMap = map; // save ref for winStack collapse
@ -3621,7 +3611,8 @@ async function showSessionDetail(sessionId) {
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
// Fit map to all points
// Ensure Leaflet has correct container size, then fit
map.invalidateSize();
if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
}
@ -3639,7 +3630,7 @@ async function showSessionDetail(sessionId) {
fsDiv.innerHTML = "&#x2715;";
map.dragging.enable();
map.scrollWheelZoom.enable();
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
// Push fullscreen map onto winStack
let parentOverlay = mapEl.closest(".modal-overlay");
winPush("fsMap", () => {
@ -3647,7 +3638,7 @@ async function showSessionDetail(sessionId) {
fsDiv.innerHTML = "&#x26F6;";
map.dragging.disable();
map.scrollWheelZoom.disable();
setTimeout(() => { map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds); }, 100);
map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds);
});
winStack[winStack.length - 1].overlay = parentOverlay;
}
@ -3689,7 +3680,7 @@ async function showSessionDetail(sessionId) {
map.on("click", () => {
if (!mapEl.classList.contains("sd-map-full")) toggleMapFs();
});
}, 200);
}
// Downsample arrays for smooth chart rendering (max ~500 points)
function downsample(arr, maxPts) {
@ -3712,14 +3703,11 @@ async function showSessionDetail(sessionId) {
);
// Render speed chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
let ctx = document.querySelector("#sd-speed-chart");
if (!ctx || !smoothed.length) return;
// Update maxSpeed display to match smoothed graph
let speedCtx = document.querySelector("#sd-speed-chart");
if (speedCtx && smoothed.length) {
let maxEl = document.querySelector("#sd-max-speed");
if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч";
new Chart(ctx, {
new Chart(speedCtx, {
type: "line",
data: {
labels: dsTimeLabels,
@ -3760,16 +3748,12 @@ async function showSessionDetail(sessionId) {
},
},
});
}, 300);
}
// Render elevation chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
let ctx = document.querySelector("#sd-elev-chart");
if (!ctx || !elevations.length) return;
let elevLabels = points.filter(p => p.altitude_gps_m != null)
.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
new Chart(ctx, {
let elevCtx = document.querySelector("#sd-elev-chart");
if (elevCtx && elevations.length) {
new Chart(elevCtx, {
type: "line",
data: {
labels: dsElevLabels,
@ -3811,23 +3795,20 @@ async function showSessionDetail(sessionId) {
},
},
});
}, 400);
}
// Render HR chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
let hrData = points.map(p => p.heart_rate || null);
if (!hrData.some(v => v)) { /* no HR data — hide chart */ return; }
let hrData = points.map(p => p.heart_rate || null);
let hrCtx = document.querySelector("#sd-hr-chart");
if (hrData.some(v => v) && hrCtx) {
let wrap = document.querySelector("#sd-hr-wrap");
if (wrap) wrap.style.display = "";
let ctx = document.querySelector("#sd-hr-chart");
if (!ctx) return;
let dsHr = downsample(hrData, MAX_CHART_PTS);
let dsHrLabels = downsample(
points.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
new Chart(ctx, {
new Chart(hrCtx, {
type: "line",
data: {
labels: dsHrLabels,
@ -3873,11 +3854,10 @@ async function showSessionDetail(sessionId) {
},
},
});
}, 500);
}
// Delete button
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
// Buttons + bike selector
{
let exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
@ -3957,18 +3937,14 @@ async function showSessionDetail(sessionId) {
history.back();
renderScreen("gps");
};
}, 100);
// IDEA 2: Goal markers — show closest approach for each marker
setTimeout(async () => {
try {
let goalSection = document.querySelector("#sd-goals-section");
let goalsList = document.querySelector("#sd-goals-list");
if (!goalSection || !goalsList || points.length < 2) return;
let markers = await DB.getGoalMarkers();
if (markers.length === 0) return;
}
// Goal markers — closest approach for each marker
try {
let goalSection = document.querySelector("#sd-goals-section");
let goalsList = document.querySelector("#sd-goals-list");
let markers = (goalSection && goalsList && points.length >= 2) ? await DB.getGoalMarkers() : [];
if (markers.length > 0) {
// For each marker, find closest point in this track
let results = [];
for (let marker of markers) {
@ -3984,8 +3960,9 @@ async function showSessionDetail(sessionId) {
let bestPrevDist = Infinity;
let bestPrevDate = null;
for (let ps of prevSessions) {
if (ps.id === sessionId) continue;
let psPts = await DB.getGpsPoints(ps.id);
let psId = ps.id_str ?? String(ps.id);
if (psId === String(sessionId)) continue;
let psPts = await DB.getGpsPoints(psId);
if (psPts.length < 2) continue;
for (let pp of psPts) {
let d = GPS.distance(pp.lat, pp.lon, marker.lat, marker.lon);
@ -4029,23 +4006,17 @@ async function showSessionDetail(sessionId) {
${progressHtml}
</div>`;
}).join("");
} catch (e) { console.error("[goals]", e); }
}, 500);
}
} catch (e) { console.error("[goals]", e); }
// IDEA 8: Food cost of workout
setTimeout(async () => {
try {
let costSection = document.querySelector("#sd-food-cost-section");
let costEl = document.querySelector("#sd-food-cost");
if (!costSection || !costEl) return;
let sessionKcal = session.total_kcal ?? stats.kcal;
if (!sessionKcal || sessionKcal <= 0) return;
let foods = await DB.getFrequentFoods(7);
if (foods.length === 0) return;
// Build "cost" descriptions: N portions of food
let parts = [];
// Food cost of workout
try {
let costSection = document.querySelector("#sd-food-cost-section");
let costEl = document.querySelector("#sd-food-cost");
let sessionKcal = session.total_kcal ?? stats.kcal;
let foods = (costSection && costEl && sessionKcal > 0) ? await DB.getFrequentFoods(7) : [];
// Build "cost" descriptions: N portions of food
let parts = [];
for (let f of foods) {
if (!f.kcal || f.kcal <= 0) continue;
// kcal per one serving (as user ate it)
@ -4061,19 +4032,17 @@ async function showSessionDetail(sessionId) {
});
}
if (parts.length === 0) return;
if (parts.length > 0) {
// Take first 3, build readable string
let display = parts.slice(0, 3);
let lines = display.map(p =>
`${p.portions} порц. ${esc(p.name)} (по ${p.amountStr || "?"}, ${p.servingKcal} кк/порц.)`
);
costSection.style.display = "";
costEl.innerHTML = `<div class="food-cost-total">${Math.round(sessionKcal)} ккал — это как:</div>
<div class="food-cost-items">${lines.map(l => `<div class="food-cost-line">${l}</div>`).join('<div class="food-cost-or">или</div>')}</div>`;
} catch (e) { console.error("[food-cost]", e); }
}, 600);
}
} catch (e) { console.error("[food-cost]", e); }
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
}
@ -4087,7 +4056,7 @@ async function showCompareModal(sessionId, currentSession, currentPoints) {
let sessions = await DB.getGpsSessions(100);
// Filter: same activity type, similar distance (+-30%), not the same session
let similar = sessions.filter(s =>
s.id !== sessionId &&
(s.id_str ?? String(s.id)) !== String(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
@ -4105,31 +4074,29 @@ async function showCompareModal(sessionId, currentSession, currentPoints) {
${similar.map(s => {
let d = s.total_distance_m != null ? GPS.formatDistance(Math.round(s.total_distance_m)) : "?";
let spd = s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : "";
return `<div class="compare-item" data-sid="${s.id}">
return `<div class="compare-item" data-sid="${s.id_str ?? 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(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Hide save/cancel buttons — this is a selection modal
let btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Hide save/cancel buttons — this is a selection modal
let btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
overlay.querySelectorAll(".compare-item").forEach(item => {
item.onclick = async () => {
let compId = parseInt(item.dataset.sid);
// Close this modal, open comparison view
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
await showComparisonView(sessionId, compId, currentSession, currentPoints);
};
});
}, 100);
overlay.querySelectorAll(".compare-item").forEach(item => {
item.onclick = async () => {
let compId = item.dataset.sid; // keep as string (large i64 id)
// Close this modal, open comparison view
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
await showComparisonView(sessionId, compId, currentSession, currentPoints);
};
});
}
async function showComparisonView(currentId, compareId, currentSession, currentPoints) {