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:
parent
1ad4cd5d1f
commit
0032c309b7
5 changed files with 88 additions and 28 deletions
|
|
@ -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.
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue