Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router

GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude
  jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel
  vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines
  when accel confirms movement. Walk/run separate speed limits (configurable).
  Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when
  GPS silent but accel shows movement. Locale.US fix for sensor JSON.

Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw).
  Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing).

Manual routes: draw on map by tapping points, elevation from past tracks +
  open-meteo API, realistic timestamps by activity speed. Manual sessions
  skip glitch filtering on recalculate. Speed from GPS.config not hardcoded.

Water tracking: water_pct field in product add/edit forms, searchProducts
  returns water_pct, "ml" label for liquids, recipe water from ingredients.

BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr.

BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp.

Products: 217 Gridnev products (bakery, desserts, bread, ready meals).
  Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements
  addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA.

Export/import: custom products + recipes + supplements in single JSON bundle.
  Export all or single product. Dedup on import by name_lower + source.

OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router
  first (2s timeout), then VPS. Update URL from update_info.json.

Session detail: renderToken prevents stale setTimeout callbacks after recalculate.
WAL disabled (PRAGMA journal_mode=DELETE).
This commit is contained in:
4aiman 2026-06-18 22:54:45 +03:00
commit 19282ded78
12 changed files with 819 additions and 93 deletions

Binary file not shown.

View file

@ -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.

View file

@ -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 (

View file

@ -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]);
}
}
}

View file

@ -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) {
</div>
</div>
<div class="form-group">
<label>Количество (г)</label>
<label id="m-amount-label">Количество (${(entry.water_pct && entry.water_pct > 80) ? "мл" : "г"})</label>
<input id="m-amount" type="text" inputmode="numeric" value="${entry.amount_g || 100}" />
</div>
${!entry.product_id && !entry.recipe_id ? `
@ -1338,6 +1343,8 @@ async function renderProducts(container) {
<div style="display:flex;gap:8px;align-items:center">
<input id="prod-search" class="search-input" type="search" style="flex:1"
placeholder="Название, бренд или ингредиент..." />
<button class="add-btn" id="export-all-custom" style="font-size:12px" title="Экспорт своих продуктов и рецептов">&#x21E9;</button>
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">&#x21E7;</button>
<button class="add-btn" id="add-custom-product" title="Свой продукт">+</button>
</div>
<div id="prod-count" class="prod-count"></div>
@ -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) {
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
</div>
</div>
<div class="form-group" style="margin-top:8px">
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" placeholder="" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г (опционально):</p>
<div class="micro-grid">${microHtml}</div>`,
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) {
<input id="ep-carbs" type="number" inputmode="decimal" step="0.1" value="${p.carbs_per_100g ?? ""}" />
</div>
</div>
<div class="form-group" style="margin-top:8px">
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
<input id="ep-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${p.water_pct ?? ""}" placeholder="" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г:</p>
<div class="micro-grid">${microHtml}</div>`,
<div class="micro-grid">${microHtml}</div>
<button id="ep-export" class="btn-secondary full-width" style="margin-top:12px;font-size:13px">Экспорт продукта</button>`,
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) {
<div class="gps-menu-wrap">
<button id="gps-menu-btn" class="gps-menu-btn">&#x22EE;</button>
<div id="gps-menu" class="gps-menu hidden">
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
@ -2281,6 +2354,8 @@ async function renderGpsScreen(container) {
}, { once: false });
// Download map tiles — region picker
container.querySelector("#gps-draw-route").onclick = () => showDrawRouteScreen(container);
container.querySelector("#gps-download-map").onclick = () => {
menu.classList.add("hidden");
showMapDownloadModal();
@ -2462,6 +2537,190 @@ async function showAddGoalMarkerModal() {
}, 200);
}
// ---------------------------------------------------------------------------
// Draw manual route on map
// ---------------------------------------------------------------------------
async function fetchElevations(points) {
try {
// Batch up to 100 points per request
const elevations = new Array(points.length).fill(null);
for (let i = 0; i < points.length; i += 100) {
const batch = points.slice(i, i + 100);
const lats = batch.map(p => p.lat.toFixed(5)).join(",");
const lons = batch.map(p => p.lon.toFixed(5)).join(",");
const resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (!resp.ok) continue;
const data = await resp.json();
if (data.elevation) {
for (let j = 0; j < data.elevation.length; j++) elevations[i + j] = data.elevation[j];
}
}
return elevations;
} catch (e) {
console.log("[Elevation API] failed:", e.message);
return null;
}
}
function showDrawRouteScreen(container) {
const points = []; // {lat, lon}
let markers = [];
let polyline = null;
container.innerHTML = `
<div class="rec-screen">
<div id="draw-map" class="rec-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="draw-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="draw-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
</div>
<div class="rec-bottom">
<select id="draw-type" style="padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
<option value="bike">Вело</option>
<option value="walk">Ходьба</option>
<option value="run">Бег</option>
</select>
<button id="draw-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
<button id="draw-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
<button id="draw-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
</div>
</div>`;
setTimeout(() => {
const mapEl = document.querySelector("#draw-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Try to center on last known position
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
}, () => {}, { enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 });
}
const distEl = document.querySelector("#draw-dist");
const ptsEl = document.querySelector("#draw-pts");
function updateStats() {
let dist = 0;
for (let i = 1; i < points.length; i++) {
dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
}
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
function updatePolyline() {
const latlngs = points.map(p => [p.lat, p.lon]);
if (polyline) map.removeLayer(polyline);
if (latlngs.length >= 2) {
polyline = L.polyline(latlngs, { color: "#bb86fc", weight: 4 }).addTo(map);
}
}
// Map click → add point
map.on("click", e => {
const { lat, lng } = e.latlng;
points.push({ lat, lon: lng });
const marker = L.circleMarker([lat, lng], {
radius: 6, color: "#bb86fc", fillColor: "#bb86fc", fillOpacity: 1, weight: 2
}).addTo(map);
markers.push(marker);
updatePolyline();
updateStats();
});
// Undo
document.querySelector("#draw-undo").onclick = () => {
if (points.length === 0) return;
points.pop();
const m = markers.pop();
if (m) map.removeLayer(m);
updatePolyline();
updateStats();
};
// Cancel
document.querySelector("#draw-cancel").onclick = () => renderScreen("gps");
// Save
document.querySelector("#draw-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
const saveBtn = document.querySelector("#draw-save");
saveBtn.textContent = "Сохранение...";
saveBtn.disabled = true;
const activityType = document.querySelector("#draw-type").value;
// Get elevation for each point
const needApi = [];
for (const p of points) {
const alt = await DB.findNearestAltitude(p.lat, p.lon, 50);
if (alt != null) { p.altitude = alt; }
else { needApi.push(p); }
}
if (needApi.length > 0) {
const elevs = await fetchElevations(needApi);
if (elevs) needApi.forEach((p, i) => { if (elevs[i] != null) p.altitude = elevs[i]; });
}
for (const p of points) { if (p.altitude == null) p.altitude = 0; }
// Build points for DB with realistic timestamps based on activity speed
const avgSpeedMs = (activityType === "bike" ? GPS.config.bikeAvgSpeedKmh
: activityType === "run" ? GPS.config.runAvgSpeedKmh
: GPS.config.walkAvgSpeedKmh) / 3.6;
const now = Date.now();
let cumTime = 0;
const dbPoints = points.map((p, i) => {
if (i > 0) {
const d = GPS.distance(points[i-1].lat, points[i-1].lon, p.lat, p.lon);
cumTime += d / avgSpeedMs; // seconds to cover this segment
}
return {
lat: p.lat, lon: p.lon,
altitude_gps_m: p.altitude,
speed_ms: avgSpeedMs,
ts: new Date(now + cumTime * 1000).toISOString(),
seq: i + 1,
};
});
// Compute stats
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
const bike = await DB.getActiveBike();
const opts = { activityType, bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type };
const stats = GPS.computeStats(dbPoints, weightKg, opts);
// Create session
const date = new Date().toISOString().slice(0, 10);
const time = new Date().toTimeString().slice(0, 5);
const session = {
id: now, date, start_time: time, end_time: time,
activity_type: activityType, source: "manual",
total_distance_m: Math.round(stats.distance),
total_kcal: Math.round(stats.kcal),
avg_speed_kmh: stats.avgSpeed,
max_speed_kmh: stats.maxSpeed,
elevation_gain_m: Math.round(stats.elevGain),
elevation_loss_m: Math.round(stats.elevLoss),
bike_id: bike?.id ?? null,
};
await DB.importGpxSession(session, dbPoints);
showToast(`Маршрут сохранён: ${GPS.formatDistance(stats.distance)}, ${Math.round(stats.kcal)} ккал`);
renderScreen("gps");
};
}, 200);
}
function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
@ -2811,7 +3070,9 @@ function showMapDownloadModal() {
// Session detail view
// ---------------------------------------------------------------------------
let _sessionDetailRender = 0;
async function showSessionDetail(sessionId) {
const renderToken = ++_sessionDetailRender;
try {
console.log("[SD] loading session", sessionId);
const session = await DB.getGpsSession(sessionId);
@ -2983,6 +3244,7 @@ async function showSessionDetail(sessionId) {
// Render map
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const mapEl = document.querySelector("#sd-map");
if (!mapEl || !points.length) return;
@ -3004,16 +3266,29 @@ async function showSessionDetail(sessionId) {
const tRange = tN - t0 || 1;
// Split into segments by GPS gap threshold
// If accelerometer shows movement on both sides of gap → keep connected (don't break)
const gapSec = GPS.config.gapThresholdSec ?? 30;
const segments = []; // array of arrays of [lat, lon]
const gapLines = []; // dashed lines for accel-confirmed gaps
let curSeg = [[points[0].lat, points[0].lon]];
for (let i = 1; i < points.length; i++) {
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
if (gap > gapSec) {
segments.push(curSeg);
curSeg = [];
const accelBefore = points[i-1].accel_rms;
const accelAfter = points[i].accel_rms;
// If both sides show movement → bridge the gap with dashed line
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
// Keep in same segment — person was moving, GPS just lost signal
curSeg.push([points[i].lat, points[i].lon]);
gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]);
} else {
segments.push(curSeg);
curSeg = [];
curSeg.push([points[i].lat, points[i].lon]);
}
} else {
curSeg.push([points[i].lat, points[i].lon]);
}
curSeg.push([points[i].lat, points[i].lon]);
}
segments.push(curSeg);
@ -3028,6 +3303,11 @@ async function showSessionDetail(sessionId) {
allCoords.push(...seg);
});
// Draw dashed lines for accel-confirmed gaps (GPS lost signal but person was moving)
for (const gl of gapLines) {
L.polyline(gl, { color: "#bb86fc", weight: 3, opacity: 0.5, dashArray: "8, 8" }).addTo(map);
}
// Invisible but tappable hit-targets on top of track
for (let i = 0; i < points.length; i++) {
const p = points[i];
@ -3136,6 +3416,7 @@ async function showSessionDetail(sessionId) {
// Render speed chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const ctx = document.querySelector("#sd-speed-chart");
if (!ctx || !smoothed.length) return;
// Update maxSpeed display to match smoothed graph
@ -3186,6 +3467,7 @@ async function showSessionDetail(sessionId) {
// Render elevation chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const ctx = document.querySelector("#sd-elev-chart");
if (!ctx || !elevations.length) return;
const elevLabels = points.filter(p => p.altitude_gps_m != null)
@ -3236,6 +3518,7 @@ async function showSessionDetail(sessionId) {
// Render HR chart
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const hrData = points.map(p => p.heart_rate || null);
if (!hrData.some(v => v)) { /* no HR data — hide chart */ return; }
const wrap = document.querySelector("#sd-hr-wrap");
@ -3297,6 +3580,7 @@ async function showSessionDetail(sessionId) {
// Delete button
setTimeout(() => {
if (renderToken !== _sessionDetailRender) return;
const exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
@ -3984,8 +4268,8 @@ async function renderSettings(container) {
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. точность (м)</div>
<div class="settings-item-desc">Фиксы точнее этого = пропуск</div>
<div class="settings-item-title">Макс. погрешность GPS (м)</div>
<div class="settings-item-desc">Хуже этого = пропуск (авто x2 при плохом сигнале)</div>
</div>
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
@ -4040,6 +4324,24 @@ async function renderSettings(container) {
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Ходьба / Бег</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. скорость (км/ч)</div>
<div class="settings-item-desc">Выше = глитч GPS</div>
</div>
<input id="s-walk-max-speed" type="number" step="1" value="${GPS.config.walkMaxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Мин. скорость (км/ч)</div>
<div class="settings-item-desc">Ниже = стоит</div>
</div>
<input id="s-walk-min-speed" type="number" step="0.1" value="${(GPS.config.walkMinSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Велосипеды</div>
<div id="s-bikes-list"></div>
@ -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");
}

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.224"
version = "0.1.244"
dependencies = [
"btleplug",
"lazy_static",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.224"
version = "0.1.244"
edition = "2021"
[lib]

View file

@ -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<Float>() // 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<String>()
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<Float>() // 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()

View file

@ -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")

View file

@ -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;

View file

@ -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");
}

View file

@ -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": [
{