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) {
- +
${!entry.product_id && !entry.recipe_id ? ` @@ -1338,6 +1343,8 @@ async function renderProducts(container) {
+ +
@@ -1509,6 +1516,48 @@ async function renderProducts(container) { // Add custom product button const addCustomBtn = container.querySelector("#add-custom-product"); if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true)); + + // Export all custom data (manual products + recipes + supplements) + container.querySelector("#export-all-custom")?.addEventListener("click", async () => { + const manualProducts = await DB.getManualProducts(); + const allRecipes = await exportAllRecipes(); + const supps = await DB.getSupplements(); + const bundle = { products: manualProducts, recipes: allRecipes, supplements: supps }; + downloadFile("baldav-custom-data.json", JSON.stringify(bundle, null, 2), "application/json"); + showToast(`Экспорт: ${manualProducts.length} продуктов, ${allRecipes.length} рецептов, ${supps.length} добавок`); + }); + + // Import custom data + container.querySelector("#import-all-custom")?.addEventListener("click", () => { + pickFile(".json", async (text) => { + try { + const data = JSON.parse(text); + let imported = { products: 0, recipes: 0, supplements: 0 }; + if (data.products && Array.isArray(data.products)) { + for (const p of data.products) { + // Dedup by name_lower + source + const existing = await DB.searchProducts(p.name_lower || p.name?.toLowerCase() || "", 1); + if (existing.some(e => e.name_lower === (p.name_lower || p.name?.toLowerCase()) && e.source === (p.source || "manual"))) continue; + delete p.id; + await DB.addProduct(p); + imported.products++; + } + } + if (data.recipes && Array.isArray(data.recipes)) { + await importRecipes(data.recipes); + imported.recipes = data.recipes.length; + } + if (data.supplements && Array.isArray(data.supplements)) { + await importSupplements(data.supplements); + imported.supplements = data.supplements.length; + } + showToast(`Импорт: ${imported.products} продуктов, ${imported.recipes} рецептов, ${imported.supplements} добавок`); + doSearch(true); + } catch (e) { + showToast("Ошибка импорта: " + e.message); + } + }); + }); } function showAddProductModal(onSaved) { @@ -1543,6 +1592,10 @@ function showAddProductModal(onSaved) { +
+ + +

Нутриенты на 100г (опционально):

${microHtml}
`, async overlay => { @@ -1554,6 +1607,7 @@ function showAddProductModal(onSaved) { protein_per_100g: parseFloat(overlay.querySelector("#np-protein").value) || null, fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null, carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null, + water_pct: parseFloat(overlay.querySelector("#np-water").value) || null, source: "manual", }; for (const f of PRODUCT_MICRO_FIELDS) { @@ -1600,20 +1654,27 @@ function showEditProductModal(p, onSaved) { +
+ + +

Нутриенты на 100г:

-
${microHtml}
`, +
${microHtml}
+ `, async overlay => { const name = overlay.querySelector("#ep-name").value.trim(); if (!name) return false; - const sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5"]; + const waterPct = parseFloat(overlay.querySelector("#ep-water").value) || null; + const sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5", "water_pct=?6"]; const vals = [ name, parseFloat(overlay.querySelector("#ep-kcal").value) || null, parseFloat(overlay.querySelector("#ep-protein").value) || null, parseFloat(overlay.querySelector("#ep-fat").value) || null, parseFloat(overlay.querySelector("#ep-carbs").value) || null, + waterPct, ]; - let idx = 6; + let idx = 7; for (const f of PRODUCT_MICRO_FIELDS) { let val = parseFloat(overlay.querySelector(`#ep-${f.key}`).value) || null; if (val && f.mult) val *= f.mult; @@ -1630,6 +1691,17 @@ function showEditProductModal(p, onSaved) { }); if (onSaved) onSaved(); }); + + // Export single product + setTimeout(() => { + const overlay = [...document.querySelectorAll(".modal-overlay")].pop(); + const expBtn = overlay?.querySelector("#ep-export"); + if (expBtn) expBtn.onclick = () => { + const clean = { ...p }; + delete clean.id; + downloadFile(`baldav-product-${(p.name || "").slice(0, 30)}.json`, JSON.stringify({ products: [clean] }, null, 2), "application/json"); + }; + }, 100); } // --------------------------------------------------------------------------- @@ -2206,6 +2278,7 @@ async function renderGpsScreen(container) {
-
Макс. точность (м)
-
Фиксы точнее этого = пропуск
+
Макс. погрешность GPS (м)
+
Хуже этого = пропуск (авто x2 при плохом сигнале)
@@ -4040,6 +4324,24 @@ async function renderSettings(container) {
+
+
Ходьба / Бег
+
+
+
Макс. скорость (км/ч)
+
Выше = глитч GPS
+
+ +
+
+
+
Мин. скорость (км/ч)
+
Ниже = стоит
+
+ +
+
+
Велосипеды
@@ -4140,7 +4442,7 @@ async function renderSettings(container) { }; // GPS config inputs - const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth"]; + const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"]; for (const id of gpsInputs) { const el = container.querySelector("#" + id); if (el) el.onchange = () => { @@ -4153,6 +4455,8 @@ async function renderSettings(container) { GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225; GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25; GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5; + GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12; + GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6; GPS.saveConfig(); }; } @@ -4896,16 +5200,20 @@ function createProductSearch(parentEl, onSelect, opts) { return { el: wrap, input, focus() { input.focus(); } }; } -function calcBmr(weights) { +function calcBmr(recentWeights) { const p = state.profile; const height = parseInt(p.height_cm); const birthYear = parseInt(p.birth_year); if (!height || !birthYear) return null; - const kg = parseFloat( - (weights.length ? weights[weights.length - 1].weight_kg : null) - ?? state.latestWeight?.weight_kg - ?? p.weight_kg - ); + // Moving average weight over recent days (smooths daily fluctuations) + let kg; + if (recentWeights && recentWeights.length >= 2) { + kg = recentWeights.reduce((s, w) => s + parseFloat(w.weight_kg), 0) / recentWeights.length; + } else if (recentWeights && recentWeights.length === 1) { + kg = parseFloat(recentWeights[0].weight_kg); + } else { + kg = parseFloat(state.latestWeight?.weight_kg ?? p.weight_kg); + } if (!kg) return null; return BMR.calculateBmr(kg, height, BMR.getAge(birthYear), p.sex ?? "m"); } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index accdaf8..f7c6bac 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.224" +version = "0.1.244" dependencies = [ "btleplug", "lazy_static", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e6a3dec..c4ea30a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.224" +version = "0.1.244" edition = "2021" [lib] diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt index a89b1a8..66c1dc6 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt @@ -14,11 +14,16 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.PowerManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.util.Log import java.io.FileOutputStream import java.io.File +import kotlin.math.sqrt -class GpsRecorderService : Service(), LocationListener { +class GpsRecorderService : Service(), LocationListener, SensorEventListener { companion object { const val TAG = "GpsRecorder" @@ -43,6 +48,15 @@ class GpsRecorderService : Service(), LocationListener { private var minDistanceM = 1f private var maxSpeedMs = 16.67f // 60 km/h default + // Sensor data aggregated between GPS points + private var sensorManager: SensorManager? = null + private var accelSamples = mutableListOf() // magnitude per sample + private var accelMax = 0f + private var gyroPitchSum = 0.0 + private var gyroYawSum = 0.0 + private var gyroSamples = 0 + private val sensorLock = Object() + private val pointBuffer = mutableListOf() private val bufferLock = Object() private val flushHandler = Handler(Looper.getMainLooper()) @@ -110,6 +124,23 @@ class GpsRecorderService : Service(), LocationListener { ) Log.d(TAG, "Location updates registered successfully") + // Register accelerometer + gyroscope (non-critical — if missing, GPS still works) + try { + sensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager + sensorManager?.let { sm -> + sm.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)?.let { + sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) + Log.d(TAG, "Accelerometer registered") + } + sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE)?.let { + sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) + Log.d(TAG, "Gyroscope registered") + } + } + } catch (e: Exception) { + Log.w(TAG, "Sensor registration failed (non-critical): ${e.message}") + } + // Flush buffer every 10 seconds fun scheduleFlush() { flushHandler.postDelayed({ @@ -118,6 +149,37 @@ class GpsRecorderService : Service(), LocationListener { }, 10000) } scheduleFlush() + + // Dead reckoning: if GPS silent 60+ sec but accel shows movement → emit estimated point + fun scheduleDeadReckoning() { + flushHandler.postDelayed({ + if (!isRunning) return@postDelayed + try { + val now = System.currentTimeMillis() + val gpsSilent = lastTime > 0 && (now - lastTime) > 60000 + if (gpsSilent && lastLat != 0.0) { + // Check if accelerometer shows movement + val rms: Float + synchronized(sensorLock) { + rms = if (accelSamples.isNotEmpty()) { + val sumSq = accelSamples.fold(0.0) { s, v -> s + v * v } + sqrt(sumSq / accelSamples.size).toFloat() + } else 0f + } + if (rms > 2f) { + // Person is moving but GPS is dead → write point at last known position + val sensorData = consumeSensorData() + val line = """{"lat":$lastLat,"lon":$lastLon,"alt":0,"speed":0,"acc":999,"ts":$now${sensorData},"estimated":true}""" + synchronized(bufferLock) { pointBuffer.add(line) } + pointCount++ + Log.d(TAG, "Dead reckoning point #$pointCount (GPS silent ${(now-lastTime)/1000}s, accel=$rms)") + } + } + } catch (_: Exception) {} + scheduleDeadReckoning() + }, 30000) + } + scheduleDeadReckoning() } catch (e: SecurityException) { Log.e(TAG, "GPS permission denied", e) stopSelf() @@ -135,27 +197,126 @@ class GpsRecorderService : Service(), LocationListener { private var pointCount = 0 + override fun onSensorChanged(event: SensorEvent?) { + if (event == null) return + try { + when (event.sensor.type) { + Sensor.TYPE_LINEAR_ACCELERATION -> { + val mag = sqrt(event.values[0] * event.values[0] + + event.values[1] * event.values[1] + + event.values[2] * event.values[2]) + synchronized(sensorLock) { + accelSamples.add(mag) + if (mag > accelMax) accelMax = mag + } + } + Sensor.TYPE_GYROSCOPE -> { + synchronized(sensorLock) { + gyroPitchSum += event.values[0].toDouble() + gyroYawSum += event.values[2].toDouble() + gyroSamples++ + } + } + } + } catch (e: Exception) { + // Sensor error — ignore, don't crash GPS recording + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + + private fun consumeSensorData(): String { + try { return consumeSensorDataInner() } catch (e: Exception) { return "" } + } + + private fun consumeSensorDataInner(): String { + synchronized(sensorLock) { + val rms = if (accelSamples.isNotEmpty()) { + val sumSq = accelSamples.fold(0.0) { s, v -> s + v * v } + sqrt(sumSq / accelSamples.size).toFloat() + } else 0f + val max = accelMax + val pitch = if (gyroSamples > 0) (gyroPitchSum / gyroSamples) else 0.0 + val yaw = if (gyroSamples > 0) (gyroYawSum / gyroSamples) else 0.0 + // Reset + accelSamples.clear() + accelMax = 0f + gyroPitchSum = 0.0 + gyroYawSum = 0.0 + gyroSamples = 0 + return if (rms > 0f || pitch != 0.0) + ""","accel_rms":${"%.3f".format(java.util.Locale.US, rms)},"accel_max":${"%.3f".format(java.util.Locale.US, max)},"gyro_pitch":${"%.4f".format(java.util.Locale.US, pitch)},"gyro_yaw":${"%.4f".format(java.util.Locale.US, yaw)}""" + else "" + } + } + + private var lastGoodPointTime = 0L + private var lastLowAccTime = 0L + private val recentAccuracies = mutableListOf() // last 10 accuracy values + private var adaptiveMaxAccuracy = 0f // 0 = use configured value + override fun onLocationChanged(location: Location) { Log.v(TAG, "Location: acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}") - if (location.accuracy > maxAccuracyM) return - if (location.speed < minSpeedMs) return - if (location.speed > maxSpeedMs) return - if (lastTime > 0) { - val results = FloatArray(1) - Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results) - if (results[0] < minDistanceM) return + + val now = location.time + val sensorData = consumeSensorData() + + // Adaptive accuracy threshold: if signal is consistently bad, relax the limit + recentAccuracies.add(location.accuracy) + if (recentAccuracies.size > 10) recentAccuracies.removeAt(0) + val effectiveMaxAcc = if (recentAccuracies.size >= 5) { + val median = recentAccuracies.sorted()[recentAccuracies.size / 2] + if (median > maxAccuracyM) { + // Bad signal: use 2x configured threshold + val adapted = maxAccuracyM * 2f + if (adaptiveMaxAccuracy == 0f) Log.d(TAG, "Bad GPS signal, relaxing accuracy to ${adapted}m") + adaptiveMaxAccuracy = adapted + adapted + } else { + if (adaptiveMaxAccuracy > 0f) Log.d(TAG, "GPS signal OK, restoring accuracy to ${maxAccuracyM}m") + adaptiveMaxAccuracy = 0f + maxAccuracyM + } + } else maxAccuracyM + + // Good accuracy: normal filtering + if (location.accuracy <= effectiveMaxAcc) { + if (location.speed > maxSpeedMs) return + if (location.speed < minSpeedMs) { + // Speed too low but check if accel shows movement — keep the point anyway + // (GPS speed unreliable in bad weather, accel is truth) + val hasAccel = sensorData.contains("accel_rms") + val rmsMatch = Regex(""""accel_rms":(\d+\.?\d*)""").find(sensorData) + val rms = rmsMatch?.groupValues?.get(1)?.toFloatOrNull() ?: 0f + if (rms < 2f) return // truly stationary + } + if (lastTime > 0) { + val results = FloatArray(1) + Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results) + if (results[0] < minDistanceM) return + } + + lastLat = location.latitude + lastLon = location.longitude + lastTime = now + lastGoodPointTime = now + } else { + // Low accuracy: accept at most 1 point per 30 seconds (keeps track alive) + if (now - lastLowAccTime < 30000) return + lastLowAccTime = now + // Use coordinates but mark as low accuracy + lastLat = location.latitude + lastLon = location.longitude + lastTime = now + Log.d(TAG, "Low accuracy point: acc=${location.accuracy}") } - lastLat = location.latitude - lastLon = location.longitude - lastTime = location.time - pointCount++ - val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${location.time}}""" + val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${now}${sensorData}}""" synchronized(bufferLock) { pointBuffer.add(line) } - Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} (buf=${pointBuffer.size})") + Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} acc=${location.accuracy} (buf=${pointBuffer.size})") } private fun flushBuffer() { @@ -185,6 +346,7 @@ class GpsRecorderService : Service(), LocationListener { flushHandler.removeCallbacksAndMessages(null) flushBuffer() // flush remaining points locationManager?.removeUpdates(this) + sensorManager?.unregisterListener(this) wakeLock?.let { if (it.isHeld) it.release() } stopForeground(true) super.onDestroy() diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index 0492fe3..cfe70ea 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -317,15 +317,35 @@ class MainActivity : TauriActivity() { try { val versionName = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.0.0" val localBuild = versionName.split(".").lastOrNull()?.toIntOrNull() ?: 0 - val conn = URL("https://4aiman.top/baldav/BUILD_NUMBER").openConnection() as HttpsURLConnection - conn.connectTimeout = 5000 - conn.readTimeout = 5000 - val remoteBuild = conn.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 0 - conn.disconnect() + + // Try LAN router first (fast), then VPS (slow) + var remoteBuild = 0 + var updateUrl = "" + val sources = listOf( + "http://192.168.1.1:88/baldav" to 2000, + "https://4aiman.top/baldav" to 5000 + ) + for ((baseUrl, timeout) in sources) { + try { + val conn = URL("$baseUrl/BUILD_NUMBER").openConnection() as java.net.HttpURLConnection + conn.connectTimeout = timeout + conn.readTimeout = timeout + val build = conn.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 0 + conn.disconnect() + if (build > remoteBuild) { + remoteBuild = build + updateUrl = "$baseUrl/baldav.apk" + } + if (build > localBuild) break // found update, no need to check further + } catch (e: Exception) { + Log.d("BalDav", "Update check $baseUrl failed: ${e.message}") + } + } + val infoFile = File(appDataDir, "update_info.json") val ts = System.currentTimeMillis() - infoFile.writeText("""{"local":$localBuild,"remote":$remoteBuild,"ts":$ts}""") - Log.d("BalDav", "Update check: local=$localBuild remote=$remoteBuild") + infoFile.writeText("""{"local":$localBuild,"remote":$remoteBuild,"url":"$updateUrl","ts":$ts}""") + Log.d("BalDav", "Update check: local=$localBuild remote=$remoteBuild url=$updateUrl") } catch (e: Exception) { Log.d("BalDav", "Update check failed: ${e.message}") val infoFile = File(appDataDir, "update_info.json") diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index b26ceaa..6148583 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -467,6 +467,23 @@ pub fn ble_is_connected( } } +#[tauri::command] +pub fn ble_clear_hr(app: tauri::AppHandle) -> Result<(), String> { + use tauri::Manager; + let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?; + // Clear HR data file so stale values don't show on next session + let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}"); + if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { + let _ = std::fs::write(root.join("ble_hr_data.json"), "{}"); + } + #[cfg(not(target_os = "android"))] + { + let mut state = BLE_STATE.lock().unwrap(); + state.latest_hr = None; + } + Ok(()) +} + #[tauri::command] pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: String) -> Result<(), String> { use tauri::Manager; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e497a5..23d34ac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -533,7 +533,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c586f74..ca69eb8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.224", + "version": "0.1.244", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" @@ -9,7 +9,7 @@ "app": { "withGlobalTauri": true, "security": { - "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com" + "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com https://api.open-meteo.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com" }, "windows": [ {