Seed DB: +5ka (3103) +ION (1078) with micronutrients, GPS fixes, UI improvements

Products:
- 5ka scraper: recursive category BFS, 3103 products imported
- ION scraper (web.ion.ru): fetch-based, 1111 products with full vitamins/minerals
- Seed DB now 13480 products (magnit 8627, 5ka 3103, ion 1078, kdv 672)
- 13 micronutrient columns added: Na, K, Ca, Mg, P, Fe, vit A/B1/B2/B3/C/E, carotene

GPS fixes:
- computeStats sorts points by seq/ts before calculating (fixes wrong elevation/kcal)
- Trail restore on recording screen sorts points
- Old tracks with NULL seq repaired via migration (db.js)
- Activity type saved to localStorage, restored on GPS screen

UI:
- Track points invisible but tappable (popup with time/alt/speed)
- Charts: pointer-events:none on canvas when minimized (page scrolls)
- Navigation history stack (20 entries), back gesture doesn't close app
- Session list sort: added id DESC tiebreaker
This commit is contained in:
4aiman 2026-05-19 23:53:49 +03:00
commit 0032c309b7
5 changed files with 88 additions and 28 deletions

View file

@ -1215,6 +1215,12 @@ body {
width: 100%;
}
/* Block touch on canvas when chart is minimized — lets page scroll normally.
Clicks on the .chart-wrap div still work (pointer-events: auto on parent). */
.chart-wrap:not(.chart-full) canvas {
pointer-events: none;
}
.chart-full {
position: fixed;
left: 0;
@ -1243,6 +1249,17 @@ body {
z-index: 10000;
}
.point-popup .leaflet-popup-content-wrapper {
background: var(--surface);
color: var(--on-bg);
font-size: 12px;
border-radius: 6px;
padding: 4px 8px;
}
.point-popup .leaflet-popup-tip {
background: var(--surface);
}
.gps-download-status {
text-align: center;
font-size: 12px;

Binary file not shown.

View file

@ -394,7 +394,7 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa
async function getGpsSessions(limit) {
return select(
"SELECT * FROM gps_sessions ORDER BY date DESC, start_time DESC LIMIT ?1",
"SELECT * FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1",
[limit ?? 50]
);
}
@ -508,6 +508,22 @@ async function runMigrations() {
if (!(await hasColumn("gps_points", "seq"))) {
await execute("ALTER TABLE gps_points ADD COLUMN seq INTEGER");
}
// Repair old tracks: assign seq to points where it's NULL, ordered by timestamp.
// Uses rowid to break ties when timestamps are identical.
const nullSeqSessions = await select(
"SELECT DISTINCT session_id FROM gps_points WHERE seq IS NULL"
);
for (const row of nullSeqSessions) {
const pts = await select(
"SELECT rowid, ts FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, rowid ASC",
[row.session_id]
);
for (let i = 0; i < pts.length; i++) {
await execute("UPDATE gps_points SET seq = ?1 WHERE rowid = ?2", [i + 1, pts[i].rowid]);
}
console.log(`[db.js] Repaired seq for session ${row.session_id}: ${pts.length} points`);
}
}
window.DB._migrationsReady = runMigrations().then(() => {

View file

@ -50,11 +50,17 @@ GPS.calcKcal = function(minutes, avgSpeedKmh, weightKg, elevGainM) {
GPS.computeStats = function(points, weightKg) {
if (points.length < 2) return { distance: 0, elevGain: 0, elevLoss: 0, avgSpeed: 0, maxSpeed: 0, kcal: 0, duration: 0 };
// Sort by seq (if present) or timestamp to fix out-of-order points from native service
const sorted = points.slice().sort((a, b) => {
if (a.seq != null && b.seq != null) return a.seq - b.seq;
return new Date(a.ts) - new Date(b.ts);
});
let distance = 0, elevGain = 0, elevLoss = 0;
const speeds = []; // m/s per segment
for (let i = 1; i < points.length; i++) {
const p = points[i], prev = points[i - 1];
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;
@ -81,8 +87,8 @@ GPS.computeStats = function(points, weightKg) {
if (smooth > maxSpeed) maxSpeed = smooth;
}
const first = new Date(points[0].ts);
const last = new Date(points[points.length - 1].ts);
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;

View file

@ -10,6 +10,11 @@ let state = {
latestWeight: null,
};
// Navigation history stack — survives in-memory across screen changes.
// When app is restored from background, we replay the stack into browser history.
const NAV_MAX = 20;
const navStack = ["today"]; // always starts with "today" as root
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
@ -55,6 +60,10 @@ async function init() {
state.profile = await DB.getFullProfile();
state.latestWeight = await DB.getLatestWeight();
// Seed browser history with a few entries so back gesture doesn't immediately close app
history.replaceState({ screen: "today" }, "");
for (let i = 0; i < 5; i++) history.pushState({ screen: "today" }, "");
if (false) {
renderScreen("settings");
} else {
@ -66,11 +75,13 @@ async function init() {
// Router
// ---------------------------------------------------------------------------
function renderScreen(name) {
function renderScreen(name, fromPopstate) {
const prevScreen = state.screen;
state.screen = name;
// Push history so "back" navigates between screens instead of exiting app
if (prevScreen && prevScreen !== name) {
// Push to nav stack and browser history (unless triggered by popstate)
if (!fromPopstate && prevScreen && prevScreen !== name) {
navStack.push(name);
if (navStack.length > NAV_MAX) navStack.shift();
history.pushState({ screen: name }, "");
}
const screens = {
@ -758,9 +769,9 @@ async function renderGpsScreen(container) {
<div class="gps-header">
<div class="gps-btns">
<select id="gps-type" class="gps-type-sel">
<option value="bike">Вело</option>
<option value="walk">Ходьба</option>
<option value="run">Бег</option>
<option value="bike"${(localStorage.getItem("baldav_gps_type")||"bike")==="bike"?" selected":""}>Вело</option>
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Трекинг" : "Старт"}
@ -796,11 +807,17 @@ async function renderGpsScreen(container) {
// Recording button
// Save activity type on change
container.querySelector("#gps-type").onchange = (e) => {
localStorage.setItem("baldav_gps_type", e.target.value);
};
container.querySelector("#gps-rec-btn").onclick = async () => {
if (GPS.recording.active) {
renderScreen("recording");
} else {
const type = container.querySelector("#gps-type").value;
localStorage.setItem("baldav_gps_type", type);
const wkg = state.latestWeight?.weight_kg ?? 80;
await GPS.startRecording(type, wkg);
renderScreen("recording");
@ -1010,9 +1027,13 @@ function renderRecordingInline(container) {
renderScreen("gps");
};
// Restore trail if already recording
// Restore trail if already recording — sort by seq/ts to avoid jumps
if (GPS.recording.points.length > 0) {
const coords = GPS.recording.points.map(p => [p.lat, p.lon]);
const sorted = GPS.recording.points.slice().sort((a, b) => {
if (a.seq != null && b.seq != null) return a.seq - b.seq;
return new Date(a.ts) - new Date(b.ts);
});
const coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
map.fitBounds(trailLine.getBounds().pad(0.1));
@ -1313,20 +1334,16 @@ async function showSessionDetail(sessionId) {
L.polyline(sortedCoords, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
L.polyline(sortedCoords, { color: "#bb86fc", weight: 5 }).addTo(map);
// Dots on top
// Invisible but tappable hit-targets on top of track
for (let i = 0; i < points.length; i++) {
const p = points[i];
const frac = (new Date(p.ts).getTime() - t0) / tRange;
// blue → red → yellow → green
let cr, cg, cb;
if (frac < 0.33) { const t = frac / 0.33; cr = Math.round(t * 220); cg = 0; cb = Math.round((1 - t) * 220); }
else if (frac < 0.66) { const t = (frac - 0.33) / 0.33; cr = 220; cg = Math.round(t * 200); cb = 0; }
else { const t = (frac - 0.66) / 0.34; cr = Math.round((1 - t) * 220); cg = 180; cb = 0; }
const dotColor = `rgb(${cr},${cg},${cb})`;
L.circleMarker([p.lat, p.lon], { radius: 5, color: "#333", fillColor: "#333", fillOpacity: 1, weight: 0 }).addTo(map);
L.circleMarker([p.lat, p.lon], { radius: 3.5, color: dotColor, fillColor: dotColor, fillOpacity: 1, weight: 0 })
.bindTooltip(`#${i} ${p.ts.split("T")[1].slice(0, 12)}`, { permanent: false })
.addTo(map);
const alt = p.altitude_gps_m ?? p.altitude;
const spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : "";
const time = p.ts.split("T")[1].slice(0, 8);
const info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}`;
L.circleMarker([p.lat, p.lon], {
radius: 8, weight: 0, fillOpacity: 0, interactive: true,
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
// Start/end
@ -2095,9 +2112,13 @@ function esc(str) {
window.addEventListener("popstate", (e) => {
// If a modal is open, don't handle here (modal has its own handler)
if (document.querySelector(".modal-overlay")) return;
// Navigate to previous screen or stay on today
if (e.state && e.state.screen) {
renderScreen(e.state.screen);
// Pop our nav stack and navigate to previous screen
if (navStack.length > 1) {
navStack.pop(); // remove current
const prev = navStack[navStack.length - 1];
renderScreen(prev, true);
} else if (e.state && e.state.screen) {
renderScreen(e.state.screen, true);
}
});