diff --git a/frontend/data/products_seed.db b/frontend/data/products_seed.db index 63eb6ad..99ed637 100644 Binary files a/frontend/data/products_seed.db and b/frontend/data/products_seed.db differ diff --git a/frontend/js/bmr.js b/frontend/js/bmr.js index ea8e869..61f37ee 100644 --- a/frontend/js/bmr.js +++ b/frontend/js/bmr.js @@ -21,6 +21,8 @@ let RDI = { zinc_mg: 15, iron_mg: 14, iodine_mg: 0.15, selenium_mcg: 70, molybdenum_mcg: 70, manganese_mg: 2, copper_mg: 1, chromium_mg: 0.05, phosphorus_mg: 800, potassium_mg: 3500, sodium_mg: 2400, + omega3_mg: 1000, epa_mg: 250, dha_mg: 250, + lutein_mg: 5, lycopene_mg: 5, silicon_mg: 30, vanadium_mg: 0.04, }; // Load RDI from DB for selected standard. Called after DB ready. diff --git a/frontend/js/db.js b/frontend/js/db.js index 5825623..6bbc668 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -110,7 +110,7 @@ async function searchProducts(query, limit) { return select( `SELECT id, name, brand, category, weight_g, kcal_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, - composition, nutrients_guessed, source, + composition, nutrients_guessed, source, water_pct, vit_a_mcg, vit_c_mg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_e_mg, calcium_mg, iron_mg, magnesium_mg, potassium_mg, sodium_mg, phosphorus_mg, carotene_mcg, selenium_mcg, zinc_mg, copper_mg, folate_mcg, @@ -140,8 +140,8 @@ async function addProduct(p) { vit_a_mcg, vit_c_mg, vit_d_mcg, vit_e_mg, vit_k_mcg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_b6_mg, vit_b12_mcg, folate_mcg, calcium_mg, iron_mg, magnesium_mg, zinc_mg, copper_mg, - selenium_mcg, potassium_mg, sodium_mg, phosphorus_mg, carotene_mcg, source) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32)`, + selenium_mcg, potassium_mg, sodium_mg, phosphorus_mg, carotene_mcg, source, water_pct) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33)`, [ p.name, p.name?.toLowerCase() ?? null, p.brand ?? null, p.category ?? null, p.weight_g ?? null, @@ -155,7 +155,7 @@ async function addProduct(p) { p.zinc_mg ?? null, p.copper_mg ?? null, p.selenium_mcg ?? null, p.potassium_mg ?? null, p.sodium_mg ?? null, p.phosphorus_mg ?? null, p.carotene_mcg ?? null, - p.source ?? "manual", + p.source ?? "manual", p.water_pct ?? null, ] ); const rows = await select("SELECT id FROM products ORDER BY id DESC LIMIT 1"); @@ -384,7 +384,7 @@ async function getFoodForDate(date) { p.vit_b12_mcg, p.folate_mcg, p.calcium_mg, p.iron_mg, p.magnesium_mg, p.zinc_mg, p.copper_mg, p.selenium_mcg, p.potassium_mg, p.sodium_mg, - p.phosphorus_mg, p.carotene_mcg, + p.phosphorus_mg, p.carotene_mcg, p.water_pct, r.name as recipe_name, r.cached_nutrients, su.name as unit_name, su.name_short as unit_short FROM food_log fl @@ -675,13 +675,31 @@ async function createGpsSession(date, startTime, activityType) { return id; } -async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleData) { - await execute( - `INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms, seq, heart_rate, spo2, bp_sys, bp_dia) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`, - [sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null, - bleData?.hr ?? null, bleData?.spo2 ?? null, bleData?.bp_sys ?? null, bleData?.bp_dia ?? null] - ); +// Column list cached after first call (avoids PRAGMA per insert) +let _gpsPointCols = null; + +async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleData, sensorData) { + // Build INSERT dynamically based on existing columns (avoids crash if migration hasn't run) + if (!_gpsPointCols) { + try { + const info = await select("PRAGMA table_info(gps_points)"); + _gpsPointCols = new Set(info.map(r => r.name)); + } catch { _gpsPointCols = new Set(["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]); } + } + const cols = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]; + const vals = [sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null]; + + const extra = [ + ["heart_rate", bleData?.hr], ["spo2", bleData?.spo2], + ["bp_sys", bleData?.bp_sys], ["bp_dia", bleData?.bp_dia], + ["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max], + ["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw], + ]; + for (const [col, val] of extra) { + if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); } + } + const ph = cols.map((_, i) => `?${i + 1}`).join(","); + await execute(`INSERT INTO gps_points (${cols.join(",")}) VALUES (${ph})`, vals); } async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) { @@ -934,6 +952,11 @@ window.DB.getSleepForDate = getSleepForDate; window.DB.saveSleep = saveSleep; window.DB.searchProducts = searchProducts; window.DB.getProduct = getProduct; + +async function getManualProducts() { + return select("SELECT * FROM products WHERE source = 'manual' ORDER BY name"); +} +window.DB.getManualProducts = getManualProducts; window.DB.addProduct = addProduct; window.DB.deleteProduct = deleteProduct; window.DB.getProductRefs = getProductRefs; @@ -989,6 +1012,19 @@ window.DB.getGpsSession = getGpsSession; window.DB.getGpsPoints = getGpsPoints; window.DB.deleteGpsSession = deleteGpsSession; window.DB.importGpxSession = importGpxSession; + +async function findNearestAltitude(lat, lon, radiusM) { + const dLat = radiusM / 111000; + const dLon = radiusM / (111000 * Math.cos(lat * Math.PI / 180)); + const rows = await select(` + SELECT altitude_gps_m FROM gps_points + WHERE altitude_gps_m IS NOT NULL + AND lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4 + ORDER BY (lat - ?5)*(lat - ?5) + (lon - ?6)*(lon - ?6) + LIMIT 1`, [lat - dLat, lat + dLat, lon - dLon, lon + dLon, lat, lon]); + return rows[0]?.altitude_gps_m ?? null; +} +window.DB.findNearestAltitude = findNearestAltitude; window.DB.getBikes = getBikes; window.DB.getBike = getBike; window.DB.getActiveBike = getActiveBike; @@ -1047,23 +1083,31 @@ async function getDailyWaterMl(date) { [date] ); let total = prodRows[0]?.ml || 0; - // From recipes (water_ml in cached_nutrients) + // From recipes — calculate water from ingredients on-the-fly (cache may be stale) const recipeRows = await select( - `SELECT fl.amount_g, r.cached_nutrients, - (SELECT SUM(ri.amount_g) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as recipe_weight, - r.servings + `SELECT fl.amount_g, fl.recipe_id, r.servings, + (SELECT SUM(ri.amount_g) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as recipe_weight FROM food_log fl JOIN recipes r ON fl.recipe_id = r.id - WHERE fl.date = ?1 AND r.cached_nutrients IS NOT NULL`, + WHERE fl.date = ?1`, [date] ); for (const rr of recipeRows) { try { - const cn = typeof rr.cached_nutrients === "string" ? JSON.parse(rr.cached_nutrients) : rr.cached_nutrients; - if (cn.water_ml) { - const servingG = Math.round((rr.recipe_weight || 0) / (rr.servings || 1)); - const scale = servingG > 0 ? rr.amount_g / servingG : 1; - total += cn.water_ml * scale; + // Sum water from recipe ingredients + const ings = await select( + `SELECT ri.amount_g, COALESCE(p.water_pct, 0) as water_pct + FROM recipe_ingredients ri LEFT JOIN products p ON ri.product_id = p.id + WHERE ri.recipe_id = ?1`, + [rr.recipe_id] + ); + let recipeWaterMl = 0; + for (const ing of ings) { + recipeWaterMl += (ing.amount_g || 0) * (ing.water_pct || 0) / 100; } + // Scale by portion: how much of the recipe was eaten + const servingG = Math.round((rr.recipe_weight || 0) / (rr.servings || 1)); + const scale = servingG > 0 ? rr.amount_g / servingG : 1; + total += recipeWaterMl / (rr.servings || 1) * scale; } catch {} } return Math.round(total); @@ -1077,6 +1121,9 @@ window.DB.getDailyWaterMl = getDailyWaterMl; // JS-side migrations — safe for any DB state (checks before altering) async function runMigrations() { + // Disable WAL — all writes go directly to main DB file (no -wal/-shm files) + await execute("PRAGMA journal_mode=DELETE"); + // Batch column checks — one PRAGMA per table instead of N separate IPC calls const productCols = (await select("PRAGMA table_info(products)")).map(r => r.name); const addProdCol = async (col, type) => { if (!productCols.includes(col)) await execute(`ALTER TABLE products ADD COLUMN ${col} ${type}`); }; @@ -1095,6 +1142,10 @@ async function runMigrations() { if (!gpsCols.includes("spo2")) await execute("ALTER TABLE gps_points ADD COLUMN spo2 INTEGER"); if (!gpsCols.includes("bp_sys")) await execute("ALTER TABLE gps_points ADD COLUMN bp_sys INTEGER"); if (!gpsCols.includes("bp_dia")) await execute("ALTER TABLE gps_points ADD COLUMN bp_dia INTEGER"); + if (!gpsCols.includes("accel_rms")) await execute("ALTER TABLE gps_points ADD COLUMN accel_rms REAL"); + if (!gpsCols.includes("accel_max")) await execute("ALTER TABLE gps_points ADD COLUMN accel_max REAL"); + if (!gpsCols.includes("gyro_pitch")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_pitch REAL"); + if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL"); // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 0ae6ee9..eaeaa61 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -15,6 +15,11 @@ GPS.config = { airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0 pedalEfficiency: 25, // КПД педалирования (%). Человек ~25% altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt + walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS) + walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч + bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов) + walkAvgSpeedKmh: 5, // средняя скорость ходьбы + runAvgSpeedKmh: 10, // средняя скорость бега }; // Load saved config @@ -109,9 +114,17 @@ 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; - // 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; + // Rolling resistance: base from surface type, corrected by tire pressure. + // If accelerometer data available, estimate Crr from vibration (accel RMS): + // RMS < 3 → smooth asphalt (0.004), 3-7 → mixed (0.006), > 7 → gravel (0.008) + let baseCrr = opts?.surfaceCrr ?? 0.006; + const accelVals = sorted.map(p => p.accel_rms).filter(v => v != null && v > 0); + if (accelVals.length > 10) { + accelVals.sort((a, b) => a - b); + const medianAccel = accelVals[Math.floor(accelVals.length / 2)]; + const accelCrr = medianAccel < 3 ? 0.004 : medianAccel < 7 ? 0.006 : 0.008; + baseCrr = accelCrr; // override with sensor-based estimate + } const tirePressure = opts?.tirePressureBar ?? 3.0; const Crr = baseCrr * Math.sqrt(3.0 / tirePressure); const wKg = parseFloat(weightKg) || 80; // ensure numeric (profile stores strings) @@ -148,7 +161,17 @@ GPS.computeStats = function(points, weightKg, opts) { } // Kcal calculation per segment - if (dt <= 0 || dt > 300) continue; // skip pauses > 5min + const isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; + if (isEstimated) { + // Estimated point (GPS dead, accel confirmed movement): use walking MET + const walkMet = 3.5; // moderate walking ~4-5 km/h + totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200; + continue; + } + if (dt <= 0) continue; + // Skip if speed is unreasonable (teleportation/glitch) + const segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); + if (v > segMaxMs) continue; if (v < 0.3) { // Standing still: only basal metabolic rate @@ -165,9 +188,15 @@ GPS.computeStats = function(points, weightKg, opts) { const sinG = gradient; const cosG = Math.sqrt(1 - sinG * sinG); + // Per-point Crr from accelerometer (if available), else session-wide Crr + const ptAccel = sorted[i].accel_rms; + const ptCrr = (ptAccel != null && ptAccel > 0) + ? (ptAccel < 3 ? 0.004 : ptAccel < 7 ? 0.006 : 0.008) * Math.sqrt(3.0 / tirePressure) + : Crr; + // Power required (watts): gravity + rolling resistance + aerodynamic drag const pGravity = mTotal * g * sinG; - const pRolling = Crr * mTotal * g * cosG; + const pRolling = ptCrr * mTotal * g * cosG; const pAero = 0.5 * CdA * rho * v * v; const power = v * (pGravity + pRolling + pAero); @@ -363,8 +392,12 @@ GPS.recording = { // Start native GPS service on Android (foreground service survives background) GPS._startNativeService = async function() { try { - // Send config along with start command: start:minSpeed:maxAcc:minDist:maxSpeed - const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${GPS.config.maxSpeedKmh / 3.6}`; + // Per-activity speed limits + const type = GPS.recording.activityType; + const isWalk = type === "walk" || type === "run"; + const minSpd = isWalk ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs; + const maxSpd = isWalk ? (GPS.config.walkMaxSpeedKmh / 3.6) : (GPS.config.maxSpeedKmh / 3.6); + const cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}`; await window.__TAURI__.core.invoke("gps_service_command", { cmd }); console.log("[GPS] Native service started with config:", cmd); GPS.recording.nativeService = true; @@ -423,6 +456,8 @@ GPS._stopHrMonitor = function() { } GPS.recording.currentHr = null; GPS.recording.currentBleData = null; + // Clear stale HR data file so next session doesn't show old values + try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} }; // Read points from native service file @@ -440,6 +475,9 @@ GPS._readNativePoints = async function() { lat: d.lat, lon: d.lon, altitude: d.alt, speed_ms: d.speed, accuracy: d.acc, ts: new Date(d.ts).toISOString(), + accel_rms: d.accel_rms, accel_max: d.accel_max, + gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw, + estimated: d.estimated || false, }); } catch { /* skip partial/corrupt lines */ } } @@ -501,7 +539,7 @@ GPS._processIncomingPoint = async function(p) { } // Save point with HR even during pause (no seq = excluded from distance/kcal) if (GPS.recording.sessionId && GPS.recording.currentBleData) { - DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData); + DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData, p); } if (GPS.recording.onPoint) GPS.recording.onPoint(p); return; @@ -520,7 +558,7 @@ GPS._processIncomingPoint = async function(p) { GPS.recording.points.push(p); GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1; if (GPS.recording.sessionId) { - await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData); + await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p); } if (GPS.recording.onPoint) GPS.recording.onPoint(p); }; @@ -586,7 +624,7 @@ GPS.resumeRecording = function() { // Recover orphaned recording session on app startup GPS.recoverSession = async function() { const saved = localStorage.getItem("baldav_gps_active"); - if (!saved) return; + if (!saved) return false; try { const s = JSON.parse(saved); if (!s.sessionId) { GPS._clearPersistedState(); return; } @@ -620,9 +658,13 @@ GPS.recoverSession = async function() { // Store bike_id if present if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId); console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points"); + GPS._clearPersistedState(); + await GPS._stopNativeService(); + return true; } GPS._clearPersistedState(); await GPS._stopNativeService(); + return false; } catch (e) { console.error("[GPS] Recovery error:", e); GPS._clearPersistedState(); @@ -1074,15 +1116,89 @@ GPS.recalculateSession = async function(sessionId) { if (points.length < 2) return null; const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v }); - - // Delete glitch points (speed > maxSpeedKmh) from DB const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); - const maxMs = GPS.config.maxSpeedKmh / 3.6; + + // Manual routes: skip glitch filtering (points are user-placed, not GPS). + // Just fix timestamps and recompute stats. + if (session.source === "manual") { + const isWalkM = session.activity_type === "walk" || session.activity_type === "run"; + const avgSpeedMs = isWalkM + ? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6 + : GPS.config.bikeAvgSpeedKmh / 3.6; + let cumTime = 0; + const baseTs = new Date(sorted[0].ts).getTime(); + for (let i = 0; i < sorted.length; i++) { + if (i > 0) { + const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon); + cumTime += d / avgSpeedMs; + } + const newTs = new Date(baseTs + cumTime * 1000).toISOString(); + await invoke("UPDATE gps_points SET ts=?1, speed_ms=?2, seq=?3 WHERE session_id=?4 AND id=?5", + [newTs, avgSpeedMs, i + 1, sessionId, sorted[i].id]); + sorted[i].ts = newTs; + sorted[i].speed_ms = avgSpeedMs; + } + // Compute stats and finish + const latestW = await DB.getLatestWeight(); + const profile = await DB.getFullProfile(); + const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); + const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; + const stats = GPS.computeStats(sorted, weightKg, { + 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, + }); + await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time, + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + return stats; + } + + // --- Recorded sessions: glitch filtering --- + const isWalk = session.activity_type === "walk" || session.activity_type === "run"; + const maxMs = (isWalk ? GPS.config.walkMaxSpeedKmh : GPS.config.maxSpeedKmh) / 3.6; + + // First pass: remove points with absurd timestamps (>1 day jump) let deleted = 0; + if (sorted.length >= 3) { + const sessionStart = new Date(sorted[0].ts).getTime(); + const sessionEnd = new Date(sorted[sorted.length - 1].ts).getTime(); + const maxDuration = 24 * 3600 * 1000; // 24 hours max session + for (let i = sorted.length - 1; i >= 0; i--) { + const t = new Date(sorted[i].ts).getTime(); + if (t - sessionStart > maxDuration || t < sessionStart - 60000) { + await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]); + sorted.splice(i, 1); + deleted++; + } + } + } + + // Second pass: speed + accel + altitude filter 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); const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000; - if (dt > 0 && d / dt > maxMs) { + const speedMs = dt > 0 ? d / dt : 0; + const accel = sorted[i].accel_rms; + + // Altitude jump: >100m between consecutive points = impossible + const alt = sorted[i].altitude_gps_m; + const prevAlt = sorted[i-1].altitude_gps_m; + const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0; + + // Glitch detection: + // 1. Speed exceeds max for activity type + // 2. Accelerometer = 0 but GPS shows movement (phone was still → GPS drift) + // 3. High speed + normal walking accel (can't walk at 40 km/h) + // 4. Altitude jumps >100m in one step (impossible on foot or bike) + const isGlitch = + (dt > 0 && speedMs > maxMs) || + (accel != null && accel === 0 && speedMs > 3) || + (isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) || + (altJump > 100); + + if (isGlitch) { await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]); sorted.splice(i, 1); i--; @@ -1091,10 +1207,60 @@ GPS.recalculateSession = async function(sessionId) { } if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`); - // Re-assign seq only if points were deleted or reordered - if (deleted > 0) { - for (let i = 0; i < sorted.length; i++) { - await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]); + // Interpolate gaps where accelerometer shows movement + // If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal + let interpolated = 0; + for (let i = 1; i < sorted.length; i++) { + const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000; + if (dt < 30 || dt > 3600) continue; // only gaps 30s - 1h + const accelBefore = sorted[i-1].accel_rms; + const accelAfter = sorted[i].accel_rms; + // Both ends show movement → person was moving during gap + if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) { + // Add interpolated distance to total (will be counted by computeStats naturally + // since the gap points are adjacent and haversine gives straight-line distance) + // Mark gap as "moving" by ensuring speed_ms is set + interpolated++; + } + } + if (interpolated) console.log(`[GPS] Found ${interpolated} gaps with movement (accel-confirmed)`); + + // Fix manual routes: timestamps are fake (1s apart), recalculate based on realistic speed + if (session.source === "manual" || sorted.length >= 2) { + const t0 = new Date(sorted[0].ts).getTime(); + const tN = new Date(sorted[sorted.length - 1].ts).getTime(); + const totalTimeMs = tN - t0; + // Compute total distance + let totalDist = 0; + for (let i = 1; i < sorted.length; i++) { + totalDist += GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon); + } + const impliedSpeedMs = totalTimeMs > 0 ? (totalDist / (totalTimeMs / 1000)) : 0; + // If implied speed is absurd (>30 m/s for walk, >50 for bike) → fix timestamps + const saneMaxMs = isWalk ? 5 : 15; // 18 km/h walk, 54 km/h bike + if (impliedSpeedMs > saneMaxMs && totalDist > 100) { + const avgSpeedMs = isWalk + ? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6 + : GPS.config.bikeAvgSpeedKmh / 3.6; + let cumTime = 0; + const baseTs = new Date(sorted[0].ts).getTime(); + for (let i = 0; i < sorted.length; i++) { + if (i > 0) { + const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon); + cumTime += d / avgSpeedMs; + } + const newTs = new Date(baseTs + cumTime * 1000).toISOString(); + await invoke("UPDATE gps_points SET ts = ?1, speed_ms = ?2, seq = ?3 WHERE session_id = ?4 AND ts = ?5", + [newTs, avgSpeedMs, i + 1, sessionId, sorted[i].ts]); + sorted[i].ts = newTs; + sorted[i].speed_ms = avgSpeedMs; + } + console.log(`[GPS] Fixed manual route timestamps: ${sorted.length} pts, avg ${avgSpeedMs} m/s`); + } else if (deleted > 0) { + // Just re-assign seq + for (let i = 0; i < sorted.length; i++) { + await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]); + } } } diff --git a/frontend/js/main.js b/frontend/js/main.js index 4a39bb8..4653dbc 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -38,8 +38,8 @@ async function checkForUpdate() { const raw = await window.__TAURI__.core.invoke("get_update_info"); const info = JSON.parse(raw); if (info.remote && info.local && info.remote > info.local) { - state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local }; - console.log(`[Update] available: ${info.local} -> ${info.remote}`); + state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local, url: info.url || null }; + console.log(`[Update] available: ${info.local} -> ${info.remote} url=${info.url}`); } else { state.updateAvailable = null; } @@ -50,7 +50,7 @@ async function checkForUpdate() { async function startUpdate() { if (!state.updateAvailable) return; - const url = UPDATE_BASE_URL + "/baldav.apk"; + const url = state.updateAvailable.url || (UPDATE_BASE_URL + "/baldav.apk"); try { await window.__TAURI__.core.invoke("gps_service_command", { cmd: "open_url:" + url }); } catch (e) { @@ -121,7 +121,10 @@ async function init() { try { if (DB._migrationsReady) await DB._migrationsReady; } catch (e) { console.error("[BOOT]", e); } // Recover any orphaned GPS recording session - try { await GPS.recoverSession(); } catch (e) { /* ignore */ } + try { + const recovered = await GPS.recoverSession(); + if (recovered) showToast("Восстановлена незавершённая тренировка"); + } catch (e) { console.error("[Recovery]", e); } // Load RDI standard and half-life data from profile (default: RU) try { @@ -278,7 +281,8 @@ async function renderToday(container) { return; } - const bmr = calcBmr(weights); + const recentWeightsForBmr = await DB.getRecentWeights(7); + const bmr = calcBmr(recentWeightsForBmr); const actLevel = parseFloat(state.profile.activity_level) || 1.2; const dailyBase = bmr ? Math.round(bmr * actLevel) : null; const intake = Math.round(foodTotals.kcal ?? 0); @@ -577,12 +581,12 @@ async function renderToday(container) { const statsContainer = container.querySelector("#stats-charts"); const statsPeriod = container.querySelector(".stats-period"); if (statsContainer && statsPeriod) { - renderStatsCharts(statsContainer, 30, foodTotals, bmr); + renderStatsCharts(statsContainer, 30, foodTotals, dailyBase); statsPeriod.querySelectorAll(".type-chip").forEach(chip => { chip.onclick = () => { statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected")); chip.classList.add("selected"); - renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, bmr); + renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase); }; }); } @@ -1144,10 +1148,11 @@ function showFoodModal() { unitChipsEl.style.display = "none"; } - // Reset amount to grams mode - amountLabel.textContent = "Количество (г)"; - amountInput.placeholder = "100"; - amountInput.value = "100"; + // Reset amount — show "мл" for liquids + const isLiquid = product.water_pct && product.water_pct > 80; + amountLabel.textContent = `Количество (${isLiquid ? "мл" : "г"})`; + amountInput.placeholder = isLiquid ? "250" : "100"; + amountInput.value = isLiquid ? "250" : "100"; selectedEl.onclick = () => { selectedProduct = null; @@ -1180,7 +1185,7 @@ function showEditFoodModal(entry) {
Нутриенты на 100г (опционально):
Нутриенты на 100г:
-